This commit is contained in:
2026-09-08 15:20:27 +02:00
parent 4b9e6c5651
commit 2eb9a9590e
15 changed files with 1023 additions and 127 deletions
+34 -7
View File
@@ -139,7 +139,14 @@ volume, and repeat mode.
Selecting, deleting, or creating a tab stops playback. Tracks are de-duplicated
by normalized source path when they are appended. Every playlist mutation is
written in one `keystore` transaction. `playlists-for-<username>` contains the
ordered playlist GUIDs; each GUID key contains that playlist's name and tracks.
ordered open playlist GUIDs; `saved-playlists-for-<username>` contains the saved
library playlist GUIDs. Each GUID key contains one playlist's name and tracks.
Both indexes are persisted atomically; a value is deleted only when neither
index refers to it. Closing a saved tab retains its library entry.
`restore-playlist-context` reuses the same in-memory object for a saved playlist
and its open tab, so track edits and renaming update both views. Old stores
without a saved index restore their original tabs without automatically saving
them in the library.
Loading validates every stored track independently against all configured
library roots, so one playlist can safely combine multiple libraries.
`language-for-<username>` stores the user's selected interface language in the
@@ -224,7 +231,7 @@ serves static assets from [`public/`](public/) and exposes these API endpoints:
| Method | Route | Responsibility |
| --- | --- | --- |
| `GET` | `/api/state` | Return the complete current state; also refresh network-renderer information. |
| `GET` | `/api/state` | Return current state, omitting unchanged tracks when `playlistVersion` matches; also refresh network-renderer information. |
| `POST` | `/api/discover` | Start asynchronous discovery and return the current state. |
| `POST` | `/api/command/:command` | Execute a command with its JSON request body and return the updated state. |
| `GET` | `/api/preferences` | Return the current user's durable UI preferences. |
@@ -241,13 +248,32 @@ header. Command failures are returned as HTTP 400 JSON responses with an
[`public/app.js`](public/app.js) implements a framework-free client. It:
- fetches a full state snapshot once per second;
- polls status once per second, receiving tracks only when the playlist version changes;
- temporarily suppresses polling while a browser-initiated command is active;
- immediately renders the state returned by successful commands;
- renders library navigation, playlists, tabs, transport status, and output
selection;
- implements keyboard actions and playlist drag-and-drop in the browser.
[`public/playlist-library.js`](public/playlist-library.js) renders the library's
Folders/Playlists tabs and saved playlist summaries. `playlist-save` adds an
open tab to the library by UUID. `playlist-open` reveals its own tab, reusing an
already open tab, and `playlist-play` also starts playback. Neither action copies
tracks into an unrelated tab. Only summary metadata is sent in `savedPlaylists`.
[`public/player-state.js`](public/player-state.js) keeps the active track array
in memory and queues state, command and discovery requests. Each request sends
the last received `playlistVersion` as a query parameter. A matching response
contains `tracks: null`; the client supplies its cached tracks to the renderers.
Requests without a version still receive the full list. Queueing prevents
responses from being applied out of order.
The player caches track JSON, count, duration and an opaque version per tab.
`save-current-tab!` invalidates this snapshot when the track list changes.
Renaming a tab leaves its track snapshot intact. Versions are unique across
tabs, users and server restarts. `renderPlaylist` uses the version to decide
when to rebuild rows instead of comparing all track metadata on every poll.
[`public/translate.js`](public/translate.js) follows the key-based translation
model used by rktplayer. It supports Dutch, English, German, French, Spanish,
Italian, Swedish, Norwegian, Finnish and Icelandic with English fallback.
@@ -314,9 +340,9 @@ sequenceDiagram
participant D as DLNA renderer
loop Browser state polling
B->>H: GET /api/state
B->>H: GET /api/state?playlistVersion=known-version
H->>P: player-state->jsexpr
P-->>H: Full state snapshot
P-->>H: State and version; tracks only if changed
H-->>B: JSON response
end
@@ -429,8 +455,9 @@ The main extension points are:
- **Per-user pipelines, shared outputs:** playlist and transport state are
isolated by username. Distinct outputs run concurrently; selecting an
occupied output explicitly stops its previous owner and transfers it.
- **Full-state snapshots:** a small and predictable client protocol, at the cost
of repeatedly transferring all tracks and browser entries.
- **Versioned playlists:** cached tracks are transferred only when the selected
playlist version changes. Other status fields, including browser entries,
remain part of each response.
- **One-second polling:** robust and dependency-free, but introduces periodic
traffic and up to one second of display latency.
- **Lazy filesystem and backend initialization:** fast startup and low idle
+19 -6
View File
@@ -100,13 +100,24 @@ betrouwbare next-ondersteuning krijgen na het natuurlijke trackeinde een
servergestuurde fallback. Een expliciet stopcommando start nooit de volgende
track.
De bibliotheek links heeft de tabs **Mappen** en **Afspeellijsten**. Met
**Afspeellijst opslaan** boven de huidige playlist geef je de tab een naam en
bewaar je hem in de bibliotheek. **** bij een bewaarde playlist opent of
selecteert zijn eigen tab; **▶** opent die tab en speelt de playlist af.
Een playlist die al open is, krijgt geen tweede tab. De inhoud van andere
tabs blijft behouden. Wijzigingen in een bewaarde playlist worden automatisch
opgeslagen, ook wanneer je de tab hernoemt. Het kruisje sluit een bewaarde tab;
de playlist blijft beschikbaar in de bibliotheek.
Playlisttabs kunnen worden toegevoegd, geselecteerd, hernoemd door dubbel te
klikken en verwijderd. Tracks kunnen worden afgespeeld, verwijderd en met
klikken en gesloten. Tracks kunnen worden afgespeeld, verwijderd en met
drag-and-drop verplaatst. Tabnamen, tabvolgorde en alle tracklijsten worden na
iedere wijziging transactioneel opgeslagen. Een verwijderde
tab verdwijnt daarbij ook uit de keystore. Voor iedere gebruiker bevat de key
`playlists-for-<username>` de geordende lijst met playlist-GUIDs. Onder iedere
GUID-key staan de naam en tracks van die playlist. Tracks uit verschillende
iedere wijziging transactioneel opgeslagen. Alleen een gesloten tab die niet
in de bibliotheek is bewaard, verdwijnt ook uit de keystore. Voor iedere gebruiker
bevat `playlists-for-<username>` de geordende lijst met geopende playlist-GUIDs,
en `saved-playlists-for-<username>` de bewaarde playlists. Onder iedere
GUID-key staan de naam en tracks van die playlist. Bestaande tabs worden niet
automatisch aan de bibliotheek toegevoegd. Tracks uit verschillende
geconfigureerde libraries mogen in dezelfde playlist staan; ontbrekende of
buiten de libraries gelegen bestanden worden bij het laden overgeslagen.
Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als
@@ -242,6 +253,8 @@ geen TLS heeft.
```console
raco test private/users.rkt private/library.rkt private/player.rkt \
private-player-agent/player-agent-config.rkt
private/server.rkt private-player-agent/player-agent-config.rkt
node tests/player-state.test.mjs
node tests/playlist-library.test.mjs
raco setup --check-pkg-deps rkt-web-player
```
+297 -55
View File
@@ -50,11 +50,13 @@
#:transparent)
(struct playlist-tab
(id [name #:mutable] [tracks #:mutable])
(id [name #:mutable] [tracks #:mutable] [snapshot #:auto #:mutable])
#:auto-value #f
#:transparent)
(struct playlist-context
([tabs #:mutable] [current-index #:mutable])
([tabs #:mutable] [current-index #:mutable] [saved #:auto #:mutable])
#:auto-value '()
#:transparent)
(struct playback-session
@@ -260,36 +262,45 @@
(list-ref (player-tabs value)
(player-current-tab-index value)))
;;; Invalidate the browser snapshot only when the immutable track list changes.
(define (save-current-tab! value)
(set-playlist-tab-tracks!
(current-tab value)
(player-tracks value)))
(let ((tab (current-tab value))
(tracks (player-tracks value)))
(unless (eq? tracks (playlist-tab-tracks tab))
(set-playlist-tab-tracks! tab tracks)
(set-playlist-tab-snapshot! tab #f))))
(define (normal-playlist-username username)
(let ((value (and (string? username)
(string-downcase (string-trim username)))))
(if (and value (not (string=? value ""))) value "anonymous")))
(define (new-playlist-context value username)
(define stored
(load-user-playlists (player-playlist-store value)
username
(player-libraries value)))
(playlist-context
(if (pair? stored)
(for/list ((tab (in-list stored)))
(playlist-tab (persisted-tab-id tab)
;;; Restore open tabs and the library using the same object for a shared UUID.
;;; Old stores have no saved index, so their tabs remain open and unsaved.
(define (restore-playlist-context store username libraries)
(let* ((restore (λ (tab) (playlist-tab (persisted-tab-id tab)
(persisted-tab-name tab)
(persisted-tab-tracks tab)))
(list (playlist-tab (uuid-string) "Default" '())))
0))
(persisted-tab-tracks tab))))
(opened (map restore (load-user-playlists store username libraries)))
(tabs (if (pair? opened) opened
(list (playlist-tab (uuid-string) "Default" '()))))
(context (playlist-context tabs 0)))
(set-playlist-context-saved!
context
(map (λ (stored)
(or (findf (λ (tab) (string=? (playlist-tab-id tab) (persisted-tab-id stored)))
tabs)
(restore stored)))
(load-user-playlists store username libraries #:saved? #t)))
context))
(define (playlist-context-for! value username)
(define normalized (normal-playlist-username username))
(hash-ref!
(player-playlist-contexts value)
normalized
(λ () (new-playlist-context value normalized))))
(λ () (restore-playlist-context (player-playlist-store value)
normalized (player-libraries value)))))
(define (context-tracks context)
(playlist-tab-tracks
@@ -361,7 +372,12 @@
(for/list ((tab (in-list (player-tabs value))))
(persisted-tab (playlist-tab-id tab)
(playlist-tab-name tab)
(playlist-tab-tracks tab)))))
(playlist-tab-tracks tab)))
#:saved
(map (λ (tab) (persisted-tab (playlist-tab-id tab)
(playlist-tab-name tab)
(playlist-tab-tracks tab)))
(playlist-context-saved context))))
(define (normalize-state state)
(cond
@@ -927,25 +943,28 @@
trimmed)
(persist-playlists! value)))))
;;; Close a tab, retaining saved playlists in the user's library.
(define (delete-tab! value session index)
(when (= (length (player-tabs value)) 1)
(raise-arguments-error
'player-command!
"the last playlist tab cannot be removed"))
(unless (and (exact-nonnegative-integer? index)
(< index (length (player-tabs value))))
(raise-arguments-error
'player-command!
"playlist tab does not exist"
"index" index))
(let ((context (playlist-context-for! value (player-active-playlist-user value))))
(when (and (= (length (player-tabs value)) 1)
(not (memq (current-tab value) (playlist-context-saved context))))
(raise-arguments-error 'player-command! "the last playlist tab cannot be removed")))
(stop-playback! value session)
(with-state-lock
value
(λ ()
(save-current-tab! value)
(let* ((tabs
(let* ((remaining
(append (take (player-tabs value) index)
(drop (player-tabs value) (+ index 1))))
(tabs (if (pair? remaining) remaining
(list (playlist-tab (uuid-string) "Default" '()))))
(new-index
(min (player-current-tab-index value)
(- (length tabs) 1))))
@@ -958,6 +977,44 @@
(set-playback-session-current-index! session #f)
(persist-playlists! value)))))
;;; Save an open tab in the library once; subsequent tab edits share its identity.
(define (save-playlist! value id name)
(let* ((context (playlist-context-for! value (player-active-playlist-user value)))
(tab (findf (λ (tab) (equal? (playlist-tab-id tab) id)) (player-tabs value)))
(trimmed (string-trim name)))
(unless tab
(raise-arguments-error 'player-command! "playlist tab does not exist" "id" id))
(when (string=? trimmed "")
(raise-arguments-error 'player-command! "playlist name cannot be empty"))
(with-state-lock
value
(λ ()
(save-current-tab! value)
(set-playlist-tab-name! tab trimmed)
(unless (memq tab (playlist-context-saved context))
(set-playlist-context-saved! context
(append (playlist-context-saved context) (list tab))))
(persist-playlists! value)))))
;;; Reveal a saved playlist as its own tab, reusing an existing tab by identity.
;;; Opening never copies tracks into another tab; play? starts this playlist.
(define (open-playlist! value session id play?)
(let* ((context (playlist-context-for! value (player-active-playlist-user value)))
(tab (findf (λ (tab) (equal? (playlist-tab-id tab) id))
(playlist-context-saved context))))
(unless tab
(raise-arguments-error 'player-command! "saved playlist does not exist" "id" id))
(let ((index (index-of (player-tabs value) tab eq?)))
(unless index
(with-state-lock
value
(λ ()
(save-current-tab! value)
(set-player-tabs! value (append (player-tabs value) (list tab))))))
(select-tab! value session (or index (- (length (player-tabs value)) 1)))))
(when (and play? (pair? (player-tracks value)))
(play-index! value session 0)))
(define (track->jsexpr item index)
(hasheq 'index index
'title (track-title item)
@@ -984,14 +1041,29 @@
'name (browser-entry-name entry)
'kind (symbol->string (browser-entry-kind entry))))
;;; Include the sum of known track durations in each tab's browser summary.
(define (tab->jsexpr tab index)
;;; Cache serialized tracks and their summary until save-current-tab! invalidates
;;; them. A fresh opaque version also prevents cache reuse across server restarts.
(define (tab-snapshot tab)
(or (playlist-tab-snapshot tab)
(let* ((tracks (playlist-tab-tracks tab))
(snapshot
(hasheq 'version (uuid-string)
'count (length tracks)
'duration (apply + (map (λ (item) (or (track-duration item) 0))
tracks))
'tracks (map track->jsexpr tracks (range (length tracks))))))
(set-playlist-tab-snapshot! tab snapshot)
snapshot)))
;;; Return a small tab summary without rebuilding or transferring its tracks.
(define (tab->jsexpr tab index [saved? #f])
(let ((snapshot (tab-snapshot tab)))
(hasheq 'index index
'id (playlist-tab-id tab)
'name (playlist-tab-name tab)
'count (length (playlist-tab-tracks tab))
'duration (apply + (map (λ (item) (or (track-duration item) 0))
(playlist-tab-tracks tab)))))
'saved saved?
'count (hash-ref snapshot 'count)
'duration (hash-ref snapshot 'duration))))
(define (normal-device-id device)
(let ((id (upnp-device-udn device)))
@@ -1121,7 +1193,12 @@
(rename-tab! value index
(or (json-string data 'name #f) "")))
((string=? command "tab-delete")
(delete-tab! value session index)))))
(delete-tab! value session index))
((string=? command "playlist-save")
(save-playlist! value (json-string data 'id #f) (or (json-string data 'name #f) "")))
((member command '("playlist-open" "playlist-play"))
(open-playlist! value session (json-string data 'id #f)
(string=? command "playlist-play"))))))
(define (perform-command! value session command data)
(cond
@@ -1131,7 +1208,7 @@
((member command '("track-remove" "track-move"
"playlist-clear" "tab-add"
"tab-select" "tab-rename"
"tab-delete"))
"tab-delete" "playlist-save" "playlist-open" "playlist-play"))
(perform-playlist-command! value session command data))
((string=? command "play")
(play-index!
@@ -1278,23 +1355,15 @@
app-id))
(string-downcase app-id)))
(define store (open-playlist-store playlist-keystore))
(define stored-tabs
(load-user-playlists store "anonymous" libraries))
(let* ((library (and (pair? libraries) (car libraries)))
(browser-entries
(if library
(browse-library library '())
'()))
(tabs
(if (pair? stored-tabs)
(for/list ((tab (in-list stored-tabs)))
(playlist-tab (persisted-tab-id tab)
(persisted-tab-name tab)
(persisted-tab-tracks tab)))
(list (playlist-tab (uuid-string) "Default" '()))))
(initial-context (restore-playlist-context store "anonymous" libraries))
(tabs (playlist-context-tabs initial-context))
(selected-index 0)
(contexts (make-hash))
(initial-context (playlist-context tabs selected-index)))
(contexts (make-hash)))
(hash-set! contexts "anonymous" initial-context)
(define value
(player libraries
@@ -1324,12 +1393,17 @@
value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return the complete browser-visible player state.
; goal : Return browser-visible state, optionally reusing known playlist tracks.
; pre : Value was created with make-player.
; post : Cached DLNA playback information has been incorporated.
; result : A JSON-compatible hash.
; result : A JSON-compatible hash with playlistVersion. tracks is null when
; playlist-version matches; otherwise it contains the complete list.
; internals: tab-snapshot caches track JSON, duration and a unique version until
; a playlist mutation invalidates it. Comparison and state assembly
; share the player locks, so the version always matches the tracks.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (player-state->jsexpr value #:username [username "anonymous"])
(define (player-state->jsexpr value #:username [username "anonymous"]
#:playlist-version [playlist-version #f])
(define normalized (normal-playlist-username username))
(call-with-semaphore
(player-command-lock value)
@@ -1340,7 +1414,7 @@
(define context (playlist-context-for! value normalized))
(define tabs (playlist-context-tabs context))
(define tab-index (playlist-context-current-index context))
(define tracks (playlist-tab-tracks (list-ref tabs tab-index)))
(define snapshot (tab-snapshot (list-ref tabs tab-index)))
(with-state-lock
value
(λ ()
@@ -1366,12 +1440,17 @@
'tabs
(for/list ((tab (in-list tabs))
(index (in-naturals)))
(tab->jsexpr tab index))
(tab->jsexpr tab index (and (memq tab (playlist-context-saved context)) #t)))
'savedPlaylists
(map (λ (tab index) (tab->jsexpr tab index #t))
(playlist-context-saved context)
(range (length (playlist-context-saved context))))
'currentTab tab-index
'playlistVersion (hash-ref snapshot 'version)
'tracks
(for/list ((item (in-list tracks))
(index (in-naturals)))
(track->jsexpr item index))
(if (equal? playlist-version (hash-ref snapshot 'version))
'null
(hash-ref snapshot 'tracks))
'renderers (map renderer->jsexpr
(player-renderers value))
'rendererId (or (playback-session-selected-id session) 'null)
@@ -1401,14 +1480,16 @@
; goal : Execute one browser player command.
; pre : Command is a string and data is a JSON object hash.
; post : The command has completed or a concrete exception is raised.
; result : The updated JSON-compatible player state.
; result : The updated JSON-compatible player state. With a matching
; playlist-version, tracks is null as in player-state->jsexpr.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define playlist-context-commands
'("item-play" "item-add" "track-remove" "track-move"
"playlist-clear" "tab-add" "tab-select" "tab-rename"
"tab-delete" "play"))
"tab-delete" "playlist-save" "playlist-open" "playlist-play" "play"))
(define (player-command! value command data #:username [username "anonymous"])
(define (player-command! value command data #:username [username "anonymous"]
#:playlist-version [playlist-version #f])
(define normalized (normal-playlist-username username))
(call-with-semaphore
(player-command-lock value)
@@ -1426,7 +1507,8 @@
(when (member command playlist-context-commands)
(activate-playlist-user! value normalized session))
(perform-command! value session command data))))
(player-state->jsexpr value #:username normalized))
(player-state->jsexpr value #:username normalized
#:playlist-version playlist-version))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Discover UPnP renderers and logical Sonos groups asynchronously.
@@ -1739,16 +1821,176 @@
(λ ()
(set-player-closed?! value #t)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests for module player.rkt
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(module+ test
(require rackunit
(require json
rackunit
racket/file)
(test-case "playlist versions cache 1000 tracks and follow user mutations"
(let ((value (make-player '() #:playlist-keystore #f #:local-output? #f)))
(dynamic-wind
void
(λ ()
(let* ((session (playback-session-for! value "anonymous"))
(tracks
(build-list
1000
(λ (index)
(track (build-path (find-system-path 'temp-dir)
(format "playlist-test-~a.flac" index))
(format "Track ~a" index) "Artist" "Album" 60 "audio/flac")))))
(replace-tracks! value session tracks)
(let* ((full (player-state->jsexpr value))
(version (hash-ref full 'playlistVersion))
(compact (player-state->jsexpr value #:playlist-version version))
(repeated (player-state->jsexpr value)))
(check-equal? (length (hash-ref full 'tracks)) 1000)
(check-equal? (hash-ref (car (hash-ref full 'tabs)) 'duration) 60000)
(check-eq? (hash-ref compact 'tracks) 'null)
(check-eq? (hash-ref full 'tracks) (hash-ref repeated 'tracks))
(check-true (< (bytes-length (jsexpr->bytes compact))
(/ (bytes-length (jsexpr->bytes full)) 20)))
(check-eq?
(hash-ref (player-command! value "repeat" (hasheq 'mode "all")
#:playlist-version version) 'tracks)
'null)
(check-eq?
(hash-ref (player-command! value "tab-rename" (hasheq 'index 0 'name "Music")
#:playlist-version version) 'tracks)
'null)
(let* ((moved (player-command! value "track-move" (hasheq 'from 0 'to 1)
#:playlist-version version))
(moved-version (hash-ref moved 'playlistVersion))
(other-browser (player-state->jsexpr value #:playlist-version version)))
(check-not-equal? moved-version version)
(check-equal? (hash-ref (car (hash-ref moved 'tracks)) 'title) "Track 1")
(check-equal? (hash-ref other-browser 'playlistVersion) moved-version)
(check-equal? (hash-ref other-browser 'tracks) (hash-ref moved 'tracks))
(let* ((removed (player-command! value "track-remove" (hasheq 'index 0)
#:playlist-version moved-version))
(removed-version (hash-ref removed 'playlistVersion))
(new-tab (player-command! value "tab-add" (hasheq)
#:playlist-version removed-version))
(selected (player-command! value "tab-select" (hasheq 'index 0)
#:playlist-version (hash-ref new-tab 'playlistVersion))))
(check-equal? (length (hash-ref removed 'tracks)) 999)
(check-not-equal? removed-version moved-version)
(check-equal? (hash-ref new-tab 'tracks) '())
(check-equal? (hash-ref selected 'playlistVersion) removed-version)
(check-equal? (length (hash-ref selected 'tracks)) 999)
(let ((other-user (player-state->jsexpr value #:username "another-user"
#:playlist-version removed-version))
(cleared (player-command! value "playlist-clear" (hasheq)
#:playlist-version removed-version)))
(check-equal? (hash-ref other-user 'tracks) '())
(check-not-equal? (hash-ref other-user 'playlistVersion) removed-version)
(check-equal? (hash-ref cleared 'tracks) '())
(check-not-equal? (hash-ref cleared 'playlistVersion) removed-version)
(append-tracks! value session (list (car tracks)))
(let* ((added (player-state->jsexpr value
#:playlist-version (hash-ref cleared 'playlistVersion)))
(added-version (hash-ref added 'playlistVersion)))
(check-equal? (length (hash-ref added 'tracks)) 1)
(check-equal? (hash-ref (car (hash-ref added 'tabs)) 'duration) 60)
(append-tracks! value session (list (car tracks)))
(check-eq? (hash-ref (player-state->jsexpr value #:playlist-version added-version)
'tracks)
'null))))))))
(λ () (player-close! value)))))
(check-equal? (error->jsexpr #f) 'null)
(check-equal?
(error->jsexpr 'dlna-renderer-unreachable)
"dlna-renderer-unreachable")
(check-equal? (error->jsexpr "technical error") "technical error")
(test-case "saved playlists reopen as shared tabs and survive closing and restart"
(let ((root (make-temporary-file "saved-playlists-~a" 'directory)))
(dynamic-wind
(λ ()
(call-with-output-file (build-path root "one.flac") void)
(call-with-output-file (build-path root "two.flac") void))
(λ ()
(let* ((libraries (make-music-libraries (list root)))
(store-file (build-path root "playlists.keystore"))
(agent-id (make-string 64 #\b))
(value (make-player libraries #:playlist-keystore store-file
#:local-output? #f #:allowed-agent-ids (list agent-id)))
(session (playback-session-for! value "anonymous"))
(first (track (build-path root "one.flac") "One" "Artist" "Album" 60 "audio/flac"))
(second (track (build-path root "two.flac") "Two" "Artist" "Album" 120 "audio/flac")))
(dynamic-wind
void
(λ ()
(replace-tracks! value session (list first second))
(let* ((initial (player-state->jsexpr value))
(id (hash-ref (car (hash-ref initial 'tabs)) 'id))
(version (hash-ref initial 'playlistVersion))
(saved (player-command! value "playlist-save" (hasheq 'id id 'name "Favorites")
#:playlist-version version)))
(check-equal? (hash-ref initial 'savedPlaylists) '())
(check-eq? (hash-ref saved 'tracks) 'null)
(check-true (hash-ref (car (hash-ref saved 'tabs)) 'saved))
(check-equal? (hash-ref (car (hash-ref saved 'savedPlaylists)) 'id) id)
(check-equal? (hash-ref (car (hash-ref saved 'savedPlaylists)) 'duration) 180)
(check-equal?
(length (hash-ref (player-command! value "playlist-save" (hasheq 'id id 'name "Favorites"))
'savedPlaylists)) 1)
(player-command! value "tab-add" (hasheq))
(replace-tracks! value session (list first))
(let ((opened (player-command! value "playlist-open" (hasheq 'id id))))
(check-equal? (length (hash-ref opened 'tabs)) 2)
(check-equal? (hash-ref opened 'currentTab) 0)
(check-equal? (map track-title (playlist-tab-tracks (list-ref (player-tabs value) 1))) '("One"))
(check-equal? (length (hash-ref (player-command! value "playlist-open" (hasheq 'id id)) 'tabs)) 2))
(player-command! value "tab-rename" (hasheq 'index 0 'name "Renamed"))
(let ((edited (player-command! value "track-remove" (hasheq 'index 0))))
(check-equal? (hash-ref (car (hash-ref edited 'savedPlaylists)) 'name) "Renamed")
(check-equal? (hash-ref (car (hash-ref edited 'savedPlaylists)) 'count) 1)
(check-equal? (hash-ref (car (hash-ref edited 'savedPlaylists)) 'duration) 120))
(player-command! value "tab-delete" (hasheq 'index 0))
(let ((opened (player-command! value "playlist-open" (hasheq 'id id))))
(check-equal? (length (hash-ref opened 'tabs)) 2)
(check-equal? (hash-ref opened 'currentTab) 1)
(check-equal? (map (λ (track) (hash-ref track 'title)) (hash-ref opened 'tracks)) '("Two")))
(player-agent-register! value (hasheq 'appId agent-id 'name "Test agent"))
(player-command! value "renderer" (hasheq 'id (agent-renderer-id agent-id)))
(let ((playing (player-command! value "playlist-play" (hasheq 'id id))))
(check-equal? (hash-ref playing 'currentIndex) 0)
(check-equal? (length (hash-ref playing 'tabs)) 2)
(check-equal?
(hash-ref (hash-ref (player-agent-poll! value (hasheq 'appId agent-id)) 'command) 'action)
"play"))
(player-command! value "tab-delete" (hasheq 'index 1))
(check-equal? (hash-ref (player-state->jsexpr value #:username "other") 'savedPlaylists) '())
(check-exn exn:fail?
(λ () (player-command! value "playlist-open" (hasheq 'id id) #:username "other")))
(player-close! value)
(let ((restored (make-player libraries #:playlist-keystore store-file #:local-output? #f)))
(dynamic-wind
void
(λ ()
(let ((state (player-state->jsexpr restored)))
(check-equal? (length (hash-ref state 'tabs)) 1)
(check-equal? (hash-ref (car (hash-ref state 'savedPlaylists)) 'id) id))
(player-command! restored "playlist-open" (hasheq 'id id))
(let ((renamed (player-command! restored "tab-rename" (hasheq 'index 1 'name "Restored"))))
(check-equal? (hash-ref (car (hash-ref renamed 'savedPlaylists)) 'name) "Restored")
(check-equal? (map (λ (track) (hash-ref track 'title)) (hash-ref renamed 'tracks)) '("Two")))
(player-command! restored "tab-delete" (hasheq 'index 0))
(let ((closed (player-command! restored "tab-delete" (hasheq 'index 0))))
(check-equal? (length (hash-ref closed 'tabs)) 1)
(check-false (hash-ref (car (hash-ref closed 'tabs)) 'saved))
(check-equal? (hash-ref closed 'tracks) '())
(check-equal? (hash-ref (car (hash-ref closed 'savedPlaylists)) 'id) id)))
(λ () (player-close! restored))))))
(λ () (player-close! value)))))
(λ () (delete-directory/files root)))))
(define root
(make-temporary-file "rkt-web-player-~a" 'directory))
+30 -19
View File
@@ -30,9 +30,9 @@
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Produces the keystore key containing one user's ordered playlist ids.
(define (user-playlists-key username)
(format "playlists-for-~a" username))
;;; Keep open tabs and saved library playlists in separate ordered UUID indexes.
(define (user-playlists-key username [saved? #f])
(format "~aplaylists-for-~a" (if saved? "saved-" "") username))
;;; Produces the keystore key containing one user's language preference.
(define (user-language-key username)
@@ -135,7 +135,7 @@
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Restore one user's ordered playlist tabs.
; goal : Restore one user's open tabs, or saved library playlists with saved?.
; pre : Store is #f or open, username is normalized, and libraries are valid.
; post : Store contents remain unchanged and unsafe track paths are omitted.
; result : Valid persisted-tab values in their saved order.
@@ -144,17 +144,16 @@
; validates each referenced tab and delegates track safety checks to
; datum->track.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (load-user-playlists store username libraries)
(-> (or/c keystore? #f)
string?
(listof music-library?)
(define/contract (load-user-playlists store username libraries #:saved? [saved? #f])
(->* ((or/c keystore? #f) string? (listof music-library?))
(#:saved? boolean?)
(listof persisted-tab?))
(if (eq? store #f)
'()
(ks-with-lock
store
(λ ()
(let ((ids (ks-get store (user-playlists-key username) '())))
(let ((ids (ks-get store (user-playlists-key username saved?) '())))
(if (list? ids)
(filter-map
(λ (id)
@@ -163,29 +162,40 @@
'()))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Persist one user's complete ordered collection of playlist tabs.
; goal : Persist one user's open tabs and saved library playlists atomically.
; pre : Store is #f or open, username is normalized, and tabs are valid.
; post : The UUID index and tab data match tabs; omitted old tabs are removed.
; post : Both UUID indexes match tabs/saved. Closing a saved tab retains its
; data; playlists absent from both indexes are removed.
; result : Void.
; internals: ks-with-lock prevents another operation from entering this update.
; ks-transaction removes stale ids from user-playlists-key, stores
; every tab using track->datum, and atomically replaces the index.
; each distinct playlist using track->datum, and replaces both indexes.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (save-user-playlists! store username tabs)
(-> (or/c keystore? #f) string? (listof persisted-tab?) void?)
(define/contract (save-user-playlists! store username tabs #:saved [saved '()])
(->* ((or/c keystore? #f) string? (listof persisted-tab?))
(#:saved (listof persisted-tab?))
void?)
(when store
(ks-with-lock
store
(λ ()
(let* ((index-key (user-playlists-key username))
(old-ids (ks-get store index-key '()))
(saved-key (user-playlists-key username #t))
(old-open (ks-get store index-key '()))
(old-saved (ks-get store saved-key '()))
(old-ids (append (if (list? old-open) old-open '())
(if (list? old-saved) old-saved '())))
(ids (map persisted-tab-id tabs))
(saved-ids (map persisted-tab-id saved))
(all-tabs (remove-duplicates (append tabs saved)
string=? #:key persisted-tab-id))
(stale-ids
(filter
(λ (id)
(and (string? id)
(not (member id ids string=?))))
(if (list? old-ids) old-ids '()))))
(not (member id ids string=?))
(not (member id saved-ids string=?))))
(remove-duplicates old-ids))))
(ks-transaction
store
(for-each (λ (id) (ks-drop! store id)) stale-ids)
@@ -197,8 +207,9 @@
(hasheq 'name (persisted-tab-name tab)
'tracks (map track->datum
(persisted-tab-tracks tab)))))
tabs)
(ks-set! store index-key ids))
all-tabs)
(ks-set! store index-key ids)
(ks-set! store saved-key saved-ids))
(void)))))
(void))
+37 -4
View File
@@ -101,12 +101,18 @@
(define (request-username auth request)
(or (auth-request-user auth request) "anonymous"))
;;; Returns the player state belonging to the requesting user.
;;; Read the opaque version of the playlist already held by this browser.
(define (request-playlist-version request)
(let ((entry (assq 'playlistVersion (url-query (request-uri request)))))
(and entry (cdr entry))))
;;; Returns user state, including tracks only when this browser needs them.
(define (state-handler player auth request)
(json-response
(player-state->jsexpr
player
#:username (request-username auth request))))
#:username (request-username auth request)
#:playlist-version (request-playlist-version request))))
;;; Starts renderer discovery and returns the updated player state.
(define (discover-handler player auth request)
@@ -114,7 +120,8 @@
(json-response
(player-state->jsexpr
player
#:username (request-username auth request))))
#:username (request-username auth request)
#:playlist-version (request-playlist-version request))))
;;; Applies one player command for the requesting user.
(define (command-handler player auth request command)
@@ -125,7 +132,8 @@
player
command
(request-jsexpr request)
#:username (request-username auth request)))))
#:username (request-username auth request)
#:playlist-version (request-playlist-version request)))))
;;; Returns the persisted interface preferences for the requesting user.
(define (preferences-handler player auth request)
@@ -405,6 +413,31 @@
(check-equal?
(request-jsexpr (test-request #"POST" "/api/preferences"))
(hasheq))
(test-case "HTTP state and commands accept the browser's playlist version"
(let* ((value (make-player '() #:playlist-keystore #f #:local-output? #f))
(auth (make-auth-manager '()))
(dispatch (make-dispatch value auth)))
(dynamic-wind
void
(λ ()
(let* ((full (response-jsexpr (dispatch (test-request #"GET" "/api/state"))))
(version (hash-ref full 'playlistVersion))
(compact
(response-jsexpr
(dispatch (test-request #"GET" (string-append "/api/state?playlistVersion=" version))))))
(check-equal? (hash-ref full 'tracks) '())
(check-eq? (hash-ref compact 'tracks) 'null)
(check-equal? (hash-ref compact 'playlistVersion) version)
(let ((renamed
(response-jsexpr
(dispatch
(test-request #"POST"
(string-append "/api/command/tab-rename?playlistVersion=" version)
#:headers (list (header #"Content-Type" #"application/json"))
#:body #"{\"index\":0,\"name\":\"Music\"}")))))
(check-eq? (hash-ref renamed 'tracks) 'null)
(check-equal? (hash-ref (car (hash-ref renamed 'tabs)) 'name) "Music"))))
(λ () (player-close! value)))))
(check-equal?
(request-jsexpr
(test-request #"POST"
+39 -14
View File
@@ -1,7 +1,8 @@
/*
* Browser client for the web player.
*
* refresh and command obtain complete player-state snapshots from the server.
* PlayerStateClient merges status updates with the cached playlist; tracks are
* transferred only when their version changes. State requests share a queue.
* render stores that snapshot and passes it to the selector, library, playlist,
* and playback renderers. Collection renderers retain a DOM signature so the
* one-second polling loop only rebuilds lists whose contents changed.
@@ -11,6 +12,9 @@
* returned state has been rendered.
*/
import { PlayerStateClient } from "./player-state.js";
import { PlaylistLibrary } from "./playlist-library.js";
///////////////////////////////////////////////////////////////////////////////
// Browser elements and state
///////////////////////////////////////////////////////////////////////////////
@@ -44,6 +48,7 @@ const elements = {
tabAdd: document.querySelector("#tab-add"),
count: document.querySelector("#track-count"),
playlistClear: document.querySelector("#playlist-clear"),
playlistSave: document.querySelector("#playlist-save"),
playlistEmpty: document.querySelector("#playlist-empty"),
playlist: document.querySelector("#playlist"),
bits: document.querySelector("#audio-bits"),
@@ -67,6 +72,9 @@ let state = null;
let seekBusy = false;
let draggedTrack = null;
let commandBusy = false;
let refreshBusy = false;
const playerState = new PlayerStateClient(api);
const playlistLibrary = new PlaylistLibrary(document.querySelector("#library-pane"), command, formatPlayingTime);
// Represents an unsuccessful API response, including its HTTP status and code.
class ApiError extends Error {
@@ -193,7 +201,7 @@ async function command(name, data = {}, pendingMessage = "") {
commandBusy = true;
try {
render(await api(`/api/command/${name}`, data));
render(await playerState.request(`/api/command/${name}`, data));
} catch (error) {
setStatus(errorMessage(error));
} finally {
@@ -376,6 +384,14 @@ function renameTab(tab) {
}
}
// Give this tab a library name while retaining its identity and current tracks.
function savePlaylist() {
if (!state) return;
const tab = state.tabs[state.currentTab];
const name = window.prompt(t("playlistName"), tab.name === "Default" ? t("defaultPlaylist") : tab.name);
if (name !== null) command("playlist-save", { id: tab.id, name });
}
// Builds one playlist tab, including its count and optional delete control.
function createTab(tab, canDelete) {
const button = document.createElement("button");
@@ -396,11 +412,11 @@ function createTab(tab, canDelete) {
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
button.addEventListener("dblclick", () => renameTab(tab));
if (canDelete) {
if (canDelete || tab.saved) {
const remove = document.createElement("span");
remove.className = "tab-delete";
remove.textContent = "×";
remove.title = t("removePlaylist");
remove.title = t(tab.saved ? "closePlaylist" : "removePlaylist");
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
@@ -414,7 +430,7 @@ function createTab(tab, canDelete) {
// Rebuilds changed tabs and marks the server-selected tab as active.
function renderTabs(nextState) {
const signature = nextState.tabs
.map((tab) => `${tab.index}:${tab.name}:${tab.count}`)
.map((tab) => `${tab.index}:${tab.name}:${tab.count}:${tab.saved}`)
.join("|");
if (elements.tabs.dataset.signature !== signature) {
const canDelete = nextState.tabs.length > 1;
@@ -510,17 +526,19 @@ function createPlaylistRow(track) {
// Rebuilds changed tracks and updates the active row and playlist summary.
function renderPlaylist(nextState) {
const signature = nextState.tracks
.map((track) => `${track.index}:${track.title}:${track.artist}:${track.album}:${track.duration}`)
.join("|");
const signature = nextState.playlistVersion;
if (elements.playlist.dataset.signature !== signature) {
const rows = nextState.tracks.map(createPlaylistRow);
elements.playlist.replaceChildren(...rows);
elements.playlist.dataset.signature = signature;
}
for (const row of elements.playlist.children) {
row.classList.toggle("current", Number(row.dataset.index) === nextState.currentIndex);
const previousRow = elements.playlist.querySelector(".current");
const currentRow = Number.isInteger(nextState.currentIndex)
? elements.playlist.children[nextState.currentIndex] : null;
if (previousRow !== currentRow) {
previousRow?.classList.remove("current");
currentRow?.classList.add("current");
}
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
@@ -528,6 +546,8 @@ function renderPlaylist(nextState) {
const tab = nextState.tabs[nextState.currentTab];
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`;
elements.playlistClear.disabled = nextState.tracks.length === 0;
elements.playlistSave.disabled = tab.saved;
elements.playlistSave.textContent = t(tab.saved ? "playlistSaved" : "savePlaylist");
}
///////////////////////////////////////////////////////////////////////////////
@@ -645,6 +665,7 @@ function render(nextState) {
state = nextState;
renderSelectors(nextState);
renderBrowser(nextState);
playlistLibrary.render(nextState.savedPlaylists);
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
@@ -726,7 +747,7 @@ function handleTranslationChange() {
// Starts renderer discovery and renders the state returned by the server.
async function discoverRenderers() {
try {
render(await api("/api/discover", {}));
render(await playerState.request("/api/discover", {}));
} catch (error) {
setStatus(errorMessage(error));
}
@@ -768,14 +789,15 @@ function seek() {
command("seek", { percentage: Number(elements.seek.value) });
}
// Polls a complete state snapshot unless a browser command is still active.
// Polls status and changed tracks, without overlapping polls or busy commands.
async function refresh() {
if (commandBusy) {
if (commandBusy || refreshBusy) {
return;
}
refreshBusy = true;
try {
render(await api("/api/state"));
render(await playerState.request("/api/state"));
hideLogin();
} catch (error) {
if (error.code === "authentication-required") {
@@ -783,6 +805,8 @@ async function refresh() {
} else {
setStatus(t("noConnection", { message: errorMessage(error) }));
}
} finally {
refreshBusy = false;
}
}
@@ -859,6 +883,7 @@ elements.library.addEventListener("change", () => {
elements.libraryUp.addEventListener("click", () => command("up"));
elements.tabAdd.addEventListener("click", () => command("tab-add"));
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
elements.playlistSave.addEventListener("click", savePlaylist);
elements.loginForm.addEventListener("submit", login);
elements.logout.addEventListener("click", logout);
+18 -2
View File
@@ -56,7 +56,12 @@
<section class="workspace">
<aside class="left-pane">
<section class="library-pane panel">
<section id="library-pane" class="library-pane panel">
<div class="library-tabs" role="tablist" data-i18n-aria="musicLibrary" aria-label="Music library">
<button id="folders-tab" class="library-tab active" type="button" role="tab" aria-selected="true" aria-controls="folders-panel" data-i18n="folders">Folders</button>
<button id="saved-playlists-tab" class="library-tab" type="button" role="tab" aria-selected="false" aria-controls="saved-playlists-panel" tabindex="-1" data-i18n="playlists">Playlists</button>
</div>
<div id="folders-panel" class="library-folders" role="tabpanel" aria-labelledby="folders-tab">
<div class="panel-header library-header">
<div>
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
@@ -70,6 +75,14 @@
<code>[libraries] muziek=D:\Muziek</code>
</div>
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
</div>
<div id="saved-playlists-panel" class="library-saved" role="tabpanel" aria-labelledby="saved-playlists-tab" hidden>
<div id="saved-playlists-empty" class="empty-state">
<p data-i18n="noSavedPlaylists">No saved playlists yet.</p>
<span data-i18n="savePlaylistHint">Save a playlist tab to find it here.</span>
</div>
<ul id="saved-playlists" class="library-list" data-i18n-aria="playlists" aria-label="Playlists"></ul>
</div>
</section>
<section class="now-playing-pane panel">
@@ -99,8 +112,11 @@
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
<strong id="track-count">0 tracks</strong>
</div>
<div class="playlist-actions">
<button id="playlist-save" class="text-button" type="button" data-i18n="savePlaylist">SAVE PLAYLIST</button>
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
</div>
</div>
<div id="playlist-empty" class="empty-state playlist-empty">
<p data-i18n="emptyPlaylist">This playlist is empty.</p>
@@ -151,6 +167,6 @@
</section>
<script src="/translate.js" defer></script>
<script src="/app.js" defer></script>
<script src="/app.js" type="module"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Keeps the active playlist in memory between player-state requests.
* State, command and discovery requests share a queue so responses cannot
* overwrite newer state or reuse tracks from a different playlist version.
*/
export class PlayerStateClient {
#sendRequest;
#snapshot = null;
#pending = Promise.resolve();
// sendRequest performs the application's JSON GET/POST transport.
constructor(sendRequest) {
this.#sendRequest = sendRequest;
}
// Queues a state-producing request and returns a complete browser snapshot.
// A failed request does not prevent subsequent requests from being sent.
request(path, body) {
const result = this.#pending.then(() => this.#read(path, body));
this.#pending = result.catch(() => {});
return result;
}
// Sends the cached version and restores omitted tracks from that exact version.
async #read(path, body) {
if (this.#snapshot) {
path += `?playlistVersion=${encodeURIComponent(this.#snapshot.playlistVersion)}`;
}
const nextState = await this.#sendRequest(path, body);
if (nextState.tracks === null) {
if (!this.#snapshot || nextState.playlistVersion !== this.#snapshot.playlistVersion) {
this.#snapshot = null;
throw new Error("Playlist cache does not match the server version.");
}
nextState.tracks = this.#snapshot.tracks;
}
this.#snapshot = nextState;
return nextState;
}
}
+124
View File
@@ -0,0 +1,124 @@
/*
* Library view for the Folders and Playlists tabs. Saved playlists are server
* objects: actions address their UUID and reveal their existing playlist tab.
* Only the selected library view and highlighted row are local UI state.
*/
export class PlaylistLibrary {
#tabs;
#panels;
#list;
#empty;
#command;
#formatDuration;
#selectedId = null;
#signature = null;
// root owns both library panels; command sends server actions and renders state.
constructor(root, command, formatDuration) {
this.#tabs = [root.querySelector("#folders-tab"), root.querySelector("#saved-playlists-tab")];
this.#panels = [root.querySelector("#folders-panel"), root.querySelector("#saved-playlists-panel")];
this.#list = root.querySelector("#saved-playlists");
this.#empty = root.querySelector("#saved-playlists-empty");
this.#command = command;
this.#formatDuration = formatDuration;
this.#tabs.forEach((tab, index) => {
tab.addEventListener("click", () => this.show(index));
tab.addEventListener("keydown", (event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
this.show(next);
this.#tabs[next].focus();
}
});
});
}
// Switch the library panel without changing the selected playlist or playback.
show(index) {
this.#tabs.forEach((tab, current) => {
const active = current === index;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
this.#panels[current].hidden = !active;
});
}
// Refresh saved playlist summaries only when metadata or language changes.
render(playlists) {
const signature = JSON.stringify([window.RktTranslate.language(), playlists]);
if (signature === this.#signature) return;
if (!playlists.some((playlist) => playlist.id === this.#selectedId)) this.#selectedId = null;
this.#list.replaceChildren(...playlists.map((playlist) => this.#createEntry(playlist)));
this.#empty.hidden = playlists.length > 0;
this.#signature = signature;
this.#select(this.#selectedId);
}
#select(id) {
this.#selectedId = id;
for (const row of this.#list.children) {
const selected = row.dataset.id === id;
row.classList.toggle("selected", selected);
row.setAttribute("aria-current", selected ? "true" : "false");
}
}
// Each action reveals this playlist by UUID; repeated opening is idempotent.
#action(label, title, playlist, play) {
const button = document.createElement("button");
button.type = "button";
button.className = "entry-action";
button.textContent = label;
button.title = title;
button.setAttribute("aria-label", title);
button.disabled = play && playlist.count === 0;
button.addEventListener("click", (event) => {
event.stopPropagation();
this.#select(playlist.id);
this.#command(play ? "playlist-play" : "playlist-open", { id: playlist.id });
});
return button;
}
#createEntry(playlist) {
const { t } = window.RktTranslate;
const row = document.createElement("li");
row.className = "library-entry";
row.dataset.id = playlist.id;
row.tabIndex = 0;
const icon = document.createElement("span");
icon.className = "entry-icon";
icon.textContent = "♫";
const name = document.createElement("span");
name.className = "entry-name";
name.textContent = playlist.name;
name.title = playlist.name;
const details = document.createElement("small");
details.className = "entry-details";
details.textContent = `${playlist.count} ${t(playlist.count === 1 ? "oneTrack" : "manyTracks")}, ${this.#formatDuration(playlist.duration)}`;
name.append(details);
const actions = document.createElement("span");
actions.className = "entry-actions";
actions.append(
this.#action("▶", t("playNow", { name: playlist.name }), playlist, true),
this.#action("", t("openPlaylistNamed", { name: playlist.name }), playlist, false),
);
row.append(icon, name, actions);
row.addEventListener("click", () => this.#select(playlist.id));
row.addEventListener("dblclick", (event) => {
if (event.target.closest("button")) return;
this.#command("playlist-open", { id: playlist.id });
});
row.addEventListener("keydown", (event) => {
if (event.target !== row) return;
if (["Enter", "+", " "].includes(event.key)) {
event.preventDefault();
this.#select(playlist.id);
if (event.key !== " ") this.#command("playlist-open", { id: playlist.id });
}
});
return row;
}
}
+61 -2
View File
@@ -332,10 +332,61 @@ input[type="range"] {
.library-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
border-bottom: 0;
}
.library-tabs {
display: flex;
border-bottom: 1px solid var(--line-soft);
}
.library-tab {
flex: 1;
min-height: 36px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 12px;
}
.library-tab.active {
color: var(--accent);
box-shadow: inset 0 -2px var(--accent);
}
.library-folders {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
min-height: 0;
}
.library-saved {
display: grid;
min-height: 0;
}
.library-folders[hidden],
.library-saved[hidden] {
display: none;
}
.library-saved > .library-list,
.library-saved > .empty-state {
grid-area: 1 / 1;
}
.library-entry.selected {
background: var(--panel-raised);
}
.entry-details {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 10px;
}
.panel-header {
min-height: 55px;
padding: 8px 10px;
@@ -601,6 +652,8 @@ input[type="range"] {
.playlist-toolbar {
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
min-height: 53px;
padding: 8px 12px;
border-bottom: 1px solid var(--line-soft);
@@ -611,6 +664,12 @@ input[type="range"] {
gap: 4px;
}
.playlist-toolbar > .playlist-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.playlist-toolbar strong {
font-size: 14px;
}
@@ -739,7 +798,7 @@ input[type="range"] {
align-self: center;
}
.library-pane > .empty-state {
.library-folders > .empty-state {
grid-row: 3;
z-index: 2;
}
+70
View File
@@ -3,6 +3,13 @@
const translations = {
en: {
folders: "Folders",
savePlaylist: "SAVE PLAYLIST",
playlistSaved: "SAVED",
noSavedPlaylists: "No saved playlists yet.",
savePlaylistHint: "Save a playlist tab to find it here.",
openPlaylistNamed: "Open {name} as a tab",
closePlaylist: "Close tab",
playingTime: "{duration} playing time",
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
rendererLocal: "LOCAL", defaultPlaylist: "Default",
@@ -34,6 +41,13 @@
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
},
nl: {
folders: "Mappen",
savePlaylist: "AFSPEELLIJST OPSLAAN",
playlistSaved: "OPGESLAGEN",
noSavedPlaylists: "Nog geen opgeslagen afspeellijsten.",
savePlaylistHint: "Sla een playlisttab op om hem hier terug te vinden.",
openPlaylistNamed: "{name} als tab openen",
closePlaylist: "Tab sluiten",
playingTime: "{duration} speeltijd",
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
@@ -65,6 +79,13 @@
"dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.",
},
de: {
folders: "Ordner",
savePlaylist: "WIEDERGABELISTE SPEICHERN",
playlistSaved: "GESPEICHERT",
noSavedPlaylists: "Noch keine gespeicherten Wiedergabelisten.",
savePlaylistHint: "Speichern Sie eine Wiedergabeliste, um sie hier zu finden.",
openPlaylistNamed: "{name} als Tab öffnen",
closePlaylist: "Tab schließen",
playingTime: "{duration} Spielzeit",
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
@@ -96,6 +117,13 @@
"dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.",
},
fr: {
folders: "Dossiers",
savePlaylist: "ENREGISTRER LA LISTE",
playlistSaved: "ENREGISTRÉE",
noSavedPlaylists: "Aucune liste de lecture enregistrée.",
savePlaylistHint: "Enregistrez une liste de lecture pour la retrouver ici.",
openPlaylistNamed: "Ouvrir {name} dans un onglet",
closePlaylist: "Fermer longlet",
playingTime: "{duration} de lecture",
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
@@ -127,6 +155,13 @@
"dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.",
},
es: {
folders: "Carpetas",
savePlaylist: "GUARDAR LISTA",
playlistSaved: "GUARDADA",
noSavedPlaylists: "Todavía no hay listas guardadas.",
savePlaylistHint: "Guarda una lista de reproducción para encontrarla aquí.",
openPlaylistNamed: "Abrir {name} en una pestaña",
closePlaylist: "Cerrar pestaña",
playingTime: "{duration} de reproducción",
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
@@ -158,6 +193,13 @@
"dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.",
},
it: {
folders: "Cartelle",
savePlaylist: "SALVA PLAYLIST",
playlistSaved: "SALVATA",
noSavedPlaylists: "Nessuna playlist salvata.",
savePlaylistHint: "Salva una playlist per ritrovarla qui.",
openPlaylistNamed: "Apri {name} in una scheda",
closePlaylist: "Chiudi scheda",
playingTime: "{duration} di riproduzione",
output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI",
rendererLocal: "LOCALE", defaultPlaylist: "Predefinita",
@@ -189,6 +231,13 @@
"dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.",
},
sv: {
folders: "Mappar",
savePlaylist: "SPARA SPELLISTA",
playlistSaved: "SPARAD",
noSavedPlaylists: "Inga sparade spellistor ännu.",
savePlaylistHint: "Spara en spellista för att hitta den här.",
openPlaylistNamed: "Öppna {name} som en flik",
closePlaylist: "Stäng fliken",
playingTime: "{duration} speltid",
output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
@@ -220,6 +269,13 @@
"dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.",
},
no: {
folders: "Mapper",
savePlaylist: "LAGRE SPILLELISTE",
playlistSaved: "LAGRET",
noSavedPlaylists: "Ingen lagrede spillelister ennå.",
savePlaylistHint: "Lagre en spilleliste for å finne den her.",
openPlaylistNamed: "Åpne {name} som en fane",
closePlaylist: "Lukk fanen",
playingTime: "{duration} spilletid",
output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
@@ -251,6 +307,13 @@
"dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.",
},
fi: {
folders: "Kansiot",
savePlaylist: "TALLENNA SOITTOLISTA",
playlistSaved: "TALLENNETTU",
noSavedPlaylists: "Ei vielä tallennettuja soittolistoja.",
savePlaylistHint: "Tallenna soittolista, niin löydät sen täältä.",
openPlaylistNamed: "Avaa {name} välilehtenä",
closePlaylist: "Sulje välilehti",
playingTime: "Toistoaika: {duration}",
output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS",
rendererLocal: "PAIKALLINEN", defaultPlaylist: "Oletus",
@@ -282,6 +345,13 @@
"dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.",
},
is: {
folders: "Möppur",
savePlaylist: "VISTA SPILUNARLISTA",
playlistSaved: "VISTAÐ",
noSavedPlaylists: "Engir vistaðir spilunarlistar enn.",
savePlaylistHint: "Vistaðu spilunarlista til að finna hann hér.",
openPlaylistNamed: "Opna {name} í flipa",
closePlaylist: "Loka flipa",
playingTime: "Spilunartími: {duration}",
output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT",
rendererLocal: "STAÐBUNDIÐ", defaultPlaylist: "Sjálfgefið",
+51 -2
View File
@@ -50,13 +50,62 @@ The central server is the heart of the application. Its main features are:
allowlist for playback agents.}
]
The browser is a thin remote control, not a second player. It polls a complete
JSON state snapshot once per second and sends commands to the central server;
The browser is a thin remote control, not a second player. It polls player
status once per second and sends commands to the central server;
playlists, preferences and playback state are kept there. The server exposes
the browser application under the root URL and its JSON API under @tt{/api}.
The API includes state, discovery, playback commands, preferences, artwork and
the agent registration, polling and media routes.
@subsection{Playlist synchronization}
State responses include an opaque @tt{playlistVersion} for the selected
playlist. The browser sends its last received version as the
@tt{playlistVersion} query parameter on @tt{/api/state}, @tt{/api/discover}
and @tt{/api/command/}@italic{command}. When that version still matches,
@tt{tracks} is JSON @tt{null}; otherwise the response includes the complete
track array. Requests without a version continue to receive all tracks.
The version and track data are returned together in one consistent snapshot.
The server caches serialized tracks, count and total duration per playlist.
Changing the track list invalidates that snapshot and generates a fresh version
when it is next requested. Renaming a tab or updating playback position does
not invalidate the tracks. Versions are renewed after a server restart and
are distinct between playlists and users. Changes from another browser are
picked up by the next poll.
The browser keeps the active playlist in memory and serializes state-producing
requests through @tt{PlayerStateClient}, so a delayed response cannot replace
newer state. Unchanged playlist rows are reused; changing the interface language
still rebuilds their translated labels.
@subsection{Saved playlists in the library}
The library has @italic{Folders} and @italic{Playlists} tabs. The playlist
toolbar's @italic{Save playlist} button names and saves the current tab in
the user's library. The library row's @tt{} button opens that playlist as
its own tab, or selects its existing tab. @tt{▶} does the same and starts
playback. These actions never append tracks to or replace another tab.
A saved playlist and its open tab share one UUID and one track list. Track
edits and renaming therefore update the saved playlist automatically. Closing
the tab keeps it in the library. Closing the last saved tab leaves a new empty
default tab. Existing tabs remain open and appear in the library only after
the user explicitly saves them.
The keystore retains @tt{playlists-for-}@italic{username} for the ordered open
tabs and adds @tt{saved-playlists-for-}@italic{username} for library playlists.
Each UUID has one stored value, shared by both indexes. The private storage
procedure @tt{load-user-playlists} reads the open index by default and the saved
index with @tt{#:saved? #t}. @tt{save-user-playlists!} accepts the saved collection
through @tt{#:saved} and updates both indexes atomically. Values absent from
both indexes are removed. Legacy stores without a saved index need no migration.
Browser state includes @tt{savedPlaylists} summaries without track arrays and
a @tt{saved} flag on each tab. @tt{playlist-save} takes an open tab's @tt{id}
and a @tt{name}; @tt{playlist-open} and @tt{playlist-play} take a saved playlist's
@tt{id}. All three commands operate within the requesting user's collection.
@subsection{Player agents}
A player agent is an additional renderer implemented by this package. It runs
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { PlayerStateClient } from "../public/player-state.js";
test("polls reuse tracks and accept edits from another browser", async () => {
const tracks = [{ title: "First" }];
const changedTracks = [{ title: "Second" }];
const responses = [
{ playlistVersion: "first", tracks, position: 0 },
{ playlistVersion: "first", tracks: null, position: 1 },
{ playlistVersion: "changed", tracks: changedTracks, position: 2 },
{ playlistVersion: "changed", tracks: null, position: 3 },
];
const paths = [];
const client = new PlayerStateClient(async (path) => {
paths.push(path);
return responses.shift();
});
assert.equal((await client.request("/api/state")).tracks, tracks);
const unchanged = await client.request("/api/state");
assert.equal(unchanged.tracks, tracks);
assert.equal(unchanged.position, 1);
assert.equal((await client.request("/api/state")).tracks, changedTracks);
assert.equal((await client.request("/api/state")).tracks, changedTracks);
assert.deepEqual(paths, [
"/api/state", "/api/state?playlistVersion=first",
"/api/state?playlistVersion=first", "/api/state?playlistVersion=changed",
]);
});
test("commands and discovery wait for polling and use its latest version", async () => {
let finishPoll;
const paths = [];
const commandBody = { index: 1 };
const client = new PlayerStateClient((path, body) => {
paths.push(path);
if (paths.length === 1) {
return new Promise((resolve) => { finishPoll = resolve; });
}
if (paths.length === 2) {
assert.equal(body, commandBody);
return { playlistVersion: "other-tab", tracks: [] };
}
return { playlistVersion: "other-tab", tracks: null };
});
const poll = client.request("/api/state");
const command = client.request("/api/command/tab-select", commandBody);
const discovery = client.request("/api/discover", {});
await Promise.resolve();
assert.deepEqual(paths, ["/api/state"]);
finishPoll({ playlistVersion: "original-tab", tracks: [{ title: "First" }] });
await poll;
assert.deepEqual((await command).tracks, []);
assert.deepEqual((await discovery).tracks, []);
assert.deepEqual(paths, [
"/api/state",
"/api/command/tab-select?playlistVersion=original-tab",
"/api/discover?playlistVersion=other-tab",
]);
});
test("a failed request leaves the queue usable", async () => {
let calls = 0;
const client = new PlayerStateClient(async () => {
if (++calls === 1) throw new Error("Offline");
return { playlistVersion: "recovered", tracks: [] };
});
await assert.rejects(client.request("/api/state"), /Offline/);
assert.deepEqual((await client.request("/api/state")).tracks, []);
});
test("mismatched omitted tracks force a full refresh instead of stale reuse", async () => {
const paths = [];
const responses = [
{ playlistVersion: "before", tracks: [{ title: "Before" }] },
{ playlistVersion: "after", tracks: null },
{ playlistVersion: "after", tracks: [] },
];
const client = new PlayerStateClient(async (path) => {
paths.push(path);
return responses.shift();
});
await client.request("/api/state");
await assert.rejects(client.request("/api/state"), /Playlist cache/);
assert.deepEqual((await client.request("/api/state")).tracks, []);
assert.equal(paths[2], "/api/state");
});
+96
View File
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { PlaylistLibrary } from "../public/playlist-library.js";
// Minimal DOM elements let the view's actions run without a browser dependency.
class Element {
constructor() {
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.classes = new Set();
this.classList = { toggle: (name, active) => active ? this.classes.add(name) : this.classes.delete(name) };
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, callback) { this.listeners[name] = callback; }
fire(name, values = {}) { this.listeners[name]({ target: this, stopPropagation() {}, preventDefault() {}, ...values }); }
focus() {}
closest() { return null; }
}
class Fixture {
constructor() {
this.nodes = new Map();
this.commands = [];
this.language = "en";
globalThis.window = { RktTranslate: {
language: () => this.language,
t: (key, values = {}) => `${key} ${values.name || ""}`,
} };
globalThis.document = { createElement: () => new Element() };
this.view = new PlaylistLibrary({ querySelector: (selector) => this.node(selector) },
(name, data) => this.commands.push({ name, data }), duration => `${duration} seconds`);
}
node(selector) {
if (!this.nodes.has(selector)) this.nodes.set(selector, new Element());
return this.nodes.get(selector);
}
}
test("library tabs change only the visible panel", () => {
const fixture = new Fixture();
fixture.node("#saved-playlists-tab").fire("click");
assert.equal(fixture.node("#folders-panel").hidden, true);
assert.equal(fixture.node("#saved-playlists-panel").hidden, false);
assert.equal(fixture.node("#saved-playlists-tab").attributes["aria-selected"], "true");
fixture.node("#saved-playlists-tab").fire("keydown", { key: "ArrowLeft" });
assert.equal(fixture.node("#folders-panel").hidden, false);
assert.deepEqual(fixture.commands, []);
});
test("plus and play address the saved playlist UUID and never use item-add/play", () => {
const fixture = new Fixture();
fixture.view.render([{ id: "saved-id", name: "Music", count: 2, duration: 120 }]);
const row = fixture.node("#saved-playlists").children[0];
row.fire("click");
assert.deepEqual(fixture.commands, []);
assert.ok(row.classes.has("selected"));
row.children[2].children[1].fire("click");
row.children[2].children[0].fire("click");
assert.deepEqual(fixture.commands, [
{ name: "playlist-open", data: { id: "saved-id" } },
{ name: "playlist-play", data: { id: "saved-id" } },
]);
assert.equal(fixture.node("#saved-playlists-empty").hidden, true);
});
test("unchanged summaries reuse rows while metadata and language changes refresh them", () => {
const fixture = new Fixture();
const playlists = [{ id: "one", name: "Music", count: 0, duration: 0 }];
fixture.view.render(playlists);
const row = fixture.node("#saved-playlists").children[0];
assert.equal(row.children[2].children[0].disabled, true);
assert.equal(row.children[2].children[1].disabled, false);
fixture.view.render(playlists);
assert.equal(fixture.node("#saved-playlists").children[0], row);
fixture.language = "nl";
fixture.view.render(playlists);
assert.notEqual(fixture.node("#saved-playlists").children[0], row);
fixture.view.render([{ ...playlists[0], name: "Renamed" }]);
assert.equal(fixture.node("#saved-playlists").children[0].children[1].textContent, "Renamed");
fixture.view.render([]);
assert.equal(fixture.node("#saved-playlists-empty").hidden, false);
});
test("keyboard opening does not intercept a nested action button", () => {
const fixture = new Fixture();
fixture.view.render([{ id: "one", name: "Music", count: 1, duration: 60 }]);
const row = fixture.node("#saved-playlists").children[0];
row.fire("keydown", { key: "Enter", target: row.children[2].children[0] });
assert.deepEqual(fixture.commands, []);
row.fire("keydown", { key: "Enter" });
assert.deepEqual(fixture.commands, [{ name: "playlist-open", data: { id: "one" } }]);
});