diff --git a/private/player.rkt b/private/player.rkt index 1afb22e..051601c 100644 --- a/private/player.rkt +++ b/private/player.rkt @@ -984,11 +984,14 @@ 'name (browser-entry-name entry) 'kind (symbol->string (browser-entry-kind entry)))) +;;; Include the sum of known track durations in each tab's browser summary. (define (tab->jsexpr tab index) (hasheq 'index index 'id (playlist-tab-id tab) 'name (playlist-tab-name tab) - 'count (length (playlist-tab-tracks tab)))) + 'count (length (playlist-tab-tracks tab)) + 'duration (apply + (map (λ (item) (or (track-duration item) 0)) + (playlist-tab-tracks tab))))) (define (normal-device-id device) (let ((id (upnp-device-udn device))) diff --git a/public/app.js b/public/app.js index 2e085c2..311dd9f 100644 --- a/public/app.js +++ b/public/app.js @@ -94,6 +94,27 @@ function formatTime(value) { return `${hours}:${minutes}:${seconds}`; } +// Formats total playing time in the interface language, rounded to minutes. +function formatPlayingTime(value) { + const totalMinutes = Number.isFinite(value) ? Math.max(0, Math.round(value / 60)) : 0; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const language = window.RktTranslate.language(); + const parts = []; + if (hours > 0) { + parts.push(new Intl.NumberFormat(language, { + style: "unit", unit: "hour", unitDisplay: "long", + }).format(hours)); + } + if (minutes > 0 || hours === 0) { + parts.push(new Intl.NumberFormat(language, { + style: "unit", unit: "minute", unitDisplay: "long", + }).format(minutes)); + } + const duration = new Intl.ListFormat(language, { style: "long", type: "conjunction" }).format(parts); + return t("playingTime", { duration }); +} + // Replaces the current status message, using an empty string for no message. function setStatus(message) { elements.status.textContent = message || ""; @@ -504,7 +525,8 @@ function renderPlaylist(nextState) { elements.playlistEmpty.hidden = nextState.tracks.length > 0; const trackLabel = nextState.tracks.length === 1 ? "oneTrack" : "manyTracks"; - elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}`; + const tab = nextState.tabs[nextState.currentTab]; + elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`; elements.playlistClear.disabled = nextState.tracks.length === 0; } diff --git a/public/translate.js b/public/translate.js index eb8ed55..355021c 100644 --- a/public/translate.js +++ b/public/translate.js @@ -3,6 +3,7 @@ const translations = { en: { + playingTime: "{duration} playing time", output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT", rendererLocal: "LOCAL", defaultPlaylist: "Default", playbackControls: "Playback controls", previousTrack: "Previous track", @@ -33,6 +34,7 @@ "dlna-renderer-unreachable": "The DLNA renderer is unreachable.", }, nl: { + playingTime: "{duration} speeltijd", output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN", rendererLocal: "LOKAAL", defaultPlaylist: "Standaard", playbackControls: "Afspeelbediening", previousTrack: "Vorige track", @@ -63,6 +65,7 @@ "dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.", }, de: { + playingTime: "{duration} Spielzeit", output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN", rendererLocal: "LOKAL", defaultPlaylist: "Standard", playbackControls: "Wiedergabesteuerung", previousTrack: "Vorheriger Titel", @@ -93,6 +96,7 @@ "dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.", }, fr: { + playingTime: "{duration} de lecture", 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", @@ -123,6 +127,7 @@ "dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.", }, es: { + playingTime: "{duration} de reproducción", output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN", rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada", playbackControls: "Controles de reproducción", previousTrack: "Pista anterior", @@ -153,6 +158,7 @@ "dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.", }, it: { + playingTime: "{duration} di riproduzione", output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI", rendererLocal: "LOCALE", defaultPlaylist: "Predefinita", playbackControls: "Controlli di riproduzione", previousTrack: "Traccia precedente", @@ -183,6 +189,7 @@ "dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.", }, sv: { + playingTime: "{duration} speltid", output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT", rendererLocal: "LOKAL", defaultPlaylist: "Standard", playbackControls: "Uppspelningskontroller", previousTrack: "Föregående spår", @@ -213,6 +220,7 @@ "dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.", }, no: { + playingTime: "{duration} spilletid", output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT", rendererLocal: "LOKAL", defaultPlaylist: "Standard", playbackControls: "Avspillingskontroller", previousTrack: "Forrige spor", @@ -243,6 +251,7 @@ "dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.", }, fi: { + playingTime: "Toistoaika: {duration}", output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS", rendererLocal: "PAIKALLINEN", defaultPlaylist: "Oletus", playbackControls: "Toiston ohjaimet", previousTrack: "Edellinen kappale", @@ -273,6 +282,7 @@ "dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.", }, is: { + playingTime: "Spilunartími: {duration}", output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT", rendererLocal: "STAÐBUNDIÐ", defaultPlaylist: "Sjálfgefið", playbackControls: "Stýringar spilunar", previousTrack: "Fyrra lag", diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 280d437..df0d195 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -33,7 +33,9 @@ The central server is the heart of the application. Its main features are: device, discovered UPnP renderers, Sonos groups, or registered agents.} @item{Playlist tabs with adding, removing, reordering, renaming and drag-and-drop support. Tracks from different libraries can share a - playlist.} + playlist. The playlist toolbar shows the selected playlist's track count + and total known playing time, rounded to minutes and expressed in hours + and minutes. Tracks without duration metadata contribute zero to the total.} @item{Gapless network playback where the renderer or playback agent supports preloading the next track, with a server-side fallback.} @item{Transport controls, seeking, volume, repeat mode and live playback