multi player / per user playing and translation
This commit is contained in:
+61
-37
@@ -8,9 +8,10 @@ can play selected files either on the host machine or on a discovered UPnP or
|
|||||||
Sonos renderer.
|
Sonos renderer.
|
||||||
|
|
||||||
The application is designed as a personal, stateful desktop or LAN service. A
|
The application is designed as a personal, stateful desktop or LAN service. A
|
||||||
single server-side player instance owns the active browser location, playlists,
|
single server-side player instance owns shared libraries, discovery and output
|
||||||
playback state, output selection, and audio backend. All connected browser
|
inventory. Each username owns its playlists, preferences and playback session.
|
||||||
windows observe and control that same instance.
|
Different users can therefore play concurrently when they select different
|
||||||
|
physical outputs.
|
||||||
|
|
||||||
## 2. System context
|
## 2. System context
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ flowchart LR
|
|||||||
|
|
||||||
The browser is a thin client: it renders a complete server-provided state
|
The browser is a thin client: it renders a complete server-provided state
|
||||||
snapshot and sends commands back to the server. The server is authoritative;
|
snapshot and sends commands back to the server. The server is authoritative;
|
||||||
there is no browser-side persistence or independent playback state.
|
the browser stores neither playlists nor language preferences.
|
||||||
|
|
||||||
## 3. Runtime structure
|
## 3. Runtime structure
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ flowchart TB
|
|||||||
Playlists[private/playlists.rkt<br/>durable playlist tabs]
|
Playlists[private/playlists.rkt<br/>durable playlist tabs]
|
||||||
DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration]
|
DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration]
|
||||||
Library[private/library.rkt<br/>filesystem and metadata]
|
Library[private/library.rkt<br/>filesystem and metadata]
|
||||||
UI[public/index.html + styles.css + app.js<br/>browser UI]
|
UI[public/index.html + styles.css + app.js + translate.js<br/>browser UI]
|
||||||
Audio[racket-audio<br/>local backend]
|
Audio[racket-audio<br/>local backend]
|
||||||
Discovery[racket-upnp + racket-sonos<br/>device discovery]
|
Discovery[racket-upnp + racket-sonos<br/>device discovery]
|
||||||
DLNA[racket-audio-dlna<br/>transport, seeking and media publication]
|
DLNA[racket-audio-dlna<br/>transport, seeking and media publication]
|
||||||
@@ -120,11 +121,12 @@ The mutable `player` structure is the aggregate root for:
|
|||||||
|
|
||||||
- configured libraries and the current browser location;
|
- configured libraries and the current browser location;
|
||||||
- playlist tabs, the selected tab, and their durably stored tracks;
|
- playlist tabs, the selected tab, and their durably stored tracks;
|
||||||
- discovered renderers and the selected renderer;
|
- discovered renderers and per-renderer session ownership;
|
||||||
- registered HTTP playback agents and their pending command queues;
|
- registered HTTP playback agents and their pending command queues;
|
||||||
- the lazily created local or network backend;
|
- a playback session per username, containing its selected renderer, lazy
|
||||||
- transport state, current track, position, duration, audio properties, volume,
|
backend, current track, transport state, audio properties, volume and repeat
|
||||||
and repeat mode;
|
mode;
|
||||||
|
- one shared DLNA media-file server used by all network backends;
|
||||||
- current error, discovery, and shutdown status;
|
- current error, discovery, and shutdown status;
|
||||||
- synchronization primitives and the DLNA publication port.
|
- synchronization primitives and the DLNA publication port.
|
||||||
|
|
||||||
@@ -140,19 +142,29 @@ written in one `keystore` transaction. `playlists-for-<username>` contains the
|
|||||||
ordered playlist GUIDs; each GUID key contains that playlist's name and tracks.
|
ordered playlist GUIDs; each GUID key contains that playlist's name and tracks.
|
||||||
Loading validates every stored track independently against all configured
|
Loading validates every stored track independently against all configured
|
||||||
library roots, so one playlist can safely combine multiple libraries.
|
library roots, so one playlist can safely combine multiple libraries.
|
||||||
|
`language-for-<username>` stores the user's selected interface language in the
|
||||||
|
same keystore.
|
||||||
|
|
||||||
### 3.4 Playback backends
|
### 3.4 Playback backends
|
||||||
|
|
||||||
The player exposes local, UPnP, and Sonos outputs as renderers with a common
|
The player exposes local, UPnP, and Sonos outputs as renderers with a common
|
||||||
logical interface, but dispatches explicitly based on backend kind.
|
logical interface, but dispatches explicitly based on backend kind.
|
||||||
|
|
||||||
The default `local` renderer uses `racket-audio`. Its backend is created on the
|
Each username has an independent playback pipeline. Separate outputs can be
|
||||||
first playback-related command, not at startup. State and end-of-track callbacks
|
active concurrently. Outputs remain shared resources: selecting an output
|
||||||
update the player and automatically advance the playlist. The UI's linear
|
claims it for that session. If another session owns it, that backend is closed,
|
||||||
0-100 volume is squared before being sent to the local audio library to provide
|
the former session becomes stopped, and ownership transfers to the selecting
|
||||||
a more useful perceived volume curve.
|
user. This deliberately avoids renderer ACLs and durable reservations.
|
||||||
|
|
||||||
Network outputs use the small `private/dlna-playback.rkt` adapter. It owns only
|
The optional `local` renderer uses `racket-audio`. It is included by default
|
||||||
|
and can be omitted with `[player] local-output=false`. Its backend is created
|
||||||
|
on the first playback-related command, not at startup. State and end-of-track
|
||||||
|
callbacks update the player and automatically advance the playlist. The UI's
|
||||||
|
linear 0-100 volume is squared before being sent to the local audio library to
|
||||||
|
provide a more useful perceived volume curve.
|
||||||
|
|
||||||
|
Network outputs use the small `private/dlna-playback.rkt` adapter. All adapters
|
||||||
|
share one `racket-audio-dlna` media-file server, while each adapter owns only
|
||||||
the playlist transition state machine; all transport commands, seeking, HTTP
|
the playlist transition state machine; all transport commands, seeking, HTTP
|
||||||
publication, UPnP calls and cached renderer information are delegated to
|
publication, UPnP calls and cached renderer information are delegated to
|
||||||
`racket-audio-dlna`. After starting a track, the adapter asks that package to
|
`racket-audio-dlna`. After starting a track, the adapter asks that package to
|
||||||
@@ -168,9 +180,9 @@ separately and never trigger that fallback. A seek uses
|
|||||||
`dlna-player-seek-percentage!` directly; its synchronously updated cached
|
`dlna-player-seek-percentage!` directly; its synchronously updated cached
|
||||||
position is pushed to the application state immediately.
|
position is pushed to the application state immediately.
|
||||||
|
|
||||||
Changing the selected renderer closes the existing backend and resets playback
|
Changing a session's selected renderer closes that session's existing backend
|
||||||
state. The replacement backend remains lazy and is created only when it is
|
and resets its playback state. The replacement backend remains lazy and is
|
||||||
needed.
|
created only when needed.
|
||||||
|
|
||||||
Registered playback agents form a fourth renderer kind. An agent keeps the
|
Registered playback agents form a fourth renderer kind. An agent keeps the
|
||||||
client/server direction unchanged: it registers and polls the server, while the
|
client/server direction unchanged: it registers and polls the server, while the
|
||||||
@@ -200,7 +212,7 @@ thread so that the initial API call can return immediately. It:
|
|||||||
4. Represents each Sonos group as one logical renderer.
|
4. Represents each Sonos group as one logical renderer.
|
||||||
5. Removes individual UPnP devices that are already members of those groups.
|
5. Removes individual UPnP devices that are already members of those groups.
|
||||||
6. Sorts the resulting outputs by display name and retains local output as the
|
6. Sorts the resulting outputs by display name and retains local output as the
|
||||||
first option.
|
first option when local output is enabled.
|
||||||
|
|
||||||
The `discovering` state lets polling clients show progress. Discovery failures
|
The `discovering` state lets polling clients show progress. Discovery failures
|
||||||
are recorded in the shared player error field.
|
are recorded in the shared player error field.
|
||||||
@@ -215,6 +227,8 @@ serves static assets from [`public/`](public/) and exposes these API endpoints:
|
|||||||
| `GET` | `/api/state` | Return the complete current state; also refresh network-renderer information. |
|
| `GET` | `/api/state` | Return the complete current state; also refresh network-renderer information. |
|
||||||
| `POST` | `/api/discover` | Start asynchronous discovery and return the current state. |
|
| `POST` | `/api/discover` | Start asynchronous discovery and return the current state. |
|
||||||
| `POST` | `/api/command/:command` | Execute a command with its JSON request body and return the updated state. |
|
| `POST` | `/api/command/:command` | Execute a command with its JSON request body and return the updated state. |
|
||||||
|
| `GET` | `/api/preferences` | Return the current user's durable UI preferences. |
|
||||||
|
| `POST` | `/api/preferences` | Persist the current user's UI language. |
|
||||||
| `POST` | `/api/agent/register` | Register or refresh a polling playback agent. |
|
| `POST` | `/api/agent/register` | Register or refresh a polling playback agent. |
|
||||||
| `POST` | `/api/agent/poll` | Accept agent state and acknowledgements and return its next command. |
|
| `POST` | `/api/agent/poll` | Accept agent state and acknowledgements and return its next command. |
|
||||||
| `GET` | `/api/agent/media/:app-id/:token` | Download the track currently assigned to an agent. |
|
| `GET` | `/api/agent/media/:app-id/:token` | Download the track currently assigned to an agent. |
|
||||||
@@ -234,6 +248,14 @@ header. Command failures are returned as HTTP 400 JSON responses with an
|
|||||||
selection;
|
selection;
|
||||||
- implements keyboard actions and playlist drag-and-drop in the browser.
|
- implements keyboard actions and playlist drag-and-drop in the browser.
|
||||||
|
|
||||||
|
[`public/translate.js`](public/translate.js) follows the key-based translation
|
||||||
|
model used by rktplayer. It supports Dutch, English, German, French and Spanish
|
||||||
|
with English fallback. Browser preferences select the initial language; a
|
||||||
|
manual selection is stored server-side per username and therefore follows the
|
||||||
|
user across browsers. The native agent uses the equivalent
|
||||||
|
[`private/translate.rkt`](private/translate.rkt) module and the operating-system
|
||||||
|
language.
|
||||||
|
|
||||||
DOM signatures prevent rebuilding unchanged library, tab, and playlist
|
DOM signatures prevent rebuilding unchanged library, tab, and playlist
|
||||||
collections on every poll. Playback status and other small values are updated
|
collections on every poll. Playback status and other small values are updated
|
||||||
on every render.
|
on every render.
|
||||||
@@ -309,16 +331,15 @@ sequenceDiagram
|
|||||||
|
|
||||||
The application uses two semaphores with separate responsibilities:
|
The application uses two semaphores with separate responsibilities:
|
||||||
|
|
||||||
- `command-lock` serializes commands and shutdown, preventing overlapping state
|
- `command-lock` serializes commands, state snapshots, agent transitions,
|
||||||
transitions and backend operations.
|
discovery commits and shutdown, preventing overlapping backend operations.
|
||||||
- `state-lock` protects short reads and mutations of the shared player fields
|
- `state-lock` protects short reads and mutations of the shared player fields
|
||||||
performed by HTTP requests, audio callbacks, and the discovery thread.
|
performed by HTTP requests, audio callbacks, and the discovery thread.
|
||||||
|
|
||||||
Potentially slow discovery runs outside the state lock. Backend calls are also
|
Potentially slow discovery runs outside the state lock. Backend calls are also
|
||||||
generally performed outside it, with their results committed in short locked
|
generally performed outside it, with their results committed in short locked
|
||||||
sections. A state request is not serialized by `command-lock`; it may observe
|
sections. Audio callbacks use only the short-lived state lock and update the
|
||||||
the last committed state while a command is performing external I/O, but its
|
playback session captured when their backend was created.
|
||||||
snapshot is internally protected by `state-lock`.
|
|
||||||
|
|
||||||
The server module stores the player in a module-level `current-player` variable.
|
The server module stores the player in a module-level `current-player` variable.
|
||||||
This matches the intended one-player-per-process deployment, but it prevents
|
This matches the intended one-player-per-process deployment, but it prevents
|
||||||
@@ -333,6 +354,7 @@ file:
|
|||||||
- web listen address, defaulting to `127.0.0.1`;
|
- web listen address, defaulting to `127.0.0.1`;
|
||||||
- web port, defaulting to `8080`;
|
- web port, defaulting to `8080`;
|
||||||
- DLNA media publication port, defaulting to `8734`;
|
- DLNA media publication port, defaulting to `8734`;
|
||||||
|
- optional server-local audio output under `[player]`, enabled by default;
|
||||||
- named library root paths under `[libraries]` (the legacy semicolon-separated
|
- named library root paths under `[libraries]` (the legacy semicolon-separated
|
||||||
setting remains supported);
|
setting remains supported);
|
||||||
- allowed 256-bit playback-agent IDs under `[playback-agents]`;
|
- allowed 256-bit playback-agent IDs under `[playback-agents]`;
|
||||||
@@ -342,9 +364,9 @@ file:
|
|||||||
Command-line network settings override INI values. Library paths from both
|
Command-line network settings override INI values. Library paths from both
|
||||||
sources are combined and de-duplicated.
|
sources are combined and de-duplicated.
|
||||||
|
|
||||||
Playlist tabs use the SQLite-backed `keystore` module at
|
Playlist tabs and per-user language preferences use the SQLite-backed
|
||||||
`data/playlists.keystore`. Output discovery, transport state, playback position
|
`keystore` module at `data/playlists.keystore`. Output discovery, transport
|
||||||
and sessions still reset when the process restarts.
|
state, playback position and sessions still reset when the process restarts.
|
||||||
|
|
||||||
## 7. Security and operational boundaries
|
## 7. Security and operational boundaries
|
||||||
|
|
||||||
@@ -353,8 +375,9 @@ Authentication must be enabled before exposing it to an untrusted network, and
|
|||||||
a reverse proxy must provide HTTPS because session cookies are always marked
|
a reverse proxy must provide HTTPS because session cookies are always marked
|
||||||
`Secure`. Forwarded client addresses are security-sensitive configuration:
|
`Secure`. Forwarded client addresses are security-sensitive configuration:
|
||||||
only known reverse-proxy peers may be trusted, and the application port should
|
only known reverse-proxy peers may be trusted, and the application port should
|
||||||
remain firewalled from the internet. Playlists are isolated by username, while
|
remain firewalled from the internet. Playlists, preferences, renderer selection
|
||||||
renderer selection and transport state remain shared.
|
and transport state are isolated by username. Physical outputs are shared and
|
||||||
|
can be transferred between sessions.
|
||||||
|
|
||||||
Playback-agent registration and polling are authorized against a default-deny
|
Playback-agent registration and polling are authorized against a default-deny
|
||||||
INI allowlist. Media URLs additionally contain an opaque per-track token. The
|
INI allowlist. Media URLs additionally contain an opaque per-track token. The
|
||||||
@@ -371,18 +394,19 @@ operate on opaque indexes instead of sending paths directly. The DLNA backend
|
|||||||
must make a selected local file reachable by the network renderer, so its media
|
must make a selected local file reachable by the network renderer, so its media
|
||||||
port also needs to be accessible on the relevant trusted network.
|
port also needs to be accessible on the relevant trusted network.
|
||||||
|
|
||||||
There is no durable job queue or retry policy. Discovery and renderer failures
|
There is no durable job queue or retry policy. Discovery failures are shared;
|
||||||
are surfaced as shared UI errors, and renderer state is retried naturally by
|
playback failures belong to the affected user's session. Renderer state is
|
||||||
subsequent polling requests.
|
retried naturally by subsequent polling requests.
|
||||||
|
|
||||||
## 8. Testing and extension points
|
## 8. Testing and extension points
|
||||||
|
|
||||||
Unit tests embedded in `private/library.rkt` cover root creation, filtering, and
|
Unit tests embedded in `private/library.rkt` cover root creation, filtering, and
|
||||||
directory ordering. `private/playlists.rkt` tests transactional round trips,
|
directory ordering. `private/playlists.rkt` tests transactional round trips,
|
||||||
per-user GUID indexes, multiple libraries, deletion and library-boundary
|
per-user GUID indexes, multiple libraries, deletion and library-boundary
|
||||||
validation. Tests in `private/player.rkt`
|
validation, and per-user language storage. Tests in `private/player.rkt`
|
||||||
cover initial state, browser navigation, repeat mode, persistent playlist-tab
|
cover initial state, browser navigation, repeat mode, persistent playlist-tab
|
||||||
operations, unknown commands, and clean shutdown. The current suite does not
|
operations, concurrent user sessions, renderer takeover, unknown commands, and
|
||||||
|
clean shutdown. The current suite does not
|
||||||
exercise real audio devices, network discovery, DLNA renderers, HTTP routing,
|
exercise real audio devices, network discovery, DLNA renderers, HTTP routing,
|
||||||
or browser behavior.
|
or browser behavior.
|
||||||
|
|
||||||
@@ -398,9 +422,9 @@ The main extension points are:
|
|||||||
|
|
||||||
## 9. Architectural constraints and trade-offs
|
## 9. Architectural constraints and trade-offs
|
||||||
|
|
||||||
- **Per-user playlists, shared transport:** playlist collections are isolated
|
- **Per-user pipelines, shared outputs:** playlist and transport state are
|
||||||
by username, while the renderer and transport remain shared. A playlist
|
isolated by username. Distinct outputs run concurrently; selecting an
|
||||||
command from another user explicitly takes over that shared player.
|
occupied output explicitly stops its previous owner and transfers it.
|
||||||
- **Full-state snapshots:** a small and predictable client protocol, at the cost
|
- **Full-state snapshots:** a small and predictable client protocol, at the cost
|
||||||
of repeatedly transferring all tracks and browser entries.
|
of repeatedly transferring all tracks and browser entries.
|
||||||
- **One-second polling:** robust and dependency-free, but introduces periodic
|
- **One-second polling:** robust and dependency-free, but introduces periodic
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ port=8080
|
|||||||
|
|
||||||
[player]
|
[player]
|
||||||
dlna-port=8734
|
dlna-port=8734
|
||||||
|
local-output=true
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
muziek=D:\Muziek
|
muziek=D:\Muziek
|
||||||
@@ -54,7 +55,9 @@ UNC-rootmap. Het oudere `[library] paths=D:\Muziek;D:\Podcasts` blijft eveneens
|
|||||||
ondersteund. Playlisttabs worden standaard opgeslagen in de keystore
|
ondersteund. Playlisttabs worden standaard opgeslagen in de keystore
|
||||||
`data/playlists.keystore` binnen de geïnstalleerde map van rkt-web-player. Met
|
`data/playlists.keystore` binnen de geïnstalleerde map van rkt-web-player. Met
|
||||||
`playlist-keystore=...` onder `[player]` kan desgewenst een ander pad worden
|
`playlist-keystore=...` onder `[player]` kan desgewenst een ander pad worden
|
||||||
gebruikt.
|
gebruikt. Met `local-output=false` wordt de audio-uitvoer van de server zelf
|
||||||
|
niet als afspeelpunt aangeboden. Als nog geen netwerkspeler of playback agent
|
||||||
|
beschikbaar is, blijft de uitvoerselectie leeg totdat er een verschijnt.
|
||||||
|
|
||||||
Zodra `[users]` minstens één gebruiker bevat, moeten alle browserclients
|
Zodra `[users]` minstens één gebruiker bevat, moeten alle browserclients
|
||||||
inloggen, zowel lokaal als via internet. Wachtwoorden staan uitsluitend als
|
inloggen, zowel lokaal als via internet. Wachtwoorden staan uitsluitend als
|
||||||
@@ -108,11 +111,21 @@ geconfigureerde libraries mogen in dezelfde playlist staan; ontbrekende of
|
|||||||
buiten de libraries gelegen bestanden worden bij het laden overgeslagen.
|
buiten de libraries gelegen bestanden worden bij het laden overgeslagen.
|
||||||
Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als
|
Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als
|
||||||
authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt.
|
authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt.
|
||||||
Het fysieke afspeelpunt en de transportbediening blijven gedeeld; een
|
Iedere gebruiker heeft daarnaast een eigen afspeelpipeline met uitvoerkeuze,
|
||||||
playlistcommando van een andere gebruiker neemt die speler over. Een oudere
|
transportstatus, huidige track, volume en herhaalmodus. Verschillende fysieke
|
||||||
|
afspeelpunten kunnen daardoor gelijktijdig voor verschillende gebruikers
|
||||||
|
spelen. De afspeelpunten zelf blijven gedeeld: kiest een gebruiker een reeds
|
||||||
|
bezet afspeelpunt, dan wordt de vorige pipeline op dat punt gestopt en neemt de
|
||||||
|
nieuwe gebruiker het over. Een oudere
|
||||||
`playlists-for-local`-verzameling blijft in de keystore staan, maar wordt niet
|
`playlists-for-local`-verzameling blijft in de keystore staan, maar wordt niet
|
||||||
automatisch aan een gebruiker toegewezen.
|
automatisch aan een gebruiker toegewezen.
|
||||||
|
|
||||||
|
De webinterface ondersteunt Nederlands, Engels, Duits, Frans en Spaans. Bij
|
||||||
|
het eerste bezoek wordt de voorkeurstaal van de browser gebruikt. Een keuze in
|
||||||
|
de taalselector wordt daarna per gebruiker als `language-for-<username>` in
|
||||||
|
`data/playlists.keystore` opgeslagen en geldt daardoor ook op andere apparaten.
|
||||||
|
Zonder authenticatie geldt dit voor de gebruiker `anonymous`.
|
||||||
|
|
||||||
De server luistert standaard alleen op localhost. Geef alleen bewust een
|
De server luistert standaard alleen op localhost. Geef alleen bewust een
|
||||||
LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de
|
LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de
|
||||||
webinterface via een publiek bereikbare reverse proxy wordt aangeboden.
|
webinterface via een publiek bereikbare reverse proxy wordt aangeboden.
|
||||||
@@ -159,6 +172,8 @@ toe wanneer zijn volledige applicatie-ID vooraf als key onder
|
|||||||
`[playback-agents]` staat en de waarde niet `false` is. Een onbekende ID krijgt
|
`[playback-agents]` staat en de waarde niet `false` is. Een onbekende ID krijgt
|
||||||
HTTP 403 en wordt niet als uitvoerpunt aangemaakt. De agent toont in dat geval
|
HTTP 403 en wordt niet als uitvoerpunt aangemaakt. De agent toont in dat geval
|
||||||
zijn ID en meldt dat de serverbeheerder deze eerst aan de INI moet toevoegen.
|
zijn ID en meldt dat de serverbeheerder deze eerst aan de INI moet toevoegen.
|
||||||
|
De GUI en het systeemvak volgen automatisch de systeemtaal en ondersteunen
|
||||||
|
dezelfde vijf talen als de webinterface.
|
||||||
|
|
||||||
Onder **Afspelen** toont de agent het playlistnummer, de huidige track en
|
Onder **Afspelen** toont de agent het playlistnummer, de huidige track en
|
||||||
bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte,
|
bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Start the audio player and its web interface.
|
; goal : Start the audio player and its web interface.
|
||||||
; pre : Ports are valid; every library spec names an existing directory.
|
; pre : Ports are valid, local-output? is a boolean, and every library spec
|
||||||
|
; names an existing directory.
|
||||||
; post : Libraries are browsed lazily; player resources close with the server.
|
; post : Libraries are browsed lazily; player resources close with the server.
|
||||||
; result : The result returned by serve/servlet.
|
; result : The result returned by serve/servlet.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
#:listen-ip [listen-ip "127.0.0.1"]
|
#:listen-ip [listen-ip "127.0.0.1"]
|
||||||
#:port [port 8080]
|
#:port [port 8080]
|
||||||
#:dlna-port [dlna-port 8734]
|
#:dlna-port [dlna-port 8734]
|
||||||
|
#:local-output? [local-output? #t]
|
||||||
#:playlist-keystore
|
#:playlist-keystore
|
||||||
[playlist-keystore default-playlist-keystore]
|
[playlist-keystore default-playlist-keystore]
|
||||||
#:launch-browser? [launch-browser? #t])
|
#:launch-browser? [launch-browser? #t])
|
||||||
@@ -72,12 +74,14 @@
|
|||||||
#:listen-ip string?
|
#:listen-ip string?
|
||||||
#:port exact-positive-integer?
|
#:port exact-positive-integer?
|
||||||
#:dlna-port exact-positive-integer?
|
#:dlna-port exact-positive-integer?
|
||||||
|
#:local-output? boolean?
|
||||||
#:playlist-keystore (or/c path-string? #f)
|
#:playlist-keystore (or/c path-string? #f)
|
||||||
#:launch-browser? boolean?)
|
#:launch-browser? boolean?)
|
||||||
any)
|
any)
|
||||||
(let* ((libraries (make-music-libraries music-paths))
|
(let* ((libraries (make-music-libraries music-paths))
|
||||||
(player (make-player libraries
|
(player (make-player libraries
|
||||||
#:allowed-agent-ids allowed-agent-ids
|
#:allowed-agent-ids allowed-agent-ids
|
||||||
|
#:local-output? local-output?
|
||||||
#:playlist-keystore playlist-keystore
|
#:playlist-keystore playlist-keystore
|
||||||
#:dlna-port dlna-port))
|
#:dlna-port dlna-port))
|
||||||
(auth-manager
|
(auth-manager
|
||||||
@@ -198,6 +202,7 @@
|
|||||||
(ini-get config 'server 'port 8080))
|
(ini-get config 'server 'port 8080))
|
||||||
#:dlna-port (or dlna-port
|
#:dlna-port (or dlna-port
|
||||||
(ini-get config 'player 'dlna-port 8734))
|
(ini-get config 'player 'dlna-port 8734))
|
||||||
|
#:local-output? (ini-get config 'player 'local-output #t)
|
||||||
#:playlist-keystore
|
#:playlist-keystore
|
||||||
(or playlist-keystore
|
(or playlist-keystore
|
||||||
(let ((configured
|
(let ((configured
|
||||||
|
|||||||
@@ -293,23 +293,29 @@
|
|||||||
(with-lock playback (lambda () (poll/locked! playback))))
|
(with-lock playback (lambda () (poll/locked! playback))))
|
||||||
(loop)))))
|
(loop)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create playlist-aware playback for one network renderer.
|
||||||
|
; pre : Device is a media renderer, callbacks are procedures, and
|
||||||
|
; media-server is a running shared media-file-server.
|
||||||
|
; post : A DLNA player and its state-monitor thread are running.
|
||||||
|
; result : A playback adapter that publishes through the supplied server.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (make-dlna-playback device
|
(define (make-dlna-playback device
|
||||||
tracks
|
tracks
|
||||||
update
|
update
|
||||||
error
|
error
|
||||||
#:port [port 8734]
|
#:media-file-server media-server
|
||||||
#:poll-seconds [poll-seconds 1])
|
#:poll-seconds [poll-seconds 1])
|
||||||
(define raw
|
(define raw
|
||||||
(make-dlna-player device
|
(make-dlna-player device
|
||||||
#:port port
|
#:media-file-server media-server))
|
||||||
#:path "/rkt-web-player/"))
|
|
||||||
(define playback
|
(define playback
|
||||||
(dlna-playback raw tracks update error 'off #f #f #f
|
(dlna-playback raw tracks update error 'off #f #f #f
|
||||||
#f #f #f #f 0 #f #t #t #f
|
#f #f #f #f 0 #f #t #t #f
|
||||||
(make-semaphore 1)))
|
(make-semaphore 1)))
|
||||||
(set-dlna-playback-monitor!
|
(set-dlna-playback-monitor!
|
||||||
playback
|
playback
|
||||||
(thread (lambda () (monitor-loop playback poll-seconds))))
|
(thread (λ () (monitor-loop playback poll-seconds))))
|
||||||
playback)
|
playback)
|
||||||
|
|
||||||
(define (dlna-playback-play-index! playback index)
|
(define (dlna-playback-play-index! playback index)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
racket/path
|
racket/path
|
||||||
racket/port
|
racket/port
|
||||||
racket/string
|
racket/string
|
||||||
simple-log)
|
simple-log
|
||||||
|
"translate.rkt")
|
||||||
|
|
||||||
(provide (struct-out player-agent-runtime)
|
(provide (struct-out player-agent-runtime)
|
||||||
make-player-agent-runtime)
|
make-player-agent-runtime)
|
||||||
@@ -321,17 +322,12 @@
|
|||||||
(with-handlers
|
(with-handlers
|
||||||
((exn:fail:agent-denied?
|
((exn:fail:agent-denied?
|
||||||
(lambda (exception)
|
(lambda (exception)
|
||||||
(define message
|
(define message (format (tr 'denied-message) app-id))
|
||||||
(string-append
|
|
||||||
"Deze playback agent is niet toegelaten door de server. "
|
|
||||||
"Voeg het volgende applicatie-ID toe aan [playback-agents] "
|
|
||||||
"in de server-INI:\n\n"
|
|
||||||
app-id))
|
|
||||||
(warn-player-agent "Agent authorization refused: ~a"
|
(warn-player-agent "Agent authorization refused: ~a"
|
||||||
(exn-message exception))
|
(exn-message exception))
|
||||||
(set-agent-error! message)
|
(set-agent-error! message)
|
||||||
(status-callback
|
(status-callback
|
||||||
"Niet geautoriseerd — applicatie-ID staat niet in de server-INI")
|
(tr 'unauthorized-status))
|
||||||
(unless authorization-notified?
|
(unless authorization-notified?
|
||||||
(set! authorization-notified? #t)
|
(set! authorization-notified? #t)
|
||||||
(denied-callback message))
|
(denied-callback message))
|
||||||
@@ -344,7 +340,7 @@
|
|||||||
(exn-message exception))
|
(exn-message exception))
|
||||||
(set-agent-error! (exn-message exception))
|
(set-agent-error! (exn-message exception))
|
||||||
(status-callback
|
(status-callback
|
||||||
(format "Niet verbonden: ~a" (exn-message exception)))
|
(format (tr 'disconnected) (exn-message exception)))
|
||||||
(when running
|
(when running
|
||||||
(sleep 3)
|
(sleep 3)
|
||||||
(poll-loop)))))
|
(poll-loop)))))
|
||||||
@@ -352,7 +348,7 @@
|
|||||||
"/api/agent/register"
|
"/api/agent/register"
|
||||||
(hasheq 'appId app-id 'name assigned-name))
|
(hasheq 'appId app-id 'name assigned-name))
|
||||||
(clear-agent-error!)
|
(clear-agent-error!)
|
||||||
(status-callback "Verbonden")
|
(status-callback (tr 'connected))
|
||||||
(info-player-agent "Registered at ~a as ~a" server-url assigned-name)
|
(info-player-agent "Registered at ~a as ~a" server-url assigned-name)
|
||||||
(let loop ()
|
(let loop ()
|
||||||
(when running
|
(when running
|
||||||
@@ -390,7 +386,7 @@
|
|||||||
(define (start!)
|
(define (start!)
|
||||||
(unless running
|
(unless running
|
||||||
(set! running #t)
|
(set! running #t)
|
||||||
(status-callback "Verbinden…")
|
(status-callback (tr 'connecting))
|
||||||
(set! worker (thread poll-loop))))
|
(set! worker (thread poll-loop))))
|
||||||
|
|
||||||
(define (stop!)
|
(define (stop!)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
simple-log
|
simple-log
|
||||||
"player-agent-config.rkt"
|
"player-agent-config.rkt"
|
||||||
"player-agent-core.rkt"
|
"player-agent-core.rkt"
|
||||||
"player-agent-tray.rkt")
|
"player-agent-tray.rkt"
|
||||||
|
"translate.rkt")
|
||||||
|
|
||||||
(provide run-player-agent-gui)
|
(provide run-player-agent-gui)
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
(define (show-denial! message)
|
(define (show-denial! message)
|
||||||
(queue-callback
|
(queue-callback
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(message-box "Playback agent niet toegestaan"
|
(message-box (tr 'denied-title)
|
||||||
message
|
message
|
||||||
frame
|
frame
|
||||||
'(ok stop)))
|
'(ok stop)))
|
||||||
@@ -97,13 +98,13 @@
|
|||||||
((and artist (not (string=? artist "")) title)
|
((and artist (not (string=? artist "")) title)
|
||||||
(format "~a — ~a" artist title))
|
(format "~a — ~a" artist title))
|
||||||
(title title)
|
(title title)
|
||||||
(else "Geen track geselecteerd")))
|
(else (tr 'no-track-selected))))
|
||||||
(define prefix
|
(define prefix
|
||||||
(cond
|
(cond
|
||||||
((string=? state "playing") "Speelt")
|
((string=? state "playing") (tr 'playing))
|
||||||
((string=? state "paused") "Gepauzeerd")
|
((string=? state "paused") (tr 'paused))
|
||||||
((string=? state "starting") "Laden")
|
((string=? state "starting") (tr 'loading))
|
||||||
((string=? state "stopped") "Gestopt")
|
((string=? state "stopped") (tr 'stopped))
|
||||||
(else state)))
|
(else state)))
|
||||||
(define position (hash-ref snapshot 'position 0))
|
(define position (hash-ref snapshot 'position 0))
|
||||||
(define duration (hash-ref snapshot 'duration 'null))
|
(define duration (hash-ref snapshot 'duration 'null))
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
"")
|
"")
|
||||||
(if (number? channels)
|
(if (number? channels)
|
||||||
(format "~a ~a" channels
|
(format "~a ~a" channels
|
||||||
(if (= channels 1) "kanaal" "kanalen"))
|
(tr (if (= channels 1) 'channel 'channels)))
|
||||||
"")
|
"")
|
||||||
(if (and (string? format-name) (not (string=? format-name "")))
|
(if (and (string? format-name) (not (string=? format-name "")))
|
||||||
format-name
|
format-name
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
(format " #~a" track-number)
|
(format " #~a" track-number)
|
||||||
"")
|
"")
|
||||||
track-label)
|
track-label)
|
||||||
"Er wordt niets afgespeeld"))
|
(tr 'no-track)))
|
||||||
(send playback-details set-label (string-join details " · "))
|
(send playback-details set-label (string-join details " · "))
|
||||||
(send playback-filename
|
(send playback-filename
|
||||||
set-label
|
set-label
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
(save-player-agent-config! config)
|
(save-player-agent-config! config)
|
||||||
(send name-field set-value next-name)
|
(send name-field set-value next-name)
|
||||||
((player-agent-runtime-reconnect! runtime) next-server next-name)
|
((player-agent-runtime-reconnect! runtime) next-server next-name)
|
||||||
(send connect-button set-label "Opnieuw verbinden"))
|
(send connect-button set-label (tr 'reconnect)))
|
||||||
|
|
||||||
(define (shutdown!)
|
(define (shutdown!)
|
||||||
(unless shutting-down?
|
(unless shutting-down?
|
||||||
@@ -191,7 +192,7 @@
|
|||||||
|
|
||||||
(set! frame
|
(set! frame
|
||||||
(new agent-frame%
|
(new agent-frame%
|
||||||
(label "RKT Web Player Agent")
|
(label (tr 'app-title))
|
||||||
(width 560)
|
(width 560)
|
||||||
(height 310)))
|
(height 310)))
|
||||||
(define panel
|
(define panel
|
||||||
@@ -199,13 +200,15 @@
|
|||||||
(parent frame)
|
(parent frame)
|
||||||
(alignment '(left top))))
|
(alignment '(left top))))
|
||||||
(set! server-field
|
(set! server-field
|
||||||
(input-field "RKT Web Player server"
|
(input-field (tr 'server)
|
||||||
(player-agent-config-server-url config)
|
(player-agent-config-server-url config)
|
||||||
panel))
|
panel))
|
||||||
(set! name-field
|
(set! name-field
|
||||||
(input-field "Naam" (player-agent-config-name config) panel))
|
(input-field (tr 'name) (player-agent-config-name config) panel))
|
||||||
(define id-field
|
(define id-field
|
||||||
(input-field "Applicatie-ID" (player-agent-config-app-id config) panel))
|
(input-field (tr 'application-id)
|
||||||
|
(player-agent-config-app-id config)
|
||||||
|
panel))
|
||||||
;; Lock the editor, not the native widget. Disabled Windows controls render
|
;; Lock the editor, not the native widget. Disabled Windows controls render
|
||||||
;; their label and text poorly on some display configurations.
|
;; their label and text poorly on some display configurations.
|
||||||
(send (send id-field get-editor) lock #t)
|
(send (send id-field get-editor) lock #t)
|
||||||
@@ -213,13 +216,13 @@
|
|||||||
(define playback-panel
|
(define playback-panel
|
||||||
(new group-box-panel%
|
(new group-box-panel%
|
||||||
(parent panel)
|
(parent panel)
|
||||||
(label "Afspelen")
|
(label (tr 'playback))
|
||||||
(alignment '(left top))
|
(alignment '(left top))
|
||||||
(stretchable-height #f)))
|
(stretchable-height #f)))
|
||||||
(set! playback-message
|
(set! playback-message
|
||||||
(new message%
|
(new message%
|
||||||
(parent playback-panel)
|
(parent playback-panel)
|
||||||
(label "Er wordt niets afgespeeld")
|
(label (tr 'no-track))
|
||||||
(auto-resize #t)))
|
(auto-resize #t)))
|
||||||
(set! playback-details
|
(set! playback-details
|
||||||
(new message%
|
(new message%
|
||||||
@@ -239,12 +242,12 @@
|
|||||||
(set! connect-button
|
(set! connect-button
|
||||||
(new button%
|
(new button%
|
||||||
(parent controls)
|
(parent controls)
|
||||||
(label "Opslaan en verbinden")
|
(label (tr 'save-connect))
|
||||||
(callback (lambda (_button _event) (reconnect!)))))
|
(callback (lambda (_button _event) (reconnect!)))))
|
||||||
(set! status-message
|
(set! status-message
|
||||||
(new message%
|
(new message%
|
||||||
(parent controls)
|
(parent controls)
|
||||||
(label "Verbinden…")
|
(label (tr 'connecting))
|
||||||
(auto-resize #t)))
|
(auto-resize #t)))
|
||||||
|
|
||||||
(set! playback-timer
|
(set! playback-timer
|
||||||
@@ -268,5 +271,5 @@
|
|||||||
|
|
||||||
(send frame show #t)
|
(send frame show #t)
|
||||||
((player-agent-runtime-start! runtime))
|
((player-agent-runtime-start! runtime))
|
||||||
(send connect-button set-label "Opnieuw verbinden")
|
(send connect-button set-label (tr 'reconnect))
|
||||||
frame)
|
frame)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
(require "translate.rkt")
|
||||||
|
|
||||||
;; SDL3 is deliberately loaded dynamically. The GUI agent keeps working
|
;; SDL3 is deliberately loaded dynamically. The GUI agent keeps working
|
||||||
;; without the optional Racket package and native SDL3 runtime.
|
;; without the optional Racket package and native SDL3 runtime.
|
||||||
|
|
||||||
@@ -20,11 +22,11 @@
|
|||||||
(define update-trays! (dynamic-require 'sdl3 'update-trays!))
|
(define update-trays! (dynamic-require 'sdl3 'update-trays!))
|
||||||
(define tray-destroy! (dynamic-require 'sdl3 'tray-destroy!))
|
(define tray-destroy! (dynamic-require 'sdl3 'tray-destroy!))
|
||||||
(sdl-init! '(video events))
|
(sdl-init! '(video events))
|
||||||
(define tray (make-tray #f "RKT Web Player Agent"))
|
(define tray (make-tray #f (tr 'app-title)))
|
||||||
(define menu (make-tray-menu tray))
|
(define menu (make-tray-menu tray))
|
||||||
(define show-entry (insert-tray-entry! menu "RKT Web Player Agent openen"))
|
(define show-entry (insert-tray-entry! menu (tr 'tray-open)))
|
||||||
(insert-tray-entry! menu #f)
|
(insert-tray-entry! menu #f)
|
||||||
(define quit-entry (insert-tray-entry! menu "Afsluiten"))
|
(define quit-entry (insert-tray-entry! menu (tr 'quit)))
|
||||||
(set-tray-entry-callback! show-entry (lambda (_) (show-window!)))
|
(set-tray-entry-callback! show-entry (lambda (_) (show-window!)))
|
||||||
(set-tray-entry-callback! quit-entry (lambda (_) (quit!)))
|
(set-tray-entry-callback! quit-entry (lambda (_) (quit!)))
|
||||||
(tray-controller
|
(tray-controller
|
||||||
|
|||||||
+563
-280
File diff suppressed because it is too large
Load Diff
+52
-1
@@ -11,7 +11,9 @@
|
|||||||
open-playlist-store
|
open-playlist-store
|
||||||
close-playlist-store!
|
close-playlist-store!
|
||||||
load-user-playlists
|
load-user-playlists
|
||||||
save-user-playlists!)
|
save-user-playlists!
|
||||||
|
load-user-language
|
||||||
|
save-user-language!)
|
||||||
|
|
||||||
(struct persisted-tab (id name tracks) #:transparent)
|
(struct persisted-tab (id name tracks) #:transparent)
|
||||||
(struct playlist-store (keystore lock) #:transparent)
|
(struct playlist-store (keystore lock) #:transparent)
|
||||||
@@ -19,6 +21,11 @@
|
|||||||
(define (user-playlists-key username)
|
(define (user-playlists-key username)
|
||||||
(format "playlists-for-~a" username))
|
(format "playlists-for-~a" username))
|
||||||
|
|
||||||
|
(define (user-language-key username)
|
||||||
|
(format "language-for-~a" username))
|
||||||
|
|
||||||
|
(define supported-language-names '("en" "nl" "de" "fr" "es"))
|
||||||
|
|
||||||
(define (track->datum item)
|
(define (track->datum item)
|
||||||
(hasheq 'file (path->string (track-file item))
|
(hasheq 'file (path->string (track-file item))
|
||||||
'title (track-title item)
|
'title (track-title item)
|
||||||
@@ -116,6 +123,45 @@
|
|||||||
(ks-set! ks index-key ids))
|
(ks-set! ks index-key ids))
|
||||||
(void)))))
|
(void)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Load one user's persisted interface language.
|
||||||
|
; pre : Store is #f or an open playlist store; username is normalized.
|
||||||
|
; post : Store contents remain unchanged.
|
||||||
|
; result : A supported ISO language name, or #f when none was saved.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (load-user-language store username)
|
||||||
|
(and store
|
||||||
|
(call-with-semaphore
|
||||||
|
(playlist-store-lock store)
|
||||||
|
(λ ()
|
||||||
|
(define value
|
||||||
|
(ks-get (playlist-store-keystore store)
|
||||||
|
(user-language-key username)
|
||||||
|
#f))
|
||||||
|
(and (member value supported-language-names) value)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Persist one user's interface language.
|
||||||
|
; pre : Store is #f or open, username is normalized, and language is one of
|
||||||
|
; en, nl, de, fr, or es.
|
||||||
|
; post : The user's language key contains language when a store exists.
|
||||||
|
; result : Void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (save-user-language! store username language)
|
||||||
|
(unless (member language supported-language-names)
|
||||||
|
(raise-argument-error
|
||||||
|
'save-user-language!
|
||||||
|
"one of en, nl, de, fr, or es"
|
||||||
|
language))
|
||||||
|
(when store
|
||||||
|
(call-with-semaphore
|
||||||
|
(playlist-store-lock store)
|
||||||
|
(λ ()
|
||||||
|
(ks-set! (playlist-store-keystore store)
|
||||||
|
(user-language-key username)
|
||||||
|
language))))
|
||||||
|
(void))
|
||||||
|
|
||||||
(module+ test
|
(module+ test
|
||||||
(require rackunit
|
(require rackunit
|
||||||
uuid/random)
|
uuid/random)
|
||||||
@@ -166,6 +212,11 @@
|
|||||||
(check-equal?
|
(check-equal?
|
||||||
(map persisted-tab-name (load-user-playlists store "local" libraries))
|
(map persisted-tab-name (load-user-playlists store "local" libraries))
|
||||||
'("Local"))
|
'("Local"))
|
||||||
|
(check-false (load-user-language store "hans"))
|
||||||
|
(save-user-language! store "hans" "fr")
|
||||||
|
(check-equal? (load-user-language store "hans") "fr")
|
||||||
|
(check-exn exn:fail:contract?
|
||||||
|
(λ () (save-user-language! store "hans" "it")))
|
||||||
|
|
||||||
;; Rewriting the user's GUID index durably removes the omitted playlist.
|
;; Rewriting the user's GUID index durably removes the omitted playlist.
|
||||||
(save-user-playlists!
|
(save-user-playlists!
|
||||||
|
|||||||
@@ -119,6 +119,24 @@
|
|||||||
(request-jsexpr request)
|
(request-jsexpr request)
|
||||||
#:username (request-username request)))))
|
#:username (request-username request)))))
|
||||||
|
|
||||||
|
(define (preferences-handler request)
|
||||||
|
(json-response
|
||||||
|
(hasheq
|
||||||
|
'language
|
||||||
|
(or (player-user-language
|
||||||
|
current-player
|
||||||
|
#:username (request-username request))
|
||||||
|
'null))))
|
||||||
|
|
||||||
|
(define (preferences-update-handler request)
|
||||||
|
(with-handlers ((exn:fail? error-response))
|
||||||
|
(define language (hash-ref (request-jsexpr request) 'language #f))
|
||||||
|
(player-user-language!
|
||||||
|
current-player
|
||||||
|
language
|
||||||
|
#:username (request-username request))
|
||||||
|
(json-response (hasheq 'language language))))
|
||||||
|
|
||||||
(define (agent-register-handler request)
|
(define (agent-register-handler request)
|
||||||
(with-handlers
|
(with-handlers
|
||||||
((exn:fail:agent-denied? agent-error-response)
|
((exn:fail:agent-denied? agent-error-response)
|
||||||
@@ -190,6 +208,8 @@
|
|||||||
[("api" "auth" "logout") #:method "post" auth-logout-handler]
|
[("api" "auth" "logout") #:method "post" auth-logout-handler]
|
||||||
[("api" "state") #:method "get" state-handler]
|
[("api" "state") #:method "get" state-handler]
|
||||||
[("api" "discover") #:method "post" discover-handler]
|
[("api" "discover") #:method "post" discover-handler]
|
||||||
|
[("api" "preferences") #:method "get" preferences-handler]
|
||||||
|
[("api" "preferences") #:method "post" preferences-update-handler]
|
||||||
[("api" "agent" "register") #:method "post" agent-register-handler]
|
[("api" "agent" "register") #:method "post" agent-register-handler]
|
||||||
[("api" "agent" "poll") #:method "post" agent-poll-handler]
|
[("api" "agent" "poll") #:method "post" agent-poll-handler]
|
||||||
[("api" "agent" "media" (string-arg) (string-arg))
|
[("api" "agent" "media" (string-arg) (string-arg))
|
||||||
|
|||||||
@@ -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))))
|
||||||
+80
-26
@@ -1,5 +1,6 @@
|
|||||||
const elements = {
|
const elements = {
|
||||||
renderer: document.querySelector("#renderer"),
|
renderer: document.querySelector("#renderer"),
|
||||||
|
language: document.querySelector("#language"),
|
||||||
discover: document.querySelector("#discover"),
|
discover: document.querySelector("#discover"),
|
||||||
previous: document.querySelector("#previous"),
|
previous: document.querySelector("#previous"),
|
||||||
play: document.querySelector("#play"),
|
play: document.querySelector("#play"),
|
||||||
@@ -9,6 +10,8 @@ const elements = {
|
|||||||
position: document.querySelector("#position"),
|
position: document.querySelector("#position"),
|
||||||
duration: document.querySelector("#duration"),
|
duration: document.querySelector("#duration"),
|
||||||
repeat: document.querySelector("#repeat"),
|
repeat: document.querySelector("#repeat"),
|
||||||
|
volumeControl: document.querySelector("#volume-control"),
|
||||||
|
volumeToggle: document.querySelector("#volume-toggle"),
|
||||||
volume: document.querySelector("#volume"),
|
volume: document.querySelector("#volume"),
|
||||||
volumeValue: document.querySelector("#volume-value"),
|
volumeValue: document.querySelector("#volume-value"),
|
||||||
library: document.querySelector("#library"),
|
library: document.querySelector("#library"),
|
||||||
@@ -41,6 +44,8 @@ const elements = {
|
|||||||
loginSubmit: document.querySelector("#login-submit"),
|
loginSubmit: document.querySelector("#login-submit"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { t } = window.RktTranslate;
|
||||||
|
|
||||||
let state = null;
|
let state = null;
|
||||||
let seekBusy = false;
|
let seekBusy = false;
|
||||||
let draggedTrack = null;
|
let draggedTrack = null;
|
||||||
@@ -102,9 +107,16 @@ async function refreshAuth() {
|
|||||||
try {
|
try {
|
||||||
const auth = await api("/api/auth/status");
|
const auth = await api("/api/auth/status");
|
||||||
elements.logout.hidden = !auth.enabled || !auth.authenticated;
|
elements.logout.hidden = !auth.enabled || !auth.authenticated;
|
||||||
if (auth.enabled && !auth.authenticated) showLogin();
|
if (auth.enabled && !auth.authenticated) {
|
||||||
|
showLogin();
|
||||||
|
} else {
|
||||||
|
const preferences = await api("/api/preferences");
|
||||||
|
if (window.RktTranslate.supported.includes(preferences.language)) {
|
||||||
|
window.RktTranslate.setLanguage(preferences.language);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Authenticatiestatus onbekend: ${error.message}`);
|
setStatus(t("authUnknown", { message: error.message }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +148,7 @@ function replaceSelect(select, items, selectedId, signature) {
|
|||||||
function renderSelectors(nextState) {
|
function renderSelectors(nextState) {
|
||||||
const rendererItems = nextState.renderers.map((item) => ({
|
const rendererItems = nextState.renderers.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: `${item.name} · ${item.kind.toUpperCase()}`,
|
label: `${item.name} · ${item.kind === "local" ? t("rendererLocal") : item.kind.toUpperCase()}`,
|
||||||
}));
|
}));
|
||||||
replaceSelect(
|
replaceSelect(
|
||||||
elements.renderer,
|
elements.renderer,
|
||||||
@@ -158,6 +170,7 @@ function renderSelectors(nextState) {
|
|||||||
|
|
||||||
elements.discover.classList.toggle("busy", nextState.discovering);
|
elements.discover.classList.toggle("busy", nextState.discovering);
|
||||||
elements.discover.disabled = nextState.discovering;
|
elements.discover.disabled = nextState.discovering;
|
||||||
|
elements.renderer.disabled = nextState.renderers.length === 0;
|
||||||
elements.library.disabled = nextState.libraries.length === 0;
|
elements.library.disabled = nextState.libraries.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +191,7 @@ function entryAction(label, title, handler) {
|
|||||||
function renderBrowser(nextState) {
|
function renderBrowser(nextState) {
|
||||||
const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId);
|
const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId);
|
||||||
const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean);
|
const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean);
|
||||||
elements.breadcrumb.textContent = path.length ? path.join(" / ") : "Geen bibliotheek";
|
elements.breadcrumb.textContent = path.length ? path.join(" / ") : t("noLibrary");
|
||||||
elements.breadcrumb.title = elements.breadcrumb.textContent;
|
elements.breadcrumb.title = elements.breadcrumb.textContent;
|
||||||
elements.libraryUp.disabled = !nextState.browser.canGoUp;
|
elements.libraryUp.disabled = !nextState.browser.canGoUp;
|
||||||
elements.libraryEmpty.hidden = nextState.libraries.length > 0;
|
elements.libraryEmpty.hidden = nextState.libraries.length > 0;
|
||||||
@@ -205,11 +218,11 @@ function renderBrowser(nextState) {
|
|||||||
const actions = document.createElement("span");
|
const actions = document.createElement("span");
|
||||||
actions.className = "entry-actions";
|
actions.className = "entry-actions";
|
||||||
actions.append(
|
actions.append(
|
||||||
entryAction("▶", `${entry.name} nu afspelen`, () => {
|
entryAction("▶", t("playNow", { name: entry.name }), () => {
|
||||||
command("item-play", { index: entry.index }, `${entry.name} laden…`);
|
command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
|
||||||
}),
|
}),
|
||||||
entryAction("+", `${entry.name} toevoegen`, () => {
|
entryAction("+", t("addNamed", { name: entry.name }), () => {
|
||||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,7 +231,7 @@ function renderBrowser(nextState) {
|
|||||||
row.addEventListener("click", () => command("browse", { index: entry.index }));
|
row.addEventListener("click", () => command("browse", { index: entry.index }));
|
||||||
} else {
|
} else {
|
||||||
row.addEventListener("dblclick", () => {
|
row.addEventListener("dblclick", () => {
|
||||||
command("item-play", { index: entry.index }, `${entry.name} laden…`);
|
command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
row.addEventListener("keydown", (event) => {
|
row.addEventListener("keydown", (event) => {
|
||||||
@@ -226,10 +239,10 @@ function renderBrowser(nextState) {
|
|||||||
command(
|
command(
|
||||||
entry.kind === "container" ? "browse" : "item-play",
|
entry.kind === "container" ? "browse" : "item-play",
|
||||||
{ index: entry.index },
|
{ index: entry.index },
|
||||||
entry.kind === "track" ? `${entry.name} laden…` : "",
|
entry.kind === "track" ? t("loadingNamed", { name: entry.name }) : "",
|
||||||
);
|
);
|
||||||
} else if (event.key === "+") {
|
} else if (event.key === "+") {
|
||||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
@@ -253,7 +266,7 @@ function renderTabs(nextState) {
|
|||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "tab-name";
|
name.className = "tab-name";
|
||||||
name.textContent = tab.name;
|
name.textContent = tab.name === "Default" ? t("defaultPlaylist") : tab.name;
|
||||||
const count = document.createElement("span");
|
const count = document.createElement("span");
|
||||||
count.className = "tab-count";
|
count.className = "tab-count";
|
||||||
count.textContent = tab.count;
|
count.textContent = tab.count;
|
||||||
@@ -261,7 +274,7 @@ function renderTabs(nextState) {
|
|||||||
|
|
||||||
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
|
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
|
||||||
button.addEventListener("dblclick", () => {
|
button.addEventListener("dblclick", () => {
|
||||||
const newName = window.prompt("Naam van de afspeellijst", tab.name);
|
const newName = window.prompt(t("playlistName"), tab.name);
|
||||||
if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
|
if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +282,7 @@ function renderTabs(nextState) {
|
|||||||
const remove = document.createElement("span");
|
const remove = document.createElement("span");
|
||||||
remove.className = "tab-delete";
|
remove.className = "tab-delete";
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
remove.title = "Afspeellijst verwijderen";
|
remove.title = t("removePlaylist");
|
||||||
remove.addEventListener("click", (event) => {
|
remove.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
command("tab-delete", { index: tab.index });
|
command("tab-delete", { index: tab.index });
|
||||||
@@ -328,8 +341,8 @@ function renderPlaylist(nextState) {
|
|||||||
remove.className = "row-action";
|
remove.className = "row-action";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "×";
|
remove.textContent = "×";
|
||||||
remove.title = `${track.title} verwijderen`;
|
remove.title = t("removeNamed", { name: track.title });
|
||||||
remove.setAttribute("aria-label", `${track.title} verwijderen`);
|
remove.setAttribute("aria-label", t("removeNamed", { name: track.title }));
|
||||||
remove.addEventListener("click", (event) => {
|
remove.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
command("track-remove", { index: track.index });
|
command("track-remove", { index: track.index });
|
||||||
@@ -366,7 +379,7 @@ function renderPlaylist(nextState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
|
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
|
||||||
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`;
|
elements.count.textContent = `${nextState.tracks.length} ${t(nextState.tracks.length === 1 ? "oneTrack" : "manyTracks")}`;
|
||||||
elements.playlistClear.disabled = nextState.tracks.length === 0;
|
elements.playlistClear.disabled = nextState.tracks.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,10 +387,10 @@ function renderPlayer(nextState) {
|
|||||||
const current = Number.isInteger(nextState.currentIndex)
|
const current = Number.isInteger(nextState.currentIndex)
|
||||||
? nextState.tracks[nextState.currentIndex]
|
? nextState.tracks[nextState.currentIndex]
|
||||||
: null;
|
: null;
|
||||||
elements.title.textContent = current ? current.title : "Nog niets gekozen";
|
elements.title.textContent = current ? current.title : t("nothingSelected");
|
||||||
elements.meta.textContent = current
|
elements.meta.textContent = current
|
||||||
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
|
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
|
||||||
: "Voeg een track toe vanuit de bibliotheek";
|
: t("addTrackHint");
|
||||||
|
|
||||||
const artworkId = current?.artworkId || "";
|
const artworkId = current?.artworkId || "";
|
||||||
if (elements.coverImage.dataset.artworkId !== artworkId) {
|
if (elements.coverImage.dataset.artworkId !== artworkId) {
|
||||||
@@ -395,8 +408,8 @@ function renderPlayer(nextState) {
|
|||||||
|
|
||||||
const playing = nextState.state === "playing" || nextState.state === "starting";
|
const playing = nextState.state === "playing" || nextState.state === "starting";
|
||||||
elements.play.textContent = playing ? "Ⅱ" : "▶";
|
elements.play.textContent = playing ? "Ⅱ" : "▶";
|
||||||
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen");
|
elements.play.setAttribute("aria-label", t(playing ? "pause" : "play"));
|
||||||
elements.play.disabled = nextState.tracks.length === 0;
|
elements.play.disabled = nextState.tracks.length === 0 || nextState.renderers.length === 0;
|
||||||
elements.previous.disabled = nextState.tracks.length === 0;
|
elements.previous.disabled = nextState.tracks.length === 0;
|
||||||
elements.next.disabled = nextState.tracks.length === 0;
|
elements.next.disabled = nextState.tracks.length === 0;
|
||||||
elements.stop.disabled = nextState.state === "stopped";
|
elements.stop.disabled = nextState.state === "stopped";
|
||||||
@@ -412,14 +425,20 @@ function renderPlayer(nextState) {
|
|||||||
|
|
||||||
elements.volume.value = nextState.volume;
|
elements.volume.value = nextState.volume;
|
||||||
elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
|
elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
|
||||||
|
elements.volumeToggle.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
t("volumePercent", { value: Math.round(nextState.volume) }),
|
||||||
|
);
|
||||||
elements.repeat.dataset.repeat = nextState.repeat;
|
elements.repeat.dataset.repeat = nextState.repeat;
|
||||||
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
|
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
|
||||||
const repeatNames = { off: "Herhalen uit", all: "Alles herhalen", one: "Eén track herhalen" };
|
const repeatNames = { off: t("repeatOff"), all: t("repeatAll"), one: t("repeatOne") };
|
||||||
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
|
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
|
||||||
|
|
||||||
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
|
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
|
||||||
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
|
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
|
||||||
elements.channels.textContent = nextState.channels ? `${nextState.channels} kanalen` : "— kanalen";
|
elements.channels.textContent = nextState.channels
|
||||||
|
? `${nextState.channels} ${t(nextState.channels === 1 ? "oneChannel" : "channels")}`
|
||||||
|
: `— ${t("channels")}`;
|
||||||
elements.format.textContent = nextState.format || "—";
|
elements.format.textContent = nextState.format || "—";
|
||||||
elements.source.textContent = nextState.source || "—";
|
elements.source.textContent = nextState.source || "—";
|
||||||
}
|
}
|
||||||
@@ -441,7 +460,7 @@ function render(nextState) {
|
|||||||
renderTabs(nextState);
|
renderTabs(nextState);
|
||||||
renderPlaylist(nextState);
|
renderPlaylist(nextState);
|
||||||
renderPlayer(nextState);
|
renderPlayer(nextState);
|
||||||
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : ""));
|
setStatus(nextState.error || (nextState.discovering ? t("searchingPlayers") : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.play.addEventListener("click", () => {
|
elements.play.addEventListener("click", () => {
|
||||||
@@ -458,6 +477,22 @@ elements.repeat.addEventListener("click", () => {
|
|||||||
command("repeat", { mode: next });
|
command("repeat", { mode: next });
|
||||||
});
|
});
|
||||||
elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value }));
|
elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value }));
|
||||||
|
elements.language.value = window.RktTranslate.language();
|
||||||
|
elements.language.addEventListener("change", async () => {
|
||||||
|
window.RktTranslate.setLanguage(elements.language.value);
|
||||||
|
try {
|
||||||
|
await api("/api/preferences", { language: elements.language.value });
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("rkt-language-change", () => {
|
||||||
|
elements.language.value = window.RktTranslate.language();
|
||||||
|
for (const element of [elements.renderer, elements.libraryEntries, elements.tabs, elements.playlist]) {
|
||||||
|
delete element.dataset.signature;
|
||||||
|
}
|
||||||
|
if (state) render(state);
|
||||||
|
});
|
||||||
elements.discover.addEventListener("click", async () => {
|
elements.discover.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
render(await api("/api/discover", {}));
|
render(await api("/api/discover", {}));
|
||||||
@@ -465,10 +500,29 @@ elements.discover.addEventListener("click", async () => {
|
|||||||
setStatus(error.message);
|
setStatus(error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
elements.volumeToggle.addEventListener("click", () => {
|
||||||
|
const open = !elements.volumeControl.classList.contains("open");
|
||||||
|
elements.volumeControl.classList.toggle("open", open);
|
||||||
|
elements.volumeToggle.setAttribute("aria-expanded", String(open));
|
||||||
|
if (open) elements.volume.focus();
|
||||||
|
});
|
||||||
elements.volume.addEventListener("input", () => {
|
elements.volume.addEventListener("input", () => {
|
||||||
elements.volumeValue.value = `${elements.volume.value}%`;
|
elements.volumeValue.value = `${elements.volume.value}%`;
|
||||||
});
|
});
|
||||||
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
|
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
if (!elements.volumeControl.contains(event.target)) {
|
||||||
|
elements.volumeControl.classList.remove("open");
|
||||||
|
elements.volumeToggle.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && elements.volumeControl.classList.contains("open")) {
|
||||||
|
elements.volumeControl.classList.remove("open");
|
||||||
|
elements.volumeToggle.setAttribute("aria-expanded", "false");
|
||||||
|
elements.volumeToggle.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
|
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
|
||||||
elements.seek.addEventListener("change", () => {
|
elements.seek.addEventListener("change", () => {
|
||||||
seekBusy = false;
|
seekBusy = false;
|
||||||
@@ -488,7 +542,7 @@ async function refresh() {
|
|||||||
if (error.code === "authentication-required") {
|
if (error.code === "authentication-required") {
|
||||||
showLogin();
|
showLogin();
|
||||||
} else {
|
} else {
|
||||||
setStatus(`Geen verbinding: ${error.message}`);
|
setStatus(t("noConnection", { message: error.message }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,7 +572,7 @@ elements.logout.addEventListener("click", async () => {
|
|||||||
await api("/api/auth/logout", {});
|
await api("/api/auth/logout", {});
|
||||||
} finally {
|
} finally {
|
||||||
elements.logout.hidden = true;
|
elements.logout.hidden = true;
|
||||||
showLogin("Je bent uitgelogd.");
|
showLogin(t("loggedOut"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+45
-38
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="nl">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -16,23 +16,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="output-control">
|
<div class="output-control">
|
||||||
<label for="renderer">UITVOER</label>
|
<label for="renderer" data-i18n="output">OUTPUT</label>
|
||||||
<select id="renderer"></select>
|
<select id="renderer"></select>
|
||||||
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken">↻</button>
|
<button id="discover" class="square-button" type="button" data-i18n-title="searchPlayers" title="Search for network players" aria-label="Search for network players">↻</button>
|
||||||
<button id="logout" class="text-button" type="button" hidden>UITLOGGEN</button>
|
<select id="language" class="language-select" aria-label="Language">
|
||||||
|
<option value="en">EN</option><option value="nl">NL</option><option value="de">DE</option><option value="fr">FR</option><option value="es">ES</option>
|
||||||
|
</select>
|
||||||
|
<button id="logout" class="text-button" type="button" data-i18n="logout" data-i18n-aria="logout" aria-label="Log out" hidden>LOG OUT</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="transport-bar" aria-label="Afspeelbediening">
|
<section class="transport-bar" data-i18n-aria="playbackControls" aria-label="Playback controls">
|
||||||
<div class="transport-buttons">
|
<div class="transport-buttons">
|
||||||
<button id="previous" class="command-button" type="button" aria-label="Vorige track">‹</button>
|
<button id="previous" class="command-button" type="button" data-i18n-aria="previousTrack" aria-label="Previous track">‹</button>
|
||||||
<button id="play" class="command-button play-button" type="button" aria-label="Afspelen of pauzeren">▶</button>
|
<button id="play" class="command-button play-button" type="button" data-i18n-aria="playOrPause" aria-label="Play or pause">▶</button>
|
||||||
<button id="stop" class="command-button" type="button" aria-label="Stoppen">■</button>
|
<button id="stop" class="command-button" type="button" data-i18n-aria="stop" aria-label="Stop">■</button>
|
||||||
<button id="next" class="command-button" type="button" aria-label="Volgende track">›</button>
|
<button id="next" class="command-button" type="button" data-i18n-aria="nextTrack" aria-label="Next track">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="seek-control">
|
<div class="seek-control">
|
||||||
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie">
|
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" data-i18n-aria="playbackPosition" aria-label="Playback position">
|
||||||
<div class="time-display">
|
<div class="time-display">
|
||||||
<span id="position">00:00:00</span>
|
<span id="position">00:00:00</span>
|
||||||
<span class="time-divider">/</span>
|
<span class="time-divider">/</span>
|
||||||
@@ -40,12 +43,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="repeat" class="command-button repeat-button" type="button" aria-label="Herhaalmodus">↻</button>
|
<button id="repeat" class="command-button repeat-button" type="button" data-i18n-aria="repeatMode" aria-label="Repeat mode">↻</button>
|
||||||
<label class="volume-control" for="volume">
|
<div id="volume-control" class="volume-control">
|
||||||
|
<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>
|
<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>
|
<output id="volume-value">50%</output>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
@@ -53,17 +59,17 @@
|
|||||||
<section class="library-pane panel">
|
<section class="library-pane panel">
|
||||||
<div class="panel-header library-header">
|
<div class="panel-header library-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-kicker">MUZIEKBIBLIOTHEEK</span>
|
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
|
||||||
<select id="library" aria-label="Muziekbibliotheek"></select>
|
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
|
||||||
</div>
|
</div>
|
||||||
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog">↑</button>
|
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder">↑</button>
|
||||||
</div>
|
</div>
|
||||||
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
|
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
|
||||||
<div id="library-empty" class="empty-state" hidden>
|
<div id="library-empty" class="empty-state" hidden>
|
||||||
<p>Geen bibliotheek geconfigureerd.</p>
|
<p data-i18n="noLibraryConfigured">No library configured.</p>
|
||||||
<code>[libraries] muziek=D:\Muziek</code>
|
<code>[libraries] muziek=D:\Muziek</code>
|
||||||
</div>
|
</div>
|
||||||
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
|
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="now-playing-pane panel">
|
<section class="now-playing-pane panel">
|
||||||
@@ -75,30 +81,30 @@
|
|||||||
<img id="cover-image" alt="" hidden>
|
<img id="cover-image" alt="" hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-summary">
|
<div class="track-summary">
|
||||||
<span class="panel-kicker">NU AAN HET SPELEN</span>
|
<span class="panel-kicker" data-i18n="nowPlaying">NOW PLAYING</span>
|
||||||
<h1 id="now-title">Nog niets gekozen</h1>
|
<h1 id="now-title" data-i18n="nothingSelected">Nothing selected yet</h1>
|
||||||
<p id="now-meta">Voeg een track toe vanuit de bibliotheek</p>
|
<p id="now-meta" data-i18n="addTrackHint">Add a track from the library</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="playlist-pane panel">
|
<section class="playlist-pane panel">
|
||||||
<div class="tabs-bar">
|
<div class="tabs-bar">
|
||||||
<div id="tabs" class="tabs" role="tablist" aria-label="Afspeellijsten"></div>
|
<div id="tabs" class="tabs" role="tablist" data-i18n-aria="playlists" aria-label="Playlists"></div>
|
||||||
<button id="tab-add" class="tab-add" type="button" title="Nieuwe afspeellijst" aria-label="Nieuwe afspeellijst">+</button>
|
<button id="tab-add" class="tab-add" type="button" data-i18n-title="newPlaylist" title="New playlist" aria-label="New playlist">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlist-toolbar">
|
<div class="playlist-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-kicker">AFSPEELLIJST</span>
|
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
|
||||||
<strong id="track-count">0 tracks</strong>
|
<strong id="track-count">0 tracks</strong>
|
||||||
</div>
|
</div>
|
||||||
<button id="playlist-clear" class="text-button" type="button">LIJST WISSEN</button>
|
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="playlist-empty" class="empty-state playlist-empty">
|
<div id="playlist-empty" class="empty-state playlist-empty">
|
||||||
<p>Deze afspeellijst is leeg.</p>
|
<p data-i18n="emptyPlaylist">This playlist is empty.</p>
|
||||||
<span>Gebruik <strong>+</strong> bij een track of map.</span>
|
<span data-i18n="emptyPlaylistHint">Use + next to a track or folder.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlist-scroll">
|
<div class="playlist-scroll">
|
||||||
@@ -106,10 +112,10 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="number-column">#</th>
|
<th class="number-column">#</th>
|
||||||
<th>TITEL</th>
|
<th data-i18n="title">TITLE</th>
|
||||||
<th>ALBUM</th>
|
<th data-i18n="album">ALBUM</th>
|
||||||
<th class="duration-column">DUUR</th>
|
<th class="duration-column" data-i18n="duration">DURATION</th>
|
||||||
<th class="action-column"><span class="visually-hidden">Acties</span></th>
|
<th class="action-column"><span class="visually-hidden" data-i18n="actions">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist"></tbody>
|
<tbody id="playlist"></tbody>
|
||||||
@@ -122,11 +128,11 @@
|
|||||||
<div class="audio-status">
|
<div class="audio-status">
|
||||||
<span id="audio-bits">— bit</span>
|
<span id="audio-bits">— bit</span>
|
||||||
<span id="audio-rate">— kHz</span>
|
<span id="audio-rate">— kHz</span>
|
||||||
<span id="audio-channels">— kanalen</span>
|
<span id="audio-channels">— channels</span>
|
||||||
<span id="audio-format">—</span>
|
<span id="audio-format">—</span>
|
||||||
<span id="audio-source">—</span>
|
<span id="audio-source">—</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="status" role="status" aria-live="polite">Verbinden…</p>
|
<p id="status" role="status" aria-live="polite" data-i18n="connecting">Connecting…</p>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -134,16 +140,17 @@
|
|||||||
<form id="login-form" class="login-panel">
|
<form id="login-form" class="login-panel">
|
||||||
<span class="brand-mark">RKT</span>
|
<span class="brand-mark">RKT</span>
|
||||||
<p class="panel-kicker">WEB PLAYER</p>
|
<p class="panel-kicker">WEB PLAYER</p>
|
||||||
<h1 id="login-title">Aanmelden</h1>
|
<h1 id="login-title" data-i18n="signIn">Sign in</h1>
|
||||||
<label for="login-username">Gebruikersnaam</label>
|
<label for="login-username" data-i18n="username">Username</label>
|
||||||
<input id="login-username" name="username" autocomplete="username" required>
|
<input id="login-username" name="username" autocomplete="username" required>
|
||||||
<label for="login-password">Wachtwoord</label>
|
<label for="login-password" data-i18n="password">Password</label>
|
||||||
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
|
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
|
||||||
<p id="login-error" class="login-error" role="alert"></p>
|
<p id="login-error" class="login-error" role="alert"></p>
|
||||||
<button id="login-submit" class="text-button" type="submit">AANMELDEN</button>
|
<button id="login-submit" class="text-button" type="submit" data-i18n="signIn">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script src="/translate.js" defer></script>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+95
-7
@@ -43,6 +43,12 @@ select {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-select {
|
||||||
|
width: 58px;
|
||||||
|
min-width: 58px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -276,19 +282,31 @@ input[type="range"] {
|
|||||||
.volume-control {
|
.volume-control {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
width: 210px;
|
width: 210px;
|
||||||
gap: 9px;
|
position: relative;
|
||||||
padding: 0 12px;
|
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control span,
|
.volume-slider {
|
||||||
.volume-control output {
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider span,
|
||||||
|
.volume-slider output {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control output {
|
.volume-slider output {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -861,7 +879,26 @@ input:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.output-control select {
|
.output-control select {
|
||||||
max-width: 180px;
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-control .language-select {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logout {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logout::after {
|
||||||
|
content: "↪";
|
||||||
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transport-buttons .command-button {
|
.transport-buttons .command-button {
|
||||||
@@ -873,7 +910,58 @@ input:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
flex: 1;
|
flex: 0 0 48px;
|
||||||
|
width: 48px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-toggle {
|
||||||
|
display: grid;
|
||||||
|
width: 47px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-toggle:hover,
|
||||||
|
.volume-toggle[aria-expanded="true"] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
display: none;
|
||||||
|
width: 58px;
|
||||||
|
height: 190px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-raised);
|
||||||
|
box-shadow: 0 14px 35px rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control.open .volume-slider {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider input[type="range"] {
|
||||||
|
width: 30px;
|
||||||
|
height: 125px;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider output {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
})();
|
||||||
@@ -4,6 +4,8 @@ port=8080
|
|||||||
|
|
||||||
[player]
|
[player]
|
||||||
dlna-port=8734
|
dlna-port=8734
|
||||||
|
; Set to false when the server itself must not appear as an audio output.
|
||||||
|
local-output=true
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
; muziek=D:\Muziek
|
; muziek=D:\Muziek
|
||||||
@@ -22,4 +24,8 @@ session-seconds=604800
|
|||||||
[users]
|
[users]
|
||||||
; Generate a hash with (make-password-hash "a long password") from
|
; Generate a hash with (make-password-hash "a long password") from
|
||||||
; rkt-web-player/users. Authentication is disabled while this section is empty.
|
; rkt-web-player/users. Authentication is disabled while this section is empty.
|
||||||
|
; you can also run racket -e "(require \"set-user.rkt\") (set-user)" from the
|
||||||
|
; directory where the rkt-web-player.ini file resides.
|
||||||
|
;
|
||||||
; hans=$argon2id$v=19$m=19456,t=2,p=1$...
|
; hans=$argon2id$v=19$m=19456,t=2,p=1$...
|
||||||
|
;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ browser by Racket's web server.
|
|||||||
[#:listen-ip listen-ip string? "127.0.0.1"]
|
[#:listen-ip listen-ip string? "127.0.0.1"]
|
||||||
[#:port port exact-positive-integer? 8080]
|
[#:port port exact-positive-integer? 8080]
|
||||||
[#:dlna-port dlna-port exact-positive-integer? 8734]
|
[#:dlna-port dlna-port exact-positive-integer? 8734]
|
||||||
|
[#:local-output? local-output? boolean? #t]
|
||||||
[#:playlist-keystore playlist-keystore (or/c path-string? #f)]
|
[#:playlist-keystore playlist-keystore (or/c path-string? #f)]
|
||||||
[#:launch-browser? launch-browser? boolean? #t]) any/c] {
|
[#:launch-browser? launch-browser? boolean? #t]) any/c] {
|
||||||
|
|
||||||
@@ -39,11 +40,19 @@ contents are browsed one level at a time. Metadata and recursive contents are
|
|||||||
only read when the user adds or plays a selected item.
|
only read when the user adds or plays a selected item.
|
||||||
|
|
||||||
The @racket[dlna-port] is used to publish local audio files to a selected
|
The @racket[dlna-port] is used to publish local audio files to a selected
|
||||||
network renderer. Playlist tabs are atomically persisted in
|
network renderer. Set @racket[local-output?] to @racket[#f] to omit the
|
||||||
|
server's own @tt{racket-audio} output. When no other renderer is available, the
|
||||||
|
player waits for discovery or playback-agent registration. Playlist tabs are
|
||||||
|
atomically persisted in
|
||||||
@racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player
|
@racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player
|
||||||
resources are closed when the web server exits. The default is
|
resources are closed when the web server exits. The default is
|
||||||
@tt{data/playlists.keystore} below the installed rkt-web-player collection.
|
@tt{data/playlists.keystore} below the installed rkt-web-player collection.
|
||||||
Each username owns an ordered GUID index and separate playlist values.
|
Each username owns an ordered GUID index and separate playlist values.
|
||||||
|
It also owns an independent playback pipeline and a durable interface-language
|
||||||
|
preference. Different outputs can play concurrently. Selecting an output that
|
||||||
|
another user owns stops the previous backend and transfers that output. The web
|
||||||
|
interface supports Dutch, English, German, French and Spanish; browser language
|
||||||
|
is the initial default and a manual choice is stored per username.
|
||||||
When @racket[users] is non-empty, every browser client must authenticate.
|
When @racket[users] is non-empty, every browser client must authenticate.
|
||||||
Sessions have a sliding idle timeout; an active browser cookie is renewed once
|
Sessions have a sliding idle timeout; an active browser cookie is renewed once
|
||||||
half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue
|
half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue
|
||||||
@@ -77,6 +86,8 @@ configured RKT Web Player server, downloads assigned tracks over HTTP, and
|
|||||||
plays them with @tt{racket-audio}. Its configured display name is authoritative
|
plays them with @tt{racket-audio}. Its configured display name is authoritative
|
||||||
and is followed by the server. Importing the module does not start the GUI; the
|
and is followed by the server. Importing the module does not start the GUI; the
|
||||||
function must be called explicitly.
|
function must be called explicitly.
|
||||||
|
The GUI and optional tray use Dutch, English, German, French or Spanish based
|
||||||
|
on the operating-system language, with English as fallback.
|
||||||
}
|
}
|
||||||
|
|
||||||
@defproc[(run-player-agent-cli [#:server-url server-url
|
@defproc[(run-player-agent-cli [#:server-url server-url
|
||||||
|
|||||||
Reference in New Issue
Block a user