ok
This commit is contained in:
+34
-7
@@ -139,7 +139,14 @@ volume, and repeat mode.
|
|||||||
Selecting, deleting, or creating a tab stops playback. Tracks are de-duplicated
|
Selecting, deleting, or creating a tab stops playback. Tracks are de-duplicated
|
||||||
by normalized source path when they are appended. Every playlist mutation is
|
by normalized source path when they are appended. Every playlist mutation is
|
||||||
written in one `keystore` transaction. `playlists-for-<username>` contains the
|
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
|
Loading validates every stored track independently against all configured
|
||||||
library roots, so one playlist can safely combine multiple libraries.
|
library roots, so one playlist can safely combine multiple libraries.
|
||||||
`language-for-<username>` stores the user's selected interface language in the
|
`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 |
|
| 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/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. |
|
| `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. |
|
| `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:
|
[`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;
|
- temporarily suppresses polling while a browser-initiated command is active;
|
||||||
- immediately renders the state returned by successful commands;
|
- immediately renders the state returned by successful commands;
|
||||||
- renders library navigation, playlists, tabs, transport status, and output
|
- renders library navigation, playlists, tabs, transport status, and output
|
||||||
selection;
|
selection;
|
||||||
- implements keyboard actions and playlist drag-and-drop in the browser.
|
- 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
|
[`public/translate.js`](public/translate.js) follows the key-based translation
|
||||||
model used by rktplayer. It supports Dutch, English, German, French, Spanish,
|
model used by rktplayer. It supports Dutch, English, German, French, Spanish,
|
||||||
Italian, Swedish, Norwegian, Finnish and Icelandic with English fallback.
|
Italian, Swedish, Norwegian, Finnish and Icelandic with English fallback.
|
||||||
@@ -314,9 +340,9 @@ sequenceDiagram
|
|||||||
participant D as DLNA renderer
|
participant D as DLNA renderer
|
||||||
|
|
||||||
loop Browser state polling
|
loop Browser state polling
|
||||||
B->>H: GET /api/state
|
B->>H: GET /api/state?playlistVersion=known-version
|
||||||
H->>P: player-state->jsexpr
|
H->>P: player-state->jsexpr
|
||||||
P-->>H: Full state snapshot
|
P-->>H: State and version; tracks only if changed
|
||||||
H-->>B: JSON response
|
H-->>B: JSON response
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -429,8 +455,9 @@ The main extension points are:
|
|||||||
- **Per-user pipelines, shared outputs:** playlist and transport state are
|
- **Per-user pipelines, shared outputs:** playlist and transport state are
|
||||||
isolated by username. Distinct outputs run concurrently; selecting an
|
isolated by username. Distinct outputs run concurrently; selecting an
|
||||||
occupied output explicitly stops its previous owner and transfers it.
|
occupied output explicitly stops its previous owner and transfers it.
|
||||||
- **Full-state snapshots:** a small and predictable client protocol, at the cost
|
- **Versioned playlists:** cached tracks are transferred only when the selected
|
||||||
of repeatedly transferring all tracks and browser entries.
|
playlist version changes. Other status fields, including browser entries,
|
||||||
|
remain part of each response.
|
||||||
- **One-second polling:** robust and dependency-free, but introduces periodic
|
- **One-second polling:** robust and dependency-free, but introduces periodic
|
||||||
traffic and up to one second of display latency.
|
traffic and up to one second of display latency.
|
||||||
- **Lazy filesystem and backend initialization:** fast startup and low idle
|
- **Lazy filesystem and backend initialization:** fast startup and low idle
|
||||||
|
|||||||
@@ -100,13 +100,24 @@ betrouwbare next-ondersteuning krijgen na het natuurlijke trackeinde een
|
|||||||
servergestuurde fallback. Een expliciet stopcommando start nooit de volgende
|
servergestuurde fallback. Een expliciet stopcommando start nooit de volgende
|
||||||
track.
|
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
|
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
|
drag-and-drop verplaatst. Tabnamen, tabvolgorde en alle tracklijsten worden na
|
||||||
iedere wijziging transactioneel opgeslagen. Een verwijderde
|
iedere wijziging transactioneel opgeslagen. Alleen een gesloten tab die niet
|
||||||
tab verdwijnt daarbij ook uit de keystore. Voor iedere gebruiker bevat de key
|
in de bibliotheek is bewaard, verdwijnt ook uit de keystore. Voor iedere gebruiker
|
||||||
`playlists-for-<username>` de geordende lijst met playlist-GUIDs. Onder iedere
|
bevat `playlists-for-<username>` de geordende lijst met geopende playlist-GUIDs,
|
||||||
GUID-key staan de naam en tracks van die playlist. Tracks uit verschillende
|
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
|
geconfigureerde libraries mogen in dezelfde playlist staan; ontbrekende of
|
||||||
buiten de libraries gelegen bestanden worden bij het laden overgeslagen.
|
buiten de libraries gelegen bestanden worden bij het laden overgeslagen.
|
||||||
Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als
|
Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als
|
||||||
@@ -242,6 +253,8 @@ geen TLS heeft.
|
|||||||
|
|
||||||
```console
|
```console
|
||||||
raco test private/users.rkt private/library.rkt private/player.rkt \
|
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
|
raco setup --check-pkg-deps rkt-web-player
|
||||||
```
|
```
|
||||||
|
|||||||
+301
-59
@@ -50,11 +50,13 @@
|
|||||||
#:transparent)
|
#:transparent)
|
||||||
|
|
||||||
(struct playlist-tab
|
(struct playlist-tab
|
||||||
(id [name #:mutable] [tracks #:mutable])
|
(id [name #:mutable] [tracks #:mutable] [snapshot #:auto #:mutable])
|
||||||
|
#:auto-value #f
|
||||||
#:transparent)
|
#:transparent)
|
||||||
|
|
||||||
(struct playlist-context
|
(struct playlist-context
|
||||||
([tabs #:mutable] [current-index #:mutable])
|
([tabs #:mutable] [current-index #:mutable] [saved #:auto #:mutable])
|
||||||
|
#:auto-value '()
|
||||||
#:transparent)
|
#:transparent)
|
||||||
|
|
||||||
(struct playback-session
|
(struct playback-session
|
||||||
@@ -260,36 +262,45 @@
|
|||||||
(list-ref (player-tabs value)
|
(list-ref (player-tabs value)
|
||||||
(player-current-tab-index value)))
|
(player-current-tab-index value)))
|
||||||
|
|
||||||
|
;;; Invalidate the browser snapshot only when the immutable track list changes.
|
||||||
(define (save-current-tab! value)
|
(define (save-current-tab! value)
|
||||||
(set-playlist-tab-tracks!
|
(let ((tab (current-tab value))
|
||||||
(current-tab value)
|
(tracks (player-tracks value)))
|
||||||
(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)
|
(define (normal-playlist-username username)
|
||||||
(let ((value (and (string? username)
|
(let ((value (and (string? username)
|
||||||
(string-downcase (string-trim username)))))
|
(string-downcase (string-trim username)))))
|
||||||
(if (and value (not (string=? value ""))) value "anonymous")))
|
(if (and value (not (string=? value ""))) value "anonymous")))
|
||||||
|
|
||||||
(define (new-playlist-context value username)
|
;;; Restore open tabs and the library using the same object for a shared UUID.
|
||||||
(define stored
|
;;; Old stores have no saved index, so their tabs remain open and unsaved.
|
||||||
(load-user-playlists (player-playlist-store value)
|
(define (restore-playlist-context store username libraries)
|
||||||
username
|
(let* ((restore (λ (tab) (playlist-tab (persisted-tab-id tab)
|
||||||
(player-libraries value)))
|
(persisted-tab-name tab)
|
||||||
(playlist-context
|
(persisted-tab-tracks tab))))
|
||||||
(if (pair? stored)
|
(opened (map restore (load-user-playlists store username libraries)))
|
||||||
(for/list ((tab (in-list stored)))
|
(tabs (if (pair? opened) opened
|
||||||
(playlist-tab (persisted-tab-id tab)
|
(list (playlist-tab (uuid-string) "Default" '()))))
|
||||||
(persisted-tab-name tab)
|
(context (playlist-context tabs 0)))
|
||||||
(persisted-tab-tracks tab)))
|
(set-playlist-context-saved!
|
||||||
(list (playlist-tab (uuid-string) "Default" '())))
|
context
|
||||||
0))
|
(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 (playlist-context-for! value username)
|
||||||
(define normalized (normal-playlist-username username))
|
(define normalized (normal-playlist-username username))
|
||||||
(hash-ref!
|
(hash-ref!
|
||||||
(player-playlist-contexts value)
|
(player-playlist-contexts value)
|
||||||
normalized
|
normalized
|
||||||
(λ () (new-playlist-context value normalized))))
|
(λ () (restore-playlist-context (player-playlist-store value)
|
||||||
|
normalized (player-libraries value)))))
|
||||||
|
|
||||||
(define (context-tracks context)
|
(define (context-tracks context)
|
||||||
(playlist-tab-tracks
|
(playlist-tab-tracks
|
||||||
@@ -361,7 +372,12 @@
|
|||||||
(for/list ((tab (in-list (player-tabs value))))
|
(for/list ((tab (in-list (player-tabs value))))
|
||||||
(persisted-tab (playlist-tab-id tab)
|
(persisted-tab (playlist-tab-id tab)
|
||||||
(playlist-tab-name 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)
|
(define (normalize-state state)
|
||||||
(cond
|
(cond
|
||||||
@@ -927,25 +943,28 @@
|
|||||||
trimmed)
|
trimmed)
|
||||||
(persist-playlists! value)))))
|
(persist-playlists! value)))))
|
||||||
|
|
||||||
|
;;; Close a tab, retaining saved playlists in the user's library.
|
||||||
(define (delete-tab! value session index)
|
(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)
|
(unless (and (exact-nonnegative-integer? index)
|
||||||
(< index (length (player-tabs value))))
|
(< index (length (player-tabs value))))
|
||||||
(raise-arguments-error
|
(raise-arguments-error
|
||||||
'player-command!
|
'player-command!
|
||||||
"playlist tab does not exist"
|
"playlist tab does not exist"
|
||||||
"index" index))
|
"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)
|
(stop-playback! value session)
|
||||||
(with-state-lock
|
(with-state-lock
|
||||||
value
|
value
|
||||||
(λ ()
|
(λ ()
|
||||||
(save-current-tab! value)
|
(save-current-tab! value)
|
||||||
(let* ((tabs
|
(let* ((remaining
|
||||||
(append (take (player-tabs value) index)
|
(append (take (player-tabs value) index)
|
||||||
(drop (player-tabs value) (+ index 1))))
|
(drop (player-tabs value) (+ index 1))))
|
||||||
|
(tabs (if (pair? remaining) remaining
|
||||||
|
(list (playlist-tab (uuid-string) "Default" '()))))
|
||||||
(new-index
|
(new-index
|
||||||
(min (player-current-tab-index value)
|
(min (player-current-tab-index value)
|
||||||
(- (length tabs) 1))))
|
(- (length tabs) 1))))
|
||||||
@@ -958,6 +977,44 @@
|
|||||||
(set-playback-session-current-index! session #f)
|
(set-playback-session-current-index! session #f)
|
||||||
(persist-playlists! value)))))
|
(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)
|
(define (track->jsexpr item index)
|
||||||
(hasheq 'index index
|
(hasheq 'index index
|
||||||
'title (track-title item)
|
'title (track-title item)
|
||||||
@@ -984,14 +1041,29 @@
|
|||||||
'name (browser-entry-name entry)
|
'name (browser-entry-name entry)
|
||||||
'kind (symbol->string (browser-entry-kind entry))))
|
'kind (symbol->string (browser-entry-kind entry))))
|
||||||
|
|
||||||
;;; Include the sum of known track durations in each tab's browser summary.
|
;;; Cache serialized tracks and their summary until save-current-tab! invalidates
|
||||||
(define (tab->jsexpr tab index)
|
;;; them. A fresh opaque version also prevents cache reuse across server restarts.
|
||||||
(hasheq 'index index
|
(define (tab-snapshot tab)
|
||||||
'id (playlist-tab-id tab)
|
(or (playlist-tab-snapshot tab)
|
||||||
'name (playlist-tab-name tab)
|
(let* ((tracks (playlist-tab-tracks tab))
|
||||||
'count (length (playlist-tab-tracks tab))
|
(snapshot
|
||||||
'duration (apply + (map (λ (item) (or (track-duration item) 0))
|
(hasheq 'version (uuid-string)
|
||||||
(playlist-tab-tracks tab)))))
|
'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)
|
||||||
|
'saved saved?
|
||||||
|
'count (hash-ref snapshot 'count)
|
||||||
|
'duration (hash-ref snapshot 'duration))))
|
||||||
|
|
||||||
(define (normal-device-id device)
|
(define (normal-device-id device)
|
||||||
(let ((id (upnp-device-udn device)))
|
(let ((id (upnp-device-udn device)))
|
||||||
@@ -1121,7 +1193,12 @@
|
|||||||
(rename-tab! value index
|
(rename-tab! value index
|
||||||
(or (json-string data 'name #f) "")))
|
(or (json-string data 'name #f) "")))
|
||||||
((string=? command "tab-delete")
|
((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)
|
(define (perform-command! value session command data)
|
||||||
(cond
|
(cond
|
||||||
@@ -1131,7 +1208,7 @@
|
|||||||
((member command '("track-remove" "track-move"
|
((member command '("track-remove" "track-move"
|
||||||
"playlist-clear" "tab-add"
|
"playlist-clear" "tab-add"
|
||||||
"tab-select" "tab-rename"
|
"tab-select" "tab-rename"
|
||||||
"tab-delete"))
|
"tab-delete" "playlist-save" "playlist-open" "playlist-play"))
|
||||||
(perform-playlist-command! value session command data))
|
(perform-playlist-command! value session command data))
|
||||||
((string=? command "play")
|
((string=? command "play")
|
||||||
(play-index!
|
(play-index!
|
||||||
@@ -1278,23 +1355,15 @@
|
|||||||
app-id))
|
app-id))
|
||||||
(string-downcase app-id)))
|
(string-downcase app-id)))
|
||||||
(define store (open-playlist-store playlist-keystore))
|
(define store (open-playlist-store playlist-keystore))
|
||||||
(define stored-tabs
|
|
||||||
(load-user-playlists store "anonymous" libraries))
|
|
||||||
(let* ((library (and (pair? libraries) (car libraries)))
|
(let* ((library (and (pair? libraries) (car libraries)))
|
||||||
(browser-entries
|
(browser-entries
|
||||||
(if library
|
(if library
|
||||||
(browse-library library '())
|
(browse-library library '())
|
||||||
'()))
|
'()))
|
||||||
(tabs
|
(initial-context (restore-playlist-context store "anonymous" libraries))
|
||||||
(if (pair? stored-tabs)
|
(tabs (playlist-context-tabs initial-context))
|
||||||
(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" '()))))
|
|
||||||
(selected-index 0)
|
(selected-index 0)
|
||||||
(contexts (make-hash))
|
(contexts (make-hash)))
|
||||||
(initial-context (playlist-context tabs selected-index)))
|
|
||||||
(hash-set! contexts "anonymous" initial-context)
|
(hash-set! contexts "anonymous" initial-context)
|
||||||
(define value
|
(define value
|
||||||
(player libraries
|
(player libraries
|
||||||
@@ -1324,12 +1393,17 @@
|
|||||||
value))
|
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.
|
; pre : Value was created with make-player.
|
||||||
; post : Cached DLNA playback information has been incorporated.
|
; 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))
|
(define normalized (normal-playlist-username username))
|
||||||
(call-with-semaphore
|
(call-with-semaphore
|
||||||
(player-command-lock value)
|
(player-command-lock value)
|
||||||
@@ -1340,7 +1414,7 @@
|
|||||||
(define context (playlist-context-for! value normalized))
|
(define context (playlist-context-for! value normalized))
|
||||||
(define tabs (playlist-context-tabs context))
|
(define tabs (playlist-context-tabs context))
|
||||||
(define tab-index (playlist-context-current-index 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
|
(with-state-lock
|
||||||
value
|
value
|
||||||
(λ ()
|
(λ ()
|
||||||
@@ -1366,12 +1440,17 @@
|
|||||||
'tabs
|
'tabs
|
||||||
(for/list ((tab (in-list tabs))
|
(for/list ((tab (in-list tabs))
|
||||||
(index (in-naturals)))
|
(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
|
'currentTab tab-index
|
||||||
|
'playlistVersion (hash-ref snapshot 'version)
|
||||||
'tracks
|
'tracks
|
||||||
(for/list ((item (in-list tracks))
|
(if (equal? playlist-version (hash-ref snapshot 'version))
|
||||||
(index (in-naturals)))
|
'null
|
||||||
(track->jsexpr item index))
|
(hash-ref snapshot 'tracks))
|
||||||
'renderers (map renderer->jsexpr
|
'renderers (map renderer->jsexpr
|
||||||
(player-renderers value))
|
(player-renderers value))
|
||||||
'rendererId (or (playback-session-selected-id session) 'null)
|
'rendererId (or (playback-session-selected-id session) 'null)
|
||||||
@@ -1401,14 +1480,16 @@
|
|||||||
; goal : Execute one browser player command.
|
; goal : Execute one browser player command.
|
||||||
; pre : Command is a string and data is a JSON object hash.
|
; pre : Command is a string and data is a JSON object hash.
|
||||||
; post : The command has completed or a concrete exception is raised.
|
; 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
|
(define playlist-context-commands
|
||||||
'("item-play" "item-add" "track-remove" "track-move"
|
'("item-play" "item-add" "track-remove" "track-move"
|
||||||
"playlist-clear" "tab-add" "tab-select" "tab-rename"
|
"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))
|
(define normalized (normal-playlist-username username))
|
||||||
(call-with-semaphore
|
(call-with-semaphore
|
||||||
(player-command-lock value)
|
(player-command-lock value)
|
||||||
@@ -1426,7 +1507,8 @@
|
|||||||
(when (member command playlist-context-commands)
|
(when (member command playlist-context-commands)
|
||||||
(activate-playlist-user! value normalized session))
|
(activate-playlist-user! value normalized session))
|
||||||
(perform-command! value session command data))))
|
(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.
|
; goal : Discover UPnP renderers and logical Sonos groups asynchronously.
|
||||||
@@ -1739,16 +1821,176 @@
|
|||||||
(λ ()
|
(λ ()
|
||||||
(set-player-closed?! value #t)))))))
|
(set-player-closed?! value #t)))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Tests for module player.rkt
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(module+ test
|
(module+ test
|
||||||
(require rackunit
|
(require json
|
||||||
|
rackunit
|
||||||
racket/file)
|
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 #f) 'null)
|
||||||
(check-equal?
|
(check-equal?
|
||||||
(error->jsexpr 'dlna-renderer-unreachable)
|
(error->jsexpr 'dlna-renderer-unreachable)
|
||||||
"dlna-renderer-unreachable")
|
"dlna-renderer-unreachable")
|
||||||
(check-equal? (error->jsexpr "technical error") "technical error")
|
(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
|
(define root
|
||||||
(make-temporary-file "rkt-web-player-~a" 'directory))
|
(make-temporary-file "rkt-web-player-~a" 'directory))
|
||||||
|
|
||||||
|
|||||||
+31
-20
@@ -30,9 +30,9 @@
|
|||||||
;; Supporting functions
|
;; Supporting functions
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
;;; Produces the keystore key containing one user's ordered playlist ids.
|
;;; Keep open tabs and saved library playlists in separate ordered UUID indexes.
|
||||||
(define (user-playlists-key username)
|
(define (user-playlists-key username [saved? #f])
|
||||||
(format "playlists-for-~a" username))
|
(format "~aplaylists-for-~a" (if saved? "saved-" "") username))
|
||||||
|
|
||||||
;;; Produces the keystore key containing one user's language preference.
|
;;; Produces the keystore key containing one user's language preference.
|
||||||
(define (user-language-key username)
|
(define (user-language-key username)
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
(void))
|
(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.
|
; pre : Store is #f or open, username is normalized, and libraries are valid.
|
||||||
; post : Store contents remain unchanged and unsafe track paths are omitted.
|
; post : Store contents remain unchanged and unsafe track paths are omitted.
|
||||||
; result : Valid persisted-tab values in their saved order.
|
; result : Valid persisted-tab values in their saved order.
|
||||||
@@ -144,17 +144,16 @@
|
|||||||
; validates each referenced tab and delegates track safety checks to
|
; validates each referenced tab and delegates track safety checks to
|
||||||
; datum->track.
|
; datum->track.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define/contract (load-user-playlists store username libraries)
|
(define/contract (load-user-playlists store username libraries #:saved? [saved? #f])
|
||||||
(-> (or/c keystore? #f)
|
(->* ((or/c keystore? #f) string? (listof music-library?))
|
||||||
string?
|
(#:saved? boolean?)
|
||||||
(listof music-library?)
|
(listof persisted-tab?))
|
||||||
(listof persisted-tab?))
|
|
||||||
(if (eq? store #f)
|
(if (eq? store #f)
|
||||||
'()
|
'()
|
||||||
(ks-with-lock
|
(ks-with-lock
|
||||||
store
|
store
|
||||||
(λ ()
|
(λ ()
|
||||||
(let ((ids (ks-get store (user-playlists-key username) '())))
|
(let ((ids (ks-get store (user-playlists-key username saved?) '())))
|
||||||
(if (list? ids)
|
(if (list? ids)
|
||||||
(filter-map
|
(filter-map
|
||||||
(λ (id)
|
(λ (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.
|
; 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.
|
; result : Void.
|
||||||
; internals: ks-with-lock prevents another operation from entering this update.
|
; internals: ks-with-lock prevents another operation from entering this update.
|
||||||
; ks-transaction removes stale ids from user-playlists-key, stores
|
; 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)
|
(define/contract (save-user-playlists! store username tabs #:saved [saved '()])
|
||||||
(-> (or/c keystore? #f) string? (listof persisted-tab?) void?)
|
(->* ((or/c keystore? #f) string? (listof persisted-tab?))
|
||||||
|
(#:saved (listof persisted-tab?))
|
||||||
|
void?)
|
||||||
(when store
|
(when store
|
||||||
(ks-with-lock
|
(ks-with-lock
|
||||||
store
|
store
|
||||||
(λ ()
|
(λ ()
|
||||||
(let* ((index-key (user-playlists-key username))
|
(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))
|
(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
|
(stale-ids
|
||||||
(filter
|
(filter
|
||||||
(λ (id)
|
(λ (id)
|
||||||
(and (string? id)
|
(and (string? id)
|
||||||
(not (member id ids string=?))))
|
(not (member id ids string=?))
|
||||||
(if (list? old-ids) old-ids '()))))
|
(not (member id saved-ids string=?))))
|
||||||
|
(remove-duplicates old-ids))))
|
||||||
(ks-transaction
|
(ks-transaction
|
||||||
store
|
store
|
||||||
(for-each (λ (id) (ks-drop! store id)) stale-ids)
|
(for-each (λ (id) (ks-drop! store id)) stale-ids)
|
||||||
@@ -197,8 +207,9 @@
|
|||||||
(hasheq 'name (persisted-tab-name tab)
|
(hasheq 'name (persisted-tab-name tab)
|
||||||
'tracks (map track->datum
|
'tracks (map track->datum
|
||||||
(persisted-tab-tracks tab)))))
|
(persisted-tab-tracks tab)))))
|
||||||
tabs)
|
all-tabs)
|
||||||
(ks-set! store index-key ids))
|
(ks-set! store index-key ids)
|
||||||
|
(ks-set! store saved-key saved-ids))
|
||||||
(void)))))
|
(void)))))
|
||||||
(void))
|
(void))
|
||||||
|
|
||||||
|
|||||||
+37
-4
@@ -101,12 +101,18 @@
|
|||||||
(define (request-username auth request)
|
(define (request-username auth request)
|
||||||
(or (auth-request-user auth request) "anonymous"))
|
(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)
|
(define (state-handler player auth request)
|
||||||
(json-response
|
(json-response
|
||||||
(player-state->jsexpr
|
(player-state->jsexpr
|
||||||
player
|
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.
|
;;; Starts renderer discovery and returns the updated player state.
|
||||||
(define (discover-handler player auth request)
|
(define (discover-handler player auth request)
|
||||||
@@ -114,7 +120,8 @@
|
|||||||
(json-response
|
(json-response
|
||||||
(player-state->jsexpr
|
(player-state->jsexpr
|
||||||
player
|
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.
|
;;; Applies one player command for the requesting user.
|
||||||
(define (command-handler player auth request command)
|
(define (command-handler player auth request command)
|
||||||
@@ -125,7 +132,8 @@
|
|||||||
player
|
player
|
||||||
command
|
command
|
||||||
(request-jsexpr request)
|
(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.
|
;;; Returns the persisted interface preferences for the requesting user.
|
||||||
(define (preferences-handler player auth request)
|
(define (preferences-handler player auth request)
|
||||||
@@ -405,6 +413,31 @@
|
|||||||
(check-equal?
|
(check-equal?
|
||||||
(request-jsexpr (test-request #"POST" "/api/preferences"))
|
(request-jsexpr (test-request #"POST" "/api/preferences"))
|
||||||
(hasheq))
|
(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?
|
(check-equal?
|
||||||
(request-jsexpr
|
(request-jsexpr
|
||||||
(test-request #"POST"
|
(test-request #"POST"
|
||||||
|
|||||||
+39
-14
@@ -1,7 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* Browser client for the web player.
|
* 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,
|
* render stores that snapshot and passes it to the selector, library, playlist,
|
||||||
* and playback renderers. Collection renderers retain a DOM signature so the
|
* and playback renderers. Collection renderers retain a DOM signature so the
|
||||||
* one-second polling loop only rebuilds lists whose contents changed.
|
* one-second polling loop only rebuilds lists whose contents changed.
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
* returned state has been rendered.
|
* returned state has been rendered.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { PlayerStateClient } from "./player-state.js";
|
||||||
|
import { PlaylistLibrary } from "./playlist-library.js";
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Browser elements and state
|
// Browser elements and state
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -44,6 +48,7 @@ const elements = {
|
|||||||
tabAdd: document.querySelector("#tab-add"),
|
tabAdd: document.querySelector("#tab-add"),
|
||||||
count: document.querySelector("#track-count"),
|
count: document.querySelector("#track-count"),
|
||||||
playlistClear: document.querySelector("#playlist-clear"),
|
playlistClear: document.querySelector("#playlist-clear"),
|
||||||
|
playlistSave: document.querySelector("#playlist-save"),
|
||||||
playlistEmpty: document.querySelector("#playlist-empty"),
|
playlistEmpty: document.querySelector("#playlist-empty"),
|
||||||
playlist: document.querySelector("#playlist"),
|
playlist: document.querySelector("#playlist"),
|
||||||
bits: document.querySelector("#audio-bits"),
|
bits: document.querySelector("#audio-bits"),
|
||||||
@@ -67,6 +72,9 @@ let state = null;
|
|||||||
let seekBusy = false;
|
let seekBusy = false;
|
||||||
let draggedTrack = null;
|
let draggedTrack = null;
|
||||||
let commandBusy = false;
|
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.
|
// Represents an unsuccessful API response, including its HTTP status and code.
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
@@ -193,7 +201,7 @@ async function command(name, data = {}, pendingMessage = "") {
|
|||||||
|
|
||||||
commandBusy = true;
|
commandBusy = true;
|
||||||
try {
|
try {
|
||||||
render(await api(`/api/command/${name}`, data));
|
render(await playerState.request(`/api/command/${name}`, data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(errorMessage(error));
|
setStatus(errorMessage(error));
|
||||||
} finally {
|
} 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.
|
// Builds one playlist tab, including its count and optional delete control.
|
||||||
function createTab(tab, canDelete) {
|
function createTab(tab, canDelete) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -396,11 +412,11 @@ function createTab(tab, canDelete) {
|
|||||||
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
|
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
|
||||||
button.addEventListener("dblclick", () => renameTab(tab));
|
button.addEventListener("dblclick", () => renameTab(tab));
|
||||||
|
|
||||||
if (canDelete) {
|
if (canDelete || tab.saved) {
|
||||||
const remove = document.createElement("span");
|
const remove = document.createElement("span");
|
||||||
remove.className = "tab-delete";
|
remove.className = "tab-delete";
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
remove.title = t("removePlaylist");
|
remove.title = t(tab.saved ? "closePlaylist" : "removePlaylist");
|
||||||
remove.addEventListener("click", (event) => {
|
remove.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
command("tab-delete", { index: tab.index });
|
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.
|
// Rebuilds changed tabs and marks the server-selected tab as active.
|
||||||
function renderTabs(nextState) {
|
function renderTabs(nextState) {
|
||||||
const signature = nextState.tabs
|
const signature = nextState.tabs
|
||||||
.map((tab) => `${tab.index}:${tab.name}:${tab.count}`)
|
.map((tab) => `${tab.index}:${tab.name}:${tab.count}:${tab.saved}`)
|
||||||
.join("|");
|
.join("|");
|
||||||
if (elements.tabs.dataset.signature !== signature) {
|
if (elements.tabs.dataset.signature !== signature) {
|
||||||
const canDelete = nextState.tabs.length > 1;
|
const canDelete = nextState.tabs.length > 1;
|
||||||
@@ -510,17 +526,19 @@ function createPlaylistRow(track) {
|
|||||||
|
|
||||||
// Rebuilds changed tracks and updates the active row and playlist summary.
|
// Rebuilds changed tracks and updates the active row and playlist summary.
|
||||||
function renderPlaylist(nextState) {
|
function renderPlaylist(nextState) {
|
||||||
const signature = nextState.tracks
|
const signature = nextState.playlistVersion;
|
||||||
.map((track) => `${track.index}:${track.title}:${track.artist}:${track.album}:${track.duration}`)
|
|
||||||
.join("|");
|
|
||||||
if (elements.playlist.dataset.signature !== signature) {
|
if (elements.playlist.dataset.signature !== signature) {
|
||||||
const rows = nextState.tracks.map(createPlaylistRow);
|
const rows = nextState.tracks.map(createPlaylistRow);
|
||||||
elements.playlist.replaceChildren(...rows);
|
elements.playlist.replaceChildren(...rows);
|
||||||
elements.playlist.dataset.signature = signature;
|
elements.playlist.dataset.signature = signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of elements.playlist.children) {
|
const previousRow = elements.playlist.querySelector(".current");
|
||||||
row.classList.toggle("current", Number(row.dataset.index) === nextState.currentIndex);
|
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;
|
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
|
||||||
@@ -528,6 +546,8 @@ function renderPlaylist(nextState) {
|
|||||||
const tab = nextState.tabs[nextState.currentTab];
|
const tab = nextState.tabs[nextState.currentTab];
|
||||||
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`;
|
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`;
|
||||||
elements.playlistClear.disabled = nextState.tracks.length === 0;
|
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;
|
state = nextState;
|
||||||
renderSelectors(nextState);
|
renderSelectors(nextState);
|
||||||
renderBrowser(nextState);
|
renderBrowser(nextState);
|
||||||
|
playlistLibrary.render(nextState.savedPlaylists);
|
||||||
renderTabs(nextState);
|
renderTabs(nextState);
|
||||||
renderPlaylist(nextState);
|
renderPlaylist(nextState);
|
||||||
renderPlayer(nextState);
|
renderPlayer(nextState);
|
||||||
@@ -726,7 +747,7 @@ function handleTranslationChange() {
|
|||||||
// Starts renderer discovery and renders the state returned by the server.
|
// Starts renderer discovery and renders the state returned by the server.
|
||||||
async function discoverRenderers() {
|
async function discoverRenderers() {
|
||||||
try {
|
try {
|
||||||
render(await api("/api/discover", {}));
|
render(await playerState.request("/api/discover", {}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(errorMessage(error));
|
setStatus(errorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -768,14 +789,15 @@ function seek() {
|
|||||||
command("seek", { percentage: Number(elements.seek.value) });
|
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() {
|
async function refresh() {
|
||||||
if (commandBusy) {
|
if (commandBusy || refreshBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshBusy = true;
|
||||||
try {
|
try {
|
||||||
render(await api("/api/state"));
|
render(await playerState.request("/api/state"));
|
||||||
hideLogin();
|
hideLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "authentication-required") {
|
if (error.code === "authentication-required") {
|
||||||
@@ -783,6 +805,8 @@ async function refresh() {
|
|||||||
} else {
|
} else {
|
||||||
setStatus(t("noConnection", { message: errorMessage(error) }));
|
setStatus(t("noConnection", { message: errorMessage(error) }));
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
refreshBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,6 +883,7 @@ elements.library.addEventListener("change", () => {
|
|||||||
elements.libraryUp.addEventListener("click", () => command("up"));
|
elements.libraryUp.addEventListener("click", () => command("up"));
|
||||||
elements.tabAdd.addEventListener("click", () => command("tab-add"));
|
elements.tabAdd.addEventListener("click", () => command("tab-add"));
|
||||||
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
|
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
|
||||||
|
elements.playlistSave.addEventListener("click", savePlaylist);
|
||||||
elements.loginForm.addEventListener("submit", login);
|
elements.loginForm.addEventListener("submit", login);
|
||||||
elements.logout.addEventListener("click", logout);
|
elements.logout.addEventListener("click", logout);
|
||||||
|
|
||||||
|
|||||||
+29
-13
@@ -56,20 +56,33 @@
|
|||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<aside class="left-pane">
|
<aside class="left-pane">
|
||||||
<section class="library-pane panel">
|
<section id="library-pane" class="library-pane panel">
|
||||||
<div class="panel-header library-header">
|
<div class="library-tabs" role="tablist" data-i18n-aria="musicLibrary" aria-label="Music library">
|
||||||
<div>
|
<button id="folders-tab" class="library-tab active" type="button" role="tab" aria-selected="true" aria-controls="folders-panel" data-i18n="folders">Folders</button>
|
||||||
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
|
<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>
|
||||||
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
|
</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>
|
||||||
|
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
|
||||||
|
</div>
|
||||||
|
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder">↑</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder">↑</button>
|
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
|
||||||
|
<div id="library-empty" class="empty-state" hidden>
|
||||||
|
<p data-i18n="noLibraryConfigured">No library configured.</p>
|
||||||
|
<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>
|
||||||
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
|
<div id="saved-playlists-panel" class="library-saved" role="tabpanel" aria-labelledby="saved-playlists-tab" hidden>
|
||||||
<div id="library-empty" class="empty-state" hidden>
|
<div id="saved-playlists-empty" class="empty-state">
|
||||||
<p data-i18n="noLibraryConfigured">No library configured.</p>
|
<p data-i18n="noSavedPlaylists">No saved playlists yet.</p>
|
||||||
<code>[libraries] muziek=D:\Muziek</code>
|
<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>
|
</div>
|
||||||
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="now-playing-pane panel">
|
<section class="now-playing-pane panel">
|
||||||
@@ -99,7 +112,10 @@
|
|||||||
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
|
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
|
||||||
<strong id="track-count">0 tracks</strong>
|
<strong id="track-count">0 tracks</strong>
|
||||||
</div>
|
</div>
|
||||||
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
|
<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>
|
||||||
|
|
||||||
<div id="playlist-empty" class="empty-state playlist-empty">
|
<div id="playlist-empty" class="empty-state playlist-empty">
|
||||||
@@ -151,6 +167,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="/translate.js" defer></script>
|
<script src="/translate.js" defer></script>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -332,10 +332,61 @@ input[type="range"] {
|
|||||||
|
|
||||||
.library-pane {
|
.library-pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
border-bottom: 0;
|
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 {
|
.panel-header {
|
||||||
min-height: 55px;
|
min-height: 55px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -601,6 +652,8 @@ input[type="range"] {
|
|||||||
|
|
||||||
.playlist-toolbar {
|
.playlist-toolbar {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
min-height: 53px;
|
min-height: 53px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid var(--line-soft);
|
border-bottom: 1px solid var(--line-soft);
|
||||||
@@ -611,6 +664,12 @@ input[type="range"] {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-toolbar > .playlist-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-toolbar strong {
|
.playlist-toolbar strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -739,7 +798,7 @@ input[type="range"] {
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-pane > .empty-state {
|
.library-folders > .empty-state {
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
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",
|
playingTime: "{duration} playing time",
|
||||||
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
|
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
|
||||||
rendererLocal: "LOCAL", defaultPlaylist: "Default",
|
rendererLocal: "LOCAL", defaultPlaylist: "Default",
|
||||||
@@ -34,6 +41,13 @@
|
|||||||
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
|
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
|
||||||
},
|
},
|
||||||
nl: {
|
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",
|
playingTime: "{duration} speeltijd",
|
||||||
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
|
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
|
||||||
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
|
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
|
||||||
@@ -65,6 +79,13 @@
|
|||||||
"dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.",
|
"dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.",
|
||||||
},
|
},
|
||||||
de: {
|
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",
|
playingTime: "{duration} Spielzeit",
|
||||||
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
|
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
|
||||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||||
@@ -96,6 +117,13 @@
|
|||||||
"dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.",
|
"dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.",
|
||||||
},
|
},
|
||||||
fr: {
|
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 l’onglet",
|
||||||
playingTime: "{duration} de lecture",
|
playingTime: "{duration} de lecture",
|
||||||
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
|
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
|
||||||
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
|
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
|
||||||
@@ -127,6 +155,13 @@
|
|||||||
"dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.",
|
"dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.",
|
||||||
},
|
},
|
||||||
es: {
|
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",
|
playingTime: "{duration} de reproducción",
|
||||||
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
|
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
|
||||||
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
|
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
|
||||||
@@ -158,6 +193,13 @@
|
|||||||
"dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.",
|
"dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.",
|
||||||
},
|
},
|
||||||
it: {
|
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",
|
playingTime: "{duration} di riproduzione",
|
||||||
output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI",
|
output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI",
|
||||||
rendererLocal: "LOCALE", defaultPlaylist: "Predefinita",
|
rendererLocal: "LOCALE", defaultPlaylist: "Predefinita",
|
||||||
@@ -189,6 +231,13 @@
|
|||||||
"dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.",
|
"dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.",
|
||||||
},
|
},
|
||||||
sv: {
|
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",
|
playingTime: "{duration} speltid",
|
||||||
output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT",
|
output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT",
|
||||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||||
@@ -220,6 +269,13 @@
|
|||||||
"dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.",
|
"dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.",
|
||||||
},
|
},
|
||||||
no: {
|
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",
|
playingTime: "{duration} spilletid",
|
||||||
output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT",
|
output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT",
|
||||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||||
@@ -251,6 +307,13 @@
|
|||||||
"dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.",
|
"dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.",
|
||||||
},
|
},
|
||||||
fi: {
|
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}",
|
playingTime: "Toistoaika: {duration}",
|
||||||
output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS",
|
output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS",
|
||||||
rendererLocal: "PAIKALLINEN", defaultPlaylist: "Oletus",
|
rendererLocal: "PAIKALLINEN", defaultPlaylist: "Oletus",
|
||||||
@@ -282,6 +345,13 @@
|
|||||||
"dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.",
|
"dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.",
|
||||||
},
|
},
|
||||||
is: {
|
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}",
|
playingTime: "Spilunartími: {duration}",
|
||||||
output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT",
|
output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT",
|
||||||
rendererLocal: "STAÐBUNDIÐ", defaultPlaylist: "Sjálfgefið",
|
rendererLocal: "STAÐBUNDIÐ", defaultPlaylist: "Sjálfgefið",
|
||||||
|
|||||||
@@ -50,13 +50,62 @@ The central server is the heart of the application. Its main features are:
|
|||||||
allowlist for playback agents.}
|
allowlist for playback agents.}
|
||||||
]
|
]
|
||||||
|
|
||||||
The browser is a thin remote control, not a second player. It polls a complete
|
The browser is a thin remote control, not a second player. It polls player
|
||||||
JSON state snapshot once per second and sends commands to the central server;
|
status once per second and sends commands to the central server;
|
||||||
playlists, preferences and playback state are kept there. The server exposes
|
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 browser application under the root URL and its JSON API under @tt{/api}.
|
||||||
The API includes state, discovery, playback commands, preferences, artwork and
|
The API includes state, discovery, playback commands, preferences, artwork and
|
||||||
the agent registration, polling and media routes.
|
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}
|
@subsection{Player agents}
|
||||||
|
|
||||||
A player agent is an additional renderer implemented by this package. It runs
|
A player agent is an additional renderer implemented by this package. It runs
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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" } }]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user