@@ -106,10 +112,10 @@
| # |
- TITEL |
- ALBUM |
- DUUR |
- Acties |
+ TITLE |
+ ALBUM |
+ DURATION |
+ Actions |
@@ -122,11 +128,11 @@
— bit
— kHz
- — kanalen
+ — channels
—
—
-
Verbinden…
+
Connecting…
@@ -134,16 +140,17 @@
+
diff --git a/public/styles.css b/public/styles.css
index d800d40..07771db 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -43,6 +43,12 @@ select {
color: var(--text);
}
+.language-select {
+ width: 58px;
+ min-width: 58px;
+ padding-right: 18px;
+}
+
button {
cursor: pointer;
}
@@ -276,19 +282,31 @@ input[type="range"] {
.volume-control {
align-self: stretch;
width: 210px;
- gap: 9px;
- padding: 0 12px;
+ position: relative;
border-left: 1px solid var(--line);
}
-.volume-control span,
-.volume-control output {
+.volume-slider {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ gap: 9px;
+ align-items: center;
+ padding: 0 12px;
+}
+
+.volume-toggle {
+ display: none;
+}
+
+.volume-slider span,
+.volume-slider output {
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
-.volume-control output {
+.volume-slider output {
width: 32px;
font-variant-numeric: tabular-nums;
}
@@ -861,7 +879,26 @@ input:focus-visible,
}
.output-control select {
- max-width: 180px;
+ max-width: 140px;
+ }
+
+ .output-control .language-select {
+ width: 52px;
+ min-width: 52px;
+ }
+
+ #logout {
+ width: 34px;
+ min-width: 34px;
+ height: 34px;
+ padding: 0;
+ overflow: hidden;
+ font-size: 0;
+ }
+
+ #logout::after {
+ content: "↪";
+ font-size: 17px;
}
.transport-buttons .command-button {
@@ -873,7 +910,58 @@ input:focus-visible,
}
.volume-control {
- flex: 1;
+ flex: 0 0 48px;
+ width: 48px;
+ padding: 0;
+ }
+
+ .volume-toggle {
+ display: grid;
+ width: 47px;
+ height: 52px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 800;
+ }
+
+ .volume-toggle:hover,
+ .volume-toggle[aria-expanded="true"] {
+ background: var(--accent-soft);
+ color: var(--accent);
+ }
+
+ .volume-slider {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 6px);
+ right: 0;
+ display: none;
+ width: 58px;
+ height: 190px;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 8px;
+ border: 1px solid var(--line);
+ background: var(--panel-raised);
+ box-shadow: 0 14px 35px rgb(0 0 0 / 55%);
+ }
+
+ .volume-control.open .volume-slider {
+ display: flex;
+ }
+
+ .volume-slider input[type="range"] {
+ width: 30px;
+ height: 125px;
+ writing-mode: vertical-lr;
+ direction: rtl;
+ }
+
+ .volume-slider output {
width: auto;
}
diff --git a/public/translate.js b/public/translate.js
new file mode 100644
index 0000000..86359dc
--- /dev/null
+++ b/public/translate.js
@@ -0,0 +1,162 @@
+(() => {
+ "use strict";
+
+ const translations = {
+ en: {
+ output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
+ rendererLocal: "LOCAL", defaultPlaylist: "Default",
+ playbackControls: "Playback controls", previousTrack: "Previous track",
+ playOrPause: "Play or pause", play: "Play", pause: "Pause", stop: "Stop",
+ nextTrack: "Next track", playbackPosition: "Playback position",
+ repeatMode: "Repeat mode", setVolume: "Set volume", volume: "Volume",
+ musicLibrary: "MUSIC LIBRARY", upFolder: "Up one folder", currentFolder: "Current folder",
+ noLibraryConfigured: "No library configured.", folderContents: "Folder contents",
+ nowPlaying: "NOW PLAYING", nothingSelected: "Nothing selected yet",
+ addTrackHint: "Add a track from the library", playlists: "Playlists",
+ newPlaylist: "New playlist", playlist: "PLAYLIST", clearList: "CLEAR LIST",
+ emptyPlaylist: "This playlist is empty.", emptyPlaylistHint: "Use + next to a track or folder.",
+ title: "TITLE", album: "ALBUM", duration: "DURATION", actions: "Actions",
+ connecting: "Connecting…", signIn: "Sign in", username: "Username",
+ password: "Password", noLibrary: "No library", playNow: "Play {name} now",
+ addNamed: "Add {name}", loadingNamed: "Loading {name}…", addingNamed: "Adding {name}…",
+ playlistName: "Playlist name", removePlaylist: "Remove playlist",
+ removeNamed: "Remove {name}", oneTrack: "track", manyTracks: "tracks",
+ repeatOff: "Repeat off", repeatAll: "Repeat all", repeatOne: "Repeat one track",
+ channels: "channels", oneChannel: "channel", searchingPlayers: "Searching for network players…",
+ authUnknown: "Authentication status unknown: {message}", noConnection: "No connection: {message}",
+ loggedOut: "You have been logged out.", volumePercent: "Set volume, {value} percent",
+ },
+ nl: {
+ output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
+ rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
+ playbackControls: "Afspeelbediening", previousTrack: "Vorige track",
+ playOrPause: "Afspelen of pauzeren", play: "Afspelen", pause: "Pauzeren", stop: "Stoppen",
+ nextTrack: "Volgende track", playbackPosition: "Afspeelpositie",
+ repeatMode: "Herhaalmodus", setVolume: "Volume instellen", volume: "Volume",
+ musicLibrary: "MUZIEKBIBLIOTHEEK", upFolder: "Eén map omhoog", currentFolder: "Huidige map",
+ noLibraryConfigured: "Geen bibliotheek geconfigureerd.", folderContents: "Mapinhoud",
+ nowPlaying: "NU AAN HET SPELEN", nothingSelected: "Nog niets gekozen",
+ addTrackHint: "Voeg een track toe vanuit de bibliotheek", playlists: "Afspeellijsten",
+ newPlaylist: "Nieuwe afspeellijst", playlist: "AFSPEELLIJST", clearList: "LIJST WISSEN",
+ emptyPlaylist: "Deze afspeellijst is leeg.", emptyPlaylistHint: "Gebruik + bij een track of map.",
+ title: "TITEL", album: "ALBUM", duration: "DUUR", actions: "Acties",
+ connecting: "Verbinden…", signIn: "Aanmelden", username: "Gebruikersnaam",
+ password: "Wachtwoord", noLibrary: "Geen bibliotheek", playNow: "{name} nu afspelen",
+ addNamed: "{name} toevoegen", loadingNamed: "{name} laden…", addingNamed: "{name} toevoegen…",
+ playlistName: "Naam van de afspeellijst", removePlaylist: "Afspeellijst verwijderen",
+ removeNamed: "{name} verwijderen", oneTrack: "track", manyTracks: "tracks",
+ repeatOff: "Herhalen uit", repeatAll: "Alles herhalen", repeatOne: "Eén track herhalen",
+ channels: "kanalen", oneChannel: "kanaal", searchingPlayers: "Netwerkspelers zoeken…",
+ authUnknown: "Authenticatiestatus onbekend: {message}", noConnection: "Geen verbinding: {message}",
+ loggedOut: "Je bent uitgelogd.", volumePercent: "Volume instellen, {value} procent",
+ },
+ de: {
+ output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
+ rendererLocal: "LOKAL", defaultPlaylist: "Standard",
+ playbackControls: "Wiedergabesteuerung", previousTrack: "Vorheriger Titel",
+ playOrPause: "Wiedergeben oder pausieren", play: "Wiedergeben", pause: "Pausieren", stop: "Stoppen",
+ nextTrack: "Nächster Titel", playbackPosition: "Wiedergabeposition",
+ repeatMode: "Wiederholungsmodus", setVolume: "Lautstärke einstellen", volume: "Lautstärke",
+ musicLibrary: "MUSIKBIBLIOTHEK", upFolder: "Einen Ordner nach oben", currentFolder: "Aktueller Ordner",
+ noLibraryConfigured: "Keine Bibliothek konfiguriert.", folderContents: "Ordnerinhalt",
+ nowPlaying: "AKTUELLE WIEDERGABE", nothingSelected: "Noch nichts ausgewählt",
+ addTrackHint: "Titel aus der Bibliothek hinzufügen", playlists: "Wiedergabelisten",
+ newPlaylist: "Neue Wiedergabeliste", playlist: "WIEDERGABELISTE", clearList: "LISTE LEEREN",
+ emptyPlaylist: "Diese Wiedergabeliste ist leer.", emptyPlaylistHint: "Verwenden Sie + neben einem Titel oder Ordner.",
+ title: "TITEL", album: "ALBUM", duration: "DAUER", actions: "Aktionen",
+ connecting: "Verbinden…", signIn: "Anmelden", username: "Benutzername",
+ password: "Passwort", noLibrary: "Keine Bibliothek", playNow: "{name} jetzt wiedergeben",
+ addNamed: "{name} hinzufügen", loadingNamed: "{name} wird geladen…", addingNamed: "{name} wird hinzugefügt…",
+ playlistName: "Name der Wiedergabeliste", removePlaylist: "Wiedergabeliste entfernen",
+ removeNamed: "{name} entfernen", oneTrack: "Titel", manyTracks: "Titel",
+ repeatOff: "Wiederholung aus", repeatAll: "Alles wiederholen", repeatOne: "Einen Titel wiederholen",
+ channels: "Kanäle", oneChannel: "Kanal", searchingPlayers: "Netzwerkplayer werden gesucht…",
+ authUnknown: "Authentifizierungsstatus unbekannt: {message}", noConnection: "Keine Verbindung: {message}",
+ loggedOut: "Sie wurden abgemeldet.", volumePercent: "Lautstärke einstellen, {value} Prozent",
+ },
+ fr: {
+ output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
+ rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
+ playbackControls: "Commandes de lecture", previousTrack: "Piste précédente",
+ playOrPause: "Lire ou mettre en pause", play: "Lire", pause: "Pause", stop: "Arrêter",
+ nextTrack: "Piste suivante", playbackPosition: "Position de lecture",
+ repeatMode: "Mode répétition", setVolume: "Régler le volume", volume: "Volume",
+ musicLibrary: "BIBLIOTHÈQUE MUSICALE", upFolder: "Dossier parent", currentFolder: "Dossier actuel",
+ noLibraryConfigured: "Aucune bibliothèque configurée.", folderContents: "Contenu du dossier",
+ nowPlaying: "LECTURE EN COURS", nothingSelected: "Aucune sélection",
+ addTrackHint: "Ajoutez une piste depuis la bibliothèque", playlists: "Listes de lecture",
+ newPlaylist: "Nouvelle liste de lecture", playlist: "LISTE DE LECTURE", clearList: "VIDER LA LISTE",
+ emptyPlaylist: "Cette liste de lecture est vide.", emptyPlaylistHint: "Utilisez + à côté d’une piste ou d’un dossier.",
+ title: "TITRE", album: "ALBUM", duration: "DURÉE", actions: "Actions",
+ connecting: "Connexion…", signIn: "Se connecter", username: "Nom d’utilisateur",
+ password: "Mot de passe", noLibrary: "Aucune bibliothèque", playNow: "Lire {name} maintenant",
+ addNamed: "Ajouter {name}", loadingNamed: "Chargement de {name}…", addingNamed: "Ajout de {name}…",
+ playlistName: "Nom de la liste de lecture", removePlaylist: "Supprimer la liste de lecture",
+ removeNamed: "Supprimer {name}", oneTrack: "piste", manyTracks: "pistes",
+ repeatOff: "Répétition désactivée", repeatAll: "Tout répéter", repeatOne: "Répéter une piste",
+ channels: "canaux", oneChannel: "canal", searchingPlayers: "Recherche des lecteurs réseau…",
+ authUnknown: "État d’authentification inconnu : {message}", noConnection: "Aucune connexion : {message}",
+ loggedOut: "Vous avez été déconnecté.", volumePercent: "Régler le volume, {value} pour cent",
+ },
+ es: {
+ output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
+ rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
+ playbackControls: "Controles de reproducción", previousTrack: "Pista anterior",
+ playOrPause: "Reproducir o pausar", play: "Reproducir", pause: "Pausar", stop: "Detener",
+ nextTrack: "Pista siguiente", playbackPosition: "Posición de reproducción",
+ repeatMode: "Modo de repetición", setVolume: "Ajustar volumen", volume: "Volumen",
+ musicLibrary: "BIBLIOTECA MUSICAL", upFolder: "Subir una carpeta", currentFolder: "Carpeta actual",
+ noLibraryConfigured: "No hay ninguna biblioteca configurada.", folderContents: "Contenido de la carpeta",
+ nowPlaying: "REPRODUCIENDO", nothingSelected: "Nada seleccionado",
+ addTrackHint: "Añade una pista desde la biblioteca", playlists: "Listas de reproducción",
+ newPlaylist: "Nueva lista de reproducción", playlist: "LISTA DE REPRODUCCIÓN", clearList: "VACIAR LISTA",
+ emptyPlaylist: "Esta lista de reproducción está vacía.", emptyPlaylistHint: "Usa + junto a una pista o carpeta.",
+ title: "TÍTULO", album: "ÁLBUM", duration: "DURACIÓN", actions: "Acciones",
+ connecting: "Conectando…", signIn: "Iniciar sesión", username: "Nombre de usuario",
+ password: "Contraseña", noLibrary: "Sin biblioteca", playNow: "Reproducir {name} ahora",
+ addNamed: "Añadir {name}", loadingNamed: "Cargando {name}…", addingNamed: "Añadiendo {name}…",
+ playlistName: "Nombre de la lista de reproducción", removePlaylist: "Eliminar lista de reproducción",
+ removeNamed: "Eliminar {name}", oneTrack: "pista", manyTracks: "pistas",
+ repeatOff: "Repetición desactivada", repeatAll: "Repetir todo", repeatOne: "Repetir una pista",
+ channels: "canales", oneChannel: "canal", searchingPlayers: "Buscando reproductores de red…",
+ authUnknown: "Estado de autenticación desconocido: {message}", noConnection: "Sin conexión: {message}",
+ loggedOut: "Has cerrado la sesión.", volumePercent: "Ajustar volumen, {value} por ciento",
+ },
+ };
+
+ const supported = Object.keys(translations);
+ const requested = [...(navigator.languages || [navigator.language])]
+ .filter(Boolean)
+ .map((value) => value.toLowerCase().split("-")[0]);
+ let language = requested.find((value) => supported.includes(value)) || "en";
+
+ function t(key, values = {}) {
+ const template = translations[language][key] ?? translations.en[key] ?? key;
+ return template.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? `{${name}}`);
+ }
+
+ function apply(root = document) {
+ document.documentElement.lang = language;
+ root.querySelectorAll("[data-i18n]").forEach((element) => {
+ element.textContent = t(element.dataset.i18n);
+ });
+ root.querySelectorAll("[data-i18n-title]").forEach((element) => {
+ const value = t(element.dataset.i18nTitle);
+ element.title = value;
+ element.setAttribute("aria-label", value);
+ });
+ root.querySelectorAll("[data-i18n-aria]").forEach((element) => {
+ element.setAttribute("aria-label", t(element.dataset.i18nAria));
+ });
+ }
+
+ function setLanguage(value) {
+ if (!supported.includes(value)) return;
+ language = value;
+ apply();
+ window.dispatchEvent(new CustomEvent("rkt-language-change"));
+ }
+
+ window.RktTranslate = { apply, language: () => language, setLanguage, supported, t };
+ window.addEventListener("DOMContentLoaded", () => apply());
+})();
diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example
index c2cc00f..d543aa6 100644
--- a/rkt-web-player.ini.example
+++ b/rkt-web-player.ini.example
@@ -4,6 +4,8 @@ port=8080
[player]
dlna-port=8734
+; Set to false when the server itself must not appear as an audio output.
+local-output=true
[libraries]
; muziek=D:\Muziek
@@ -22,4 +24,8 @@ session-seconds=604800
[users]
; Generate a hash with (make-password-hash "a long password") from
; rkt-web-player/users. Authentication is disabled while this section is empty.
+; you can also run racket -e "(require \"set-user.rkt\") (set-user)" from the
+; directory where the rkt-web-player.ini file resides.
+;
; hans=$argon2id$v=19$m=19456,t=2,p=1$...
+;
diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl
index 47fae73..37a1228 100644
--- a/scribblings/rkt-web-player.scrbl
+++ b/scribblings/rkt-web-player.scrbl
@@ -26,6 +26,7 @@ browser by Racket's web server.
[#:listen-ip listen-ip string? "127.0.0.1"]
[#:port port exact-positive-integer? 8080]
[#:dlna-port dlna-port exact-positive-integer? 8734]
+ [#:local-output? local-output? boolean? #t]
[#:playlist-keystore playlist-keystore (or/c path-string? #f)]
[#:launch-browser? launch-browser? boolean? #t]) any/c] {
@@ -39,11 +40,19 @@ contents are browsed one level at a time. Metadata and recursive contents are
only read when the user adds or plays a selected item.
The @racket[dlna-port] is used to publish local audio files to a selected
-network renderer. Playlist tabs are atomically persisted in
+network renderer. Set @racket[local-output?] to @racket[#f] to omit the
+server's own @tt{racket-audio} output. When no other renderer is available, the
+player waits for discovery or playback-agent registration. Playlist tabs are
+atomically persisted in
@racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player
resources are closed when the web server exits. The default is
@tt{data/playlists.keystore} below the installed rkt-web-player collection.
Each username owns an ordered GUID index and separate playlist values.
+It also owns an independent playback pipeline and a durable interface-language
+preference. Different outputs can play concurrently. Selecting an output that
+another user owns stops the previous backend and transfers that output. The web
+interface supports Dutch, English, German, French and Spanish; browser language
+is the initial default and a manual choice is stored per username.
When @racket[users] is non-empty, every browser client must authenticate.
Sessions have a sliding idle timeout; an active browser cookie is renewed once
half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue
@@ -77,6 +86,8 @@ configured RKT Web Player server, downloads assigned tracks over HTTP, and
plays them with @tt{racket-audio}. Its configured display name is authoritative
and is followed by the server. Importing the module does not start the GUI; the
function must be called explicitly.
+The GUI and optional tray use Dutch, English, German, French or Spanish based
+on the operating-system language, with English as fallback.
}
@defproc[(run-player-agent-cli [#:server-url server-url