From e10f2cf7d767bfeff5f8d21f5a769e49ac121a84 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Fri, 28 Aug 2026 16:53:05 +0200 Subject: [PATCH] multi player / per user playing and translation --- ARCHITECTURE.md | 98 ++-- README.md | 21 +- main.rkt | 7 +- private/dlna-playback.rkt | 14 +- private/player-agent-core.rkt | 18 +- private/player-agent-gui.rkt | 41 +- private/player-agent-tray.rkt | 8 +- private/player.rkt | 939 ++++++++++++++++++++----------- private/playlists.rkt | 53 +- private/server.rkt | 20 + private/translate.rkt | 217 +++++++ public/app.js | 106 +++- public/index.html | 89 +-- public/styles.css | 102 +++- public/translate.js | 162 ++++++ rkt-web-player.ini.example | 6 + scribblings/rkt-web-player.scrbl | 13 +- 17 files changed, 1432 insertions(+), 482 deletions(-) create mode 100644 private/translate.rkt create mode 100644 public/translate.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fc8c97..4aa4027 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,9 +8,10 @@ can play selected files either on the host machine or on a discovered UPnP or Sonos renderer. The application is designed as a personal, stateful desktop or LAN service. A -single server-side player instance owns the active browser location, playlists, -playback state, output selection, and audio backend. All connected browser -windows observe and control that same instance. +single server-side player instance owns shared libraries, discovery and output +inventory. Each username owns its playlists, preferences and playback session. +Different users can therefore play concurrently when they select different +physical outputs. ## 2. System context @@ -29,7 +30,7 @@ flowchart LR 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; -there is no browser-side persistence or independent playback state. +the browser stores neither playlists nor language preferences. ## 3. Runtime structure @@ -42,7 +43,7 @@ flowchart TB Playlists[private/playlists.rkt
durable playlist tabs] DLNAAdapter[private/dlna-playback.rkt
playlist transition orchestration] Library[private/library.rkt
filesystem and metadata] - UI[public/index.html + styles.css + app.js
browser UI] + UI[public/index.html + styles.css + app.js + translate.js
browser UI] Audio[racket-audio
local backend] Discovery[racket-upnp + racket-sonos
device discovery] DLNA[racket-audio-dlna
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; - 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; -- the lazily created local or network backend; -- transport state, current track, position, duration, audio properties, volume, - and repeat mode; +- a playback session per username, containing its selected renderer, lazy + backend, current track, transport state, audio properties, volume and repeat + mode; +- one shared DLNA media-file server used by all network backends; - current error, discovery, and shutdown status; - synchronization primitives and the DLNA publication port. @@ -140,19 +142,29 @@ written in one `keystore` transaction. `playlists-for-` contains the ordered playlist GUIDs; each GUID key contains that playlist's name and tracks. Loading validates every stored track independently against all configured library roots, so one playlist can safely combine multiple libraries. +`language-for-` stores the user's selected interface language in the +same keystore. ### 3.4 Playback backends The player exposes local, UPnP, and Sonos outputs as renderers with a common logical interface, but dispatches explicitly based on backend kind. -The default `local` renderer uses `racket-audio`. 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. +Each username has an independent playback pipeline. Separate outputs can be +active concurrently. Outputs remain shared resources: selecting an output +claims it for that session. If another session owns it, that backend is closed, +the former session becomes stopped, and ownership transfers to the selecting +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 publication, UPnP calls and cached renderer information are delegated 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 position is pushed to the application state immediately. -Changing the selected renderer closes the existing backend and resets playback -state. The replacement backend remains lazy and is created only when it is -needed. +Changing a session's selected renderer closes that session's existing backend +and resets its playback state. The replacement backend remains lazy and is +created only when needed. Registered playback agents form a fourth renderer kind. An agent keeps 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. 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 - first option. + first option when local output is enabled. The `discovering` state lets polling clients show progress. Discovery failures 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. | | `POST` | `/api/discover` | Start asynchronous discovery and return the current state. | | `POST` | `/api/command/:command` | Execute a command with its JSON request body and return the updated state. | +| `GET` | `/api/preferences` | Return the current user's durable UI preferences. | +| `POST` | `/api/preferences` | Persist the current user's UI language. | | `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. | | `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; - 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 collections on every poll. Playback status and other small values are updated on every render. @@ -309,16 +331,15 @@ sequenceDiagram The application uses two semaphores with separate responsibilities: -- `command-lock` serializes commands and shutdown, preventing overlapping state - transitions and backend operations. +- `command-lock` serializes commands, state snapshots, agent transitions, + discovery commits and shutdown, preventing overlapping backend operations. - `state-lock` protects short reads and mutations of the shared player fields performed by HTTP requests, audio callbacks, and the discovery thread. Potentially slow discovery runs outside the state lock. Backend calls are also generally performed outside it, with their results committed in short locked -sections. A state request is not serialized by `command-lock`; it may observe -the last committed state while a command is performing external I/O, but its -snapshot is internally protected by `state-lock`. +sections. Audio callbacks use only the short-lived state lock and update the +playback session captured when their backend was created. 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 @@ -333,6 +354,7 @@ file: - web listen address, defaulting to `127.0.0.1`; - web port, defaulting to `8080`; - 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 setting remains supported); - 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 sources are combined and de-duplicated. -Playlist tabs use the SQLite-backed `keystore` module at -`data/playlists.keystore`. Output discovery, transport state, playback position -and sessions still reset when the process restarts. +Playlist tabs and per-user language preferences use the SQLite-backed +`keystore` module at `data/playlists.keystore`. Output discovery, transport +state, playback position and sessions still reset when the process restarts. ## 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 `Secure`. Forwarded client addresses are security-sensitive configuration: only known reverse-proxy peers may be trusted, and the application port should -remain firewalled from the internet. Playlists are isolated by username, while -renderer selection and transport state remain shared. +remain firewalled from the internet. Playlists, preferences, renderer selection +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 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 port also needs to be accessible on the relevant trusted network. -There is no durable job queue or retry policy. Discovery and renderer failures -are surfaced as shared UI errors, and renderer state is retried naturally by -subsequent polling requests. +There is no durable job queue or retry policy. Discovery failures are shared; +playback failures belong to the affected user's session. Renderer state is +retried naturally by subsequent polling requests. ## 8. Testing and extension points Unit tests embedded in `private/library.rkt` cover root creation, filtering, and directory ordering. `private/playlists.rkt` tests transactional round trips, 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 -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, or browser behavior. @@ -398,9 +422,9 @@ The main extension points are: ## 9. Architectural constraints and trade-offs -- **Per-user playlists, shared transport:** playlist collections are isolated - by username, while the renderer and transport remain shared. A playlist - command from another user explicitly takes over that shared player. +- **Per-user pipelines, shared outputs:** playlist and transport state are + isolated by username. Distinct outputs run concurrently; selecting an + occupied output explicitly stops its previous owner and transfers it. - **Full-state snapshots:** a small and predictable client protocol, at the cost of repeatedly transferring all tracks and browser entries. - **One-second polling:** robust and dependency-free, but introduces periodic diff --git a/README.md b/README.md index 72f8d38..4bee30c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ port=8080 [player] dlna-port=8734 +local-output=true [libraries] 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 `data/playlists.keystore` binnen de geïnstalleerde map van rkt-web-player. Met `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 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. Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt. -Het fysieke afspeelpunt en de transportbediening blijven gedeeld; een -playlistcommando van een andere gebruiker neemt die speler over. Een oudere +Iedere gebruiker heeft daarnaast een eigen afspeelpipeline met uitvoerkeuze, +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 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-` 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 LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de 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 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. +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 bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte, diff --git a/main.rkt b/main.rkt index d11e678..bebb111 100644 --- a/main.rkt +++ b/main.rkt @@ -48,7 +48,8 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. ; result : The result returned by serve/servlet. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -61,6 +62,7 @@ #:listen-ip [listen-ip "127.0.0.1"] #:port [port 8080] #:dlna-port [dlna-port 8734] + #:local-output? [local-output? #t] #:playlist-keystore [playlist-keystore default-playlist-keystore] #:launch-browser? [launch-browser? #t]) @@ -72,12 +74,14 @@ #:listen-ip string? #:port exact-positive-integer? #:dlna-port exact-positive-integer? + #:local-output? boolean? #:playlist-keystore (or/c path-string? #f) #:launch-browser? boolean?) any) (let* ((libraries (make-music-libraries music-paths)) (player (make-player libraries #:allowed-agent-ids allowed-agent-ids + #:local-output? local-output? #:playlist-keystore playlist-keystore #:dlna-port dlna-port)) (auth-manager @@ -198,6 +202,7 @@ (ini-get config 'server 'port 8080)) #:dlna-port (or dlna-port (ini-get config 'player 'dlna-port 8734)) + #:local-output? (ini-get config 'player 'local-output #t) #:playlist-keystore (or playlist-keystore (let ((configured diff --git a/private/dlna-playback.rkt b/private/dlna-playback.rkt index 448b3d9..77cd5a8 100644 --- a/private/dlna-playback.rkt +++ b/private/dlna-playback.rkt @@ -293,23 +293,29 @@ (with-lock playback (lambda () (poll/locked! playback)))) (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 tracks update error - #:port [port 8734] + #:media-file-server media-server #:poll-seconds [poll-seconds 1]) (define raw (make-dlna-player device - #:port port - #:path "/rkt-web-player/")) + #:media-file-server media-server)) (define playback (dlna-playback raw tracks update error 'off #f #f #f #f #f #f #f 0 #f #t #t #f (make-semaphore 1))) (set-dlna-playback-monitor! playback - (thread (lambda () (monitor-loop playback poll-seconds)))) + (thread (λ () (monitor-loop playback poll-seconds)))) playback) (define (dlna-playback-play-index! playback index) diff --git a/private/player-agent-core.rkt b/private/player-agent-core.rkt index a8e4b87..8edabcd 100644 --- a/private/player-agent-core.rkt +++ b/private/player-agent-core.rkt @@ -7,7 +7,8 @@ racket/path racket/port racket/string - simple-log) + simple-log + "translate.rkt") (provide (struct-out player-agent-runtime) make-player-agent-runtime) @@ -321,17 +322,12 @@ (with-handlers ((exn:fail:agent-denied? (lambda (exception) - (define message - (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)) + (define message (format (tr 'denied-message) app-id)) (warn-player-agent "Agent authorization refused: ~a" (exn-message exception)) (set-agent-error! message) (status-callback - "Niet geautoriseerd — applicatie-ID staat niet in de server-INI") + (tr 'unauthorized-status)) (unless authorization-notified? (set! authorization-notified? #t) (denied-callback message)) @@ -344,7 +340,7 @@ (exn-message exception)) (set-agent-error! (exn-message exception)) (status-callback - (format "Niet verbonden: ~a" (exn-message exception))) + (format (tr 'disconnected) (exn-message exception))) (when running (sleep 3) (poll-loop))))) @@ -352,7 +348,7 @@ "/api/agent/register" (hasheq 'appId app-id 'name assigned-name)) (clear-agent-error!) - (status-callback "Verbonden") + (status-callback (tr 'connected)) (info-player-agent "Registered at ~a as ~a" server-url assigned-name) (let loop () (when running @@ -390,7 +386,7 @@ (define (start!) (unless running (set! running #t) - (status-callback "Verbinden…") + (status-callback (tr 'connecting)) (set! worker (thread poll-loop)))) (define (stop!) diff --git a/private/player-agent-gui.rkt b/private/player-agent-gui.rkt index c28a235..17555f0 100644 --- a/private/player-agent-gui.rkt +++ b/private/player-agent-gui.rkt @@ -9,7 +9,8 @@ simple-log "player-agent-config.rkt" "player-agent-core.rkt" - "player-agent-tray.rkt") + "player-agent-tray.rkt" + "translate.rkt") (provide run-player-agent-gui) @@ -69,7 +70,7 @@ (define (show-denial! message) (queue-callback (lambda () - (message-box "Playback agent niet toegestaan" + (message-box (tr 'denied-title) message frame '(ok stop))) @@ -97,13 +98,13 @@ ((and artist (not (string=? artist "")) title) (format "~a — ~a" artist title)) (title title) - (else "Geen track geselecteerd"))) + (else (tr 'no-track-selected)))) (define prefix (cond - ((string=? state "playing") "Speelt") - ((string=? state "paused") "Gepauzeerd") - ((string=? state "starting") "Laden") - ((string=? state "stopped") "Gestopt") + ((string=? state "playing") (tr 'playing)) + ((string=? state "paused") (tr 'paused)) + ((string=? state "starting") (tr 'loading)) + ((string=? state "stopped") (tr 'stopped)) (else state))) (define position (hash-ref snapshot 'position 0)) (define duration (hash-ref snapshot 'duration 'null)) @@ -124,7 +125,7 @@ "") (if (number? 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 ""))) format-name @@ -138,7 +139,7 @@ (format " #~a" track-number) "") track-label) - "Er wordt niets afgespeeld")) + (tr 'no-track))) (send playback-details set-label (string-join details " · ")) (send playback-filename set-label @@ -160,7 +161,7 @@ (save-player-agent-config! config) (send name-field set-value 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!) (unless shutting-down? @@ -191,7 +192,7 @@ (set! frame (new agent-frame% - (label "RKT Web Player Agent") + (label (tr 'app-title)) (width 560) (height 310))) (define panel @@ -199,13 +200,15 @@ (parent frame) (alignment '(left top)))) (set! server-field - (input-field "RKT Web Player server" + (input-field (tr 'server) (player-agent-config-server-url config) panel)) (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 - (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 ;; their label and text poorly on some display configurations. (send (send id-field get-editor) lock #t) @@ -213,13 +216,13 @@ (define playback-panel (new group-box-panel% (parent panel) - (label "Afspelen") + (label (tr 'playback)) (alignment '(left top)) (stretchable-height #f))) (set! playback-message (new message% (parent playback-panel) - (label "Er wordt niets afgespeeld") + (label (tr 'no-track)) (auto-resize #t))) (set! playback-details (new message% @@ -239,12 +242,12 @@ (set! connect-button (new button% (parent controls) - (label "Opslaan en verbinden") + (label (tr 'save-connect)) (callback (lambda (_button _event) (reconnect!))))) (set! status-message (new message% (parent controls) - (label "Verbinden…") + (label (tr 'connecting)) (auto-resize #t))) (set! playback-timer @@ -268,5 +271,5 @@ (send frame show #t) ((player-agent-runtime-start! runtime)) - (send connect-button set-label "Opnieuw verbinden") + (send connect-button set-label (tr 'reconnect)) frame) diff --git a/private/player-agent-tray.rkt b/private/player-agent-tray.rkt index 4367dd5..62afd84 100644 --- a/private/player-agent-tray.rkt +++ b/private/player-agent-tray.rkt @@ -1,5 +1,7 @@ #lang racket/base +(require "translate.rkt") + ;; SDL3 is deliberately loaded dynamically. The GUI agent keeps working ;; without the optional Racket package and native SDL3 runtime. @@ -20,11 +22,11 @@ (define update-trays! (dynamic-require 'sdl3 'update-trays!)) (define tray-destroy! (dynamic-require 'sdl3 'tray-destroy!)) (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 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) - (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! quit-entry (lambda (_) (quit!))) (tray-controller diff --git a/private/player.rkt b/private/player.rkt index f78adf6..2f6abc7 100644 --- a/private/player.rkt +++ b/private/player.rkt @@ -23,6 +23,8 @@ player-agent-poll! player-agent-media player-track-artwork + player-user-language + player-user-language! exn:fail:agent-denied? player-close!) @@ -54,17 +56,9 @@ ([tabs #:mutable] [current-index #:mutable]) #:transparent) -(struct player - (libraries - allowed-agent-ids - [agents #:mutable] - [current-library-id #:mutable] - [browser-path #:mutable] - [browser-entries #:mutable] +(struct playback-session + (username [tracks #:mutable] - [tabs #:mutable] - [current-tab-index #:mutable] - [renderers #:mutable] [selected-id #:mutable] [backend #:mutable] [backend-kind #:mutable] @@ -79,15 +73,32 @@ [volume #:mutable] [repeat #:mutable] [error #:mutable] + local-music-indexes) + #:transparent) + +(struct player + (libraries + allowed-agent-ids + [agents #:mutable] + [current-library-id #:mutable] + [browser-path #:mutable] + [browser-entries #:mutable] + [tracks #:mutable] + [tabs #:mutable] + [current-tab-index #:mutable] + [renderers #:mutable] + [error #:mutable] [discovering? #:mutable] [closed? #:mutable] state-lock command-lock - local-music-indexes playlist-store playlist-contexts [active-playlist-user #:mutable] - dlna-port) + dlna-port + playback-sessions + renderer-owners + [media-file-server #:mutable]) #:transparent) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -98,9 +109,10 @@ (call-with-semaphore (player-state-lock value) proc)) (define (renderer-by-id value id) - (findf (λ (item) - (string=? (renderer-id item) id)) - (player-renderers value))) + (and (string? id) + (findf (λ (item) + (string=? (renderer-id item) id)) + (player-renderers value)))) (define (agent-renderer-id app-id) (string-append "agent:" app-id)) @@ -205,14 +217,23 @@ (filter (λ (item) (not (member (renderer-id item) stale-ids))) (player-renderers value))) - (when (member (player-selected-id value) stale-ids) - (set-player-selected-id! value "local") - (set-player-backend! value #f) - (set-player-backend-kind! value #f) - (set-player-state! value 'stopped) - (set-player-position! value 0) - (set-player-duration! value #f) - (reset-audio-info! value))))))) + (for ((session (in-hash-values (player-playback-sessions value)))) + (when (member (playback-session-selected-id session) stale-ids) + (hash-remove! (player-renderer-owners value) + (playback-session-selected-id session)) + (set-playback-session-selected-id! + session + (and (pair? (player-renderers value)) + (renderer-id (car (player-renderers value))))) + (set-playback-session-backend! session #f) + (set-playback-session-backend-kind! session #f) + (set-playback-session-state! session 'stopped) + (set-playback-session-position! session 0) + (set-playback-session-duration! session #f) + (set-playback-session-error! + session + "De playback agent is niet meer verbonden") + (reset-audio-info! session)))))))) (define (library-by-id value id) (findf (λ (library) @@ -259,14 +280,47 @@ normalized (lambda () (new-playlist-context value normalized)))) -(define (activate-playlist-user! value username) +(define (context-tracks context) + (playlist-tab-tracks + (list-ref (playlist-context-tabs context) + (playlist-context-current-index context)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Resolve or lazily create one user's independent playback pipeline. +; pre : Value is a player and username can be normalized to a playlist user. +; post : A missing session is initialized from that user's selected playlist. +; result : The stable playback-session belonging to the normalized username. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (playback-session-for! value username) + (define normalized (normal-playlist-username username)) + (hash-ref! + (player-playback-sessions value) + normalized + (λ () + (define context (playlist-context-for! value normalized)) + (playback-session + normalized + (context-tracks context) + (and (pair? (player-renderers value)) + (renderer-id (car (player-renderers value)))) + #f + #f + #f + 'stopped + 0 + #f + #f + #f + #f + #f + 50 + 'off + #f + (make-hash))))) + +(define (activate-playlist-user! value username session) (define normalized (normal-playlist-username username)) (unless (string=? normalized (player-active-playlist-user value)) - ;; Playback uses the active playlist's track indexes. Stop before another - ;; user's playlist command replaces that context. - (when (and (player-backend value) - (not (eq? (player-state value) 'stopped))) - (stop-playback! value)) (define context (playlist-context-for! value normalized)) (with-state-lock value @@ -277,11 +331,11 @@ (playlist-context-current-index context)) (set-player-tracks! value - (playlist-tab-tracks + (playlist-tab-tracks (list-ref (playlist-context-tabs context) (playlist-context-current-index context)))) - (set-player-current-index! value #f) - (set-player-active-playlist-user! value normalized))))) + (set-player-active-playlist-user! value normalized)))) + (set-playback-session-tracks! session (player-tracks value))) (define (persist-playlists! value) (define username (player-active-playlist-user value)) @@ -313,141 +367,185 @@ (λ () (set-player-error! value message)))) -(define (clear-error! value) - (set-error! value #f)) - -(define (reset-audio-info! value) - (set-player-rate! value #f) - (set-player-channels! value #f) - (set-player-bits! value #f) - (set-player-decoder! value #f)) - -(define (local-state-callback value handle state full-state) +(define (set-session-error! value session message) (with-state-lock value (λ () - (when (eq? handle (player-backend value)) + (set-playback-session-error! session message)))) + +(define (clear-session-error! value session) + (set-session-error! value session #f)) + +(define (reset-audio-info! session) + (set-playback-session-rate! session #f) + (set-playback-session-channels! session #f) + (set-playback-session-bits! session #f) + (set-playback-session-decoder! session #f)) + +(define (local-state-callback value session handle state full-state) + (with-state-lock + value + (λ () + (when (eq? handle (playback-session-backend session)) (let* ((music-id (hash-ref full-state 'at-music-id #f)) (audible-index (and (number? music-id) - (hash-ref (player-local-music-indexes value) + (hash-ref (playback-session-local-music-indexes session) music-id #f)))) (when (exact-nonnegative-integer? audible-index) - (set-player-current-index! value audible-index))) - (set-player-state! value (normalize-state state)) - (set-player-position! - value + (set-playback-session-current-index! session audible-index))) + (set-playback-session-state! session (normalize-state state)) + (set-playback-session-position! + session (or (hash-ref full-state 'at-second #f) 0)) - (set-player-duration! - value + (set-playback-session-duration! + session (hash-ref full-state 'duration #f)) - (set-player-rate! value - (hash-ref full-state 'rate #f)) - (set-player-channels! value - (hash-ref full-state 'channels #f)) - (set-player-bits! value - (hash-ref full-state 'bits #f)) - (set-player-decoder! value - (hash-ref full-state 'decoder #f)))))) + (set-playback-session-rate! + session + (hash-ref full-state 'rate #f)) + (set-playback-session-channels! + session + (hash-ref full-state 'channels #f)) + (set-playback-session-bits! + session + (hash-ref full-state 'bits #f)) + (set-playback-session-decoder! + session + (hash-ref full-state 'decoder #f)))))) -(define (local-end-callback value handle) - (when (eq? handle (player-backend value)) +(define (local-end-callback value session handle) + (when (eq? handle (playback-session-backend session)) (with-handlers ((exn:fail? (λ (exception) - (set-error! value (exn-message exception))))) + (set-session-error! value session (exn-message exception))))) (call-with-semaphore (player-command-lock value) (λ () - (let ((index (next-index value 1))) + (let ((index (next-index session 1))) ;; Queue immediately at decoder EOF, but keep the old track selected ;; until libao reports the new music id as actually audible. (when index - (play-index! value index #t)))))))) + (play-index! value session index #t)))))))) -(define (make-local-backend value) +(define (make-local-backend value session) (let ((backend (make-audio-player (λ (handle state full-state) - (local-state-callback value handle state full-state)) + (local-state-callback value session handle state full-state)) (λ (handle) - (local-end-callback value handle))))) + (local-end-callback value session handle))))) (audio-ao-buf-ms! backend 500) (audio-buf-seconds! backend 4 10) (let ((logical-volume - (/ (player-volume value) 100.0))) + (/ (playback-session-volume session) 100.0))) (audio-volume! backend (* 100.0 logical-volume logical-volume))) backend)) -(define (network-state-callback value state index info) +(define (network-state-callback value session state index info) (define track-info (dlna-info-track info)) (with-state-lock value - (lambda () - (when (valid-track-index? value index) - (set-player-current-index! value index)) - (set-player-state! value state) - (set-player-position! value (or (dlna-info-position info) 0)) - (set-player-duration! value (dlna-info-duration info)) - (set-player-rate! - value + (λ () + (when (valid-track-index? session index) + (set-playback-session-current-index! session index)) + (set-playback-session-state! session state) + (set-playback-session-position! + session + (or (dlna-info-position info) 0)) + (set-playback-session-duration! session (dlna-info-duration info)) + (set-playback-session-rate! + session (and track-info (dlna-track-info-sample-rate track-info))) - (set-player-channels! - value + (set-playback-session-channels! + session (and track-info (dlna-track-info-channels track-info))) - (set-player-bits! value #f) - (set-player-decoder! value 'dlna) + (set-playback-session-bits! session #f) + (set-playback-session-decoder! session 'dlna) (when (number? (dlna-info-volume info)) - (set-player-volume! value (dlna-info-volume info)))))) + (set-playback-session-volume! session (dlna-info-volume info)))))) -(define (make-network-backend value device) +(define (ensure-media-file-server! value) + (or (player-media-file-server value) + (let* ((address (upnp-default-ipv4-address)) + (server + (start-media-file-server + (format "http://~a:~a/rkt-web-player/" + address + (player-dlna-port value)) + #:listen-ip address))) + (set-player-media-file-server! value server) + server))) + +(define (make-network-backend value session device) (define backend (make-dlna-playback device - (lambda () (player-tracks value)) - (lambda (state index info) - (network-state-callback value state index info)) - (lambda (message) (set-error! value message)) - #:port (player-dlna-port value))) - (dlna-playback-repeat! backend (player-repeat value)) + (λ () (playback-session-tracks session)) + (λ (state index info) + (network-state-callback value session state index info)) + (λ (message) (set-session-error! value session message)) + #:media-file-server (ensure-media-file-server! value))) + (dlna-playback-repeat! backend (playback-session-repeat session)) backend) -(define (ensure-backend! value) - (if (player-backend value) - (player-backend value) +(define (claim-renderer! value session) + (define id (playback-session-selected-id session)) + (define owner (and id (hash-ref (player-renderer-owners value) id #f))) + (when (and owner (not (eq? owner session))) + (close-backend! value owner) + (set-session-error! + value + owner + "Het afspeelpunt is door een andere gebruiker overgenomen")) + (when id + (hash-set! (player-renderer-owners value) id session))) + +(define (ensure-backend! value session) + (if (playback-session-backend session) + (playback-session-backend session) (let* ((selected (renderer-by-id value - (player-selected-id value))) - (kind (and selected (renderer-kind selected))) + (playback-session-selected-id session))) + (_ + (unless selected + (raise-arguments-error + 'player-command! + "selected renderer is unavailable" + "renderer" (playback-session-selected-id session)))) + (kind (renderer-kind selected)) + (_claimed (claim-renderer! value session)) (backend (cond ((eq? kind 'local) - (make-local-backend value)) + (make-local-backend value session)) ((or (eq? kind 'upnp) (eq? kind 'sonos)) (make-network-backend value + session (renderer-device selected))) ((eq? kind 'agent) (renderer-device selected)) (else (raise-arguments-error 'player-command! - "selected renderer is unavailable" - "renderer" (player-selected-id value)))))) + "unsupported renderer kind" + "kind" kind))))) (with-state-lock value (λ () - (set-player-backend! value backend) - (set-player-backend-kind! value kind))) + (set-playback-session-backend! session backend) + (set-playback-session-backend-kind! session kind))) backend))) -(define (close-backend! value) - (let ((backend (player-backend value)) - (kind (player-backend-kind value))) +(define (close-backend! value session) + (let ((backend (playback-session-backend session)) + (kind (playback-session-backend-kind session))) (when backend (with-handlers ((exn:fail? @@ -466,68 +564,69 @@ (with-state-lock value (λ () - (set-player-backend! value #f) - (set-player-backend-kind! value #f) - (set-player-state! value 'stopped) - (set-player-position! value 0) - (set-player-duration! value #f) - (reset-audio-info! value))))) + (set-playback-session-backend! session #f) + (set-playback-session-backend-kind! session #f) + (set-playback-session-state! session 'stopped) + (set-playback-session-position! session 0) + (set-playback-session-duration! session #f) + (reset-audio-info! session))))) -(define (stop-playback! value) - (when (player-backend value) +(define (stop-playback! value session) + (when (playback-session-backend session) (cond - ((eq? (player-backend-kind value) 'local) - (audio-stop! (player-backend value))) - ((eq? (player-backend-kind value) 'agent) + ((eq? (playback-session-backend-kind session) 'local) + (audio-stop! (playback-session-backend session))) + ((eq? (playback-session-backend-kind session) 'agent) (enqueue-agent-command! value - (player-backend value) + (playback-session-backend session) "stop")) (else - (dlna-playback-stop! (player-backend value))))) + (dlna-playback-stop! (playback-session-backend session))))) (with-state-lock value (λ () - (set-player-state! value 'stopped) - (set-player-position! value 0) - (reset-audio-info! value)))) + (set-playback-session-state! session 'stopped) + (set-playback-session-position! session 0) + (reset-audio-info! session)))) -(define (valid-track-index? value index) +(define (valid-track-index? session index) (and (exact-nonnegative-integer? index) - (< index (length (player-tracks value))))) + (< index (length (playback-session-tracks session))))) -(define (play-index! value index [defer-current-index? #f]) - (unless (valid-track-index? value index) +(define (play-index! value session index [defer-current-index? #f]) + (unless (valid-track-index? session index) (raise-arguments-error 'player-command! "track index is outside the playlist" "index" index)) - (let* ((item (list-ref (player-tracks value) index)) - (backend (ensure-backend! value)) - (kind (player-backend-kind value))) + (let* ((item (list-ref (playback-session-tracks session) index)) + (backend (ensure-backend! value session)) + (kind (playback-session-backend-kind session))) (with-state-lock value (λ () (unless defer-current-index? - (set-player-current-index! value index) - (set-player-state! value 'starting) - (set-player-position! value 0) - (set-player-duration! value (track-duration item))))) + (set-playback-session-current-index! session index) + (set-playback-session-state! session 'starting) + (set-playback-session-position! session 0) + (set-playback-session-duration! session (track-duration item))))) (cond ((eq? kind 'local) (let ((music-id (audio-play! backend (track-file item)))) (with-state-lock value (λ () - (hash-set! (player-local-music-indexes value) + (hash-set! (playback-session-local-music-indexes session) music-id index))))) ((eq? kind 'agent) (let* ((token (fresh-media-token)) (data (agent-track-data item index token)) - (following-index (next-index value 1)) + (following-index (next-index session 1)) (following-item (and following-index - (list-ref (player-tracks value) following-index))) + (list-ref (playback-session-tracks session) + following-index))) (following-token (and following-item (fresh-media-token))) (following-data @@ -551,12 +650,12 @@ value backend "prefetch" following-data)))) (else (dlna-playback-play-index! backend index))) - (clear-error! value))) + (clear-session-error! value session))) -(define (next-index value direction) - (let ((count (length (player-tracks value))) - (current (player-current-index value)) - (repeat (player-repeat value))) +(define (next-index session direction) + (let ((count (length (playback-session-tracks session))) + (current (playback-session-current-index session)) + (repeat (playback-session-repeat session))) (cond ((zero? count) #f) ((eq? repeat 'one) current) @@ -570,46 +669,57 @@ (if (positive? direction) 0 (- count 1))) (else #f))))))) -(define (refresh-agent-state! value) +(define (refresh-agent-state! value session) (define reported - (playback-agent-reported-state (player-backend value))) + (playback-agent-reported-state (playback-session-backend session))) ;; Keep optimistic command state until playback-changing work is acknowledged. (when (and (hash? reported) (andmap (λ (command) (string=? (hash-ref command 'action "") "prefetch")) - (playback-agent-commands (player-backend value)))) + (playback-agent-commands + (playback-session-backend session)))) (with-state-lock value (λ () (let ((state (hash-ref reported 'state #f))) (when (string? state) - (set-player-state! - value + (set-playback-session-state! + session (normalize-state (string->symbol state))))) - (set-player-position! value - (or (json-number reported 'position #f) 0)) - (set-player-duration! value - (json-number reported 'duration #f)) - (set-player-rate! value (json-number reported 'rate #f)) - (set-player-channels! value (json-number reported 'channels #f)) - (set-player-bits! value (json-number reported 'bits #f)) + (set-playback-session-position! + session + (or (json-number reported 'position #f) 0)) + (set-playback-session-duration! + session + (json-number reported 'duration #f)) + (set-playback-session-rate! + session + (json-number reported 'rate #f)) + (set-playback-session-channels! + session + (json-number reported 'channels #f)) + (set-playback-session-bits! + session + (json-number reported 'bits #f)) (let ((decoder (json-string reported 'format #f))) - (set-player-decoder! - value + (set-playback-session-decoder! + session (and decoder (string->symbol decoder)))) (let ((volume (json-number reported 'volume #f))) - (when volume (set-player-volume! value volume))) - (set-player-error! value (json-string reported 'error #f)))))) + (when volume (set-playback-session-volume! session volume))) + (set-playback-session-error! + session + (json-string reported 'error #f)))))) -(define (refresh-network-state! value) - (when (and (player-backend value) - (eq? (player-backend-kind value) 'agent)) +(define (refresh-network-state! value session) + (when (and (playback-session-backend session) + (eq? (playback-session-backend-kind session) 'agent)) (with-handlers ((exn:fail? (λ (exception) - (set-error! value (exn-message exception))))) - (refresh-agent-state! value)))) + (set-session-error! value session (exn-message exception))))) + (refresh-agent-state! value session)))) (define (entry-by-index value index) (and (exact-nonnegative-integer? index) @@ -645,7 +755,7 @@ (equal? (normal-case-path (track-file first)) (normal-case-path (track-file second)))) -(define (append-tracks! value tracks) +(define (append-tracks! value session tracks) (let ((combined (foldl (λ (item result) @@ -660,27 +770,29 @@ value (λ () (set-player-tracks! value combined) + (set-playback-session-tracks! session combined) (save-current-tab! value) (persist-playlists! value))))) -(define (replace-tracks! value tracks) - (stop-playback! value) +(define (replace-tracks! value session tracks) + (stop-playback! value session) (with-state-lock value (λ () (set-player-tracks! value tracks) - (set-player-current-index! value #f) + (set-playback-session-tracks! session tracks) + (set-playback-session-current-index! session #f) (save-current-tab! value) (persist-playlists! value)))) -(define (drop-track! value index) - (unless (valid-track-index? value index) +(define (drop-track! value session index) + (unless (valid-track-index? session index) (raise-arguments-error 'player-command! "track index is outside the playlist" "index" index)) - (when (equal? index (player-current-index value)) - (stop-playback! value)) + (when (equal? index (playback-session-current-index session)) + (stop-playback! value session)) (with-state-lock value (λ () @@ -688,20 +800,21 @@ value (append (take (player-tracks value) index) (drop (player-tracks value) (+ index 1)))) + (set-playback-session-tracks! session (player-tracks value)) (cond - ((equal? index (player-current-index value)) - (set-player-current-index! value #f)) - ((and (player-current-index value) - (< index (player-current-index value))) - (set-player-current-index! - value - (- (player-current-index value) 1)))) + ((equal? index (playback-session-current-index session)) + (set-playback-session-current-index! session #f)) + ((and (playback-session-current-index session) + (< index (playback-session-current-index session))) + (set-playback-session-current-index! + session + (- (playback-session-current-index session) 1)))) (save-current-tab! value) (persist-playlists! value)))) -(define (move-track! value from-index to-index) - (unless (and (valid-track-index? value from-index) - (valid-track-index? value to-index)) +(define (move-track! value session from-index to-index) + (unless (and (valid-track-index? session from-index) + (valid-track-index? session to-index)) (raise-arguments-error 'player-command! "track move indexes are outside the playlist" @@ -720,22 +833,23 @@ (append (take without to-index) (list item) (drop without to-index))) - (current (player-current-index value))) + (current (playback-session-current-index session))) (set-player-tracks! value moved) + (set-playback-session-tracks! session moved) (when current (cond ((= current from-index) - (set-player-current-index! value to-index)) + (set-playback-session-current-index! session to-index)) ((and (< from-index current) (<= current to-index)) - (set-player-current-index! value (- current 1))) + (set-playback-session-current-index! session (- current 1))) ((and (<= to-index current) (< current from-index)) - (set-player-current-index! value (+ current 1))))) + (set-playback-session-current-index! session (+ current 1))))) (save-current-tab! value) (persist-playlists! value)))))) -(define (select-tab! value index) +(define (select-tab! value session index) (unless (and (exact-nonnegative-integer? index) (< index (length (player-tabs value)))) (raise-arguments-error @@ -743,7 +857,7 @@ "playlist tab does not exist" "index" index)) (unless (= index (player-current-tab-index value)) - (stop-playback! value) + (stop-playback! value session) (with-state-lock value (λ () @@ -752,10 +866,11 @@ (set-player-tracks! value (playlist-tab-tracks (current-tab value))) - (set-player-current-index! value #f) + (set-playback-session-tracks! session (player-tracks value)) + (set-playback-session-current-index! session #f) (persist-playlists! value))))) -(define (add-tab! value) +(define (add-tab! value session) (with-state-lock value (λ () @@ -770,7 +885,8 @@ (set-player-tabs! value (append tabs (list tab))) (set-player-current-tab-index! value (length tabs)) (set-player-tracks! value '()) - (set-player-current-index! value #f) + (set-playback-session-tracks! session '()) + (set-playback-session-current-index! session #f) (persist-playlists! value))))) (define (rename-tab! value index name) @@ -793,7 +909,7 @@ trimmed) (persist-playlists! value))))) -(define (delete-tab! value index) +(define (delete-tab! value session index) (when (= (length (player-tabs value)) 1) (raise-arguments-error 'player-command! @@ -804,7 +920,7 @@ 'player-command! "playlist tab does not exist" "index" index)) - (stop-playback! value) + (stop-playback! value session) (with-state-lock value (λ () @@ -820,7 +936,8 @@ (set-player-tracks! value (playlist-tab-tracks (list-ref tabs new-index))) - (set-player-current-index! value #f) + (set-playback-session-tracks! session (player-tracks value)) + (set-playback-session-current-index! session #f) (persist-playlists! value))))) (define (track->jsexpr item index) @@ -918,7 +1035,7 @@ (let ((value (hash-ref data key default))) (if (string? value) value default))) -(define (perform-library-command! value command data) +(define (perform-library-command! value session command data) (let ((index (json-number data 'index #f))) (cond ((string=? command "library") @@ -954,91 +1071,94 @@ (raise-arguments-error 'player-command! "selected item contains no supported audio")) - (replace-tracks! value tracks) - (play-index! value 0))) + (replace-tracks! value session tracks) + (play-index! value session 0))) ((string=? command "item-add") (append-tracks! value + session (selected-entry-tracks value index)))))) -(define (perform-playlist-command! value command data) +(define (perform-playlist-command! value session command data) (let ((index (json-number data 'index #f))) (cond ((string=? command "track-remove") - (drop-track! value index)) + (drop-track! value session index)) ((string=? command "track-move") (move-track! value + session (json-number data 'from #f) (json-number data 'to #f))) ((string=? command "playlist-clear") - (replace-tracks! value '())) + (replace-tracks! value session '())) ((string=? command "tab-add") - (stop-playback! value) - (add-tab! value)) + (stop-playback! value session) + (add-tab! value session)) ((string=? command "tab-select") - (select-tab! value index)) + (select-tab! value session index)) ((string=? command "tab-rename") (rename-tab! value index (or (json-string data 'name #f) ""))) ((string=? command "tab-delete") - (delete-tab! value index))))) + (delete-tab! value session index))))) -(define (perform-command! value command data) +(define (perform-command! value session command data) (cond ((member command '("library" "browse" "up" "item-play" "item-add")) - (perform-library-command! value command data)) + (perform-library-command! value session command data)) ((member command '("track-remove" "track-move" "playlist-clear" "tab-add" "tab-select" "tab-rename" "tab-delete")) - (perform-playlist-command! value command data)) + (perform-playlist-command! value session command data)) ((string=? command "play") (play-index! value + session (or (json-number data 'index #f) - (player-current-index value) + (playback-session-current-index session) 0))) ((string=? command "pause") - (let ((backend (ensure-backend! value))) + (let ((backend (ensure-backend! value session))) (cond - ((eq? (player-backend-kind value) 'local) + ((eq? (playback-session-backend-kind session) 'local) (audio-pause! backend #t)) - ((eq? (player-backend-kind value) 'agent) + ((eq? (playback-session-backend-kind session) 'agent) (enqueue-agent-command! value backend "pause")) (else (dlna-playback-pause! backend))))) ((string=? command "resume") - (let ((backend (ensure-backend! value))) + (let ((backend (ensure-backend! value session))) (cond - ((eq? (player-backend-kind value) 'local) + ((eq? (playback-session-backend-kind session) 'local) (audio-pause! backend #f)) - ((eq? (player-backend-kind value) 'agent) + ((eq? (playback-session-backend-kind session) 'agent) (enqueue-agent-command! value backend "resume")) (else (dlna-playback-resume! backend))))) ((string=? command "stop") - (stop-playback! value)) + (stop-playback! value session)) ((string=? command "next") - (let ((index (next-index value 1))) + (let ((index (next-index session 1))) (if index - (play-index! value index) - (stop-playback! value)))) + (play-index! value session index) + (stop-playback! value session)))) ((string=? command "previous") - (let ((index (next-index value -1))) + (let ((index (next-index session -1))) (when index - (play-index! value index)))) + (play-index! value session index)))) ((string=? command "seek") (let ((percentage (json-number data 'percentage #f))) (unless percentage (raise-arguments-error 'player-command! "seek requires a numeric percentage")) - (let ((backend (ensure-backend! value))) + (let ((backend (ensure-backend! value session))) (cond - ((eq? (player-backend-kind value) 'local) + ((eq? (playback-session-backend-kind session) 'local) (audio-seek! backend percentage)) - ((eq? (player-backend-kind value) 'agent) + ((eq? (playback-session-backend-kind session) 'agent) (enqueue-agent-command! value backend "seek" (hasheq 'percentage percentage))) @@ -1051,14 +1171,14 @@ 'player-command! "volume requires a numeric value")) (let* ((clamped (min 100 (max 0 percentage))) - (backend (ensure-backend! value))) + (backend (ensure-backend! value session))) (cond - ((eq? (player-backend-kind value) 'local) + ((eq? (playback-session-backend-kind session) 'local) (let ((logical-volume (/ clamped 100.0))) (audio-volume! backend (* 100.0 logical-volume logical-volume)))) - ((eq? (player-backend-kind value) 'agent) + ((eq? (playback-session-backend-kind session) 'agent) (enqueue-agent-command! value backend "volume" (hasheq 'value clamped))) @@ -1067,7 +1187,7 @@ (with-state-lock value (λ () - (set-player-volume! value clamped)))))) + (set-playback-session-volume! session clamped)))))) ((string=? command "repeat") (let* ((name (json-string data 'mode "off")) (mode (string->symbol name))) @@ -1079,9 +1199,11 @@ (with-state-lock value (λ () - (set-player-repeat! value mode))) - (when (member (player-backend-kind value) '(upnp sonos)) - (dlna-playback-repeat! (player-backend value) mode)))) + (set-playback-session-repeat! session mode))) + (when (member (playback-session-backend-kind session) '(upnp sonos)) + (dlna-playback-repeat! + (playback-session-backend session) + mode)))) ((string=? command "renderer") (let* ((id (json-string data 'id #f)) (selected (and id (renderer-by-id value id)))) @@ -1090,12 +1212,17 @@ 'player-command! "renderer does not exist" "id" id)) - (unless (string=? id (player-selected-id value)) - (close-backend! value) + (unless (equal? id (playback-session-selected-id session)) + (let ((old-id (playback-session-selected-id session))) + (close-backend! value session) + (when (eq? (hash-ref (player-renderer-owners value) old-id #f) + session) + (hash-remove! (player-renderer-owners value) old-id))) (with-state-lock value (λ () - (set-player-selected-id! value id)))))) + (set-playback-session-selected-id! session id))) + (claim-renderer! value session)))) (else (raise-arguments-error 'player-command! @@ -1108,14 +1235,19 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a player for lazily browsed music libraries. -; pre : Libraries are valid; allowed agent IDs are 256-bit hex strings. +; pre : Libraries are valid, allowed agent IDs are 256-bit hex strings, and +; local-output? is a boolean. ; post : Only the selected root directory has been listed; no backend exists. -; result : A player that initially selects local playback. +; result : A player that initially selects local playback when it is enabled; +; otherwise no renderer is selected until one becomes available. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (make-player libraries #:allowed-agent-ids [allowed-agent-ids '()] + #:local-output? [local-output? #t] #:playlist-keystore [playlist-keystore #f] #:dlna-port [dlna-port 8734]) + (unless (boolean? local-output?) + (raise-argument-error 'make-player "boolean?" local-output?)) (define normalized-agent-ids (for/list ((app-id (in-list allowed-agent-ids))) (unless (valid-agent-id? app-id) @@ -1153,30 +1285,21 @@ (playlist-tab-tracks (list-ref tabs selected-index)) tabs selected-index - (list (renderer "local" "Server audio output" 'local #f)) - "local" - #f - #f - #f - 'stopped - 0 - #f - #f - #f - #f - #f - 50 - 'off + (if local-output? + (list (renderer "local" "Server audio output" 'local #f)) + '()) #f #f #f (make-semaphore 1) (make-semaphore 1) - (make-hash) store contexts "anonymous" - dlna-port)) + dlna-port + (make-hash) + (make-hash) + #f)) value)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1189,9 +1312,10 @@ (define normalized (normal-playlist-username username)) (call-with-semaphore (player-command-lock value) - (lambda () + (λ () (prune-stale-agents! value) - (refresh-network-state! value) + (define session (playback-session-for! value normalized)) + (refresh-network-state! value session) (define context (playlist-context-for! value normalized)) (define tabs (playlist-context-tabs context)) (define tab-index (playlist-context-current-index context)) @@ -1200,12 +1324,12 @@ value (λ () (let ((current - (and (player-current-index value) + (and (playback-session-current-index session) (valid-track-index? - value - (player-current-index value)) - (list-ref (player-tracks value) - (player-current-index value))))) + session + (playback-session-current-index session)) + (list-ref (playback-session-tracks session) + (playback-session-current-index session))))) (hasheq 'libraries (map library->jsexpr (player-libraries value)) @@ -1229,29 +1353,28 @@ (track->jsexpr item index)) 'renderers (map renderer->jsexpr (player-renderers value)) - 'rendererId (player-selected-id value) - 'currentIndex - (if (string=? normalized (player-active-playlist-user value)) - (or (player-current-index value) 'null) - 'null) - 'state (symbol->string (player-state value)) - 'position (player-position value) - 'duration (or (player-duration value) 'null) - 'rate (or (player-rate value) 'null) - 'channels (or (player-channels value) 'null) - 'bits (or (player-bits value) 'null) - 'format (if (player-decoder value) - (format "~a" (player-decoder value)) + 'rendererId (or (playback-session-selected-id session) 'null) + 'currentIndex (or (playback-session-current-index session) 'null) + 'state (symbol->string (playback-session-state session)) + 'position (playback-session-position session) + 'duration (or (playback-session-duration session) 'null) + 'rate (or (playback-session-rate session) 'null) + 'channels (or (playback-session-channels session) 'null) + 'bits (or (playback-session-bits session) 'null) + 'format (if (playback-session-decoder session) + (format "~a" (playback-session-decoder session)) "") 'source (if current (path->string (or (file-name-from-path (track-file current)) (track-file current))) "") - 'volume (player-volume value) - 'repeat (symbol->string (player-repeat value)) + 'volume (playback-session-volume session) + 'repeat (symbol->string (playback-session-repeat session)) 'discovering (player-discovering? value) - 'error (or (player-error value) 'null)))))))) + 'error (or (playback-session-error session) + (player-error value) + 'null)))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Execute one browser player command. @@ -1269,6 +1392,7 @@ (call-with-semaphore (player-command-lock value) (λ () + (define session (playback-session-for! value normalized)) (when (player-closed? value) (raise-arguments-error 'player-command! @@ -1276,11 +1400,11 @@ (with-handlers ((exn:fail? (λ (exception) - (set-error! value (exn-message exception)) + (set-session-error! value session (exn-message exception)) (raise exception)))) (when (member command playlist-context-commands) - (activate-playlist-user! value normalized)) - (perform-command! value command data)))) + (activate-playlist-user! value normalized session)) + (perform-command! value session command data)))) (player-state->jsexpr value #:username normalized)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1307,21 +1431,41 @@ (λ (exception) (set-error! value (exn-message exception))))) (let ((found (discover-renderers))) - (with-state-lock - value + (call-with-semaphore + (player-command-lock value) (λ () - (set-player-renderers! - value - (append - (list (renderer "local" - "Server audio output" - 'local - #f)) - found - (filter (λ (item) - (eq? (renderer-kind item) 'agent)) - (player-renderers value)))) - (set-player-error! value #f))))) + (let ((outputs + (append + (filter (λ (item) + (eq? (renderer-kind item) 'local)) + (player-renderers value)) + found + (filter (λ (item) + (eq? (renderer-kind item) 'agent)) + (player-renderers value))))) + (set-player-renderers! value outputs) + (for ((session + (in-hash-values + (player-playback-sessions value)))) + (unless (renderer-by-id + value + (playback-session-selected-id session)) + (define old-id + (playback-session-selected-id session)) + (close-backend! value session) + (when (and old-id + (eq? session + (hash-ref + (player-renderer-owners value) + old-id + #f))) + (hash-remove! (player-renderer-owners value) + old-id)) + (set-playback-session-selected-id! + session + (and (pair? outputs) + (renderer-id (car outputs)))))) + (set-player-error! value #f)))))) (with-state-lock value (λ () @@ -1351,42 +1495,52 @@ "appId must contain exactly 64 hexadecimal characters" "appId" supplied-app-id)) (require-authorized-agent! value app-id) - (with-state-lock - value + (call-with-semaphore + (player-command-lock value) (λ () - (let ((existing (agent-by-id value app-id))) - (if existing - (begin - (set-playback-agent-name! existing advertised-name) - (let ((agent-output (agent-renderer value existing))) - (when agent-output - (set-renderer-name! agent-output advertised-name))) - (set-playback-agent-last-seen! - existing - (current-seconds)) - (hasheq 'name (playback-agent-name existing) - 'pollIntervalMs 1000)) - (let* ((name advertised-name) - (agent - (playback-agent - app-id name (current-seconds) - (hasheq 'state "stopped" - 'position 0 - 'volume 50) - '() 1 (make-hash) 0))) - (set-player-agents! - value - (append (player-agents value) (list agent))) - (set-player-renderers! - value - (append - (player-renderers value) - (list (renderer (agent-renderer-id app-id) - name - 'agent - agent)))) - (hasheq 'name name - 'pollIntervalMs 1000)))))))) + (with-state-lock + value + (λ () + (let ((existing (agent-by-id value app-id))) + (if existing + (begin + (set-playback-agent-name! existing advertised-name) + (let ((agent-output (agent-renderer value existing))) + (when agent-output + (set-renderer-name! agent-output advertised-name))) + (set-playback-agent-last-seen! + existing + (current-seconds)) + (hasheq 'name (playback-agent-name existing) + 'pollIntervalMs 1000)) + (let* ((name advertised-name) + (agent + (playback-agent + app-id name (current-seconds) + (hasheq 'state "stopped" + 'position 0 + 'volume 50) + '() 1 (make-hash) 0))) + (set-player-agents! + value + (append (player-agents value) (list agent))) + (set-player-renderers! + value + (append + (player-renderers value) + (list (renderer (agent-renderer-id app-id) + name + 'agent + agent)))) + (for ((session + (in-hash-values + (player-playback-sessions value)))) + (unless (playback-session-selected-id session) + (set-playback-session-selected-id! + session + (agent-renderer-id app-id)))) + (hasheq 'name name + 'pollIntervalMs 1000)))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Accept agent state and return its oldest unacknowledged command. @@ -1449,14 +1603,17 @@ (playback-agent-commands agent)))) (when (exact-nonnegative-integer? ended) (set-playback-agent-ended-counter! agent ended)))) + (define owner + (hash-ref (player-renderer-owners value) + (agent-renderer-id app-id) + #f)) (when (and (> ended previous-ended) - (string=? (player-selected-id value) - (agent-renderer-id app-id)) - (eq? (player-backend value) agent)) - (let ((index (next-index value 1))) + owner + (eq? (playback-session-backend owner) agent)) + (let ((index (next-index owner 1))) (if index - (play-index! value index) - (stop-playback! value)))) + (play-index! value owner index) + (stop-playback! value owner)))) (hasheq 'name (playback-agent-name agent) 'command @@ -1507,17 +1664,54 @@ candidates)))) (and item (track-artwork item))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Load one user's persisted web-interface language. +; pre : Value was created with make-player. +; post : Player and keystore state remain unchanged. +; result : A supported ISO language name, or #f when no preference was saved. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-user-language value #:username [username "anonymous"]) + (call-with-semaphore + (player-command-lock value) + (λ () + (load-user-language + (player-playlist-store value) + (normal-playlist-username username))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Persist one user's web-interface language. +; pre : Value was created with make-player; language is supported. +; post : The language preference is durable when a keystore is configured. +; result : Void. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-user-language! value language + #:username [username "anonymous"]) + (call-with-semaphore + (player-command-lock value) + (λ () + (save-user-language! + (player-playlist-store value) + (normal-playlist-username username) + language)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Stop playback and release all player resources. ; pre : Value was created with make-player. -; post : The active backend is closed; repeated calls are harmless. +; post : All backends and the shared media server are closed; repeated calls +; are harmless. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (player-close! value) (call-with-semaphore (player-command-lock value) (λ () (unless (player-closed? value) - (close-backend! value) + (for ((session + (in-hash-values (player-playback-sessions value)))) + (close-backend! value session)) + (when (player-media-file-server value) + (media-file-server-stop! (player-media-file-server value)) + (set-player-media-file-server! value #f)) + (hash-clear! (player-renderer-owners value)) (close-playlist-store! (player-playlist-store value)) (with-state-lock value @@ -1761,6 +1955,13 @@ (player-state->jsexpr example-player #:username "anonymous")) (check-equal? (length (hash-ref anonymous-after-hans 'tabs)) 1) (check-equal? (length (hash-ref anonymous-after-hans 'tracks)) 2) + (player-user-language! example-player "nl") + (player-user-language! + example-player "de" #:username "hans") + (check-equal? (player-user-language example-player) "nl") + (check-equal? + (player-user-language example-player #:username "hans") + "de") (player-close! example-player) (define restored-player @@ -1777,6 +1978,88 @@ (check-equal? (hash-ref (second (hash-ref restored-hans 'tabs)) 'name) "Hans favorieten") - (player-close! restored-player))) + (check-equal? (player-user-language restored-player) "nl") + (check-equal? + (player-user-language restored-player #:username "hans") + "de") + (player-close! restored-player) + + (define no-local-player + (make-player libraries + #:allowed-agent-ids (list test-agent-id) + #:local-output? #f)) + (define no-local-state (player-state->jsexpr no-local-player)) + (check-equal? (hash-ref no-local-state 'renderers) '()) + (check-equal? (hash-ref no-local-state 'rendererId) 'null) + (player-agent-register! + no-local-player + (hasheq 'appId test-agent-id 'name "Test agent")) + (define agent-only-state (player-state->jsexpr no-local-player)) + (check-equal? (length (hash-ref agent-only-state 'renderers)) 1) + (check-equal? (hash-ref agent-only-state 'rendererId) + (string-append "agent:" test-agent-id)) + (player-close! no-local-player) + + ;; Two users can drive different outputs concurrently. Selecting an + ;; occupied output transfers it and stops only its previous owner. + (define second-agent-id (make-string 64 #\b)) + (define multi-user-player + (make-player + libraries + #:allowed-agent-ids (list test-agent-id second-agent-id) + #:local-output? #f)) + (player-agent-register! + multi-user-player + (hasheq 'appId test-agent-id 'name "First room")) + (player-agent-register! + multi-user-player + (hasheq 'appId second-agent-id 'name "Second room")) + (define anonymous-session + (playback-session-for! multi-user-player "anonymous")) + (define hans-session + (playback-session-for! multi-user-player "hans")) + (activate-playlist-user! + multi-user-player "anonymous" anonymous-session) + (append-tracks! + multi-user-player anonymous-session + (list (track first-file "First" "Artist" "Album" 60 + "audio/flac"))) + (activate-playlist-user! multi-user-player "hans" hans-session) + (append-tracks! + multi-user-player hans-session + (list (track second-file "Second" "Artist" "Album" 60 + "audio/flac"))) + (player-command! + multi-user-player + "renderer" + (hasheq 'id (agent-renderer-id test-agent-id))) + (player-command! + multi-user-player + "renderer" + (hasheq 'id (agent-renderer-id second-agent-id)) + #:username "hans") + (player-command! multi-user-player "play" (hasheq 'index 0)) + (player-command! + multi-user-player "play" (hasheq 'index 0) #:username "hans") + (check-equal? (playback-session-state anonymous-session) 'starting) + (check-equal? (playback-session-state hans-session) 'starting) + (check-not-eq? (playback-session-backend anonymous-session) + (playback-session-backend hans-session)) + + (player-command! + multi-user-player + "renderer" + (hasheq 'id (agent-renderer-id test-agent-id)) + #:username "hans") + (check-equal? (playback-session-state anonymous-session) 'stopped) + (check-false (playback-session-backend anonymous-session)) + (check-equal? + (playback-session-error anonymous-session) + "Het afspeelpunt is door een andere gebruiker overgenomen") + (check-eq? + (hash-ref (player-renderer-owners multi-user-player) + (agent-renderer-id test-agent-id)) + hans-session) + (player-close! multi-user-player))) (λ () (delete-directory/files root)))) diff --git a/private/playlists.rkt b/private/playlists.rkt index ba1dd7c..1ca6ca5 100644 --- a/private/playlists.rkt +++ b/private/playlists.rkt @@ -11,7 +11,9 @@ open-playlist-store close-playlist-store! load-user-playlists - save-user-playlists!) + save-user-playlists! + load-user-language + save-user-language!) (struct persisted-tab (id name tracks) #:transparent) (struct playlist-store (keystore lock) #:transparent) @@ -19,6 +21,11 @@ (define (user-playlists-key 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) (hasheq 'file (path->string (track-file item)) 'title (track-title item) @@ -116,6 +123,45 @@ (ks-set! ks index-key ids)) (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 (require rackunit uuid/random) @@ -166,6 +212,11 @@ (check-equal? (map persisted-tab-name (load-user-playlists store "local" libraries)) '("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. (save-user-playlists! diff --git a/private/server.rkt b/private/server.rkt index f913e7b..5f43ba9 100644 --- a/private/server.rkt +++ b/private/server.rkt @@ -119,6 +119,24 @@ (request-jsexpr 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) (with-handlers ((exn:fail:agent-denied? agent-error-response) @@ -190,6 +208,8 @@ [("api" "auth" "logout") #:method "post" auth-logout-handler] [("api" "state") #:method "get" state-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" "poll") #:method "post" agent-poll-handler] [("api" "agent" "media" (string-arg) (string-arg)) diff --git a/private/translate.rkt b/private/translate.rkt new file mode 100644 index 0000000..0beda67 --- /dev/null +++ b/private/translate.rkt @@ -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 d’application" + '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 n’est pas autorisé par le serveur. Ajoutez l’ID d’application suivant à [playback-agents] dans le fichier INI du serveur :\n\n~a" + 'unauthorized-status "Non autorisé — l’ID d’application 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 l’agent 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)))) diff --git a/public/app.js b/public/app.js index 8fbc625..0e3cd99 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,6 @@ const elements = { renderer: document.querySelector("#renderer"), + language: document.querySelector("#language"), discover: document.querySelector("#discover"), previous: document.querySelector("#previous"), play: document.querySelector("#play"), @@ -9,6 +10,8 @@ const elements = { position: document.querySelector("#position"), duration: document.querySelector("#duration"), repeat: document.querySelector("#repeat"), + volumeControl: document.querySelector("#volume-control"), + volumeToggle: document.querySelector("#volume-toggle"), volume: document.querySelector("#volume"), volumeValue: document.querySelector("#volume-value"), library: document.querySelector("#library"), @@ -41,6 +44,8 @@ const elements = { loginSubmit: document.querySelector("#login-submit"), }; +const { t } = window.RktTranslate; + let state = null; let seekBusy = false; let draggedTrack = null; @@ -102,9 +107,16 @@ async function refreshAuth() { try { const auth = await api("/api/auth/status"); 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) { - setStatus(`Authenticatiestatus onbekend: ${error.message}`); + setStatus(t("authUnknown", { message: error.message })); } } @@ -136,7 +148,7 @@ function replaceSelect(select, items, selectedId, signature) { function renderSelectors(nextState) { const rendererItems = nextState.renderers.map((item) => ({ id: item.id, - label: `${item.name} · ${item.kind.toUpperCase()}`, + label: `${item.name} · ${item.kind === "local" ? t("rendererLocal") : item.kind.toUpperCase()}`, })); replaceSelect( elements.renderer, @@ -158,6 +170,7 @@ function renderSelectors(nextState) { elements.discover.classList.toggle("busy", nextState.discovering); elements.discover.disabled = nextState.discovering; + elements.renderer.disabled = nextState.renderers.length === 0; elements.library.disabled = nextState.libraries.length === 0; } @@ -178,7 +191,7 @@ function entryAction(label, title, handler) { function renderBrowser(nextState) { const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId); 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.libraryUp.disabled = !nextState.browser.canGoUp; elements.libraryEmpty.hidden = nextState.libraries.length > 0; @@ -205,11 +218,11 @@ function renderBrowser(nextState) { const actions = document.createElement("span"); actions.className = "entry-actions"; actions.append( - entryAction("▶", `${entry.name} nu afspelen`, () => { - command("item-play", { index: entry.index }, `${entry.name} laden…`); + entryAction("▶", t("playNow", { name: entry.name }), () => { + command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name })); }), - entryAction("+", `${entry.name} toevoegen`, () => { - command("item-add", { index: entry.index }, `${entry.name} toevoegen…`); + entryAction("+", t("addNamed", { name: entry.name }), () => { + 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 })); } else { 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) => { @@ -226,10 +239,10 @@ function renderBrowser(nextState) { command( entry.kind === "container" ? "browse" : "item-play", { index: entry.index }, - entry.kind === "track" ? `${entry.name} laden…` : "", + entry.kind === "track" ? t("loadingNamed", { name: entry.name }) : "", ); } 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; @@ -253,7 +266,7 @@ function renderTabs(nextState) { const name = document.createElement("span"); name.className = "tab-name"; - name.textContent = tab.name; + name.textContent = tab.name === "Default" ? t("defaultPlaylist") : tab.name; const count = document.createElement("span"); count.className = "tab-count"; count.textContent = tab.count; @@ -261,7 +274,7 @@ function renderTabs(nextState) { button.addEventListener("click", () => command("tab-select", { index: tab.index })); 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 }); }); @@ -269,7 +282,7 @@ function renderTabs(nextState) { const remove = document.createElement("span"); remove.className = "tab-delete"; remove.textContent = "×"; - remove.title = "Afspeellijst verwijderen"; + remove.title = t("removePlaylist"); remove.addEventListener("click", (event) => { event.stopPropagation(); command("tab-delete", { index: tab.index }); @@ -328,8 +341,8 @@ function renderPlaylist(nextState) { remove.className = "row-action"; remove.type = "button"; remove.textContent = "×"; - remove.title = `${track.title} verwijderen`; - remove.setAttribute("aria-label", `${track.title} verwijderen`); + remove.title = t("removeNamed", { name: track.title }); + remove.setAttribute("aria-label", t("removeNamed", { name: track.title })); remove.addEventListener("click", (event) => { event.stopPropagation(); command("track-remove", { index: track.index }); @@ -366,7 +379,7 @@ function renderPlaylist(nextState) { } 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; } @@ -374,10 +387,10 @@ function renderPlayer(nextState) { const current = Number.isInteger(nextState.currentIndex) ? nextState.tracks[nextState.currentIndex] : null; - elements.title.textContent = current ? current.title : "Nog niets gekozen"; + elements.title.textContent = current ? current.title : t("nothingSelected"); elements.meta.textContent = current ? [current.artist, current.album].filter(Boolean).join(" · ") || current.source - : "Voeg een track toe vanuit de bibliotheek"; + : t("addTrackHint"); const artworkId = current?.artworkId || ""; if (elements.coverImage.dataset.artworkId !== artworkId) { @@ -395,8 +408,8 @@ function renderPlayer(nextState) { const playing = nextState.state === "playing" || nextState.state === "starting"; elements.play.textContent = playing ? "Ⅱ" : "▶"; - elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen"); - elements.play.disabled = nextState.tracks.length === 0; + elements.play.setAttribute("aria-label", t(playing ? "pause" : "play")); + elements.play.disabled = nextState.tracks.length === 0 || nextState.renderers.length === 0; elements.previous.disabled = nextState.tracks.length === 0; elements.next.disabled = nextState.tracks.length === 0; elements.stop.disabled = nextState.state === "stopped"; @@ -412,14 +425,20 @@ function renderPlayer(nextState) { elements.volume.value = 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.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.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit"; 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.source.textContent = nextState.source || "—"; } @@ -441,7 +460,7 @@ function render(nextState) { renderTabs(nextState); renderPlaylist(nextState); renderPlayer(nextState); - setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : "")); + setStatus(nextState.error || (nextState.discovering ? t("searchingPlayers") : "")); } elements.play.addEventListener("click", () => { @@ -458,6 +477,22 @@ elements.repeat.addEventListener("click", () => { command("repeat", { mode: next }); }); 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 () => { try { render(await api("/api/discover", {})); @@ -465,10 +500,29 @@ elements.discover.addEventListener("click", async () => { 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.volumeValue.value = `${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("change", () => { seekBusy = false; @@ -488,7 +542,7 @@ async function refresh() { if (error.code === "authentication-required") { showLogin(); } 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", {}); } finally { elements.logout.hidden = true; - showLogin("Je bent uitgelogd."); + showLogin(t("loggedOut")); } }); diff --git a/public/index.html b/public/index.html index ca01a51..c5bb91d 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + @@ -16,23 +16,26 @@
- + - - + + +
-
+
- - - - + + + +
- +
00:00:00 / @@ -40,12 +43,15 @@
- - + +
+ + +
@@ -53,17 +59,17 @@
- MUZIEKBIBLIOTHEEK - + MUSIC LIBRARY +
- +
- + -
    +
      @@ -75,30 +81,30 @@
      - NU AAN HET SPELEN -

      Nog niets gekozen

      -

      Voeg een track toe vanuit de bibliotheek

      + NOW PLAYING +

      Nothing selected yet

      +

      Add a track from the library

      -
      - +
      +
      - AFSPEELLIJST + PLAYLIST 0 tracks
      - +
      -

      Deze afspeellijst is leeg.

      - Gebruik bij een track of map. +

      This playlist is empty.

      + Use + next to a track or folder.
      @@ -106,10 +112,10 @@ # - TITEL - ALBUM - DUUR - Acties + TITLE + ALBUM + DURATION + Actions @@ -122,11 +128,11 @@
      — bit — kHz - — kanalen + — channels
      -

      Verbinden…

      +

      Connecting…

      @@ -134,16 +140,17 @@
      + diff --git a/public/styles.css b/public/styles.css index d800d40..07771db 100644 --- a/public/styles.css +++ b/public/styles.css @@ -43,6 +43,12 @@ select { color: var(--text); } +.language-select { + width: 58px; + min-width: 58px; + padding-right: 18px; +} + button { cursor: pointer; } @@ -276,19 +282,31 @@ input[type="range"] { .volume-control { align-self: stretch; width: 210px; - gap: 9px; - padding: 0 12px; + position: relative; border-left: 1px solid var(--line); } -.volume-control span, -.volume-control output { +.volume-slider { + 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); font-size: 10px; font-weight: 800; } -.volume-control output { +.volume-slider output { width: 32px; font-variant-numeric: tabular-nums; } @@ -861,7 +879,26 @@ input:focus-visible, } .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 { @@ -873,7 +910,58 @@ input:focus-visible, } .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; } diff --git a/public/translate.js b/public/translate.js new file mode 100644 index 0000000..86359dc --- /dev/null +++ b/public/translate.js @@ -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é d’une piste ou d’un dossier.", + title: "TITRE", album: "ALBUM", duration: "DURÉE", actions: "Actions", + connecting: "Connexion…", signIn: "Se connecter", username: "Nom d’utilisateur", + 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 d’authentification 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()); +})(); diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example index c2cc00f..d543aa6 100644 --- a/rkt-web-player.ini.example +++ b/rkt-web-player.ini.example @@ -4,6 +4,8 @@ port=8080 [player] dlna-port=8734 +; Set to false when the server itself must not appear as an audio output. +local-output=true [libraries] ; muziek=D:\Muziek @@ -22,4 +24,8 @@ session-seconds=604800 [users] ; Generate a hash with (make-password-hash "a long password") from ; 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$... +; diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 47fae73..37a1228 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -26,6 +26,7 @@ browser by Racket's web server. [#:listen-ip listen-ip string? "127.0.0.1"] [#:port port exact-positive-integer? 8080] [#:dlna-port dlna-port exact-positive-integer? 8734] + [#:local-output? local-output? boolean? #t] [#:playlist-keystore playlist-keystore (or/c path-string? #f)] [#: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. 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 resources are closed when the web server exits. The default is @tt{data/playlists.keystore} below the installed rkt-web-player collection. 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. Sessions have a sliding idle timeout; an active browser cookie is renewed once 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 and is followed by the server. Importing the module does not start the GUI; the 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