diff --git a/CLAUDE.md b/CLAUDE.md
index 1f7b63f..431dc99 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -659,7 +659,8 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
nobody can answer a prompt. Steps: `ServerCheckWorker` (`ssh test -d`; an
existing folder asks before replacing it), `ExportWorker`, then
`UploadWorker`, all shown in `gui/web_upload_progress.py`. The web
- player has a **shuffle** button (and the S key): a shuffled walk that starts at
+ player has **previous/next** buttons (the arrow keys, wrapping at either
+ end, through the shuffle order when on) and a **shuffle** button (and the S key): a shuffled walk that starts at
the current song and plays every song once. A mix can carry several
images (`plan.images`): the dialog always keeps one empty row to add
another, and the page shows them in order under the description, a blank
diff --git a/lintunes/__init__.py b/lintunes/__init__.py
index 8b8466a..aa06f75 100644
--- a/lintunes/__init__.py
+++ b/lintunes/__init__.py
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
-__version__ = "0.50.0"
+__version__ = "0.51.0"
diff --git a/lintunes/export/templates/player.css b/lintunes/export/templates/player.css
index fbac128..e587c4c 100644
--- a/lintunes/export/templates/player.css
+++ b/lintunes/export/templates/player.css
@@ -27,7 +27,7 @@
.audiojs audio { position: absolute; left: -1px; }
.audiojs {
- width: 286px;
+ width: 334px;
height: 36px;
background: #eee;
color: #000;
@@ -40,9 +40,9 @@
}
.audiojs .play-pause {
- width: 40px;
+ width: 36px;
height: 40px;
- padding: 4px 6px;
+ padding: 4px 2px;
margin: 0px;
float: left;
overflow: hidden;
@@ -88,6 +88,21 @@
.audiojs .time em { padding: 0px 2px 0px 0px; color: #000; font-style: normal; }
.audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; }
+/* Previous / next, either side of play-pause: plain black glyphs. */
+.audiojs .skip {
+ float: left;
+ width: 24px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #000;
+ cursor: pointer;
+}
+.audiojs .prev { margin-left: 4px; }
+.audiojs .skip:hover { color: var(--accent, #8c764a); }
+.audiojs .skip:focus-visible { outline: 1px dotted #000; }
+
/* Shuffle: black like the other glyphs while off, the accent while on
* (the same fill as the progress bar, with the text color picked for it). */
.audiojs .shuffle {
diff --git a/lintunes/export/templates/player.js b/lintunes/export/templates/player.js
index 6de5935..b23ae2a 100644
--- a/lintunes/export/templates/player.js
+++ b/lintunes/export/templates/player.js
@@ -24,10 +24,20 @@
"use strict";
var WRAPPER =
+ '
' +
+ '' +
+ '
' +
'
' +
'' +
'' +
'
' +
+ '
' +
+ '' +
+ '
' +
'
' +
'
00:00/00:00
' +
'' +
@@ -62,7 +72,9 @@
played: wrap.querySelector(".played"),
duration: wrap.querySelector(".duration"),
error: wrap.querySelector(".error-message"),
- shuffle: wrap.querySelector(".shuffle")
+ shuffle: wrap.querySelector(".shuffle"),
+ prev: wrap.querySelector(".prev"),
+ next: wrap.querySelector(".next")
};
function state(name) {
@@ -127,6 +139,8 @@
return {
element: audio,
shuffleButton: ui.shuffle,
+ prevButton: ui.prev,
+ nextButton: ui.next,
load: function (src) {
ui.progress.style.width = "0%";
ui.loaded.style.width = "0%";
@@ -176,16 +190,30 @@
function first() { return (order || items)[0]; }
function last() { var list = order || items; return list[list.length - 1]; }
- player.shuffleButton.addEventListener("click", function () {
- setShuffle(!order);
- });
- player.shuffleButton.addEventListener("keydown", function (e) {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- e.stopPropagation();
- setShuffle(!order);
- }
- });
+ // The previous/next buttons and the arrow keys: wrap around at either
+ // end, through the shuffled walk when shuffle is on.
+ function goNext() {
+ var current = document.querySelector("ol li.playing");
+ select((current && step(current, 1)) || first(), true);
+ }
+ function goPrev() {
+ var current = document.querySelector("ol li.playing");
+ select((current && step(current, -1)) || last(), true);
+ }
+
+ function button(el, action) {
+ el.addEventListener("click", action);
+ el.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ action();
+ }
+ });
+ }
+ button(player.shuffleButton, function () { setShuffle(!order); });
+ button(player.prevButton, goPrev);
+ button(player.nextButton, goNext);
function select(li, autoplay) {
items.forEach(function (other) { other.classList.remove("playing"); });
@@ -215,13 +243,12 @@
var tag = (target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || target.isContentEditable) return;
- var current = document.querySelector("ol li.playing");
if (e.key === "ArrowRight") {
e.preventDefault();
- select((current && step(current, 1)) || first(), true);
+ goNext();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
- select((current && step(current, -1)) || last(), true);
+ goPrev();
} else if ((e.key === "s" || e.key === "S") && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShuffle(!order);
diff --git a/tests/test_round81.py b/tests/test_round81.py
index e8f0e9e..1b9d143 100644
--- a/tests/test_round81.py
+++ b/tests/test_round81.py
@@ -565,3 +565,11 @@ def test_upload_window_shows_the_size(qapp, window):
_wait_for(qapp, lambda: _FakeUpload.runs)
from lintunes.gui import web_upload_progress as wp
assert "KB" in window._web_progress.status(wp.EXPORT)
+
+
+def test_player_ships_previous_and_next_buttons():
+ js = (exporter.templates() / "player.js").read_text()
+ css = (exporter.templates() / "player.css").read_text()
+ assert 'class="skip prev"' in js and 'class="skip next"' in js
+ assert "button(player.prevButton, goPrev)" in js
+ assert ".audiojs .skip" in css