Initial import of rkt-web-player
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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))))
|
||||
@@ -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?))
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[server]
|
||||
listen-ip=127.0.0.1
|
||||
port=8080
|
||||
|
||||
[player]
|
||||
dlna-port=8734
|
||||
|
||||
[library]
|
||||
paths=
|
||||
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user