multi player / per user playing and translation

This commit is contained in:
2026-08-28 16:53:05 +02:00
parent 2743dfb4c0
commit e10f2cf7d7
17 changed files with 1432 additions and 482 deletions
+61 -37
View File
@@ -8,9 +8,10 @@ can play selected files either on the host machine or on a discovered UPnP or
Sonos renderer.
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<br/>durable playlist tabs]
DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration]
Library[private/library.rkt<br/>filesystem and metadata]
UI[public/index.html + styles.css + app.js<br/>browser UI]
UI[public/index.html + styles.css + app.js + translate.js<br/>browser UI]
Audio[racket-audio<br/>local backend]
Discovery[racket-upnp + racket-sonos<br/>device discovery]
DLNA[racket-audio-dlna<br/>transport, seeking and media publication]
@@ -120,11 +121,12 @@ The mutable `player` structure is the aggregate root for:
- configured libraries and the current browser location;
- 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-<username>` 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-<username>` 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
+18 -3
View File
@@ -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-<username>` in
`data/playlists.keystore` opgeslagen en geldt daardoor ook op andere apparaten.
Zonder authenticatie geldt dit voor de gebruiker `anonymous`.
De server luistert standaard alleen op localhost. Geef alleen bewust een
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,
+6 -1
View File
@@ -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
+10 -4
View File
@@ -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)
+7 -11
View File
@@ -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!)
+22 -19
View File
@@ -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)
+5 -3
View File
@@ -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
+563 -280
View File
File diff suppressed because it is too large Load Diff
+52 -1
View File
@@ -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!
+20
View File
@@ -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))
+217
View File
@@ -0,0 +1,217 @@
#lang racket/base
(require racket/string)
(provide tr
__
languages
set-lang!
current-lang)
(define translation-map
(hasheq
'en
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player server"
'name "Name"
'application-id "Application ID"
'playback "Playback"
'no-track "Nothing is playing"
'no-track-selected "No track selected"
'save-connect "Save and connect"
'reconnect "Reconnect"
'connecting "Connecting…"
'connected "Connected"
'denied-title "Playback agent not allowed"
'denied-message "This playback agent is not allowed by the server. Add the following application ID to [playback-agents] in the server INI:\n\n~a"
'unauthorized-status "Not authorized — application ID is not in the server INI"
'disconnected "Not connected: ~a"
'playing "Playing"
'paused "Paused"
'loading "Loading"
'stopped "Stopped"
'channel "channel"
'channels "channels"
'tray-open "Open RKT Web Player Agent"
'quit "Quit")
'nl
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player server"
'name "Naam"
'application-id "Applicatie-ID"
'playback "Afspelen"
'no-track "Er wordt niets afgespeeld"
'no-track-selected "Geen track geselecteerd"
'save-connect "Opslaan en verbinden"
'reconnect "Opnieuw verbinden"
'connecting "Verbinden…"
'connected "Verbonden"
'denied-title "Playback agent niet toegestaan"
'denied-message "Deze playback agent is niet toegelaten door de server. Voeg het volgende applicatie-ID toe aan [playback-agents] in de server-INI:\n\n~a"
'unauthorized-status "Niet geautoriseerd — applicatie-ID staat niet in de server-INI"
'disconnected "Niet verbonden: ~a"
'playing "Speelt"
'paused "Gepauzeerd"
'loading "Laden"
'stopped "Gestopt"
'channel "kanaal"
'channels "kanalen"
'tray-open "RKT Web Player Agent openen"
'quit "Afsluiten")
'de
(hasheq
'app-title "RKT Web Player Agent"
'server "RKT Web Player Server"
'name "Name"
'application-id "Anwendungs-ID"
'playback "Wiedergabe"
'no-track "Keine Wiedergabe"
'no-track-selected "Kein Titel ausgewählt"
'save-connect "Speichern und verbinden"
'reconnect "Neu verbinden"
'connecting "Verbinden…"
'connected "Verbunden"
'denied-title "Playback-Agent nicht zugelassen"
'denied-message "Dieser Playback-Agent ist vom Server nicht zugelassen. Fügen Sie die folgende Anwendungs-ID unter [playback-agents] in die Server-INI ein:\n\n~a"
'unauthorized-status "Nicht autorisiert — Anwendungs-ID fehlt in der Server-INI"
'disconnected "Nicht verbunden: ~a"
'playing "Wiedergabe"
'paused "Pausiert"
'loading "Laden"
'stopped "Gestoppt"
'channel "Kanal"
'channels "Kanäle"
'tray-open "RKT Web Player Agent öffnen"
'quit "Beenden")
'fr
(hasheq
'app-title "Agent RKT Web Player"
'server "Serveur RKT Web Player"
'name "Nom"
'application-id "ID dapplication"
'playback "Lecture"
'no-track "Aucune lecture en cours"
'no-track-selected "Aucune piste sélectionnée"
'save-connect "Enregistrer et connecter"
'reconnect "Reconnecter"
'connecting "Connexion…"
'connected "Connecté"
'denied-title "Agent de lecture non autorisé"
'denied-message "Cet agent de lecture nest pas autorisé par le serveur. Ajoutez lID dapplication suivant à [playback-agents] dans le fichier INI du serveur :\n\n~a"
'unauthorized-status "Non autorisé — lID dapplication est absent du fichier INI du serveur"
'disconnected "Non connecté : ~a"
'playing "Lecture"
'paused "En pause"
'loading "Chargement"
'stopped "Arrêté"
'channel "canal"
'channels "canaux"
'tray-open "Ouvrir lagent RKT Web Player"
'quit "Quitter")
'es
(hasheq
'app-title "Agente de RKT Web Player"
'server "Servidor RKT Web Player"
'name "Nombre"
'application-id "ID de aplicación"
'playback "Reproducción"
'no-track "No se está reproduciendo nada"
'no-track-selected "No hay ninguna pista seleccionada"
'save-connect "Guardar y conectar"
'reconnect "Volver a conectar"
'connecting "Conectando…"
'connected "Conectado"
'denied-title "Agente de reproducción no permitido"
'denied-message "El servidor no permite este agente de reproducción. Añade el siguiente ID de aplicación a [playback-agents] en el INI del servidor:\n\n~a"
'unauthorized-status "No autorizado — el ID de aplicación no está en el INI del servidor"
'disconnected "Sin conexión: ~a"
'playing "Reproduciendo"
'paused "En pausa"
'loading "Cargando"
'stopped "Detenido"
'channel "canal"
'channels "canales"
'tray-open "Abrir el agente de RKT Web Player"
'quit "Salir")))
(define (system-language)
(define language-name
(string-downcase (format "~a" (system-language+country))))
(define short-name (car (regexp-split #px"[-_]" language-name)))
(define candidate (string->symbol short-name))
(if (hash-has-key? translation-map candidate) candidate 'en))
(define language (system-language))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return the supported language symbols and their native names.
; pre : None.
; post : Translation state remains unchanged.
; result : An association list suitable for a language selector.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (languages)
'((en "English")
(nl "Nederlands")
(de "Deutsch")
(fr "Français")
(es "Español")))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Select the language used by tr and __.
; pre : Value is one of the symbols returned by languages.
; post : Subsequent translations use value.
; result : Void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (set-lang! value)
(unless (hash-has-key? translation-map value)
(raise-argument-error 'set-lang! "supported language symbol" value))
(set! language value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Report the active translation language.
; pre : None.
; post : Translation state remains unchanged.
; result : A supported language symbol.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (current-lang)
language)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Translate an application string identifier.
; pre : Id is a symbol.
; post : Translation state remains unchanged.
; result : The active translation, its English fallback, or the identifier.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (tr id)
(hash-ref (hash-ref translation-map language)
id
(λ ()
(hash-ref (hash-ref translation-map 'en)
id
(λ () (symbol->string id))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Provide the conventional short alias used by rktplayer GUI code.
; pre : Id is a symbol.
; post : Translation state remains unchanged.
; result : The same translated string as tr.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (__ id)
(tr id))
(module+ test
(require rackunit)
(define original-language (current-lang))
(dynamic-wind
void
(λ ()
(set-lang! 'nl)
(check-equal? (tr 'connected) "Verbonden")
(set-lang! 'de)
(check-equal? (__ 'quit) "Beenden")
(check-equal? (tr 'unknown-translation) "unknown-translation")
(check-exn exn:fail:contract? (λ () (set-lang! 'xx))))
(λ () (set-lang! original-language))))
+80 -26
View File
@@ -1,5 +1,6 @@
const elements = {
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"));
}
});
+45 -38
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="nl">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -16,23 +16,26 @@
</div>
<div class="output-control">
<label for="renderer">UITVOER</label>
<label for="renderer" data-i18n="output">OUTPUT</label>
<select id="renderer"></select>
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button>
<button id="logout" class="text-button" type="button" hidden>UITLOGGEN</button>
<button id="discover" class="square-button" type="button" data-i18n-title="searchPlayers" title="Search for network players" aria-label="Search for network players"></button>
<select id="language" class="language-select" aria-label="Language">
<option value="en">EN</option><option value="nl">NL</option><option value="de">DE</option><option value="fr">FR</option><option value="es">ES</option>
</select>
<button id="logout" class="text-button" type="button" data-i18n="logout" data-i18n-aria="logout" aria-label="Log out" hidden>LOG OUT</button>
</div>
</header>
<section class="transport-bar" aria-label="Afspeelbediening">
<section class="transport-bar" data-i18n-aria="playbackControls" aria-label="Playback controls">
<div class="transport-buttons">
<button id="previous" class="command-button" type="button" aria-label="Vorige track"></button>
<button id="play" class="command-button play-button" type="button" aria-label="Afspelen of pauzeren"></button>
<button id="stop" class="command-button" type="button" aria-label="Stoppen"></button>
<button id="next" class="command-button" type="button" aria-label="Volgende track"></button>
<button id="previous" class="command-button" type="button" data-i18n-aria="previousTrack" aria-label="Previous track"></button>
<button id="play" class="command-button play-button" type="button" data-i18n-aria="playOrPause" aria-label="Play or pause"></button>
<button id="stop" class="command-button" type="button" data-i18n-aria="stop" aria-label="Stop"></button>
<button id="next" class="command-button" type="button" data-i18n-aria="nextTrack" aria-label="Next track"></button>
</div>
<div class="seek-control">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" data-i18n-aria="playbackPosition" aria-label="Playback position">
<div class="time-display">
<span id="position">00:00:00</span>
<span class="time-divider">/</span>
@@ -40,12 +43,15 @@
</div>
</div>
<button id="repeat" class="command-button repeat-button" type="button" aria-label="Herhaalmodus"></button>
<label class="volume-control" for="volume">
<button id="repeat" class="command-button repeat-button" type="button" data-i18n-aria="repeatMode" aria-label="Repeat mode"></button>
<div id="volume-control" class="volume-control">
<button id="volume-toggle" class="volume-toggle" type="button" data-i18n-aria="setVolume" aria-label="Set volume" aria-controls="volume-slider" aria-expanded="false">VOL</button>
<label id="volume-slider" class="volume-slider" for="volume">
<span aria-hidden="true">VOL</span>
<input id="volume" type="range" min="0" max="100" value="50">
<input id="volume" type="range" min="0" max="100" value="50" data-i18n-aria="volume" aria-label="Volume">
<output id="volume-value">50%</output>
</label>
</div>
</section>
<section class="workspace">
@@ -53,17 +59,17 @@
<section class="library-pane panel">
<div class="panel-header library-header">
<div>
<span class="panel-kicker">MUZIEKBIBLIOTHEEK</span>
<select id="library" aria-label="Muziekbibliotheek"></select>
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
</div>
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog"></button>
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder"></button>
</div>
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
<div id="library-empty" class="empty-state" hidden>
<p>Geen bibliotheek geconfigureerd.</p>
<p data-i18n="noLibraryConfigured">No library configured.</p>
<code>[libraries] muziek=D:\Muziek</code>
</div>
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
</section>
<section class="now-playing-pane panel">
@@ -75,30 +81,30 @@
<img id="cover-image" alt="" hidden>
</div>
<div class="track-summary">
<span class="panel-kicker">NU AAN HET SPELEN</span>
<h1 id="now-title">Nog niets gekozen</h1>
<p id="now-meta">Voeg een track toe vanuit de bibliotheek</p>
<span class="panel-kicker" data-i18n="nowPlaying">NOW PLAYING</span>
<h1 id="now-title" data-i18n="nothingSelected">Nothing selected yet</h1>
<p id="now-meta" data-i18n="addTrackHint">Add a track from the library</p>
</div>
</section>
</aside>
<section class="playlist-pane panel">
<div class="tabs-bar">
<div id="tabs" class="tabs" role="tablist" aria-label="Afspeellijsten"></div>
<button id="tab-add" class="tab-add" type="button" title="Nieuwe afspeellijst" aria-label="Nieuwe afspeellijst"></button>
<div id="tabs" class="tabs" role="tablist" data-i18n-aria="playlists" aria-label="Playlists"></div>
<button id="tab-add" class="tab-add" type="button" data-i18n-title="newPlaylist" title="New playlist" aria-label="New playlist"></button>
</div>
<div class="playlist-toolbar">
<div>
<span class="panel-kicker">AFSPEELLIJST</span>
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
<strong id="track-count">0 tracks</strong>
</div>
<button id="playlist-clear" class="text-button" type="button">LIJST WISSEN</button>
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
</div>
<div id="playlist-empty" class="empty-state playlist-empty">
<p>Deze afspeellijst is leeg.</p>
<span>Gebruik <strong></strong> bij een track of map.</span>
<p data-i18n="emptyPlaylist">This playlist is empty.</p>
<span data-i18n="emptyPlaylistHint">Use next to a track or folder.</span>
</div>
<div class="playlist-scroll">
@@ -106,10 +112,10 @@
<thead>
<tr>
<th class="number-column">#</th>
<th>TITEL</th>
<th>ALBUM</th>
<th class="duration-column">DUUR</th>
<th class="action-column"><span class="visually-hidden">Acties</span></th>
<th data-i18n="title">TITLE</th>
<th data-i18n="album">ALBUM</th>
<th class="duration-column" data-i18n="duration">DURATION</th>
<th class="action-column"><span class="visually-hidden" data-i18n="actions">Actions</span></th>
</tr>
</thead>
<tbody id="playlist"></tbody>
@@ -122,11 +128,11 @@
<div class="audio-status">
<span id="audio-bits">— bit</span>
<span id="audio-rate">— kHz</span>
<span id="audio-channels">kanalen</span>
<span id="audio-channels">channels</span>
<span id="audio-format"></span>
<span id="audio-source"></span>
</div>
<p id="status" role="status" aria-live="polite">Verbinden</p>
<p id="status" role="status" aria-live="polite" data-i18n="connecting">Connecting</p>
</footer>
</main>
@@ -134,16 +140,17 @@
<form id="login-form" class="login-panel">
<span class="brand-mark">RKT</span>
<p class="panel-kicker">WEB PLAYER</p>
<h1 id="login-title">Aanmelden</h1>
<label for="login-username">Gebruikersnaam</label>
<h1 id="login-title" data-i18n="signIn">Sign in</h1>
<label for="login-username" data-i18n="username">Username</label>
<input id="login-username" name="username" autocomplete="username" required>
<label for="login-password">Wachtwoord</label>
<label for="login-password" data-i18n="password">Password</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
<p id="login-error" class="login-error" role="alert"></p>
<button id="login-submit" class="text-button" type="submit">AANMELDEN</button>
<button id="login-submit" class="text-button" type="submit" data-i18n="signIn">Sign in</button>
</form>
</section>
<script src="/translate.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+95 -7
View File
@@ -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;
}
+162
View File
@@ -0,0 +1,162 @@
(() => {
"use strict";
const translations = {
en: {
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
rendererLocal: "LOCAL", defaultPlaylist: "Default",
playbackControls: "Playback controls", previousTrack: "Previous track",
playOrPause: "Play or pause", play: "Play", pause: "Pause", stop: "Stop",
nextTrack: "Next track", playbackPosition: "Playback position",
repeatMode: "Repeat mode", setVolume: "Set volume", volume: "Volume",
musicLibrary: "MUSIC LIBRARY", upFolder: "Up one folder", currentFolder: "Current folder",
noLibraryConfigured: "No library configured.", folderContents: "Folder contents",
nowPlaying: "NOW PLAYING", nothingSelected: "Nothing selected yet",
addTrackHint: "Add a track from the library", playlists: "Playlists",
newPlaylist: "New playlist", playlist: "PLAYLIST", clearList: "CLEAR LIST",
emptyPlaylist: "This playlist is empty.", emptyPlaylistHint: "Use next to a track or folder.",
title: "TITLE", album: "ALBUM", duration: "DURATION", actions: "Actions",
connecting: "Connecting…", signIn: "Sign in", username: "Username",
password: "Password", noLibrary: "No library", playNow: "Play {name} now",
addNamed: "Add {name}", loadingNamed: "Loading {name}…", addingNamed: "Adding {name}…",
playlistName: "Playlist name", removePlaylist: "Remove playlist",
removeNamed: "Remove {name}", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Repeat off", repeatAll: "Repeat all", repeatOne: "Repeat one track",
channels: "channels", oneChannel: "channel", searchingPlayers: "Searching for network players…",
authUnknown: "Authentication status unknown: {message}", noConnection: "No connection: {message}",
loggedOut: "You have been logged out.", volumePercent: "Set volume, {value} percent",
},
nl: {
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
playbackControls: "Afspeelbediening", previousTrack: "Vorige track",
playOrPause: "Afspelen of pauzeren", play: "Afspelen", pause: "Pauzeren", stop: "Stoppen",
nextTrack: "Volgende track", playbackPosition: "Afspeelpositie",
repeatMode: "Herhaalmodus", setVolume: "Volume instellen", volume: "Volume",
musicLibrary: "MUZIEKBIBLIOTHEEK", upFolder: "Eén map omhoog", currentFolder: "Huidige map",
noLibraryConfigured: "Geen bibliotheek geconfigureerd.", folderContents: "Mapinhoud",
nowPlaying: "NU AAN HET SPELEN", nothingSelected: "Nog niets gekozen",
addTrackHint: "Voeg een track toe vanuit de bibliotheek", playlists: "Afspeellijsten",
newPlaylist: "Nieuwe afspeellijst", playlist: "AFSPEELLIJST", clearList: "LIJST WISSEN",
emptyPlaylist: "Deze afspeellijst is leeg.", emptyPlaylistHint: "Gebruik bij een track of map.",
title: "TITEL", album: "ALBUM", duration: "DUUR", actions: "Acties",
connecting: "Verbinden…", signIn: "Aanmelden", username: "Gebruikersnaam",
password: "Wachtwoord", noLibrary: "Geen bibliotheek", playNow: "{name} nu afspelen",
addNamed: "{name} toevoegen", loadingNamed: "{name} laden…", addingNamed: "{name} toevoegen…",
playlistName: "Naam van de afspeellijst", removePlaylist: "Afspeellijst verwijderen",
removeNamed: "{name} verwijderen", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Herhalen uit", repeatAll: "Alles herhalen", repeatOne: "Eén track herhalen",
channels: "kanalen", oneChannel: "kanaal", searchingPlayers: "Netwerkspelers zoeken…",
authUnknown: "Authenticatiestatus onbekend: {message}", noConnection: "Geen verbinding: {message}",
loggedOut: "Je bent uitgelogd.", volumePercent: "Volume instellen, {value} procent",
},
de: {
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
playbackControls: "Wiedergabesteuerung", previousTrack: "Vorheriger Titel",
playOrPause: "Wiedergeben oder pausieren", play: "Wiedergeben", pause: "Pausieren", stop: "Stoppen",
nextTrack: "Nächster Titel", playbackPosition: "Wiedergabeposition",
repeatMode: "Wiederholungsmodus", setVolume: "Lautstärke einstellen", volume: "Lautstärke",
musicLibrary: "MUSIKBIBLIOTHEK", upFolder: "Einen Ordner nach oben", currentFolder: "Aktueller Ordner",
noLibraryConfigured: "Keine Bibliothek konfiguriert.", folderContents: "Ordnerinhalt",
nowPlaying: "AKTUELLE WIEDERGABE", nothingSelected: "Noch nichts ausgewählt",
addTrackHint: "Titel aus der Bibliothek hinzufügen", playlists: "Wiedergabelisten",
newPlaylist: "Neue Wiedergabeliste", playlist: "WIEDERGABELISTE", clearList: "LISTE LEEREN",
emptyPlaylist: "Diese Wiedergabeliste ist leer.", emptyPlaylistHint: "Verwenden Sie neben einem Titel oder Ordner.",
title: "TITEL", album: "ALBUM", duration: "DAUER", actions: "Aktionen",
connecting: "Verbinden…", signIn: "Anmelden", username: "Benutzername",
password: "Passwort", noLibrary: "Keine Bibliothek", playNow: "{name} jetzt wiedergeben",
addNamed: "{name} hinzufügen", loadingNamed: "{name} wird geladen…", addingNamed: "{name} wird hinzugefügt…",
playlistName: "Name der Wiedergabeliste", removePlaylist: "Wiedergabeliste entfernen",
removeNamed: "{name} entfernen", oneTrack: "Titel", manyTracks: "Titel",
repeatOff: "Wiederholung aus", repeatAll: "Alles wiederholen", repeatOne: "Einen Titel wiederholen",
channels: "Kanäle", oneChannel: "Kanal", searchingPlayers: "Netzwerkplayer werden gesucht…",
authUnknown: "Authentifizierungsstatus unbekannt: {message}", noConnection: "Keine Verbindung: {message}",
loggedOut: "Sie wurden abgemeldet.", volumePercent: "Lautstärke einstellen, {value} Prozent",
},
fr: {
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
playbackControls: "Commandes de lecture", previousTrack: "Piste précédente",
playOrPause: "Lire ou mettre en pause", play: "Lire", pause: "Pause", stop: "Arrêter",
nextTrack: "Piste suivante", playbackPosition: "Position de lecture",
repeatMode: "Mode répétition", setVolume: "Régler le volume", volume: "Volume",
musicLibrary: "BIBLIOTHÈQUE MUSICALE", upFolder: "Dossier parent", currentFolder: "Dossier actuel",
noLibraryConfigured: "Aucune bibliothèque configurée.", folderContents: "Contenu du dossier",
nowPlaying: "LECTURE EN COURS", nothingSelected: "Aucune sélection",
addTrackHint: "Ajoutez une piste depuis la bibliothèque", playlists: "Listes de lecture",
newPlaylist: "Nouvelle liste de lecture", playlist: "LISTE DE LECTURE", clearList: "VIDER LA LISTE",
emptyPlaylist: "Cette liste de lecture est vide.", emptyPlaylistHint: "Utilisez à côté dune piste ou dun dossier.",
title: "TITRE", album: "ALBUM", duration: "DURÉE", actions: "Actions",
connecting: "Connexion…", signIn: "Se connecter", username: "Nom dutilisateur",
password: "Mot de passe", noLibrary: "Aucune bibliothèque", playNow: "Lire {name} maintenant",
addNamed: "Ajouter {name}", loadingNamed: "Chargement de {name}…", addingNamed: "Ajout de {name}…",
playlistName: "Nom de la liste de lecture", removePlaylist: "Supprimer la liste de lecture",
removeNamed: "Supprimer {name}", oneTrack: "piste", manyTracks: "pistes",
repeatOff: "Répétition désactivée", repeatAll: "Tout répéter", repeatOne: "Répéter une piste",
channels: "canaux", oneChannel: "canal", searchingPlayers: "Recherche des lecteurs réseau…",
authUnknown: "État dauthentification inconnu : {message}", noConnection: "Aucune connexion : {message}",
loggedOut: "Vous avez été déconnecté.", volumePercent: "Régler le volume, {value} pour cent",
},
es: {
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
playbackControls: "Controles de reproducción", previousTrack: "Pista anterior",
playOrPause: "Reproducir o pausar", play: "Reproducir", pause: "Pausar", stop: "Detener",
nextTrack: "Pista siguiente", playbackPosition: "Posición de reproducción",
repeatMode: "Modo de repetición", setVolume: "Ajustar volumen", volume: "Volumen",
musicLibrary: "BIBLIOTECA MUSICAL", upFolder: "Subir una carpeta", currentFolder: "Carpeta actual",
noLibraryConfigured: "No hay ninguna biblioteca configurada.", folderContents: "Contenido de la carpeta",
nowPlaying: "REPRODUCIENDO", nothingSelected: "Nada seleccionado",
addTrackHint: "Añade una pista desde la biblioteca", playlists: "Listas de reproducción",
newPlaylist: "Nueva lista de reproducción", playlist: "LISTA DE REPRODUCCIÓN", clearList: "VACIAR LISTA",
emptyPlaylist: "Esta lista de reproducción está vacía.", emptyPlaylistHint: "Usa junto a una pista o carpeta.",
title: "TÍTULO", album: "ÁLBUM", duration: "DURACIÓN", actions: "Acciones",
connecting: "Conectando…", signIn: "Iniciar sesión", username: "Nombre de usuario",
password: "Contraseña", noLibrary: "Sin biblioteca", playNow: "Reproducir {name} ahora",
addNamed: "Añadir {name}", loadingNamed: "Cargando {name}…", addingNamed: "Añadiendo {name}…",
playlistName: "Nombre de la lista de reproducción", removePlaylist: "Eliminar lista de reproducción",
removeNamed: "Eliminar {name}", oneTrack: "pista", manyTracks: "pistas",
repeatOff: "Repetición desactivada", repeatAll: "Repetir todo", repeatOne: "Repetir una pista",
channels: "canales", oneChannel: "canal", searchingPlayers: "Buscando reproductores de red…",
authUnknown: "Estado de autenticación desconocido: {message}", noConnection: "Sin conexión: {message}",
loggedOut: "Has cerrado la sesión.", volumePercent: "Ajustar volumen, {value} por ciento",
},
};
const supported = Object.keys(translations);
const requested = [...(navigator.languages || [navigator.language])]
.filter(Boolean)
.map((value) => value.toLowerCase().split("-")[0]);
let language = requested.find((value) => supported.includes(value)) || "en";
function t(key, values = {}) {
const template = translations[language][key] ?? translations.en[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? `{${name}}`);
}
function apply(root = document) {
document.documentElement.lang = language;
root.querySelectorAll("[data-i18n]").forEach((element) => {
element.textContent = t(element.dataset.i18n);
});
root.querySelectorAll("[data-i18n-title]").forEach((element) => {
const value = t(element.dataset.i18nTitle);
element.title = value;
element.setAttribute("aria-label", value);
});
root.querySelectorAll("[data-i18n-aria]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAria));
});
}
function setLanguage(value) {
if (!supported.includes(value)) return;
language = value;
apply();
window.dispatchEvent(new CustomEvent("rkt-language-change"));
}
window.RktTranslate = { apply, language: () => language, setLanguage, supported, t };
window.addEventListener("DOMContentLoaded", () => apply());
})();
+6
View File
@@ -4,6 +4,8 @@ port=8080
[player]
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$...
;
+12 -1
View File
@@ -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