Initial import of rkt-web-player

This commit is contained in:
2026-08-26 22:12:52 +02:00
parent e1247719a9
commit d22a7ad529
13 changed files with 3045 additions and 1 deletions
+6
View File
@@ -7,6 +7,7 @@
*.rkt.bak
\#*.rkt#
\#*.rkt#*#
*.bak
# Compiled racket bytecode
compiled/
@@ -15,3 +16,8 @@ compiled/
# Dependency tracking files
*.dep
doc
scrbl/*.html
scrbl/*.js
scrbl/*.css
+28
View File
@@ -0,0 +1,28 @@
(require racket-makefile)
(makefile 'rkt-web-player
(target 'all
(displayln "Use make clean, make zip, etc."))
(target 'clean
(for-each (λ (f) (displayln f) (rm-f f))
(list-files "." #px"([.]bak|~)$" #:recursive #t))
(for-each (λ (d) (displayln d) (rm-rf d))
(list-dirs "." #px"(compiled|doc|docs)$" #:recursive #t))
(unless (directory-exists? "scrbl")
(make-directory "scrbl"))
(for-each (λ (f) (displayln f) (rm-f f))
(list-files "scrbl" #px"[.](css|js|html)$"
)))
(target 'zip
(deps 'clean)
(zip-package))
(target 'version
(git-next-version))
)
+68 -1
View File
@@ -1,3 +1,70 @@
# rkt-web-player
Audio player with web interface
Een audioplayer met webinterface, gebouwd op de bestaande Racket-pakketten:
- `racket-audio` voor lokale weergave en metadata;
- `racket-upnp` en `racket-sonos` voor ontdekking van netwerkspelers;
- `racket-audio-dlna` voor het publiceren en afspelen van lokale bestanden;
- `racket-mimetypes`, `simple-ini` en `simple-log` voor ondersteunende taken.
## Starten
De gekoppelde ontwikkelpackages kunnen direct worden gebruikt:
```console
racket main.rkt D:\Muziek
```
Standaard opent de speler `http://127.0.0.1:8080/`. Iedere opgegeven map wordt
een afzonderlijke muziekbibliotheek. Bij het starten wordt alleen de inhoud van
de eerste rootmap gelezen; er vindt geen recursieve bibliotheekscan plaats.
```console
racket main.rkt --port 8090 --no-browser D:\Muziek D:\Podcasts
```
Met een klein INI-bestand zijn vaste defaults mogelijk:
```ini
[server]
listen-ip=127.0.0.1
port=8080
[player]
dlna-port=8734
[library]
paths=D:\Muziek;D:\Podcasts
```
Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Meerdere
bibliotheekpaden worden met een puntkomma gescheiden.
## Werking
De interface volgt de informatiearchitectuur van `rktplayer`, maar is
responsive en geschikt voor muis, toetsenbord en touch. Links staat de lazy
mappenbrowser met trackinformatie; rechts staan playlisttabs en de huidige
playlist. Een map wordt pas recursief gelezen wanneer die met **afspelen** of
**toevoegen** wordt gekozen. Trackmetadata wordt eveneens pas op dat moment
geladen.
De webinterface praat met een kleine JSON-API onder `/api`. Lokale weergave
wordt pas geïnitialiseerd bij het eerste afspeelcommando. De knop naast de
uitvoerkeuze start UPnP-discovery; gevonden Sonos-zones worden als logische
groepen aangeboden en niet nogmaals als losse UPnP-renderers.
Playlisttabs kunnen worden toegevoegd, geselecteerd, hernoemd door dubbel te
klikken en verwijderd. Tracks kunnen worden afgespeeld, verwijderd en met
drag-and-drop verplaatst. De tabs zijn in deze versie alleen in het geheugen
aanwezig en worden niet na een herstart hersteld.
De server luistert standaard alleen op localhost. Geef alleen bewust een
LAN-adres aan `--listen-ip`; de huidige opzet bevat geen authenticatie.
## Controleren
```console
raco test --drdr private/library.rkt private/player.rkt
raco setup --check-pkg-deps rkt-web-player
```
+26
View File
@@ -0,0 +1,26 @@
#lang info
(define collection "rkt-web-player")
(define version "0.1")
(define pkg-authors '(hnmdijkema))
(define license 'MIT)
(define pkg-desc "Audio player with a web interface")
(define deps
'("base"
"web-server-lib"
"racket-audio"
"racket-audio-dlna"
"racket-mimetypes"
"racket-sonos"
"racket-upnp"
"simple-ini"
"simple-log"))
(define build-deps
'("racket-doc"
"rackunit-lib"
"scribble-lib"))
(define scribblings
'(("scribblings/rkt-web-player.scrbl" (multi-page) (application))))
+121
View File
@@ -0,0 +1,121 @@
#lang racket/base
(require racket/cmdline
racket/contract
racket/list
racket/string
simple-ini
simple-log
"private/library.rkt"
"private/player.rkt"
"private/server.rkt")
(provide run-web-player)
(sl-def-log rkt-web-player)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Start the audio player and its web interface.
; pre : The ports are valid TCP ports; every music path is an existing directory.
; post : Libraries are browsed lazily; player resources close with the server.
; result : The result returned by serve/servlet.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (run-web-player music-paths
#:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080]
#:dlna-port [dlna-port 8734]
#:launch-browser? [launch-browser? #t])
(->* ((listof path-string?))
(#:listen-ip string?
#:port exact-positive-integer?
#:dlna-port exact-positive-integer?
#:launch-browser? boolean?)
any)
(let* ((libraries (make-music-libraries music-paths))
(player (make-player libraries #:dlna-port dlna-port)))
(info-rkt-web-player
"Starting with ~a music library/libraries on http://~a:~a/"
(length libraries)
listen-ip
port)
(dynamic-wind
void
(λ ()
(serve-player player
#:listen-ip listen-ip
#:port port
#:launch-browser? launch-browser?))
(λ ()
(player-close! player)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Command line
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(module+ main
(define listen-ip #f)
(define port #f)
(define dlna-port #f)
(define launch-browser? #t)
(define config-file #f)
(define music-paths
(command-line
#:program "rkt-web-player"
#:once-each
[("--listen-ip") address
"Address on which the web server listens"
(set! listen-ip address)]
[("--port") value
"Web server port"
(set! port (string->number value))]
[("--dlna-port") value
"Port used to publish local audio to DLNA renderers"
(set! dlna-port (string->number value))]
[("--config") path
"Read defaults from an INI file"
(set! config-file path)]
[("--no-browser")
"Do not open the web interface automatically"
(set! launch-browser? #f)]
#:args paths
paths))
(define config
(if config-file
(file->ini config-file)
(make-ini)))
(define configured-paths-value
(ini-get config 'library 'paths ""))
(define configured-paths
(cond
((list? configured-paths-value)
configured-paths-value)
((and (string? configured-paths-value)
(not (string=? (string-trim configured-paths-value) "")))
(map string-trim
(string-split configured-paths-value ";")))
(else '())))
(define all-paths
(remove-duplicates
(append music-paths
configured-paths)
equal?))
(sl-log-to-display)
(run-web-player
all-paths
#:listen-ip (or listen-ip
(ini-get config 'server 'listen-ip "127.0.0.1"))
#:port (or port
(ini-get config 'server 'port 8080))
#:dlna-port (or dlna-port
(ini-get config 'player 'dlna-port 8734))
#:launch-browser? launch-browser?))
+222
View File
@@ -0,0 +1,222 @@
#lang racket/base
(require racket-audio
racket/file
racket/list
racket-mimetypes
racket/path
racket/string)
(provide (struct-out music-library)
(struct-out browser-entry)
(struct-out track)
make-music-libraries
browse-library
browser-entry->tracks)
(struct music-library
(id name root)
#:transparent)
(struct browser-entry
(name kind relative-path)
#:transparent)
(struct track
(file title artist album duration mime-type)
#:transparent)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define supported-extensions
(audio-supported-extensions))
(define (audio-file? file)
(let ((extension (path-get-extension file)))
(and extension
(member (string-downcase
(string-trim
(bytes->string/utf-8 extension)
"."))
supported-extensions)
#t)))
(define (hidden-name? path)
(string-prefix? (path->string path) "."))
(define (file-title file)
(let* ((name (file-name-from-path file))
(without-extension
(if name
(path-replace-extension name #"")
file)))
(path->string without-extension)))
(define (nonempty value fallback)
(if (and (string? value)
(not (string=? (string-trim value) "")))
value
fallback))
(define (path->track file)
(let ((fallback-title (file-title file)))
(with-handlers
((exn:fail?
(λ (_)
(track file fallback-title "" "" #f
(mimetype-for-ext file)))))
(call-with-id3-tags
file
(λ (tags)
(if (tags-valid? tags)
(track file
(nonempty (tags-title tags) fallback-title)
(nonempty (tags-artist tags) "")
(nonempty (tags-album tags) "")
(let ((length (tags-length tags)))
(if (and (number? length) (positive? length))
length
#f))
(mimetype-for-ext file))
(track file fallback-title "" "" #f
(mimetype-for-ext file))))))))
(define (library-path library relative-path)
(if (null? relative-path)
(music-library-root library)
(apply build-path
(music-library-root library)
relative-path)))
(define (path-kind path)
(cond
((directory-exists? path) 'container)
((and (file-exists? path)
(audio-file? path))
'track)
(else #f)))
(define (entry<? first second)
(cond
((and (eq? (browser-entry-kind first) 'container)
(eq? (browser-entry-kind second) 'track))
#t)
((and (eq? (browser-entry-kind first) 'track)
(eq? (browser-entry-kind second) 'container))
#f)
(else
(string-ci<? (browser-entry-name first)
(browser-entry-name second)))))
(define (directory-tracks library relative-path)
(append-map
(λ (entry)
(if (eq? (browser-entry-kind entry) 'container)
(directory-tracks library
(browser-entry-relative-path entry))
(list
(path->track
(library-path library
(browser-entry-relative-path entry))))))
(browse-library library relative-path)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Turn configured directory paths into music libraries.
; pre : Every value is a path-string naming an existing directory.
; post : No directory contents or audio metadata have been read.
; result : Libraries in configuration order, without duplicate roots.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (make-music-libraries paths)
(let ((roots
(remove-duplicates
(map (λ (path)
(normal-case-path
(path->complete-path path)))
paths)
equal?)))
(for/list ((root (in-list roots))
(index (in-naturals)))
(unless (directory-exists? root)
(raise-arguments-error
'make-music-libraries
"music library is not an existing directory"
"path" root))
(let ((name (file-name-from-path root)))
(music-library
(format "library-~a" index)
(if name
(path->string name)
(path->string root))
root)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List the immediate folders and supported audio files in a library.
; pre : Relative-path was produced by a previous browse result.
; post : Child directories are listed before tracks; metadata is not read.
; result : Browser entries for one directory level.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (browse-library library relative-path)
(let ((path (library-path library relative-path)))
(unless (directory-exists? path)
(raise-arguments-error
'browse-library
"library directory does not exist"
"path" path))
(sort
(filter-map
(λ (name)
(let* ((full-path (build-path path name))
(kind (path-kind full-path)))
(and kind
(not (and (eq? kind 'container)
(hidden-name? name)))
(browser-entry
(path->string name)
kind
(append relative-path (list name))))))
(directory-list path))
entry<?)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Resolve a selected browser entry to playable tracks.
; pre : Entry belongs to library and was produced by browse-library.
; post : Track metadata is read; containers are traversed recursively.
; result : One track, or all supported tracks below the selected container.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (browser-entry->tracks library entry)
(if (eq? (browser-entry-kind entry) 'container)
(directory-tracks library
(browser-entry-relative-path entry))
(list
(path->track
(library-path library
(browser-entry-relative-path entry))))))
(module+ test
(require rackunit)
(define root
(make-temporary-file "rkt-web-library-~a" 'directory))
(dynamic-wind
void
(λ ()
(make-directory (build-path root "Album"))
(call-with-output-file (build-path root "track.mp3") void)
(call-with-output-file (build-path root "cover.jpg") void)
(let* ((libraries (make-music-libraries (list root)))
(entries (browse-library (car libraries) '())))
(check-equal? (length libraries) 1)
(check-equal? (length entries) 2)
(check-equal? (browser-entry-name (car entries)) "Album")
(check-eq? (browser-entry-kind (car entries)) 'container)
(check-equal? (browser-entry-name (cadr entries)) "track.mp3")
(check-eq? (browser-entry-kind (cadr entries)) 'track)))
(λ ()
(delete-directory/files root))))
+1037
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
#lang racket/base
(require json
racket/contract
racket/runtime-path
web-server/dispatch
web-server/http
web-server/http/json
web-server/servlet-env
"player.rkt")
(provide serve-player)
(define-runtime-path public-directory "../public")
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HTTP handlers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-player #f)
(define (json-response value #:code [code 200])
(response/jsexpr
value
#:code code
#:headers (list (header #"Cache-Control" #"no-store"))))
(define (error-response exception)
(json-response
(hasheq 'error (exn-message exception))
#:code 400))
(define (request-jsexpr request)
(let ((body (request-post-data/raw request)))
(if (and body (positive? (bytes-length body)))
(bytes->jsexpr body)
(hasheq))))
(define (state-handler _request)
(json-response (player-state->jsexpr current-player)))
(define (discover-handler _request)
(player-discover! current-player)
(json-response (player-state->jsexpr current-player)))
(define (command-handler request command)
(with-handlers
((exn:fail? error-response))
(json-response
(player-command!
current-player
command
(request-jsexpr request)))))
(define-values (dispatch _url)
(dispatch-rules
[("api" "state") #:method "get" state-handler]
[("api" "discover") #:method "post" discover-handler]
[("api" "command" (string-arg))
#:method "post"
command-handler]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Serve the web interface and JSON player API.
; pre : Value is a player, listen-ip is a string, and port is valid.
; post : Static files and API routes are served until the server stops.
; result : The result returned by serve/servlet.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (serve-player value
#:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080]
#:launch-browser? [launch-browser? #t])
(->* (any/c)
(#:listen-ip string?
#:port exact-positive-integer?
#:launch-browser? boolean?)
any)
(set! current-player value)
(serve/servlet
dispatch
#:listen-ip listen-ip
#:port port
#:connection-close? #t
#:launch-browser? launch-browser?
#:quit? #f
#:banner? #t
#:servlet-regexp #rx"^/api(?:/|$)"
#:extra-files-paths (list public-directory)))
+424
View File
@@ -0,0 +1,424 @@
const elements = {
renderer: document.querySelector("#renderer"),
discover: document.querySelector("#discover"),
previous: document.querySelector("#previous"),
play: document.querySelector("#play"),
stop: document.querySelector("#stop"),
next: document.querySelector("#next"),
seek: document.querySelector("#seek"),
position: document.querySelector("#position"),
duration: document.querySelector("#duration"),
repeat: document.querySelector("#repeat"),
volume: document.querySelector("#volume"),
volumeValue: document.querySelector("#volume-value"),
library: document.querySelector("#library"),
libraryUp: document.querySelector("#library-up"),
breadcrumb: document.querySelector("#breadcrumb"),
libraryEmpty: document.querySelector("#library-empty"),
libraryEntries: document.querySelector("#library-entries"),
title: document.querySelector("#now-title"),
meta: document.querySelector("#now-meta"),
tabs: document.querySelector("#tabs"),
tabAdd: document.querySelector("#tab-add"),
count: document.querySelector("#track-count"),
playlistClear: document.querySelector("#playlist-clear"),
playlistEmpty: document.querySelector("#playlist-empty"),
playlist: document.querySelector("#playlist"),
bits: document.querySelector("#audio-bits"),
rate: document.querySelector("#audio-rate"),
channels: document.querySelector("#audio-channels"),
format: document.querySelector("#audio-format"),
source: document.querySelector("#audio-source"),
status: document.querySelector("#status"),
};
let state = null;
let seekBusy = false;
let draggedTrack = null;
let commandBusy = false;
function formatTime(value) {
if (!Number.isFinite(value) || value < 0) return "00:00:00";
const whole = Math.floor(value);
const hours = Math.floor(whole / 3600).toString().padStart(2, "0");
const minutes = Math.floor((whole % 3600) / 60).toString().padStart(2, "0");
const seconds = (whole % 60).toString().padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
function setStatus(message) {
elements.status.textContent = message || "";
}
async function api(path, body) {
const options = body === undefined
? { cache: "no-store" }
: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
const response = await fetch(path, options);
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function command(name, data = {}, pendingMessage = "") {
if (pendingMessage) setStatus(pendingMessage);
commandBusy = true;
try {
render(await api(`/api/command/${name}`, data));
} catch (error) {
setStatus(error.message);
} finally {
commandBusy = false;
}
}
function replaceSelect(select, items, selectedId, signature) {
if (select.dataset.signature !== signature) {
select.replaceChildren(...items.map((item) => {
const option = document.createElement("option");
option.value = item.id;
option.textContent = item.label;
return option;
}));
select.dataset.signature = signature;
}
select.value = selectedId || "";
}
function renderSelectors(nextState) {
const rendererItems = nextState.renderers.map((item) => ({
id: item.id,
label: `${item.name} · ${item.kind.toUpperCase()}`,
}));
replaceSelect(
elements.renderer,
rendererItems,
nextState.rendererId,
rendererItems.map((item) => item.id).join("|"),
);
const libraryItems = nextState.libraries.map((item) => ({
id: item.id,
label: item.name,
}));
replaceSelect(
elements.library,
libraryItems,
nextState.libraryId,
libraryItems.map((item) => `${item.id}:${item.label}`).join("|"),
);
elements.discover.classList.toggle("busy", nextState.discovering);
elements.discover.disabled = nextState.discovering;
elements.library.disabled = nextState.libraries.length === 0;
}
function entryAction(label, title, handler) {
const button = document.createElement("button");
button.className = "entry-action";
button.type = "button";
button.textContent = label;
button.title = title;
button.setAttribute("aria-label", title);
button.addEventListener("click", (event) => {
event.stopPropagation();
handler();
});
return button;
}
function renderBrowser(nextState) {
const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId);
const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean);
elements.breadcrumb.textContent = path.length ? path.join(" / ") : "Geen bibliotheek";
elements.breadcrumb.title = elements.breadcrumb.textContent;
elements.libraryUp.disabled = !nextState.browser.canGoUp;
elements.libraryEmpty.hidden = nextState.libraries.length > 0;
const signature = nextState.browser.entries
.map((entry) => `${entry.index}:${entry.kind}:${entry.name}`)
.join("|");
if (elements.libraryEntries.dataset.signature === signature) return;
const rows = nextState.browser.entries.map((entry) => {
const row = document.createElement("li");
row.className = "library-entry";
row.tabIndex = 0;
const icon = document.createElement("span");
icon.className = "entry-icon";
icon.textContent = entry.kind === "container" ? "▸" : "♪";
const name = document.createElement("span");
name.className = "entry-name";
name.textContent = entry.name;
name.title = entry.name;
const actions = document.createElement("span");
actions.className = "entry-actions";
actions.append(
entryAction("▶", `${entry.name} nu afspelen`, () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`);
}),
entryAction("", `${entry.name} toevoegen`, () => {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
}),
);
row.append(icon, name, actions);
if (entry.kind === "container") {
row.addEventListener("click", () => command("browse", { index: entry.index }));
} else {
row.addEventListener("dblclick", () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`);
});
}
row.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
command(
entry.kind === "container" ? "browse" : "item-play",
{ index: entry.index },
entry.kind === "track" ? `${entry.name} laden…` : "",
);
} else if (event.key === "+") {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
}
});
return row;
});
elements.libraryEntries.replaceChildren(...rows);
elements.libraryEntries.dataset.signature = signature;
}
function renderTabs(nextState) {
const signature = nextState.tabs
.map((tab) => `${tab.index}:${tab.name}:${tab.count}`)
.join("|");
if (elements.tabs.dataset.signature !== signature) {
const tabs = nextState.tabs.map((tab) => {
const button = document.createElement("button");
button.className = "tab";
button.type = "button";
button.dataset.index = tab.index;
button.setAttribute("role", "tab");
const name = document.createElement("span");
name.className = "tab-name";
name.textContent = tab.name;
const count = document.createElement("span");
count.className = "tab-count";
count.textContent = tab.count;
button.append(name, count);
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
button.addEventListener("dblclick", () => {
const newName = window.prompt("Naam van de afspeellijst", tab.name);
if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
});
if (nextState.tabs.length > 1) {
const remove = document.createElement("span");
remove.className = "tab-delete";
remove.textContent = "×";
remove.title = "Afspeellijst verwijderen";
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
});
button.append(remove);
}
return button;
});
elements.tabs.replaceChildren(...tabs);
elements.tabs.dataset.signature = signature;
}
for (const tab of elements.tabs.children) {
const active = Number(tab.dataset.index) === nextState.currentTab;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
}
}
function renderPlaylist(nextState) {
const signature = nextState.tracks
.map((track) => `${track.index}:${track.title}:${track.artist}:${track.album}:${track.duration}`)
.join("|");
if (elements.playlist.dataset.signature !== signature) {
const rows = nextState.tracks.map((track) => {
const row = document.createElement("tr");
row.className = "playlist-row";
row.tabIndex = 0;
row.draggable = true;
row.dataset.index = track.index;
const number = document.createElement("td");
number.className = "track-number number-column";
number.textContent = String(track.index + 1);
const titleCell = document.createElement("td");
const title = document.createElement("div");
title.className = "track-title";
title.textContent = track.title;
const artist = document.createElement("div");
artist.className = "track-artist";
artist.textContent = track.artist || track.source;
titleCell.append(title, artist);
const album = document.createElement("td");
album.className = "track-album";
album.textContent = track.album;
const duration = document.createElement("td");
duration.className = "track-duration duration-column";
duration.textContent = formatTime(track.duration);
const action = document.createElement("td");
action.className = "action-column";
const remove = document.createElement("button");
remove.className = "row-action";
remove.type = "button";
remove.textContent = "×";
remove.title = `${track.title} verwijderen`;
remove.setAttribute("aria-label", `${track.title} verwijderen`);
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("track-remove", { index: track.index });
});
action.append(remove);
row.append(number, titleCell, album, duration, action);
row.addEventListener("click", () => command("play", { index: track.index }));
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
command("play", { index: track.index });
} else if (event.key === "Delete") {
command("track-remove", { index: track.index });
}
});
row.addEventListener("dragstart", () => { draggedTrack = track.index; });
row.addEventListener("dragover", (event) => event.preventDefault());
row.addEventListener("drop", (event) => {
event.preventDefault();
if (Number.isInteger(draggedTrack) && draggedTrack !== track.index) {
command("track-move", { from: draggedTrack, to: track.index });
}
draggedTrack = null;
});
return row;
});
elements.playlist.replaceChildren(...rows);
elements.playlist.dataset.signature = signature;
}
for (const row of elements.playlist.children) {
row.classList.toggle("current", Number(row.dataset.index) === nextState.currentIndex);
}
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`;
elements.playlistClear.disabled = nextState.tracks.length === 0;
}
function renderPlayer(nextState) {
const current = Number.isInteger(nextState.currentIndex)
? nextState.tracks[nextState.currentIndex]
: null;
elements.title.textContent = current ? current.title : "Nog niets gekozen";
elements.meta.textContent = current
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
: "Voeg een track toe vanuit de bibliotheek";
const playing = nextState.state === "playing" || nextState.state === "starting";
elements.play.textContent = playing ? "Ⅱ" : "▶";
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen");
elements.play.disabled = nextState.tracks.length === 0;
elements.previous.disabled = nextState.tracks.length === 0;
elements.next.disabled = nextState.tracks.length === 0;
elements.stop.disabled = nextState.state === "stopped";
elements.position.textContent = formatTime(nextState.position);
elements.duration.textContent = formatTime(nextState.duration);
if (!seekBusy) {
elements.seek.value = nextState.duration
? Math.min(100, (nextState.position / nextState.duration) * 100)
: 0;
}
elements.seek.disabled = !nextState.duration;
elements.volume.value = nextState.volume;
elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
elements.repeat.dataset.repeat = nextState.repeat;
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
const repeatNames = { off: "Herhalen uit", all: "Alles herhalen", one: "Eén track herhalen" };
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
elements.channels.textContent = nextState.channels ? `${nextState.channels} kanalen` : "— kanalen";
elements.format.textContent = nextState.format || "—";
elements.source.textContent = nextState.source || "—";
}
function render(nextState) {
state = nextState;
renderSelectors(nextState);
renderBrowser(nextState);
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : ""));
}
elements.play.addEventListener("click", () => {
if (!state) return;
const playing = state.state === "playing" || state.state === "starting";
command(playing ? "pause" : state.state === "paused" ? "resume" : "play");
});
elements.previous.addEventListener("click", () => command("previous"));
elements.stop.addEventListener("click", () => command("stop"));
elements.next.addEventListener("click", () => command("next"));
elements.repeat.addEventListener("click", () => {
if (!state) return;
const next = state.repeat === "off" ? "all" : state.repeat === "all" ? "one" : "off";
command("repeat", { mode: next });
});
elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value }));
elements.discover.addEventListener("click", async () => {
try {
render(await api("/api/discover", {}));
} catch (error) {
setStatus(error.message);
}
});
elements.volume.addEventListener("input", () => {
elements.volumeValue.value = `${elements.volume.value}%`;
});
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
elements.seek.addEventListener("change", () => {
seekBusy = false;
command("seek", { percentage: Number(elements.seek.value) });
});
elements.library.addEventListener("change", () => command("library", { id: elements.library.value }));
elements.libraryUp.addEventListener("click", () => command("up"));
elements.tabAdd.addEventListener("click", () => command("tab-add"));
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
async function refresh() {
if (commandBusy) return;
try {
render(await api("/api/state"));
} catch (error) {
setStatus(`Geen verbinding: ${error.message}`);
}
}
refresh();
setInterval(refresh, 1000);
+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#171716">
<title>RKT Web Player</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app-shell">
<header class="app-header">
<div class="brand">
<span class="brand-mark">RKT</span>
<span class="brand-name">WEB PLAYER</span>
</div>
<div class="output-control">
<label for="renderer">UITVOER</label>
<select id="renderer"></select>
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button>
</div>
</header>
<section class="transport-bar" aria-label="Afspeelbediening">
<div class="transport-buttons">
<button id="previous" class="command-button" type="button" aria-label="Vorige track"></button>
<button id="play" class="command-button play-button" type="button" aria-label="Afspelen of pauzeren"></button>
<button id="stop" class="command-button" type="button" aria-label="Stoppen"></button>
<button id="next" class="command-button" type="button" aria-label="Volgende track"></button>
</div>
<div class="seek-control">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie">
<div class="time-display">
<span id="position">00:00:00</span>
<span class="time-divider">/</span>
<span id="duration">00:00:00</span>
</div>
</div>
<button id="repeat" class="command-button repeat-button" type="button" aria-label="Herhaalmodus"></button>
<label class="volume-control" for="volume">
<span aria-hidden="true">VOL</span>
<input id="volume" type="range" min="0" max="100" value="50">
<output id="volume-value">50%</output>
</label>
</section>
<section class="workspace">
<aside class="left-pane">
<section class="library-pane panel">
<div class="panel-header library-header">
<div>
<span class="panel-kicker">MUZIEKBIBLIOTHEEK</span>
<select id="library" aria-label="Muziekbibliotheek"></select>
</div>
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog"></button>
</div>
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
<div id="library-empty" class="empty-state" hidden>
<p>Geen bibliotheek geconfigureerd.</p>
<code>[library] paths=D:\Muziek</code>
</div>
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
</section>
<section class="now-playing-pane panel">
<div class="cover-art" aria-hidden="true">
<div class="record"></div>
<span>RKT</span>
</div>
<div class="track-summary">
<span class="panel-kicker">NU AAN HET SPELEN</span>
<h1 id="now-title">Nog niets gekozen</h1>
<p id="now-meta">Voeg een track toe vanuit de bibliotheek</p>
</div>
</section>
</aside>
<section class="playlist-pane panel">
<div class="tabs-bar">
<div id="tabs" class="tabs" role="tablist" aria-label="Afspeellijsten"></div>
<button id="tab-add" class="tab-add" type="button" title="Nieuwe afspeellijst" aria-label="Nieuwe afspeellijst"></button>
</div>
<div class="playlist-toolbar">
<div>
<span class="panel-kicker">AFSPEELLIJST</span>
<strong id="track-count">0 tracks</strong>
</div>
<button id="playlist-clear" class="text-button" type="button">LIJST WISSEN</button>
</div>
<div id="playlist-empty" class="empty-state playlist-empty">
<p>Deze afspeellijst is leeg.</p>
<span>Gebruik <strong></strong> bij een track of map.</span>
</div>
<div class="playlist-scroll">
<table class="playlist-table">
<thead>
<tr>
<th class="number-column">#</th>
<th>TITEL</th>
<th>ALBUM</th>
<th class="duration-column">DUUR</th>
<th class="action-column"><span class="visually-hidden">Acties</span></th>
</tr>
</thead>
<tbody id="playlist"></tbody>
</table>
</div>
</section>
</section>
<footer class="status-bar">
<div class="audio-status">
<span id="audio-bits">— bit</span>
<span id="audio-rate">— kHz</span>
<span id="audio-channels">— kanalen</span>
<span id="audio-format"></span>
<span id="audio-source"></span>
</div>
<p id="status" role="status" aria-live="polite">Verbinden…</p>
</footer>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+847
View File
@@ -0,0 +1,847 @@
:root {
color-scheme: dark;
--background: #171716;
--panel: #20201f;
--panel-raised: #292927;
--line: #41413e;
--line-soft: #30302e;
--text: #f4f3ee;
--muted: #aaa9a1;
--accent: #f3961e;
--accent-soft: rgb(243 150 30 / 14%);
--danger: #f07067;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--background);
color: var(--text);
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
min-height: 100%;
margin: 0;
}
body {
background:
radial-gradient(circle at 85% 0, rgb(243 150 30 / 7%), transparent 30rem),
var(--background);
}
button,
select,
input {
font: inherit;
}
button,
select {
color: var(--text);
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: .38;
}
.app-shell {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
width: 100%;
height: 100vh;
min-height: 620px;
padding: 10px;
gap: 8px;
}
.app-header,
.transport-bar,
.status-bar,
.output-control,
.volume-control,
.tabs-bar,
.playlist-toolbar,
.library-header {
display: flex;
align-items: center;
}
.app-header {
justify-content: space-between;
min-height: 46px;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: var(--accent);
color: #171716;
font-size: 10px;
font-weight: 900;
}
.brand-name,
.panel-kicker,
.output-control label,
.playlist-table th {
font-size: 10px;
font-weight: 800;
letter-spacing: .14em;
}
.brand-name {
letter-spacing: .18em;
}
.output-control {
gap: 8px;
}
.output-control label,
.panel-kicker,
.playlist-table th {
color: var(--muted);
}
select,
.square-button,
.text-button {
border: 1px solid var(--line);
background: var(--panel-raised);
}
select {
max-width: 230px;
min-height: 34px;
padding: 5px 30px 5px 9px;
border-radius: 5px;
}
.square-button {
display: grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
border-radius: 5px;
font-size: 18px;
}
.square-button.busy {
animation: spin .9s linear infinite;
}
.transport-bar {
min-height: 54px;
border: 1px solid var(--line);
background: var(--panel);
}
.transport-buttons {
display: flex;
align-self: stretch;
}
.command-button {
display: grid;
width: 52px;
min-width: 44px;
height: 100%;
min-height: 52px;
padding: 0;
place-items: center;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
font-size: 25px;
}
.command-button:hover,
.command-button.active {
background: var(--accent-soft);
color: var(--accent);
}
.play-button {
color: var(--accent);
}
.seek-control {
display: grid;
grid-template-columns: minmax(100px, 1fr) auto;
flex: 1;
gap: 14px;
align-items: center;
padding: 0 16px;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.time-display {
display: flex;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.time-divider {
color: var(--line);
}
.repeat-button {
border-right: 0;
border-left: 1px solid var(--line);
font-size: 19px;
}
.repeat-button[data-repeat="one"]::after {
content: "1";
position: absolute;
margin: 16px 0 0 18px;
font-size: 9px;
font-weight: 900;
}
.volume-control {
align-self: stretch;
width: 210px;
gap: 9px;
padding: 0 12px;
border-left: 1px solid var(--line);
}
.volume-control span,
.volume-control output {
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.volume-control output {
width: 32px;
font-variant-numeric: tabular-nums;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 31%) minmax(0, 1fr);
min-height: 0;
}
.left-pane {
display: grid;
grid-template-rows: minmax(230px, 58%) minmax(180px, 42%);
min-height: 0;
}
.panel {
min-width: 0;
min-height: 0;
border: 1px solid var(--line);
background: var(--panel);
}
.library-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
border-bottom: 0;
}
.panel-header {
min-height: 55px;
padding: 8px 10px;
border-bottom: 1px solid var(--line-soft);
}
.library-header {
justify-content: space-between;
gap: 8px;
}
.library-header > div {
display: grid;
min-width: 0;
gap: 4px;
}
.library-header select {
max-width: 100%;
min-height: 30px;
padding-top: 2px;
padding-bottom: 2px;
border: 0;
background: transparent;
font-size: 15px;
font-weight: 700;
}
.breadcrumb {
min-height: 31px;
padding: 8px 11px;
overflow: hidden;
border-bottom: 1px solid var(--line-soft);
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-list {
grid-row: 3;
min-height: 0;
margin: 0;
padding: 4px;
overflow-y: auto;
list-style: none;
}
.library-entry {
display: grid;
grid-template-columns: 25px minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
min-height: 35px;
padding: 3px 5px;
border-radius: 4px;
cursor: default;
}
.library-entry:hover,
.library-entry:focus-within {
background: var(--panel-raised);
}
.entry-icon {
color: var(--accent);
text-align: center;
}
.entry-name {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-actions {
display: flex;
gap: 3px;
opacity: 0;
}
.library-entry:hover .entry-actions,
.library-entry:focus-within .entry-actions {
opacity: 1;
}
.entry-action,
.row-action {
display: grid;
width: 27px;
height: 27px;
padding: 0;
place-items: center;
border: 1px solid var(--line);
border-radius: 4px;
background: #181817;
color: var(--muted);
}
.entry-action:hover,
.row-action:hover {
border-color: var(--accent);
color: var(--accent);
}
.now-playing-pane {
position: relative;
display: grid;
grid-template-columns: minmax(110px, 42%) minmax(0, 1fr);
gap: 16px;
align-items: center;
padding: 16px;
overflow: hidden;
}
.cover-art {
position: relative;
display: grid;
aspect-ratio: 1;
max-height: 100%;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
background:
linear-gradient(140deg, transparent 49.5%, rgb(243 150 30 / 55%) 50%, transparent 50.5%),
#2b2b29;
}
.record {
width: 72%;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, var(--accent) 0 8%, #111 8.5% 13%, transparent 13.5%),
repeating-radial-gradient(circle, #2f2f2c 0 2px, #141413 3px 6px);
box-shadow: 0 12px 30px rgb(0 0 0 / 45%);
}
.cover-art > span {
position: absolute;
color: #111;
font-size: 8px;
font-weight: 900;
}
.track-summary {
min-width: 0;
}
.track-summary h1 {
margin: 8px 0 5px;
overflow: hidden;
font-size: clamp(18px, 2.1vw, 30px);
line-height: 1.05;
letter-spacing: -.035em;
text-overflow: ellipsis;
}
.track-summary p {
margin: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
}
.playlist-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
border-left: 0;
}
.tabs-bar {
min-height: 38px;
border-bottom: 1px solid var(--line);
background: #1b1b1a;
}
.tabs {
display: flex;
flex: 1;
align-self: stretch;
min-width: 0;
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 7px;
max-width: 190px;
padding: 0 10px;
border: 0;
border-right: 1px solid var(--line-soft);
background: transparent;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.tab.active {
background: var(--panel);
color: var(--accent);
font-weight: 750;
box-shadow: inset 0 2px var(--accent);
}
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
}
.tab-count {
color: var(--muted);
font-size: 10px;
}
.tab-delete {
display: grid;
width: 18px;
height: 18px;
margin-left: 2px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
}
.tab-delete:hover {
background: rgb(240 112 103 / 18%);
color: var(--danger);
}
.tab-add {
align-self: stretch;
width: 40px;
border: 0;
border-left: 1px solid var(--line-soft);
background: transparent;
color: var(--muted);
font-size: 19px;
}
.playlist-toolbar {
justify-content: space-between;
min-height: 53px;
padding: 8px 12px;
border-bottom: 1px solid var(--line-soft);
}
.playlist-toolbar > div {
display: grid;
gap: 4px;
}
.playlist-toolbar strong {
font-size: 14px;
}
.text-button {
min-height: 30px;
padding: 0 10px;
border-radius: 4px;
color: var(--muted);
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
}
.playlist-scroll {
grid-row: 3;
min-height: 0;
overflow: auto;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.playlist-table th {
position: sticky;
z-index: 1;
top: 0;
padding: 8px 7px;
border-bottom: 1px solid var(--line);
background: #1b1b1a;
text-align: left;
}
.playlist-table td {
padding: 9px 7px;
overflow: hidden;
border-bottom: 1px solid var(--line-soft);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-row {
cursor: default;
}
.playlist-row:hover {
background: var(--panel-raised);
}
.playlist-row.current {
background: var(--accent-soft);
}
.playlist-row.current .track-title,
.playlist-row.current .track-number {
color: var(--accent);
}
.track-number,
.track-album,
.track-duration,
.track-artist {
color: var(--muted);
}
.track-title {
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
}
.track-artist {
margin-top: 2px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.number-column {
width: 43px;
text-align: right !important;
}
.duration-column {
width: 70px;
text-align: right !important;
}
.action-column {
width: 42px;
}
.row-action {
border: 0;
background: transparent;
color: transparent;
}
.playlist-row:hover .row-action,
.row-action:focus-visible {
color: var(--danger);
}
.empty-state {
padding: 28px 16px;
color: var(--muted);
font-size: 12px;
text-align: center;
}
.empty-state p {
margin: 0 0 8px;
}
.empty-state code {
color: var(--accent);
}
.playlist-empty {
grid-row: 3;
z-index: 2;
align-self: center;
}
.library-pane > .empty-state {
grid-row: 3;
z-index: 2;
}
.playlist-empty[hidden],
.library-pane .empty-state[hidden] {
display: none;
}
.status-bar {
justify-content: space-between;
min-height: 29px;
padding: 0 9px;
border: 1px solid var(--line);
background: #121211;
color: var(--muted);
font-size: 10px;
}
.audio-status {
display: flex;
min-width: 0;
gap: 13px;
}
.audio-status span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#status {
max-width: 46%;
margin: 0;
overflow: hidden;
color: var(--danger);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
white-space: nowrap;
}
button:focus-visible,
select:focus-visible,
input:focus-visible,
.library-entry:focus-visible,
.playlist-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (hover: none) {
.entry-actions {
opacity: 1;
}
.row-action {
color: var(--danger);
}
}
@media (max-width: 850px) {
.app-shell {
height: auto;
min-height: 100vh;
}
.transport-bar {
flex-wrap: wrap;
}
.seek-control {
order: 3;
width: 100%;
min-height: 42px;
border-top: 1px solid var(--line-soft);
}
.transport-buttons {
flex: 1;
}
.command-button {
height: 52px;
}
.volume-control {
width: 190px;
}
.workspace {
grid-template-columns: 1fr;
}
.left-pane {
grid-template-rows: minmax(330px, 48vh) 190px;
}
.playlist-pane {
min-height: 520px;
border-top: 0;
border-left: 1px solid var(--line);
}
}
@media (max-width: 560px) {
.app-shell {
padding: 6px;
}
.app-header {
align-items: flex-start;
gap: 12px;
}
.brand-name,
.output-control label {
display: none;
}
.output-control {
min-width: 0;
}
.output-control select {
max-width: 180px;
}
.transport-buttons .command-button {
flex: 1;
}
.repeat-button {
width: 48px;
}
.volume-control {
flex: 1;
width: auto;
}
.now-playing-pane {
grid-template-columns: 110px minmax(0, 1fr);
}
.playlist-table th:nth-child(3),
.playlist-table td:nth-child(3) {
display: none;
}
.status-bar {
align-items: flex-start;
flex-direction: column;
gap: 5px;
padding: 7px 9px;
}
.audio-status {
flex-wrap: wrap;
gap: 5px 11px;
}
#status {
max-width: 100%;
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
[server]
listen-ip=127.0.0.1
port=8080
[player]
dlna-port=8734
[library]
paths=
+34
View File
@@ -0,0 +1,34 @@
#lang scribble/manual
@(require (for-label racket/base
racket/contract
rkt-web-player))
@title{RKT Web Player}
@author{Hans van Dijkema}
@defmodule[rkt-web-player]
RKT Web Player combines local playback through @tt{racket-audio}
with UPnP and Sonos discovery and playback through
@tt{racket-audio-dlna}. The user interface is served to a web
browser by Racket's web server.
@defproc[(run-web-player
[music-paths (listof path-string?)]
[#:listen-ip listen-ip string? "127.0.0.1"]
[#:port port exact-positive-integer? 8080]
[#:dlna-port dlna-port exact-positive-integer? 8734]
[#:launch-browser? launch-browser? boolean? #t]) any/c] {
Treats every entry in @racket[music-paths] as a separate music library and
starts the web interface on @racket[listen-ip] and @racket[port]. Directory
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. Player resources are closed when the web server exits.
The default listen address only exposes the interface to the local computer.
Use a LAN address deliberately if other devices should control the player.
}