multi player / per user playing and translation

This commit is contained in:
2026-08-28 16:53:05 +02:00
parent 2743dfb4c0
commit e10f2cf7d7
17 changed files with 1432 additions and 482 deletions
+61 -37
View File
@@ -8,9 +8,10 @@ can play selected files either on the host machine or on a discovered UPnP or
Sonos renderer. Sonos renderer.
The application is designed as a personal, stateful desktop or LAN service. A The application is designed as a personal, stateful desktop or LAN service. A
single server-side player instance owns the active browser location, playlists, single server-side player instance owns shared libraries, discovery and output
playback state, output selection, and audio backend. All connected browser inventory. Each username owns its playlists, preferences and playback session.
windows observe and control that same instance. Different users can therefore play concurrently when they select different
physical outputs.
## 2. System context ## 2. System context
@@ -29,7 +30,7 @@ flowchart LR
The browser is a thin client: it renders a complete server-provided state The browser is a thin client: it renders a complete server-provided state
snapshot and sends commands back to the server. The server is authoritative; snapshot and sends commands back to the server. The server is authoritative;
there is no browser-side persistence or independent playback state. the browser stores neither playlists nor language preferences.
## 3. Runtime structure ## 3. Runtime structure
@@ -42,7 +43,7 @@ flowchart TB
Playlists[private/playlists.rkt<br/>durable playlist tabs] Playlists[private/playlists.rkt<br/>durable playlist tabs]
DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration] DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration]
Library[private/library.rkt<br/>filesystem and metadata] Library[private/library.rkt<br/>filesystem and metadata]
UI[public/index.html + styles.css + app.js<br/>browser UI] UI[public/index.html + styles.css + app.js + translate.js<br/>browser UI]
Audio[racket-audio<br/>local backend] Audio[racket-audio<br/>local backend]
Discovery[racket-upnp + racket-sonos<br/>device discovery] Discovery[racket-upnp + racket-sonos<br/>device discovery]
DLNA[racket-audio-dlna<br/>transport, seeking and media publication] DLNA[racket-audio-dlna<br/>transport, seeking and media publication]
@@ -120,11 +121,12 @@ The mutable `player` structure is the aggregate root for:
- configured libraries and the current browser location; - configured libraries and the current browser location;
- playlist tabs, the selected tab, and their durably stored tracks; - playlist tabs, the selected tab, and their durably stored tracks;
- discovered renderers and the selected renderer; - discovered renderers and per-renderer session ownership;
- registered HTTP playback agents and their pending command queues; - registered HTTP playback agents and their pending command queues;
- the lazily created local or network backend; - a playback session per username, containing its selected renderer, lazy
- transport state, current track, position, duration, audio properties, volume, backend, current track, transport state, audio properties, volume and repeat
and repeat mode; mode;
- one shared DLNA media-file server used by all network backends;
- current error, discovery, and shutdown status; - current error, discovery, and shutdown status;
- synchronization primitives and the DLNA publication port. - synchronization primitives and the DLNA publication port.
@@ -140,19 +142,29 @@ written in one `keystore` transaction. `playlists-for-<username>` contains the
ordered playlist GUIDs; each GUID key contains that playlist's name and tracks. ordered playlist GUIDs; each GUID key contains that playlist's name and tracks.
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
same keystore.
### 3.4 Playback backends ### 3.4 Playback backends
The player exposes local, UPnP, and Sonos outputs as renderers with a common The player exposes local, UPnP, and Sonos outputs as renderers with a common
logical interface, but dispatches explicitly based on backend kind. logical interface, but dispatches explicitly based on backend kind.
The default `local` renderer uses `racket-audio`. Its backend is created on the Each username has an independent playback pipeline. Separate outputs can be
first playback-related command, not at startup. State and end-of-track callbacks active concurrently. Outputs remain shared resources: selecting an output
update the player and automatically advance the playlist. The UI's linear claims it for that session. If another session owns it, that backend is closed,
0-100 volume is squared before being sent to the local audio library to provide the former session becomes stopped, and ownership transfers to the selecting
a more useful perceived volume curve. user. This deliberately avoids renderer ACLs and durable reservations.
Network outputs use the small `private/dlna-playback.rkt` adapter. It owns only The optional `local` renderer uses `racket-audio`. It is included by default
and can be omitted with `[player] local-output=false`. Its backend is created
on the first playback-related command, not at startup. State and end-of-track
callbacks update the player and automatically advance the playlist. The UI's
linear 0-100 volume is squared before being sent to the local audio library to
provide a more useful perceived volume curve.
Network outputs use the small `private/dlna-playback.rkt` adapter. All adapters
share one `racket-audio-dlna` media-file server, while each adapter owns only
the playlist transition state machine; all transport commands, seeking, HTTP the playlist transition state machine; all transport commands, seeking, HTTP
publication, UPnP calls and cached renderer information are delegated to publication, UPnP calls and cached renderer information are delegated to
`racket-audio-dlna`. After starting a track, the adapter asks that package to `racket-audio-dlna`. After starting a track, the adapter asks that package to
@@ -168,9 +180,9 @@ separately and never trigger that fallback. A seek uses
`dlna-player-seek-percentage!` directly; its synchronously updated cached `dlna-player-seek-percentage!` directly; its synchronously updated cached
position is pushed to the application state immediately. position is pushed to the application state immediately.
Changing the selected renderer closes the existing backend and resets playback Changing a session's selected renderer closes that session's existing backend
state. The replacement backend remains lazy and is created only when it is and resets its playback state. The replacement backend remains lazy and is
needed. created only when needed.
Registered playback agents form a fourth renderer kind. An agent keeps the Registered playback agents form a fourth renderer kind. An agent keeps the
client/server direction unchanged: it registers and polls the server, while the client/server direction unchanged: it registers and polls the server, while the
@@ -200,7 +212,7 @@ thread so that the initial API call can return immediately. It:
4. Represents each Sonos group as one logical renderer. 4. Represents each Sonos group as one logical renderer.
5. Removes individual UPnP devices that are already members of those groups. 5. Removes individual UPnP devices that are already members of those groups.
6. Sorts the resulting outputs by display name and retains local output as the 6. Sorts the resulting outputs by display name and retains local output as the
first option. first option when local output is enabled.
The `discovering` state lets polling clients show progress. Discovery failures The `discovering` state lets polling clients show progress. Discovery failures
are recorded in the shared player error field. are recorded in the shared player error field.
@@ -215,6 +227,8 @@ serves static assets from [`public/`](public/) and exposes these API endpoints:
| `GET` | `/api/state` | Return the complete current state; also refresh network-renderer information. | | `GET` | `/api/state` | Return the complete current state; 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. |
| `POST` | `/api/preferences` | Persist the current user's UI language. |
| `POST` | `/api/agent/register` | Register or refresh a polling playback agent. | | `POST` | `/api/agent/register` | Register or refresh a polling playback agent. |
| `POST` | `/api/agent/poll` | Accept agent state and acknowledgements and return its next command. | | `POST` | `/api/agent/poll` | Accept agent state and acknowledgements and return its next command. |
| `GET` | `/api/agent/media/:app-id/:token` | Download the track currently assigned to an agent. | | `GET` | `/api/agent/media/:app-id/:token` | Download the track currently assigned to an agent. |
@@ -234,6 +248,14 @@ header. Command failures are returned as HTTP 400 JSON responses with an
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/translate.js`](public/translate.js) follows the key-based translation
model used by rktplayer. It supports Dutch, English, German, French and Spanish
with English fallback. Browser preferences select the initial language; a
manual selection is stored server-side per username and therefore follows the
user across browsers. The native agent uses the equivalent
[`private/translate.rkt`](private/translate.rkt) module and the operating-system
language.
DOM signatures prevent rebuilding unchanged library, tab, and playlist DOM signatures prevent rebuilding unchanged library, tab, and playlist
collections on every poll. Playback status and other small values are updated collections on every poll. Playback status and other small values are updated
on every render. on every render.
@@ -309,16 +331,15 @@ sequenceDiagram
The application uses two semaphores with separate responsibilities: The application uses two semaphores with separate responsibilities:
- `command-lock` serializes commands and shutdown, preventing overlapping state - `command-lock` serializes commands, state snapshots, agent transitions,
transitions and backend operations. discovery commits and shutdown, preventing overlapping backend operations.
- `state-lock` protects short reads and mutations of the shared player fields - `state-lock` protects short reads and mutations of the shared player fields
performed by HTTP requests, audio callbacks, and the discovery thread. performed by HTTP requests, audio callbacks, and the discovery thread.
Potentially slow discovery runs outside the state lock. Backend calls are also Potentially slow discovery runs outside the state lock. Backend calls are also
generally performed outside it, with their results committed in short locked generally performed outside it, with their results committed in short locked
sections. A state request is not serialized by `command-lock`; it may observe sections. Audio callbacks use only the short-lived state lock and update the
the last committed state while a command is performing external I/O, but its playback session captured when their backend was created.
snapshot is internally protected by `state-lock`.
The server module stores the player in a module-level `current-player` variable. The server module stores the player in a module-level `current-player` variable.
This matches the intended one-player-per-process deployment, but it prevents This matches the intended one-player-per-process deployment, but it prevents
@@ -333,6 +354,7 @@ file:
- web listen address, defaulting to `127.0.0.1`; - web listen address, defaulting to `127.0.0.1`;
- web port, defaulting to `8080`; - web port, defaulting to `8080`;
- DLNA media publication port, defaulting to `8734`; - DLNA media publication port, defaulting to `8734`;
- optional server-local audio output under `[player]`, enabled by default;
- named library root paths under `[libraries]` (the legacy semicolon-separated - named library root paths under `[libraries]` (the legacy semicolon-separated
setting remains supported); setting remains supported);
- allowed 256-bit playback-agent IDs under `[playback-agents]`; - allowed 256-bit playback-agent IDs under `[playback-agents]`;
@@ -342,9 +364,9 @@ file:
Command-line network settings override INI values. Library paths from both Command-line network settings override INI values. Library paths from both
sources are combined and de-duplicated. sources are combined and de-duplicated.
Playlist tabs use the SQLite-backed `keystore` module at Playlist tabs and per-user language preferences use the SQLite-backed
`data/playlists.keystore`. Output discovery, transport state, playback position `keystore` module at `data/playlists.keystore`. Output discovery, transport
and sessions still reset when the process restarts. state, playback position and sessions still reset when the process restarts.
## 7. Security and operational boundaries ## 7. Security and operational boundaries
@@ -353,8 +375,9 @@ Authentication must be enabled before exposing it to an untrusted network, and
a reverse proxy must provide HTTPS because session cookies are always marked a reverse proxy must provide HTTPS because session cookies are always marked
`Secure`. Forwarded client addresses are security-sensitive configuration: `Secure`. Forwarded client addresses are security-sensitive configuration:
only known reverse-proxy peers may be trusted, and the application port should only known reverse-proxy peers may be trusted, and the application port should
remain firewalled from the internet. Playlists are isolated by username, while remain firewalled from the internet. Playlists, preferences, renderer selection
renderer selection and transport state remain shared. and transport state are isolated by username. Physical outputs are shared and
can be transferred between sessions.
Playback-agent registration and polling are authorized against a default-deny Playback-agent registration and polling are authorized against a default-deny
INI allowlist. Media URLs additionally contain an opaque per-track token. The INI allowlist. Media URLs additionally contain an opaque per-track token. The
@@ -371,18 +394,19 @@ operate on opaque indexes instead of sending paths directly. The DLNA backend
must make a selected local file reachable by the network renderer, so its media must make a selected local file reachable by the network renderer, so its media
port also needs to be accessible on the relevant trusted network. port also needs to be accessible on the relevant trusted network.
There is no durable job queue or retry policy. Discovery and renderer failures There is no durable job queue or retry policy. Discovery failures are shared;
are surfaced as shared UI errors, and renderer state is retried naturally by playback failures belong to the affected user's session. Renderer state is
subsequent polling requests. retried naturally by subsequent polling requests.
## 8. Testing and extension points ## 8. Testing and extension points
Unit tests embedded in `private/library.rkt` cover root creation, filtering, and Unit tests embedded in `private/library.rkt` cover root creation, filtering, and
directory ordering. `private/playlists.rkt` tests transactional round trips, directory ordering. `private/playlists.rkt` tests transactional round trips,
per-user GUID indexes, multiple libraries, deletion and library-boundary per-user GUID indexes, multiple libraries, deletion and library-boundary
validation. Tests in `private/player.rkt` validation, and per-user language storage. Tests in `private/player.rkt`
cover initial state, browser navigation, repeat mode, persistent playlist-tab cover initial state, browser navigation, repeat mode, persistent playlist-tab
operations, unknown commands, and clean shutdown. The current suite does not operations, concurrent user sessions, renderer takeover, unknown commands, and
clean shutdown. The current suite does not
exercise real audio devices, network discovery, DLNA renderers, HTTP routing, exercise real audio devices, network discovery, DLNA renderers, HTTP routing,
or browser behavior. or browser behavior.
@@ -398,9 +422,9 @@ The main extension points are:
## 9. Architectural constraints and trade-offs ## 9. Architectural constraints and trade-offs
- **Per-user playlists, shared transport:** playlist collections are isolated - **Per-user pipelines, shared outputs:** playlist and transport state are
by username, while the renderer and transport remain shared. A playlist isolated by username. Distinct outputs run concurrently; selecting an
command from another user explicitly takes over that shared player. occupied output explicitly stops its previous owner and transfers it.
- **Full-state snapshots:** a small and predictable client protocol, at the cost - **Full-state snapshots:** a small and predictable client protocol, at the cost
of repeatedly transferring all tracks and browser entries. of repeatedly transferring all tracks and browser entries.
- **One-second polling:** robust and dependency-free, but introduces periodic - **One-second polling:** robust and dependency-free, but introduces periodic
+18 -3
View File
@@ -32,6 +32,7 @@ port=8080
[player] [player]
dlna-port=8734 dlna-port=8734
local-output=true
[libraries] [libraries]
muziek=D:\Muziek muziek=D:\Muziek
@@ -54,7 +55,9 @@ UNC-rootmap. Het oudere `[library] paths=D:\Muziek;D:\Podcasts` blijft eveneens
ondersteund. Playlisttabs worden standaard opgeslagen in de keystore ondersteund. Playlisttabs worden standaard opgeslagen in de keystore
`data/playlists.keystore` binnen de geïnstalleerde map van rkt-web-player. Met `data/playlists.keystore` binnen de geïnstalleerde map van rkt-web-player. Met
`playlist-keystore=...` onder `[player]` kan desgewenst een ander pad worden `playlist-keystore=...` onder `[player]` kan desgewenst een ander pad worden
gebruikt. gebruikt. Met `local-output=false` wordt de audio-uitvoer van de server zelf
niet als afspeelpunt aangeboden. Als nog geen netwerkspeler of playback agent
beschikbaar is, blijft de uitvoerselectie leeg totdat er een verschijnt.
Zodra `[users]` minstens één gebruiker bevat, moeten alle browserclients Zodra `[users]` minstens één gebruiker bevat, moeten alle browserclients
inloggen, zowel lokaal als via internet. Wachtwoorden staan uitsluitend als inloggen, zowel lokaal als via internet. Wachtwoorden staan uitsluitend als
@@ -108,11 +111,21 @@ 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
authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt. authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt.
Het fysieke afspeelpunt en de transportbediening blijven gedeeld; een Iedere gebruiker heeft daarnaast een eigen afspeelpipeline met uitvoerkeuze,
playlistcommando van een andere gebruiker neemt die speler over. Een oudere transportstatus, huidige track, volume en herhaalmodus. Verschillende fysieke
afspeelpunten kunnen daardoor gelijktijdig voor verschillende gebruikers
spelen. De afspeelpunten zelf blijven gedeeld: kiest een gebruiker een reeds
bezet afspeelpunt, dan wordt de vorige pipeline op dat punt gestopt en neemt de
nieuwe gebruiker het over. Een oudere
`playlists-for-local`-verzameling blijft in de keystore staan, maar wordt niet `playlists-for-local`-verzameling blijft in de keystore staan, maar wordt niet
automatisch aan een gebruiker toegewezen. automatisch aan een gebruiker toegewezen.
De webinterface ondersteunt Nederlands, Engels, Duits, Frans en Spaans. Bij
het eerste bezoek wordt de voorkeurstaal van de browser gebruikt. Een keuze in
de taalselector wordt daarna per gebruiker als `language-for-<username>` in
`data/playlists.keystore` opgeslagen en geldt daardoor ook op andere apparaten.
Zonder authenticatie geldt dit voor de gebruiker `anonymous`.
De server luistert standaard alleen op localhost. Geef alleen bewust een De server luistert standaard alleen op localhost. Geef alleen bewust een
LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de
webinterface via een publiek bereikbare reverse proxy wordt aangeboden. webinterface via een publiek bereikbare reverse proxy wordt aangeboden.
@@ -159,6 +172,8 @@ toe wanneer zijn volledige applicatie-ID vooraf als key onder
`[playback-agents]` staat en de waarde niet `false` is. Een onbekende ID krijgt `[playback-agents]` staat en de waarde niet `false` is. Een onbekende ID krijgt
HTTP 403 en wordt niet als uitvoerpunt aangemaakt. De agent toont in dat geval HTTP 403 en wordt niet als uitvoerpunt aangemaakt. De agent toont in dat geval
zijn ID en meldt dat de serverbeheerder deze eerst aan de INI moet toevoegen. zijn ID en meldt dat de serverbeheerder deze eerst aan de INI moet toevoegen.
De GUI en het systeemvak volgen automatisch de systeemtaal en ondersteunen
dezelfde vijf talen als de webinterface.
Onder **Afspelen** toont de agent het playlistnummer, de huidige track en Onder **Afspelen** toont de agent het playlistnummer, de huidige track en
bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte, bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte,
+6 -1
View File
@@ -48,7 +48,8 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Start the audio player and its web interface. ; goal : Start the audio player and its web interface.
; pre : Ports are valid; every library spec names an existing directory. ; pre : Ports are valid, local-output? is a boolean, and every library spec
; names an existing directory.
; post : Libraries are browsed lazily; player resources close with the server. ; post : Libraries are browsed lazily; player resources close with the server.
; result : The result returned by serve/servlet. ; result : The result returned by serve/servlet.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -61,6 +62,7 @@
#:listen-ip [listen-ip "127.0.0.1"] #:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080] #:port [port 8080]
#:dlna-port [dlna-port 8734] #:dlna-port [dlna-port 8734]
#:local-output? [local-output? #t]
#:playlist-keystore #:playlist-keystore
[playlist-keystore default-playlist-keystore] [playlist-keystore default-playlist-keystore]
#:launch-browser? [launch-browser? #t]) #:launch-browser? [launch-browser? #t])
@@ -72,12 +74,14 @@
#:listen-ip string? #:listen-ip string?
#:port exact-positive-integer? #:port exact-positive-integer?
#:dlna-port exact-positive-integer? #:dlna-port exact-positive-integer?
#:local-output? boolean?
#:playlist-keystore (or/c path-string? #f) #:playlist-keystore (or/c path-string? #f)
#:launch-browser? boolean?) #:launch-browser? boolean?)
any) any)
(let* ((libraries (make-music-libraries music-paths)) (let* ((libraries (make-music-libraries music-paths))
(player (make-player libraries (player (make-player libraries
#:allowed-agent-ids allowed-agent-ids #:allowed-agent-ids allowed-agent-ids
#:local-output? local-output?
#:playlist-keystore playlist-keystore #:playlist-keystore playlist-keystore
#:dlna-port dlna-port)) #:dlna-port dlna-port))
(auth-manager (auth-manager
@@ -198,6 +202,7 @@
(ini-get config 'server 'port 8080)) (ini-get config 'server 'port 8080))
#:dlna-port (or dlna-port #:dlna-port (or dlna-port
(ini-get config 'player 'dlna-port 8734)) (ini-get config 'player 'dlna-port 8734))
#:local-output? (ini-get config 'player 'local-output #t)
#:playlist-keystore #:playlist-keystore
(or playlist-keystore (or playlist-keystore
(let ((configured (let ((configured
+10 -4
View File
@@ -293,23 +293,29 @@
(with-lock playback (lambda () (poll/locked! playback)))) (with-lock playback (lambda () (poll/locked! playback))))
(loop))))) (loop)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Create playlist-aware playback for one network renderer.
; pre : Device is a media renderer, callbacks are procedures, and
; media-server is a running shared media-file-server.
; post : A DLNA player and its state-monitor thread are running.
; result : A playback adapter that publishes through the supplied server.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (make-dlna-playback device (define (make-dlna-playback device
tracks tracks
update update
error error
#:port [port 8734] #:media-file-server media-server
#:poll-seconds [poll-seconds 1]) #:poll-seconds [poll-seconds 1])
(define raw (define raw
(make-dlna-player device (make-dlna-player device
#:port port #:media-file-server media-server))
#:path "/rkt-web-player/"))
(define playback (define playback
(dlna-playback raw tracks update error 'off #f #f #f (dlna-playback raw tracks update error 'off #f #f #f
#f #f #f #f 0 #f #t #t #f #f #f #f #f 0 #f #t #t #f
(make-semaphore 1))) (make-semaphore 1)))
(set-dlna-playback-monitor! (set-dlna-playback-monitor!
playback playback
(thread (lambda () (monitor-loop playback poll-seconds)))) (thread (λ () (monitor-loop playback poll-seconds))))
playback) playback)
(define (dlna-playback-play-index! playback index) (define (dlna-playback-play-index! playback index)
+7 -11
View File
@@ -7,7 +7,8 @@
racket/path racket/path
racket/port racket/port
racket/string racket/string
simple-log) simple-log
"translate.rkt")
(provide (struct-out player-agent-runtime) (provide (struct-out player-agent-runtime)
make-player-agent-runtime) make-player-agent-runtime)
@@ -321,17 +322,12 @@
(with-handlers (with-handlers
((exn:fail:agent-denied? ((exn:fail:agent-denied?
(lambda (exception) (lambda (exception)
(define message (define message (format (tr 'denied-message) app-id))
(string-append
"Deze playback agent is niet toegelaten door de server. "
"Voeg het volgende applicatie-ID toe aan [playback-agents] "
"in de server-INI:\n\n"
app-id))
(warn-player-agent "Agent authorization refused: ~a" (warn-player-agent "Agent authorization refused: ~a"
(exn-message exception)) (exn-message exception))
(set-agent-error! message) (set-agent-error! message)
(status-callback (status-callback
"Niet geautoriseerd — applicatie-ID staat niet in de server-INI") (tr 'unauthorized-status))
(unless authorization-notified? (unless authorization-notified?
(set! authorization-notified? #t) (set! authorization-notified? #t)
(denied-callback message)) (denied-callback message))
@@ -344,7 +340,7 @@
(exn-message exception)) (exn-message exception))
(set-agent-error! (exn-message exception)) (set-agent-error! (exn-message exception))
(status-callback (status-callback
(format "Niet verbonden: ~a" (exn-message exception))) (format (tr 'disconnected) (exn-message exception)))
(when running (when running
(sleep 3) (sleep 3)
(poll-loop))))) (poll-loop)))))
@@ -352,7 +348,7 @@
"/api/agent/register" "/api/agent/register"
(hasheq 'appId app-id 'name assigned-name)) (hasheq 'appId app-id 'name assigned-name))
(clear-agent-error!) (clear-agent-error!)
(status-callback "Verbonden") (status-callback (tr 'connected))
(info-player-agent "Registered at ~a as ~a" server-url assigned-name) (info-player-agent "Registered at ~a as ~a" server-url assigned-name)
(let loop () (let loop ()
(when running (when running
@@ -390,7 +386,7 @@
(define (start!) (define (start!)
(unless running (unless running
(set! running #t) (set! running #t)
(status-callback "Verbinden…") (status-callback (tr 'connecting))
(set! worker (thread poll-loop)))) (set! worker (thread poll-loop))))
(define (stop!) (define (stop!)
+22 -19
View File
@@ -9,7 +9,8 @@
simple-log simple-log
"player-agent-config.rkt" "player-agent-config.rkt"
"player-agent-core.rkt" "player-agent-core.rkt"
"player-agent-tray.rkt") "player-agent-tray.rkt"
"translate.rkt")
(provide run-player-agent-gui) (provide run-player-agent-gui)
@@ -69,7 +70,7 @@
(define (show-denial! message) (define (show-denial! message)
(queue-callback (queue-callback
(lambda () (lambda ()
(message-box "Playback agent niet toegestaan" (message-box (tr 'denied-title)
message message
frame frame
'(ok stop))) '(ok stop)))
@@ -97,13 +98,13 @@
((and artist (not (string=? artist "")) title) ((and artist (not (string=? artist "")) title)
(format "~a — ~a" artist title)) (format "~a — ~a" artist title))
(title title) (title title)
(else "Geen track geselecteerd"))) (else (tr 'no-track-selected))))
(define prefix (define prefix
(cond (cond
((string=? state "playing") "Speelt") ((string=? state "playing") (tr 'playing))
((string=? state "paused") "Gepauzeerd") ((string=? state "paused") (tr 'paused))
((string=? state "starting") "Laden") ((string=? state "starting") (tr 'loading))
((string=? state "stopped") "Gestopt") ((string=? state "stopped") (tr 'stopped))
(else state))) (else state)))
(define position (hash-ref snapshot 'position 0)) (define position (hash-ref snapshot 'position 0))
(define duration (hash-ref snapshot 'duration 'null)) (define duration (hash-ref snapshot 'duration 'null))
@@ -124,7 +125,7 @@
"") "")
(if (number? channels) (if (number? channels)
(format "~a ~a" channels (format "~a ~a" channels
(if (= channels 1) "kanaal" "kanalen")) (tr (if (= channels 1) 'channel 'channels)))
"") "")
(if (and (string? format-name) (not (string=? format-name ""))) (if (and (string? format-name) (not (string=? format-name "")))
format-name format-name
@@ -138,7 +139,7 @@
(format " #~a" track-number) (format " #~a" track-number)
"") "")
track-label) track-label)
"Er wordt niets afgespeeld")) (tr 'no-track)))
(send playback-details set-label (string-join details " · ")) (send playback-details set-label (string-join details " · "))
(send playback-filename (send playback-filename
set-label set-label
@@ -160,7 +161,7 @@
(save-player-agent-config! config) (save-player-agent-config! config)
(send name-field set-value next-name) (send name-field set-value next-name)
((player-agent-runtime-reconnect! runtime) next-server next-name) ((player-agent-runtime-reconnect! runtime) next-server next-name)
(send connect-button set-label "Opnieuw verbinden")) (send connect-button set-label (tr 'reconnect)))
(define (shutdown!) (define (shutdown!)
(unless shutting-down? (unless shutting-down?
@@ -191,7 +192,7 @@
(set! frame (set! frame
(new agent-frame% (new agent-frame%
(label "RKT Web Player Agent") (label (tr 'app-title))
(width 560) (width 560)
(height 310))) (height 310)))
(define panel (define panel
@@ -199,13 +200,15 @@
(parent frame) (parent frame)
(alignment '(left top)))) (alignment '(left top))))
(set! server-field (set! server-field
(input-field "RKT Web Player server" (input-field (tr 'server)
(player-agent-config-server-url config) (player-agent-config-server-url config)
panel)) panel))
(set! name-field (set! name-field
(input-field "Naam" (player-agent-config-name config) panel)) (input-field (tr 'name) (player-agent-config-name config) panel))
(define id-field (define id-field
(input-field "Applicatie-ID" (player-agent-config-app-id config) panel)) (input-field (tr 'application-id)
(player-agent-config-app-id config)
panel))
;; Lock the editor, not the native widget. Disabled Windows controls render ;; Lock the editor, not the native widget. Disabled Windows controls render
;; their label and text poorly on some display configurations. ;; their label and text poorly on some display configurations.
(send (send id-field get-editor) lock #t) (send (send id-field get-editor) lock #t)
@@ -213,13 +216,13 @@
(define playback-panel (define playback-panel
(new group-box-panel% (new group-box-panel%
(parent panel) (parent panel)
(label "Afspelen") (label (tr 'playback))
(alignment '(left top)) (alignment '(left top))
(stretchable-height #f))) (stretchable-height #f)))
(set! playback-message (set! playback-message
(new message% (new message%
(parent playback-panel) (parent playback-panel)
(label "Er wordt niets afgespeeld") (label (tr 'no-track))
(auto-resize #t))) (auto-resize #t)))
(set! playback-details (set! playback-details
(new message% (new message%
@@ -239,12 +242,12 @@
(set! connect-button (set! connect-button
(new button% (new button%
(parent controls) (parent controls)
(label "Opslaan en verbinden") (label (tr 'save-connect))
(callback (lambda (_button _event) (reconnect!))))) (callback (lambda (_button _event) (reconnect!)))))
(set! status-message (set! status-message
(new message% (new message%
(parent controls) (parent controls)
(label "Verbinden…") (label (tr 'connecting))
(auto-resize #t))) (auto-resize #t)))
(set! playback-timer (set! playback-timer
@@ -268,5 +271,5 @@
(send frame show #t) (send frame show #t)
((player-agent-runtime-start! runtime)) ((player-agent-runtime-start! runtime))
(send connect-button set-label "Opnieuw verbinden") (send connect-button set-label (tr 'reconnect))
frame) frame)
+5 -3
View File
@@ -1,5 +1,7 @@
#lang racket/base #lang racket/base
(require "translate.rkt")
;; SDL3 is deliberately loaded dynamically. The GUI agent keeps working ;; SDL3 is deliberately loaded dynamically. The GUI agent keeps working
;; without the optional Racket package and native SDL3 runtime. ;; without the optional Racket package and native SDL3 runtime.
@@ -20,11 +22,11 @@
(define update-trays! (dynamic-require 'sdl3 'update-trays!)) (define update-trays! (dynamic-require 'sdl3 'update-trays!))
(define tray-destroy! (dynamic-require 'sdl3 'tray-destroy!)) (define tray-destroy! (dynamic-require 'sdl3 'tray-destroy!))
(sdl-init! '(video events)) (sdl-init! '(video events))
(define tray (make-tray #f "RKT Web Player Agent")) (define tray (make-tray #f (tr 'app-title)))
(define menu (make-tray-menu tray)) (define menu (make-tray-menu tray))
(define show-entry (insert-tray-entry! menu "RKT Web Player Agent openen")) (define show-entry (insert-tray-entry! menu (tr 'tray-open)))
(insert-tray-entry! menu #f) (insert-tray-entry! menu #f)
(define quit-entry (insert-tray-entry! menu "Afsluiten")) (define quit-entry (insert-tray-entry! menu (tr 'quit)))
(set-tray-entry-callback! show-entry (lambda (_) (show-window!))) (set-tray-entry-callback! show-entry (lambda (_) (show-window!)))
(set-tray-entry-callback! quit-entry (lambda (_) (quit!))) (set-tray-entry-callback! quit-entry (lambda (_) (quit!)))
(tray-controller (tray-controller
+611 -328
View File
File diff suppressed because it is too large Load Diff
+52 -1
View File
@@ -11,7 +11,9 @@
open-playlist-store open-playlist-store
close-playlist-store! close-playlist-store!
load-user-playlists load-user-playlists
save-user-playlists!) save-user-playlists!
load-user-language
save-user-language!)
(struct persisted-tab (id name tracks) #:transparent) (struct persisted-tab (id name tracks) #:transparent)
(struct playlist-store (keystore lock) #:transparent) (struct playlist-store (keystore lock) #:transparent)
@@ -19,6 +21,11 @@
(define (user-playlists-key username) (define (user-playlists-key username)
(format "playlists-for-~a" username)) (format "playlists-for-~a" username))
(define (user-language-key username)
(format "language-for-~a" username))
(define supported-language-names '("en" "nl" "de" "fr" "es"))
(define (track->datum item) (define (track->datum item)
(hasheq 'file (path->string (track-file item)) (hasheq 'file (path->string (track-file item))
'title (track-title item) 'title (track-title item)
@@ -116,6 +123,45 @@
(ks-set! ks index-key ids)) (ks-set! ks index-key ids))
(void))))) (void)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Load one user's persisted interface language.
; pre : Store is #f or an open playlist store; username is normalized.
; post : Store contents remain unchanged.
; result : A supported ISO language name, or #f when none was saved.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (load-user-language store username)
(and store
(call-with-semaphore
(playlist-store-lock store)
(λ ()
(define value
(ks-get (playlist-store-keystore store)
(user-language-key username)
#f))
(and (member value supported-language-names) value)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Persist one user's interface language.
; pre : Store is #f or open, username is normalized, and language is one of
; en, nl, de, fr, or es.
; post : The user's language key contains language when a store exists.
; result : Void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-user-language! store username language)
(unless (member language supported-language-names)
(raise-argument-error
'save-user-language!
"one of en, nl, de, fr, or es"
language))
(when store
(call-with-semaphore
(playlist-store-lock store)
(λ ()
(ks-set! (playlist-store-keystore store)
(user-language-key username)
language))))
(void))
(module+ test (module+ test
(require rackunit (require rackunit
uuid/random) uuid/random)
@@ -166,6 +212,11 @@
(check-equal? (check-equal?
(map persisted-tab-name (load-user-playlists store "local" libraries)) (map persisted-tab-name (load-user-playlists store "local" libraries))
'("Local")) '("Local"))
(check-false (load-user-language store "hans"))
(save-user-language! store "hans" "fr")
(check-equal? (load-user-language store "hans") "fr")
(check-exn exn:fail:contract?
(λ () (save-user-language! store "hans" "it")))
;; Rewriting the user's GUID index durably removes the omitted playlist. ;; Rewriting the user's GUID index durably removes the omitted playlist.
(save-user-playlists! (save-user-playlists!
+20
View File
@@ -119,6 +119,24 @@
(request-jsexpr request) (request-jsexpr request)
#:username (request-username request))))) #:username (request-username request)))))
(define (preferences-handler request)
(json-response
(hasheq
'language
(or (player-user-language
current-player
#:username (request-username request))
'null))))
(define (preferences-update-handler request)
(with-handlers ((exn:fail? error-response))
(define language (hash-ref (request-jsexpr request) 'language #f))
(player-user-language!
current-player
language
#:username (request-username request))
(json-response (hasheq 'language language))))
(define (agent-register-handler request) (define (agent-register-handler request)
(with-handlers (with-handlers
((exn:fail:agent-denied? agent-error-response) ((exn:fail:agent-denied? agent-error-response)
@@ -190,6 +208,8 @@
[("api" "auth" "logout") #:method "post" auth-logout-handler] [("api" "auth" "logout") #:method "post" auth-logout-handler]
[("api" "state") #:method "get" state-handler] [("api" "state") #:method "get" state-handler]
[("api" "discover") #:method "post" discover-handler] [("api" "discover") #:method "post" discover-handler]
[("api" "preferences") #:method "get" preferences-handler]
[("api" "preferences") #:method "post" preferences-update-handler]
[("api" "agent" "register") #:method "post" agent-register-handler] [("api" "agent" "register") #:method "post" agent-register-handler]
[("api" "agent" "poll") #:method "post" agent-poll-handler] [("api" "agent" "poll") #:method "post" agent-poll-handler]
[("api" "agent" "media" (string-arg) (string-arg)) [("api" "agent" "media" (string-arg) (string-arg))
+217
View File
@@ -0,0 +1,217 @@
#lang racket/base
(require racket/string)
(provide tr
__
languages
set-lang!
current-lang)
(define translation-map
(hasheq
'en
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player server"
'name "Name"
'application-id "Application ID"
'playback "Playback"
'no-track "Nothing is playing"
'no-track-selected "No track selected"
'save-connect "Save and connect"
'reconnect "Reconnect"
'connecting "Connecting…"
'connected "Connected"
'denied-title "Playback agent not allowed"
'denied-message "This playback agent is not allowed by the server. Add the following application ID to [playback-agents] in the server INI:\n\n~a"
'unauthorized-status "Not authorized — application ID is not in the server INI"
'disconnected "Not connected: ~a"
'playing "Playing"
'paused "Paused"
'loading "Loading"
'stopped "Stopped"
'channel "channel"
'channels "channels"
'tray-open "Open RKT Web Player Agent"
'quit "Quit")
'nl
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player server"
'name "Naam"
'application-id "Applicatie-ID"
'playback "Afspelen"
'no-track "Er wordt niets afgespeeld"
'no-track-selected "Geen track geselecteerd"
'save-connect "Opslaan en verbinden"
'reconnect "Opnieuw verbinden"
'connecting "Verbinden…"
'connected "Verbonden"
'denied-title "Playback agent niet toegestaan"
'denied-message "Deze playback agent is niet toegelaten door de server. Voeg het volgende applicatie-ID toe aan [playback-agents] in de server-INI:\n\n~a"
'unauthorized-status "Niet geautoriseerd — applicatie-ID staat niet in de server-INI"
'disconnected "Niet verbonden: ~a"
'playing "Speelt"
'paused "Gepauzeerd"
'loading "Laden"
'stopped "Gestopt"
'channel "kanaal"
'channels "kanalen"
'tray-open "RKT Web Player Agent openen"
'quit "Afsluiten")
'de
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player Server"
'name "Name"
'application-id "Anwendungs-ID"
'playback "Wiedergabe"
'no-track "Keine Wiedergabe"
'no-track-selected "Kein Titel ausgewählt"
'save-connect "Speichern und verbinden"
'reconnect "Neu verbinden"
'connecting "Verbinden…"
'connected "Verbunden"
'denied-title "Playback-Agent nicht zugelassen"
'denied-message "Dieser Playback-Agent ist vom Server nicht zugelassen. Fügen Sie die folgende Anwendungs-ID unter [playback-agents] in die Server-INI ein:\n\n~a"
'unauthorized-status "Nicht autorisiert — Anwendungs-ID fehlt in der Server-INI"
'disconnected "Nicht verbunden: ~a"
'playing "Wiedergabe"
'paused "Pausiert"
'loading "Laden"
'stopped "Gestoppt"
'channel "Kanal"
'channels "Kanäle"
'tray-open "RKT Web Player Agent öffnen"
'quit "Beenden")
'fr
(hasheq
'app-title "Agent RKT Web Player"
'server "Serveur RKT Web Player"
'name "Nom"
'application-id "ID dapplication"
'playback "Lecture"
'no-track "Aucune lecture en cours"
'no-track-selected "Aucune piste sélectionnée"
'save-connect "Enregistrer et connecter"
'reconnect "Reconnecter"
'connecting "Connexion…"
'connected "Connecté"
'denied-title "Agent de lecture non autorisé"
'denied-message "Cet agent de lecture nest pas autorisé par le serveur. Ajoutez lID dapplication suivant à [playback-agents] dans le fichier INI du serveur :\n\n~a"
'unauthorized-status "Non autorisé — lID dapplication est absent du fichier INI du serveur"
'disconnected "Non connecté : ~a"
'playing "Lecture"
'paused "En pause"
'loading "Chargement"
'stopped "Arrêté"
'channel "canal"
'channels "canaux"
'tray-open "Ouvrir lagent RKT Web Player"
'quit "Quitter")
'es
(hasheq
'app-title "Agente de RKT Web Player"
'server "Servidor RKT Web Player"
'name "Nombre"
'application-id "ID de aplicación"
'playback "Reproducción"
'no-track "No se está reproduciendo nada"
'no-track-selected "No hay ninguna pista seleccionada"
'save-connect "Guardar y conectar"
'reconnect "Volver a conectar"
'connecting "Conectando…"
'connected "Conectado"
'denied-title "Agente de reproducción no permitido"
'denied-message "El servidor no permite este agente de reproducción. Añade el siguiente ID de aplicación a [playback-agents] en el INI del servidor:\n\n~a"
'unauthorized-status "No autorizado — el ID de aplicación no está en el INI del servidor"
'disconnected "Sin conexión: ~a"
'playing "Reproduciendo"
'paused "En pausa"
'loading "Cargando"
'stopped "Detenido"
'channel "canal"
'channels "canales"
'tray-open "Abrir el agente de RKT Web Player"
'quit "Salir")))
(define (system-language)
(define language-name
(string-downcase (format "~a" (system-language+country))))
(define short-name (car (regexp-split #px"[-_]" language-name)))
(define candidate (string->symbol short-name))
(if (hash-has-key? translation-map candidate) candidate 'en))
(define language (system-language))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return the supported language symbols and their native names.
; pre : None.
; post : Translation state remains unchanged.
; result : An association list suitable for a language selector.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (languages)
'((en "English")
(nl "Nederlands")
(de "Deutsch")
(fr "Français")
(es "Español")))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Select the language used by tr and __.
; pre : Value is one of the symbols returned by languages.
; post : Subsequent translations use value.
; result : Void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (set-lang! value)
(unless (hash-has-key? translation-map value)
(raise-argument-error 'set-lang! "supported language symbol" value))
(set! language value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Report the active translation language.
; pre : None.
; post : Translation state remains unchanged.
; result : A supported language symbol.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (current-lang)
language)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Translate an application string identifier.
; pre : Id is a symbol.
; post : Translation state remains unchanged.
; result : The active translation, its English fallback, or the identifier.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (tr id)
(hash-ref (hash-ref translation-map language)
id
(λ ()
(hash-ref (hash-ref translation-map 'en)
id
(λ () (symbol->string id))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Provide the conventional short alias used by rktplayer GUI code.
; pre : Id is a symbol.
; post : Translation state remains unchanged.
; result : The same translated string as tr.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (__ id)
(tr id))
(module+ test
(require rackunit)
(define original-language (current-lang))
(dynamic-wind
void
(λ ()
(set-lang! 'nl)
(check-equal? (tr 'connected) "Verbonden")
(set-lang! 'de)
(check-equal? (__ 'quit) "Beenden")
(check-equal? (tr 'unknown-translation) "unknown-translation")
(check-exn exn:fail:contract? (λ () (set-lang! 'xx))))
(λ () (set-lang! original-language))))
+80 -26
View File
@@ -1,5 +1,6 @@
const elements = { const elements = {
renderer: document.querySelector("#renderer"), renderer: document.querySelector("#renderer"),
language: document.querySelector("#language"),
discover: document.querySelector("#discover"), discover: document.querySelector("#discover"),
previous: document.querySelector("#previous"), previous: document.querySelector("#previous"),
play: document.querySelector("#play"), play: document.querySelector("#play"),
@@ -9,6 +10,8 @@ const elements = {
position: document.querySelector("#position"), position: document.querySelector("#position"),
duration: document.querySelector("#duration"), duration: document.querySelector("#duration"),
repeat: document.querySelector("#repeat"), repeat: document.querySelector("#repeat"),
volumeControl: document.querySelector("#volume-control"),
volumeToggle: document.querySelector("#volume-toggle"),
volume: document.querySelector("#volume"), volume: document.querySelector("#volume"),
volumeValue: document.querySelector("#volume-value"), volumeValue: document.querySelector("#volume-value"),
library: document.querySelector("#library"), library: document.querySelector("#library"),
@@ -41,6 +44,8 @@ const elements = {
loginSubmit: document.querySelector("#login-submit"), loginSubmit: document.querySelector("#login-submit"),
}; };
const { t } = window.RktTranslate;
let state = null; let state = null;
let seekBusy = false; let seekBusy = false;
let draggedTrack = null; let draggedTrack = null;
@@ -102,9 +107,16 @@ async function refreshAuth() {
try { try {
const auth = await api("/api/auth/status"); const auth = await api("/api/auth/status");
elements.logout.hidden = !auth.enabled || !auth.authenticated; elements.logout.hidden = !auth.enabled || !auth.authenticated;
if (auth.enabled && !auth.authenticated) showLogin(); if (auth.enabled && !auth.authenticated) {
showLogin();
} else {
const preferences = await api("/api/preferences");
if (window.RktTranslate.supported.includes(preferences.language)) {
window.RktTranslate.setLanguage(preferences.language);
}
}
} catch (error) { } catch (error) {
setStatus(`Authenticatiestatus onbekend: ${error.message}`); setStatus(t("authUnknown", { message: error.message }));
} }
} }
@@ -136,7 +148,7 @@ function replaceSelect(select, items, selectedId, signature) {
function renderSelectors(nextState) { function renderSelectors(nextState) {
const rendererItems = nextState.renderers.map((item) => ({ const rendererItems = nextState.renderers.map((item) => ({
id: item.id, id: item.id,
label: `${item.name} · ${item.kind.toUpperCase()}`, label: `${item.name} · ${item.kind === "local" ? t("rendererLocal") : item.kind.toUpperCase()}`,
})); }));
replaceSelect( replaceSelect(
elements.renderer, elements.renderer,
@@ -158,6 +170,7 @@ function renderSelectors(nextState) {
elements.discover.classList.toggle("busy", nextState.discovering); elements.discover.classList.toggle("busy", nextState.discovering);
elements.discover.disabled = nextState.discovering; elements.discover.disabled = nextState.discovering;
elements.renderer.disabled = nextState.renderers.length === 0;
elements.library.disabled = nextState.libraries.length === 0; elements.library.disabled = nextState.libraries.length === 0;
} }
@@ -178,7 +191,7 @@ function entryAction(label, title, handler) {
function renderBrowser(nextState) { function renderBrowser(nextState) {
const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId); const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId);
const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean); const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean);
elements.breadcrumb.textContent = path.length ? path.join(" / ") : "Geen bibliotheek"; elements.breadcrumb.textContent = path.length ? path.join(" / ") : t("noLibrary");
elements.breadcrumb.title = elements.breadcrumb.textContent; elements.breadcrumb.title = elements.breadcrumb.textContent;
elements.libraryUp.disabled = !nextState.browser.canGoUp; elements.libraryUp.disabled = !nextState.browser.canGoUp;
elements.libraryEmpty.hidden = nextState.libraries.length > 0; elements.libraryEmpty.hidden = nextState.libraries.length > 0;
@@ -205,11 +218,11 @@ function renderBrowser(nextState) {
const actions = document.createElement("span"); const actions = document.createElement("span");
actions.className = "entry-actions"; actions.className = "entry-actions";
actions.append( actions.append(
entryAction("▶", `${entry.name} nu afspelen`, () => { entryAction("▶", t("playNow", { name: entry.name }), () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`); command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
}), }),
entryAction("", `${entry.name} toevoegen`, () => { entryAction("", t("addNamed", { name: entry.name }), () => {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`); command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
}), }),
); );
@@ -218,7 +231,7 @@ function renderBrowser(nextState) {
row.addEventListener("click", () => command("browse", { index: entry.index })); row.addEventListener("click", () => command("browse", { index: entry.index }));
} else { } else {
row.addEventListener("dblclick", () => { row.addEventListener("dblclick", () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`); command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
}); });
} }
row.addEventListener("keydown", (event) => { row.addEventListener("keydown", (event) => {
@@ -226,10 +239,10 @@ function renderBrowser(nextState) {
command( command(
entry.kind === "container" ? "browse" : "item-play", entry.kind === "container" ? "browse" : "item-play",
{ index: entry.index }, { index: entry.index },
entry.kind === "track" ? `${entry.name} laden…` : "", entry.kind === "track" ? t("loadingNamed", { name: entry.name }) : "",
); );
} else if (event.key === "+") { } else if (event.key === "+") {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`); command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
} }
}); });
return row; return row;
@@ -253,7 +266,7 @@ function renderTabs(nextState) {
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "tab-name"; name.className = "tab-name";
name.textContent = tab.name; name.textContent = tab.name === "Default" ? t("defaultPlaylist") : tab.name;
const count = document.createElement("span"); const count = document.createElement("span");
count.className = "tab-count"; count.className = "tab-count";
count.textContent = tab.count; count.textContent = tab.count;
@@ -261,7 +274,7 @@ function renderTabs(nextState) {
button.addEventListener("click", () => command("tab-select", { index: tab.index })); button.addEventListener("click", () => command("tab-select", { index: tab.index }));
button.addEventListener("dblclick", () => { button.addEventListener("dblclick", () => {
const newName = window.prompt("Naam van de afspeellijst", tab.name); const newName = window.prompt(t("playlistName"), tab.name);
if (newName !== null) command("tab-rename", { index: tab.index, name: newName }); if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
}); });
@@ -269,7 +282,7 @@ function renderTabs(nextState) {
const remove = document.createElement("span"); const remove = document.createElement("span");
remove.className = "tab-delete"; remove.className = "tab-delete";
remove.textContent = "×"; remove.textContent = "×";
remove.title = "Afspeellijst verwijderen"; remove.title = t("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 });
@@ -328,8 +341,8 @@ function renderPlaylist(nextState) {
remove.className = "row-action"; remove.className = "row-action";
remove.type = "button"; remove.type = "button";
remove.textContent = "×"; remove.textContent = "×";
remove.title = `${track.title} verwijderen`; remove.title = t("removeNamed", { name: track.title });
remove.setAttribute("aria-label", `${track.title} verwijderen`); remove.setAttribute("aria-label", t("removeNamed", { name: track.title }));
remove.addEventListener("click", (event) => { remove.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
command("track-remove", { index: track.index }); command("track-remove", { index: track.index });
@@ -366,7 +379,7 @@ function renderPlaylist(nextState) {
} }
elements.playlistEmpty.hidden = nextState.tracks.length > 0; elements.playlistEmpty.hidden = nextState.tracks.length > 0;
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`; elements.count.textContent = `${nextState.tracks.length} ${t(nextState.tracks.length === 1 ? "oneTrack" : "manyTracks")}`;
elements.playlistClear.disabled = nextState.tracks.length === 0; elements.playlistClear.disabled = nextState.tracks.length === 0;
} }
@@ -374,10 +387,10 @@ function renderPlayer(nextState) {
const current = Number.isInteger(nextState.currentIndex) const current = Number.isInteger(nextState.currentIndex)
? nextState.tracks[nextState.currentIndex] ? nextState.tracks[nextState.currentIndex]
: null; : null;
elements.title.textContent = current ? current.title : "Nog niets gekozen"; elements.title.textContent = current ? current.title : t("nothingSelected");
elements.meta.textContent = current elements.meta.textContent = current
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source ? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
: "Voeg een track toe vanuit de bibliotheek"; : t("addTrackHint");
const artworkId = current?.artworkId || ""; const artworkId = current?.artworkId || "";
if (elements.coverImage.dataset.artworkId !== artworkId) { if (elements.coverImage.dataset.artworkId !== artworkId) {
@@ -395,8 +408,8 @@ function renderPlayer(nextState) {
const playing = nextState.state === "playing" || nextState.state === "starting"; const playing = nextState.state === "playing" || nextState.state === "starting";
elements.play.textContent = playing ? "Ⅱ" : "▶"; elements.play.textContent = playing ? "Ⅱ" : "▶";
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen"); elements.play.setAttribute("aria-label", t(playing ? "pause" : "play"));
elements.play.disabled = nextState.tracks.length === 0; elements.play.disabled = nextState.tracks.length === 0 || nextState.renderers.length === 0;
elements.previous.disabled = nextState.tracks.length === 0; elements.previous.disabled = nextState.tracks.length === 0;
elements.next.disabled = nextState.tracks.length === 0; elements.next.disabled = nextState.tracks.length === 0;
elements.stop.disabled = nextState.state === "stopped"; elements.stop.disabled = nextState.state === "stopped";
@@ -412,14 +425,20 @@ function renderPlayer(nextState) {
elements.volume.value = nextState.volume; elements.volume.value = nextState.volume;
elements.volumeValue.value = `${Math.round(nextState.volume)}%`; elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
elements.volumeToggle.setAttribute(
"aria-label",
t("volumePercent", { value: Math.round(nextState.volume) }),
);
elements.repeat.dataset.repeat = nextState.repeat; elements.repeat.dataset.repeat = nextState.repeat;
elements.repeat.classList.toggle("active", nextState.repeat !== "off"); elements.repeat.classList.toggle("active", nextState.repeat !== "off");
const repeatNames = { off: "Herhalen uit", all: "Alles herhalen", one: "Eén track herhalen" }; const repeatNames = { off: t("repeatOff"), all: t("repeatAll"), one: t("repeatOne") };
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off; elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit"; elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz"; elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
elements.channels.textContent = nextState.channels ? `${nextState.channels} kanalen` : "— kanalen"; elements.channels.textContent = nextState.channels
? `${nextState.channels} ${t(nextState.channels === 1 ? "oneChannel" : "channels")}`
: `${t("channels")}`;
elements.format.textContent = nextState.format || "—"; elements.format.textContent = nextState.format || "—";
elements.source.textContent = nextState.source || "—"; elements.source.textContent = nextState.source || "—";
} }
@@ -441,7 +460,7 @@ function render(nextState) {
renderTabs(nextState); renderTabs(nextState);
renderPlaylist(nextState); renderPlaylist(nextState);
renderPlayer(nextState); renderPlayer(nextState);
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : "")); setStatus(nextState.error || (nextState.discovering ? t("searchingPlayers") : ""));
} }
elements.play.addEventListener("click", () => { elements.play.addEventListener("click", () => {
@@ -458,6 +477,22 @@ elements.repeat.addEventListener("click", () => {
command("repeat", { mode: next }); command("repeat", { mode: next });
}); });
elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value })); elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value }));
elements.language.value = window.RktTranslate.language();
elements.language.addEventListener("change", async () => {
window.RktTranslate.setLanguage(elements.language.value);
try {
await api("/api/preferences", { language: elements.language.value });
} catch (error) {
setStatus(error.message);
}
});
window.addEventListener("rkt-language-change", () => {
elements.language.value = window.RktTranslate.language();
for (const element of [elements.renderer, elements.libraryEntries, elements.tabs, elements.playlist]) {
delete element.dataset.signature;
}
if (state) render(state);
});
elements.discover.addEventListener("click", async () => { elements.discover.addEventListener("click", async () => {
try { try {
render(await api("/api/discover", {})); render(await api("/api/discover", {}));
@@ -465,10 +500,29 @@ elements.discover.addEventListener("click", async () => {
setStatus(error.message); setStatus(error.message);
} }
}); });
elements.volumeToggle.addEventListener("click", () => {
const open = !elements.volumeControl.classList.contains("open");
elements.volumeControl.classList.toggle("open", open);
elements.volumeToggle.setAttribute("aria-expanded", String(open));
if (open) elements.volume.focus();
});
elements.volume.addEventListener("input", () => { elements.volume.addEventListener("input", () => {
elements.volumeValue.value = `${elements.volume.value}%`; elements.volumeValue.value = `${elements.volume.value}%`;
}); });
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) })); elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
document.addEventListener("pointerdown", (event) => {
if (!elements.volumeControl.contains(event.target)) {
elements.volumeControl.classList.remove("open");
elements.volumeToggle.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && elements.volumeControl.classList.contains("open")) {
elements.volumeControl.classList.remove("open");
elements.volumeToggle.setAttribute("aria-expanded", "false");
elements.volumeToggle.focus();
}
});
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; }); elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
elements.seek.addEventListener("change", () => { elements.seek.addEventListener("change", () => {
seekBusy = false; seekBusy = false;
@@ -488,7 +542,7 @@ async function refresh() {
if (error.code === "authentication-required") { if (error.code === "authentication-required") {
showLogin(); showLogin();
} else { } else {
setStatus(`Geen verbinding: ${error.message}`); setStatus(t("noConnection", { message: error.message }));
} }
} }
} }
@@ -518,7 +572,7 @@ elements.logout.addEventListener("click", async () => {
await api("/api/auth/logout", {}); await api("/api/auth/logout", {});
} finally { } finally {
elements.logout.hidden = true; elements.logout.hidden = true;
showLogin("Je bent uitgelogd."); showLogin(t("loggedOut"));
} }
}); });
+48 -41
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="nl"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -16,23 +16,26 @@
</div> </div>
<div class="output-control"> <div class="output-control">
<label for="renderer">UITVOER</label> <label for="renderer" data-i18n="output">OUTPUT</label>
<select id="renderer"></select> <select id="renderer"></select>
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button> <button id="discover" class="square-button" type="button" data-i18n-title="searchPlayers" title="Search for network players" aria-label="Search for network players"></button>
<button id="logout" class="text-button" type="button" hidden>UITLOGGEN</button> <select id="language" class="language-select" aria-label="Language">
<option value="en">EN</option><option value="nl">NL</option><option value="de">DE</option><option value="fr">FR</option><option value="es">ES</option>
</select>
<button id="logout" class="text-button" type="button" data-i18n="logout" data-i18n-aria="logout" aria-label="Log out" hidden>LOG OUT</button>
</div> </div>
</header> </header>
<section class="transport-bar" aria-label="Afspeelbediening"> <section class="transport-bar" data-i18n-aria="playbackControls" aria-label="Playback controls">
<div class="transport-buttons"> <div class="transport-buttons">
<button id="previous" class="command-button" type="button" aria-label="Vorige track"></button> <button id="previous" class="command-button" type="button" data-i18n-aria="previousTrack" aria-label="Previous track"></button>
<button id="play" class="command-button play-button" type="button" aria-label="Afspelen of pauzeren"></button> <button id="play" class="command-button play-button" type="button" data-i18n-aria="playOrPause" aria-label="Play or pause"></button>
<button id="stop" class="command-button" type="button" aria-label="Stoppen"></button> <button id="stop" class="command-button" type="button" data-i18n-aria="stop" aria-label="Stop"></button>
<button id="next" class="command-button" type="button" aria-label="Volgende track"></button> <button id="next" class="command-button" type="button" data-i18n-aria="nextTrack" aria-label="Next track"></button>
</div> </div>
<div class="seek-control"> <div class="seek-control">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie"> <input id="seek" type="range" min="0" max="100" value="0" step="0.1" data-i18n-aria="playbackPosition" aria-label="Playback position">
<div class="time-display"> <div class="time-display">
<span id="position">00:00:00</span> <span id="position">00:00:00</span>
<span class="time-divider">/</span> <span class="time-divider">/</span>
@@ -40,12 +43,15 @@
</div> </div>
</div> </div>
<button id="repeat" class="command-button repeat-button" type="button" aria-label="Herhaalmodus"></button> <button id="repeat" class="command-button repeat-button" type="button" data-i18n-aria="repeatMode" aria-label="Repeat mode"></button>
<label class="volume-control" for="volume"> <div id="volume-control" class="volume-control">
<span aria-hidden="true">VOL</span> <button id="volume-toggle" class="volume-toggle" type="button" data-i18n-aria="setVolume" aria-label="Set volume" aria-controls="volume-slider" aria-expanded="false">VOL</button>
<input id="volume" type="range" min="0" max="100" value="50"> <label id="volume-slider" class="volume-slider" for="volume">
<output id="volume-value">50%</output> <span aria-hidden="true">VOL</span>
</label> <input id="volume" type="range" min="0" max="100" value="50" data-i18n-aria="volume" aria-label="Volume">
<output id="volume-value">50%</output>
</label>
</div>
</section> </section>
<section class="workspace"> <section class="workspace">
@@ -53,17 +59,17 @@
<section class="library-pane panel"> <section class="library-pane panel">
<div class="panel-header library-header"> <div class="panel-header library-header">
<div> <div>
<span class="panel-kicker">MUZIEKBIBLIOTHEEK</span> <span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
<select id="library" aria-label="Muziekbibliotheek"></select> <select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
</div> </div>
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog"></button> <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>
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav> <nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
<div id="library-empty" class="empty-state" hidden> <div id="library-empty" class="empty-state" hidden>
<p>Geen bibliotheek geconfigureerd.</p> <p data-i18n="noLibraryConfigured">No library configured.</p>
<code>[libraries] muziek=D:\Muziek</code> <code>[libraries] muziek=D:\Muziek</code>
</div> </div>
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul> <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">
@@ -75,30 +81,30 @@
<img id="cover-image" alt="" hidden> <img id="cover-image" alt="" hidden>
</div> </div>
<div class="track-summary"> <div class="track-summary">
<span class="panel-kicker">NU AAN HET SPELEN</span> <span class="panel-kicker" data-i18n="nowPlaying">NOW PLAYING</span>
<h1 id="now-title">Nog niets gekozen</h1> <h1 id="now-title" data-i18n="nothingSelected">Nothing selected yet</h1>
<p id="now-meta">Voeg een track toe vanuit de bibliotheek</p> <p id="now-meta" data-i18n="addTrackHint">Add a track from the library</p>
</div> </div>
</section> </section>
</aside> </aside>
<section class="playlist-pane panel"> <section class="playlist-pane panel">
<div class="tabs-bar"> <div class="tabs-bar">
<div id="tabs" class="tabs" role="tablist" aria-label="Afspeellijsten"></div> <div id="tabs" class="tabs" role="tablist" data-i18n-aria="playlists" aria-label="Playlists"></div>
<button id="tab-add" class="tab-add" type="button" title="Nieuwe afspeellijst" aria-label="Nieuwe afspeellijst"></button> <button id="tab-add" class="tab-add" type="button" data-i18n-title="newPlaylist" title="New playlist" aria-label="New playlist"></button>
</div> </div>
<div class="playlist-toolbar"> <div class="playlist-toolbar">
<div> <div>
<span class="panel-kicker">AFSPEELLIJST</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">LIJST WISSEN</button> <button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
</div> </div>
<div id="playlist-empty" class="empty-state playlist-empty"> <div id="playlist-empty" class="empty-state playlist-empty">
<p>Deze afspeellijst is leeg.</p> <p data-i18n="emptyPlaylist">This playlist is empty.</p>
<span>Gebruik <strong></strong> bij een track of map.</span> <span data-i18n="emptyPlaylistHint">Use next to a track or folder.</span>
</div> </div>
<div class="playlist-scroll"> <div class="playlist-scroll">
@@ -106,10 +112,10 @@
<thead> <thead>
<tr> <tr>
<th class="number-column">#</th> <th class="number-column">#</th>
<th>TITEL</th> <th data-i18n="title">TITLE</th>
<th>ALBUM</th> <th data-i18n="album">ALBUM</th>
<th class="duration-column">DUUR</th> <th class="duration-column" data-i18n="duration">DURATION</th>
<th class="action-column"><span class="visually-hidden">Acties</span></th> <th class="action-column"><span class="visually-hidden" data-i18n="actions">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="playlist"></tbody> <tbody id="playlist"></tbody>
@@ -122,11 +128,11 @@
<div class="audio-status"> <div class="audio-status">
<span id="audio-bits">— bit</span> <span id="audio-bits">— bit</span>
<span id="audio-rate">— kHz</span> <span id="audio-rate">— kHz</span>
<span id="audio-channels">kanalen</span> <span id="audio-channels">channels</span>
<span id="audio-format"></span> <span id="audio-format"></span>
<span id="audio-source"></span> <span id="audio-source"></span>
</div> </div>
<p id="status" role="status" aria-live="polite">Verbinden</p> <p id="status" role="status" aria-live="polite" data-i18n="connecting">Connecting</p>
</footer> </footer>
</main> </main>
@@ -134,16 +140,17 @@
<form id="login-form" class="login-panel"> <form id="login-form" class="login-panel">
<span class="brand-mark">RKT</span> <span class="brand-mark">RKT</span>
<p class="panel-kicker">WEB PLAYER</p> <p class="panel-kicker">WEB PLAYER</p>
<h1 id="login-title">Aanmelden</h1> <h1 id="login-title" data-i18n="signIn">Sign in</h1>
<label for="login-username">Gebruikersnaam</label> <label for="login-username" data-i18n="username">Username</label>
<input id="login-username" name="username" autocomplete="username" required> <input id="login-username" name="username" autocomplete="username" required>
<label for="login-password">Wachtwoord</label> <label for="login-password" data-i18n="password">Password</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required> <input id="login-password" name="password" type="password" autocomplete="current-password" required>
<p id="login-error" class="login-error" role="alert"></p> <p id="login-error" class="login-error" role="alert"></p>
<button id="login-submit" class="text-button" type="submit">AANMELDEN</button> <button id="login-submit" class="text-button" type="submit" data-i18n="signIn">Sign in</button>
</form> </form>
</section> </section>
<script src="/translate.js" defer></script>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>
+95 -7
View File
@@ -43,6 +43,12 @@ select {
color: var(--text); color: var(--text);
} }
.language-select {
width: 58px;
min-width: 58px;
padding-right: 18px;
}
button { button {
cursor: pointer; cursor: pointer;
} }
@@ -276,19 +282,31 @@ input[type="range"] {
.volume-control { .volume-control {
align-self: stretch; align-self: stretch;
width: 210px; width: 210px;
gap: 9px; position: relative;
padding: 0 12px;
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
} }
.volume-control span, .volume-slider {
.volume-control output { display: flex;
width: 100%;
height: 100%;
gap: 9px;
align-items: center;
padding: 0 12px;
}
.volume-toggle {
display: none;
}
.volume-slider span,
.volume-slider output {
color: var(--muted); color: var(--muted);
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
} }
.volume-control output { .volume-slider output {
width: 32px; width: 32px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -861,7 +879,26 @@ input:focus-visible,
} }
.output-control select { .output-control select {
max-width: 180px; max-width: 140px;
}
.output-control .language-select {
width: 52px;
min-width: 52px;
}
#logout {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
overflow: hidden;
font-size: 0;
}
#logout::after {
content: "↪";
font-size: 17px;
} }
.transport-buttons .command-button { .transport-buttons .command-button {
@@ -873,7 +910,58 @@ input:focus-visible,
} }
.volume-control { .volume-control {
flex: 1; flex: 0 0 48px;
width: 48px;
padding: 0;
}
.volume-toggle {
display: grid;
width: 47px;
height: 52px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.volume-toggle:hover,
.volume-toggle[aria-expanded="true"] {
background: var(--accent-soft);
color: var(--accent);
}
.volume-slider {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
right: 0;
display: none;
width: 58px;
height: 190px;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
border: 1px solid var(--line);
background: var(--panel-raised);
box-shadow: 0 14px 35px rgb(0 0 0 / 55%);
}
.volume-control.open .volume-slider {
display: flex;
}
.volume-slider input[type="range"] {
width: 30px;
height: 125px;
writing-mode: vertical-lr;
direction: rtl;
}
.volume-slider output {
width: auto; width: auto;
} }
+162
View File
@@ -0,0 +1,162 @@
(() => {
"use strict";
const translations = {
en: {
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
rendererLocal: "LOCAL", defaultPlaylist: "Default",
playbackControls: "Playback controls", previousTrack: "Previous track",
playOrPause: "Play or pause", play: "Play", pause: "Pause", stop: "Stop",
nextTrack: "Next track", playbackPosition: "Playback position",
repeatMode: "Repeat mode", setVolume: "Set volume", volume: "Volume",
musicLibrary: "MUSIC LIBRARY", upFolder: "Up one folder", currentFolder: "Current folder",
noLibraryConfigured: "No library configured.", folderContents: "Folder contents",
nowPlaying: "NOW PLAYING", nothingSelected: "Nothing selected yet",
addTrackHint: "Add a track from the library", playlists: "Playlists",
newPlaylist: "New playlist", playlist: "PLAYLIST", clearList: "CLEAR LIST",
emptyPlaylist: "This playlist is empty.", emptyPlaylistHint: "Use next to a track or folder.",
title: "TITLE", album: "ALBUM", duration: "DURATION", actions: "Actions",
connecting: "Connecting…", signIn: "Sign in", username: "Username",
password: "Password", noLibrary: "No library", playNow: "Play {name} now",
addNamed: "Add {name}", loadingNamed: "Loading {name}…", addingNamed: "Adding {name}…",
playlistName: "Playlist name", removePlaylist: "Remove playlist",
removeNamed: "Remove {name}", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Repeat off", repeatAll: "Repeat all", repeatOne: "Repeat one track",
channels: "channels", oneChannel: "channel", searchingPlayers: "Searching for network players…",
authUnknown: "Authentication status unknown: {message}", noConnection: "No connection: {message}",
loggedOut: "You have been logged out.", volumePercent: "Set volume, {value} percent",
},
nl: {
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
playbackControls: "Afspeelbediening", previousTrack: "Vorige track",
playOrPause: "Afspelen of pauzeren", play: "Afspelen", pause: "Pauzeren", stop: "Stoppen",
nextTrack: "Volgende track", playbackPosition: "Afspeelpositie",
repeatMode: "Herhaalmodus", setVolume: "Volume instellen", volume: "Volume",
musicLibrary: "MUZIEKBIBLIOTHEEK", upFolder: "Eén map omhoog", currentFolder: "Huidige map",
noLibraryConfigured: "Geen bibliotheek geconfigureerd.", folderContents: "Mapinhoud",
nowPlaying: "NU AAN HET SPELEN", nothingSelected: "Nog niets gekozen",
addTrackHint: "Voeg een track toe vanuit de bibliotheek", playlists: "Afspeellijsten",
newPlaylist: "Nieuwe afspeellijst", playlist: "AFSPEELLIJST", clearList: "LIJST WISSEN",
emptyPlaylist: "Deze afspeellijst is leeg.", emptyPlaylistHint: "Gebruik bij een track of map.",
title: "TITEL", album: "ALBUM", duration: "DUUR", actions: "Acties",
connecting: "Verbinden…", signIn: "Aanmelden", username: "Gebruikersnaam",
password: "Wachtwoord", noLibrary: "Geen bibliotheek", playNow: "{name} nu afspelen",
addNamed: "{name} toevoegen", loadingNamed: "{name} laden…", addingNamed: "{name} toevoegen…",
playlistName: "Naam van de afspeellijst", removePlaylist: "Afspeellijst verwijderen",
removeNamed: "{name} verwijderen", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Herhalen uit", repeatAll: "Alles herhalen", repeatOne: "Eén track herhalen",
channels: "kanalen", oneChannel: "kanaal", searchingPlayers: "Netwerkspelers zoeken…",
authUnknown: "Authenticatiestatus onbekend: {message}", noConnection: "Geen verbinding: {message}",
loggedOut: "Je bent uitgelogd.", volumePercent: "Volume instellen, {value} procent",
},
de: {
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
playbackControls: "Wiedergabesteuerung", previousTrack: "Vorheriger Titel",
playOrPause: "Wiedergeben oder pausieren", play: "Wiedergeben", pause: "Pausieren", stop: "Stoppen",
nextTrack: "Nächster Titel", playbackPosition: "Wiedergabeposition",
repeatMode: "Wiederholungsmodus", setVolume: "Lautstärke einstellen", volume: "Lautstärke",
musicLibrary: "MUSIKBIBLIOTHEK", upFolder: "Einen Ordner nach oben", currentFolder: "Aktueller Ordner",
noLibraryConfigured: "Keine Bibliothek konfiguriert.", folderContents: "Ordnerinhalt",
nowPlaying: "AKTUELLE WIEDERGABE", nothingSelected: "Noch nichts ausgewählt",
addTrackHint: "Titel aus der Bibliothek hinzufügen", playlists: "Wiedergabelisten",
newPlaylist: "Neue Wiedergabeliste", playlist: "WIEDERGABELISTE", clearList: "LISTE LEEREN",
emptyPlaylist: "Diese Wiedergabeliste ist leer.", emptyPlaylistHint: "Verwenden Sie neben einem Titel oder Ordner.",
title: "TITEL", album: "ALBUM", duration: "DAUER", actions: "Aktionen",
connecting: "Verbinden…", signIn: "Anmelden", username: "Benutzername",
password: "Passwort", noLibrary: "Keine Bibliothek", playNow: "{name} jetzt wiedergeben",
addNamed: "{name} hinzufügen", loadingNamed: "{name} wird geladen…", addingNamed: "{name} wird hinzugefügt…",
playlistName: "Name der Wiedergabeliste", removePlaylist: "Wiedergabeliste entfernen",
removeNamed: "{name} entfernen", oneTrack: "Titel", manyTracks: "Titel",
repeatOff: "Wiederholung aus", repeatAll: "Alles wiederholen", repeatOne: "Einen Titel wiederholen",
channels: "Kanäle", oneChannel: "Kanal", searchingPlayers: "Netzwerkplayer werden gesucht…",
authUnknown: "Authentifizierungsstatus unbekannt: {message}", noConnection: "Keine Verbindung: {message}",
loggedOut: "Sie wurden abgemeldet.", volumePercent: "Lautstärke einstellen, {value} Prozent",
},
fr: {
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
playbackControls: "Commandes de lecture", previousTrack: "Piste précédente",
playOrPause: "Lire ou mettre en pause", play: "Lire", pause: "Pause", stop: "Arrêter",
nextTrack: "Piste suivante", playbackPosition: "Position de lecture",
repeatMode: "Mode répétition", setVolume: "Régler le volume", volume: "Volume",
musicLibrary: "BIBLIOTHÈQUE MUSICALE", upFolder: "Dossier parent", currentFolder: "Dossier actuel",
noLibraryConfigured: "Aucune bibliothèque configurée.", folderContents: "Contenu du dossier",
nowPlaying: "LECTURE EN COURS", nothingSelected: "Aucune sélection",
addTrackHint: "Ajoutez une piste depuis la bibliothèque", playlists: "Listes de lecture",
newPlaylist: "Nouvelle liste de lecture", playlist: "LISTE DE LECTURE", clearList: "VIDER LA LISTE",
emptyPlaylist: "Cette liste de lecture est vide.", emptyPlaylistHint: "Utilisez à côté dune piste ou dun dossier.",
title: "TITRE", album: "ALBUM", duration: "DURÉE", actions: "Actions",
connecting: "Connexion…", signIn: "Se connecter", username: "Nom dutilisateur",
password: "Mot de passe", noLibrary: "Aucune bibliothèque", playNow: "Lire {name} maintenant",
addNamed: "Ajouter {name}", loadingNamed: "Chargement de {name}…", addingNamed: "Ajout de {name}…",
playlistName: "Nom de la liste de lecture", removePlaylist: "Supprimer la liste de lecture",
removeNamed: "Supprimer {name}", oneTrack: "piste", manyTracks: "pistes",
repeatOff: "Répétition désactivée", repeatAll: "Tout répéter", repeatOne: "Répéter une piste",
channels: "canaux", oneChannel: "canal", searchingPlayers: "Recherche des lecteurs réseau…",
authUnknown: "État dauthentification inconnu : {message}", noConnection: "Aucune connexion : {message}",
loggedOut: "Vous avez été déconnecté.", volumePercent: "Régler le volume, {value} pour cent",
},
es: {
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
playbackControls: "Controles de reproducción", previousTrack: "Pista anterior",
playOrPause: "Reproducir o pausar", play: "Reproducir", pause: "Pausar", stop: "Detener",
nextTrack: "Pista siguiente", playbackPosition: "Posición de reproducción",
repeatMode: "Modo de repetición", setVolume: "Ajustar volumen", volume: "Volumen",
musicLibrary: "BIBLIOTECA MUSICAL", upFolder: "Subir una carpeta", currentFolder: "Carpeta actual",
noLibraryConfigured: "No hay ninguna biblioteca configurada.", folderContents: "Contenido de la carpeta",
nowPlaying: "REPRODUCIENDO", nothingSelected: "Nada seleccionado",
addTrackHint: "Añade una pista desde la biblioteca", playlists: "Listas de reproducción",
newPlaylist: "Nueva lista de reproducción", playlist: "LISTA DE REPRODUCCIÓN", clearList: "VACIAR LISTA",
emptyPlaylist: "Esta lista de reproducción está vacía.", emptyPlaylistHint: "Usa junto a una pista o carpeta.",
title: "TÍTULO", album: "ÁLBUM", duration: "DURACIÓN", actions: "Acciones",
connecting: "Conectando…", signIn: "Iniciar sesión", username: "Nombre de usuario",
password: "Contraseña", noLibrary: "Sin biblioteca", playNow: "Reproducir {name} ahora",
addNamed: "Añadir {name}", loadingNamed: "Cargando {name}…", addingNamed: "Añadiendo {name}…",
playlistName: "Nombre de la lista de reproducción", removePlaylist: "Eliminar lista de reproducción",
removeNamed: "Eliminar {name}", oneTrack: "pista", manyTracks: "pistas",
repeatOff: "Repetición desactivada", repeatAll: "Repetir todo", repeatOne: "Repetir una pista",
channels: "canales", oneChannel: "canal", searchingPlayers: "Buscando reproductores de red…",
authUnknown: "Estado de autenticación desconocido: {message}", noConnection: "Sin conexión: {message}",
loggedOut: "Has cerrado la sesión.", volumePercent: "Ajustar volumen, {value} por ciento",
},
};
const supported = Object.keys(translations);
const requested = [...(navigator.languages || [navigator.language])]
.filter(Boolean)
.map((value) => value.toLowerCase().split("-")[0]);
let language = requested.find((value) => supported.includes(value)) || "en";
function t(key, values = {}) {
const template = translations[language][key] ?? translations.en[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? `{${name}}`);
}
function apply(root = document) {
document.documentElement.lang = language;
root.querySelectorAll("[data-i18n]").forEach((element) => {
element.textContent = t(element.dataset.i18n);
});
root.querySelectorAll("[data-i18n-title]").forEach((element) => {
const value = t(element.dataset.i18nTitle);
element.title = value;
element.setAttribute("aria-label", value);
});
root.querySelectorAll("[data-i18n-aria]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAria));
});
}
function setLanguage(value) {
if (!supported.includes(value)) return;
language = value;
apply();
window.dispatchEvent(new CustomEvent("rkt-language-change"));
}
window.RktTranslate = { apply, language: () => language, setLanguage, supported, t };
window.addEventListener("DOMContentLoaded", () => apply());
})();
+6
View File
@@ -4,6 +4,8 @@ port=8080
[player] [player]
dlna-port=8734 dlna-port=8734
; Set to false when the server itself must not appear as an audio output.
local-output=true
[libraries] [libraries]
; muziek=D:\Muziek ; muziek=D:\Muziek
@@ -22,4 +24,8 @@ session-seconds=604800
[users] [users]
; Generate a hash with (make-password-hash "a long password") from ; Generate a hash with (make-password-hash "a long password") from
; rkt-web-player/users. Authentication is disabled while this section is empty. ; rkt-web-player/users. Authentication is disabled while this section is empty.
; you can also run racket -e "(require \"set-user.rkt\") (set-user)" from the
; directory where the rkt-web-player.ini file resides.
;
; hans=$argon2id$v=19$m=19456,t=2,p=1$... ; hans=$argon2id$v=19$m=19456,t=2,p=1$...
;
+12 -1
View File
@@ -26,6 +26,7 @@ browser by Racket's web server.
[#:listen-ip listen-ip string? "127.0.0.1"] [#:listen-ip listen-ip string? "127.0.0.1"]
[#:port port exact-positive-integer? 8080] [#:port port exact-positive-integer? 8080]
[#:dlna-port dlna-port exact-positive-integer? 8734] [#:dlna-port dlna-port exact-positive-integer? 8734]
[#:local-output? local-output? boolean? #t]
[#:playlist-keystore playlist-keystore (or/c path-string? #f)] [#:playlist-keystore playlist-keystore (or/c path-string? #f)]
[#:launch-browser? launch-browser? boolean? #t]) any/c] { [#:launch-browser? launch-browser? boolean? #t]) any/c] {
@@ -39,11 +40,19 @@ contents are browsed one level at a time. Metadata and recursive contents are
only read when the user adds or plays a selected item. only read when the user adds or plays a selected item.
The @racket[dlna-port] is used to publish local audio files to a selected The @racket[dlna-port] is used to publish local audio files to a selected
network renderer. Playlist tabs are atomically persisted in network renderer. Set @racket[local-output?] to @racket[#f] to omit the
server's own @tt{racket-audio} output. When no other renderer is available, the
player waits for discovery or playback-agent registration. Playlist tabs are
atomically persisted in
@racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player @racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player
resources are closed when the web server exits. The default is resources are closed when the web server exits. The default is
@tt{data/playlists.keystore} below the installed rkt-web-player collection. @tt{data/playlists.keystore} below the installed rkt-web-player collection.
Each username owns an ordered GUID index and separate playlist values. Each username owns an ordered GUID index and separate playlist values.
It also owns an independent playback pipeline and a durable interface-language
preference. Different outputs can play concurrently. Selecting an output that
another user owns stops the previous backend and transfers that output. The web
interface supports Dutch, English, German, French and Spanish; browser language
is the initial default and a manual choice is stored per username.
When @racket[users] is non-empty, every browser client must authenticate. When @racket[users] is non-empty, every browser client must authenticate.
Sessions have a sliding idle timeout; an active browser cookie is renewed once Sessions have a sliding idle timeout; an active browser cookie is renewed once
half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue
@@ -77,6 +86,8 @@ configured RKT Web Player server, downloads assigned tracks over HTTP, and
plays them with @tt{racket-audio}. Its configured display name is authoritative plays them with @tt{racket-audio}. Its configured display name is authoritative
and is followed by the server. Importing the module does not start the GUI; the and is followed by the server. Importing the module does not start the GUI; the
function must be called explicitly. function must be called explicitly.
The GUI and optional tray use Dutch, English, German, French or Spanish based
on the operating-system language, with English as fallback.
} }
@defproc[(run-player-agent-cli [#:server-url server-url @defproc[(run-player-agent-cli [#:server-url server-url