From d22a7ad52958791617c426ff5768933b3c4ff40b Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Wed, 26 Aug 2026 22:12:52 +0200 Subject: [PATCH] Initial import of rkt-web-player --- .gitignore | 6 + Makefile.mk | 28 + README.md | 69 +- info.rkt | 26 + main.rkt | 121 ++++ private/library.rkt | 222 +++++++ private/player.rkt | 1037 ++++++++++++++++++++++++++++++ private/server.rkt | 92 +++ public/app.js | 424 ++++++++++++ public/index.html | 131 ++++ public/styles.css | 847 ++++++++++++++++++++++++ rkt-web-player.ini.example | 9 + scribblings/rkt-web-player.scrbl | 34 + 13 files changed, 3045 insertions(+), 1 deletion(-) create mode 100644 Makefile.mk create mode 100644 info.rkt create mode 100644 main.rkt create mode 100644 private/library.rkt create mode 100644 private/player.rkt create mode 100644 private/server.rkt create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 rkt-web-player.ini.example create mode 100644 scribblings/rkt-web-player.scrbl diff --git a/.gitignore b/.gitignore index 39a4f9c..e4d56ce 100644 --- a/.gitignore +++ b/.gitignore @@ -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 + diff --git a/Makefile.mk b/Makefile.mk new file mode 100644 index 0000000..9940f9d --- /dev/null +++ b/Makefile.mk @@ -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)) + + ) + + \ No newline at end of file diff --git a/README.md b/README.md index 5a8aaee..b0ae112 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ # rkt-web-player -Audio player with web interface \ No newline at end of file +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 +``` diff --git a/info.rkt b/info.rkt new file mode 100644 index 0000000..d0a55e7 --- /dev/null +++ b/info.rkt @@ -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)))) diff --git a/main.rkt b/main.rkt new file mode 100644 index 0000000..142b24e --- /dev/null +++ b/main.rkt @@ -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?)) diff --git a/private/library.rkt b/private/library.rkt new file mode 100644 index 0000000..1a79971 --- /dev/null +++ b/private/library.rkt @@ -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 (entrytrack + (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)) + entrytracks 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)))) diff --git a/private/player.rkt b/private/player.rkt new file mode 100644 index 0000000..1a6c2bf --- /dev/null +++ b/private/player.rkt @@ -0,0 +1,1037 @@ +#lang racket/base + +(require racket-audio + racket-audio-dlna + racket/list + racket/path + racket/string + racket-sonos + racket-upnp + simple-log + "library.rkt") + +(provide make-player + player-state->jsexpr + player-command! + player-discover! + player-close!) + +(sl-def-log web-player) + +(struct renderer + (id name kind device) + #:transparent) + +(struct playlist-tab + (id [name #:mutable] [tracks #:mutable]) + #:transparent) + +(struct player + (libraries + [current-library-id #:mutable] + [browser-path #:mutable] + [browser-entries #:mutable] + [tracks #:mutable] + [tabs #:mutable] + [current-tab-index #:mutable] + [renderers #:mutable] + [selected-id #:mutable] + [backend #:mutable] + [backend-kind #:mutable] + [current-index #:mutable] + [state #:mutable] + [position #:mutable] + [duration #:mutable] + [rate #:mutable] + [channels #:mutable] + [bits #:mutable] + [decoder #:mutable] + [volume #:mutable] + [repeat #:mutable] + [error #:mutable] + [discovering? #:mutable] + [closed? #:mutable] + state-lock + command-lock + dlna-port) + #:transparent) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Internal state / functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (with-state-lock value proc) + (call-with-semaphore (player-state-lock value) proc)) + +(define (renderer-by-id value id) + (findf (λ (item) + (string=? (renderer-id item) id)) + (player-renderers value))) + +(define (library-by-id value id) + (findf (λ (library) + (string=? (music-library-id library) id)) + (player-libraries value))) + +(define (current-library value) + (and (player-current-library-id value) + (library-by-id value + (player-current-library-id value)))) + +(define (current-tab value) + (list-ref (player-tabs value) + (player-current-tab-index value))) + +(define (save-current-tab! value) + (set-playlist-tab-tracks! + (current-tab value) + (player-tracks value))) + +(define (normalize-state state) + (cond + ((eq? state 'playing) 'playing) + ((eq? state 'paused) 'paused) + ((eq? state 'transitioning) 'starting) + ((eq? state 'initialized) 'stopped) + ((eq? state 'no-media) 'stopped) + (else state))) + +(define (set-error! value message) + (with-state-lock + value + (λ () + (set-player-error! value message)))) + +(define (clear-error! value) + (set-error! value #f)) + +(define (reset-audio-info! value) + (set-player-rate! value #f) + (set-player-channels! value #f) + (set-player-bits! value #f) + (set-player-decoder! value #f)) + +(define (local-state-callback value handle state full-state) + (with-state-lock + value + (λ () + (when (eq? handle (player-backend value)) + (set-player-state! value (normalize-state state)) + (set-player-position! + value + (or (hash-ref full-state 'at-second #f) 0)) + (set-player-duration! + value + (hash-ref full-state 'duration #f)) + (set-player-rate! value + (hash-ref full-state 'rate #f)) + (set-player-channels! value + (hash-ref full-state 'channels #f)) + (set-player-bits! value + (hash-ref full-state 'bits #f)) + (set-player-decoder! value + (hash-ref full-state 'decoder #f)))))) + +(define (local-end-callback value handle) + (when (eq? handle (player-backend value)) + (with-handlers + ((exn:fail? + (λ (exception) + (set-error! value (exn-message exception))))) + (player-command! value "next" (hasheq))))) + +(define (make-local-backend value) + (let ((backend + (make-audio-player + (λ (handle state full-state) + (local-state-callback value handle state full-state)) + (λ (handle) + (local-end-callback value handle))))) + (audio-ao-buf-ms! backend 500) + (audio-buf-seconds! backend 4 10) + (let ((logical-volume + (/ (player-volume value) 100.0))) + (audio-volume! + backend + (* 100.0 logical-volume logical-volume))) + backend)) + +(define (make-network-backend value device) + (make-dlna-player device + #:port (player-dlna-port value) + #:path "/rkt-web-player/")) + +(define (ensure-backend! value) + (if (player-backend value) + (player-backend value) + (let* ((selected + (renderer-by-id value + (player-selected-id value))) + (kind (and selected (renderer-kind selected))) + (backend + (cond + ((eq? kind 'local) + (make-local-backend value)) + ((or (eq? kind 'upnp) + (eq? kind 'sonos)) + (make-network-backend + value + (renderer-device selected))) + (else + (raise-arguments-error + 'player-command! + "selected renderer is unavailable" + "renderer" (player-selected-id value)))))) + (with-state-lock + value + (λ () + (set-player-backend! value backend) + (set-player-backend-kind! value kind))) + backend))) + +(define (close-backend! value) + (let ((backend (player-backend value)) + (kind (player-backend-kind value))) + (when backend + (with-handlers + ((exn:fail? + (λ (exception) + (warn-web-player + "Could not close ~a player: ~a" + kind + (exn-message exception))))) + (if (eq? kind 'local) + (audio-quit! backend) + (dlna-player-close! backend)))) + (with-state-lock + value + (λ () + (set-player-backend! value #f) + (set-player-backend-kind! value #f) + (set-player-state! value 'stopped) + (set-player-position! value 0) + (set-player-duration! value #f) + (reset-audio-info! value))))) + +(define (stop-playback! value) + (when (player-backend value) + (if (eq? (player-backend-kind value) 'local) + (audio-stop! (player-backend value)) + (dlna-player-stop! (player-backend value)))) + (with-state-lock + value + (λ () + (set-player-state! value 'stopped) + (set-player-position! value 0) + (reset-audio-info! value)))) + +(define (valid-track-index? value index) + (and (exact-nonnegative-integer? index) + (< index (length (player-tracks value))))) + +(define (play-index! value index) + (unless (valid-track-index? value index) + (raise-arguments-error + 'player-command! + "track index is outside the playlist" + "index" index)) + (let* ((item (list-ref (player-tracks value) index)) + (backend (ensure-backend! value)) + (kind (player-backend-kind value))) + (with-state-lock + value + (λ () + (set-player-current-index! value index) + (set-player-state! value 'starting) + (set-player-position! value 0) + (set-player-duration! value (track-duration item)))) + (if (eq? kind 'local) + (audio-play! backend (track-file item)) + (dlna-player-play! backend (track-file item))) + (clear-error! value))) + +(define (next-index value direction) + (let ((count (length (player-tracks value))) + (current (player-current-index value)) + (repeat (player-repeat value))) + (cond + ((zero? count) #f) + ((eq? repeat 'one) current) + ((eq? current #f) + (if (positive? direction) 0 (- count 1))) + (else + (let ((candidate (+ current direction))) + (cond + ((and (>= candidate 0) (< candidate count)) candidate) + ((eq? repeat 'all) + (if (positive? direction) 0 (- count 1))) + (else #f))))))) + +(define (refresh-network-state! value) + (when (and (player-backend value) + (not (eq? (player-backend-kind value) 'local))) + (with-handlers + ((exn:fail? + (λ (exception) + (set-error! value (exn-message exception))))) + (let* ((info (dlna-player-info (player-backend value))) + (track-info (dlna-info-track info))) + (with-state-lock + value + (λ () + (set-player-state! + value + (normalize-state (dlna-info-state info))) + (set-player-position! + value + (or (dlna-info-position info) 0)) + (set-player-duration! + value + (dlna-info-duration info)) + (set-player-rate! + value + (and track-info + (dlna-track-info-sample-rate track-info))) + (set-player-channels! + value + (and track-info + (dlna-track-info-channels track-info))) + (set-player-bits! value #f) + (set-player-decoder! value 'dlna) + (when (number? (dlna-info-volume info)) + (set-player-volume! value + (dlna-info-volume info))))))))) + +(define (entry-by-index value index) + (and (exact-nonnegative-integer? index) + (< index (length (player-browser-entries value))) + (list-ref (player-browser-entries value) index))) + +(define (refresh-browser! value library relative-path) + (let ((entries (browse-library library relative-path))) + (with-state-lock + value + (λ () + (set-player-current-library-id! + value + (music-library-id library)) + (set-player-browser-path! value relative-path) + (set-player-browser-entries! value entries))))) + +(define (selected-entry-tracks value index) + (let ((entry (entry-by-index value index)) + (library (current-library value))) + (unless entry + (raise-arguments-error + 'player-command! + "library entry does not exist" + "index" index)) + (unless library + (raise-arguments-error + 'player-command! + "no music library is selected")) + (browser-entry->tracks library entry))) + +(define (same-track? first second) + (equal? (normal-case-path (track-file first)) + (normal-case-path (track-file second)))) + +(define (append-tracks! value tracks) + (let ((combined + (foldl + (λ (item result) + (if (findf (λ (existing) + (same-track? item existing)) + result) + result + (append result (list item)))) + (player-tracks value) + tracks))) + (with-state-lock + value + (λ () + (set-player-tracks! value combined) + (save-current-tab! value))))) + +(define (replace-tracks! value tracks) + (stop-playback! value) + (with-state-lock + value + (λ () + (set-player-tracks! value tracks) + (set-player-current-index! value #f) + (save-current-tab! value)))) + +(define (drop-track! value index) + (unless (valid-track-index? value index) + (raise-arguments-error + 'player-command! + "track index is outside the playlist" + "index" index)) + (when (equal? index (player-current-index value)) + (stop-playback! value)) + (with-state-lock + value + (λ () + (set-player-tracks! + value + (append (take (player-tracks value) index) + (drop (player-tracks value) (+ index 1)))) + (cond + ((equal? index (player-current-index value)) + (set-player-current-index! value #f)) + ((and (player-current-index value) + (< index (player-current-index value))) + (set-player-current-index! + value + (- (player-current-index value) 1)))) + (save-current-tab! value)))) + +(define (move-track! value from-index to-index) + (unless (and (valid-track-index? value from-index) + (valid-track-index? value to-index)) + (raise-arguments-error + 'player-command! + "track move indexes are outside the playlist" + "from" from-index + "to" to-index)) + (unless (= from-index to-index) + (with-state-lock + value + (λ () + (let* ((tracks (player-tracks value)) + (item (list-ref tracks from-index)) + (without + (append (take tracks from-index) + (drop tracks (+ from-index 1)))) + (moved + (append (take without to-index) + (list item) + (drop without to-index))) + (current (player-current-index value))) + (set-player-tracks! value moved) + (when current + (cond + ((= current from-index) + (set-player-current-index! value to-index)) + ((and (< from-index current) + (<= current to-index)) + (set-player-current-index! value (- current 1))) + ((and (<= to-index current) + (< current from-index)) + (set-player-current-index! value (+ current 1))))) + (save-current-tab! value)))))) + +(define (select-tab! value index) + (unless (and (exact-nonnegative-integer? index) + (< index (length (player-tabs value)))) + (raise-arguments-error + 'player-command! + "playlist tab does not exist" + "index" index)) + (unless (= index (player-current-tab-index value)) + (stop-playback! value) + (with-state-lock + value + (λ () + (save-current-tab! value) + (set-player-current-tab-index! value index) + (set-player-tracks! + value + (playlist-tab-tracks (current-tab value))) + (set-player-current-index! value #f))))) + +(define (add-tab! value) + (with-state-lock + value + (λ () + (save-current-tab! value) + (let* ((tabs (player-tabs value)) + (number (+ (length tabs) 1)) + (tab + (playlist-tab + (format "tab-~a-~a" + (current-milliseconds) + (random 10000)) + (format "Playlist ~a" number) + '()))) + (set-player-tabs! value (append tabs (list tab))) + (set-player-current-tab-index! value (length tabs)) + (set-player-tracks! value '()) + (set-player-current-index! value #f))))) + +(define (rename-tab! value index name) + (unless (and (exact-nonnegative-integer? index) + (< index (length (player-tabs value)))) + (raise-arguments-error + 'player-command! + "playlist tab does not exist" + "index" index)) + (let ((trimmed (string-trim name))) + (when (string=? trimmed "") + (raise-arguments-error + 'player-command! + "playlist name cannot be empty")) + (with-state-lock + value + (λ () + (set-playlist-tab-name! + (list-ref (player-tabs value) index) + trimmed))))) + +(define (delete-tab! value index) + (when (= (length (player-tabs value)) 1) + (raise-arguments-error + 'player-command! + "the last playlist tab cannot be removed")) + (unless (and (exact-nonnegative-integer? index) + (< index (length (player-tabs value)))) + (raise-arguments-error + 'player-command! + "playlist tab does not exist" + "index" index)) + (stop-playback! value) + (with-state-lock + value + (λ () + (save-current-tab! value) + (let* ((tabs + (append (take (player-tabs value) index) + (drop (player-tabs value) (+ index 1)))) + (new-index + (min (player-current-tab-index value) + (- (length tabs) 1)))) + (set-player-tabs! value tabs) + (set-player-current-tab-index! value new-index) + (set-player-tracks! + value + (playlist-tab-tracks (list-ref tabs new-index))) + (set-player-current-index! value #f))))) + +(define (track->jsexpr item index) + (hasheq 'index index + 'title (track-title item) + 'artist (track-artist item) + 'album (track-album item) + 'duration (or (track-duration item) 'null) + 'mimeType (track-mime-type item) + 'source (path->string + (or (file-name-from-path (track-file item)) + (track-file item))))) + +(define (renderer->jsexpr item) + (hasheq 'id (renderer-id item) + 'name (renderer-name item) + 'kind (symbol->string (renderer-kind item)))) + +(define (library->jsexpr library) + (hasheq 'id (music-library-id library) + 'name (music-library-name library))) + +(define (browser-entry->jsexpr entry index) + (hasheq 'index index + 'name (browser-entry-name entry) + 'kind (symbol->string (browser-entry-kind entry)))) + +(define (tab->jsexpr tab index) + (hasheq 'index index + 'id (playlist-tab-id tab) + 'name (playlist-tab-name tab) + 'count (length (playlist-tab-tracks tab)))) + +(define (normal-device-id device) + (let ((id (upnp-device-udn device))) + (and id + (let ((match + (regexp-match + #px"(?i:RINCON_[0-9A-F]+)" + id))) + (if match + (string-upcase (car match)) + (regexp-replace #px"(?i:^uuid:)" id "")))))) + +(define (sonos-member? device member-ids) + (let ((id (normal-device-id device))) + (and id + (ormap (λ (member-id) + (string-ci=? id member-id)) + member-ids)))) + +(define (discover-renderers) + (let* ((devices (query-upnp-devices 'all)) + (media-renderers (filter media-renderer? devices)) + (groups + (with-handlers + ((exn:fail? + (λ (exception) + (warn-web-player + "Could not read Sonos topology: ~a" + (exn-message exception)) + '()))) + (sonos-groups devices))) + (member-ids + (append-map sonos-group-member-ids groups)) + (upnp-renderers + (for/list ((device (in-list media-renderers)) + #:unless (sonos-member? device member-ids)) + (renderer + (format "upnp:~a" + (or (upnp-device-udn device) + (media-renderer-address device))) + (if (sonos-device? device) + (sonos-device-name device) + (media-renderer-name device)) + 'upnp + device))) + (sonos-renderers + (for/list ((group (in-list groups))) + (renderer + (format "sonos:~a" (sonos-group-id group)) + (sonos-group-name group) + 'sonos + (sonos-group-renderer group))))) + (sort (append upnp-renderers sonos-renderers) + string-cisymbol name))) + (unless (member mode '(off all one)) + (raise-arguments-error + 'player-command! + "repeat mode must be off, all, or one" + "mode" name)) + (with-state-lock + value + (λ () + (set-player-repeat! value mode))))) + ((string=? command "renderer") + (let* ((id (json-string data 'id #f)) + (selected (and id (renderer-by-id value id)))) + (unless selected + (raise-arguments-error + 'player-command! + "renderer does not exist" + "id" id)) + (unless (string=? id (player-selected-id value)) + (close-backend! value) + (with-state-lock + value + (λ () + (set-player-selected-id! value id)))))) + (else + (raise-arguments-error + 'player-command! + "unknown player command" + "command" command)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Provided functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Create a player for lazily browsed music libraries. +; pre : Libraries is a list of music-library values; DLNA port is positive. +; post : Only the selected root directory has been listed; no backend exists. +; result : A player that initially selects local playback. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (make-player libraries #:dlna-port [dlna-port 8734]) + (let* ((library (and (pair? libraries) (car libraries))) + (browser-entries + (if library + (browse-library library '()) + '())) + (tab (playlist-tab "default" "Default" '()))) + (player libraries + (and library (music-library-id library)) + '() + browser-entries + '() + (list tab) + 0 + (list (renderer "local" "Dit apparaat" 'local #f)) + "local" + #f + #f + #f + 'stopped + 0 + #f + #f + #f + #f + #f + 50 + 'off + #f + #f + #f + (make-semaphore 1) + (make-semaphore 1) + dlna-port))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Return the complete browser-visible player state. +; pre : Value was created with make-player. +; post : Cached DLNA playback information has been incorporated. +; result : A JSON-compatible hash. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-state->jsexpr value) + (refresh-network-state! value) + (with-state-lock + value + (λ () + (let ((current + (and (player-current-index value) + (valid-track-index? + value + (player-current-index value)) + (list-ref (player-tracks value) + (player-current-index value))))) + (hasheq + 'libraries (map library->jsexpr + (player-libraries value)) + 'libraryId (or (player-current-library-id value) 'null) + 'browser + (hasheq + 'path (map path->string (player-browser-path value)) + 'canGoUp (not (null? (player-browser-path value))) + 'entries + (for/list ((entry (in-list (player-browser-entries value))) + (index (in-naturals))) + (browser-entry->jsexpr entry index))) + 'tabs + (for/list ((tab (in-list (player-tabs value))) + (index (in-naturals))) + (tab->jsexpr tab index)) + 'currentTab (player-current-tab-index value) + 'tracks + (for/list ((item (in-list (player-tracks value))) + (index (in-naturals))) + (track->jsexpr item index)) + 'renderers (map renderer->jsexpr + (player-renderers value)) + 'rendererId (player-selected-id value) + 'currentIndex (or (player-current-index value) 'null) + 'state (symbol->string (player-state value)) + 'position (player-position value) + 'duration (or (player-duration value) 'null) + 'rate (or (player-rate value) 'null) + 'channels (or (player-channels value) 'null) + 'bits (or (player-bits value) 'null) + 'format (if (player-decoder value) + (format "~a" (player-decoder value)) + "") + 'source (if current + (path->string + (or (file-name-from-path (track-file current)) + (track-file current))) + "") + 'volume (player-volume value) + 'repeat (symbol->string (player-repeat value)) + 'discovering (player-discovering? value) + 'error (or (player-error value) 'null)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Execute one browser player command. +; pre : Command is a string and data is a JSON object hash. +; post : The command has completed or a concrete exception is raised. +; result : The updated JSON-compatible player state. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-command! value command data) + (call-with-semaphore + (player-command-lock value) + (λ () + (when (player-closed? value) + (raise-arguments-error + 'player-command! + "player has been closed")) + (with-handlers + ((exn:fail? + (λ (exception) + (set-error! value (exn-message exception)) + (raise exception)))) + (perform-command! value command data)) + (player-state->jsexpr value)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Discover UPnP renderers and logical Sonos groups asynchronously. +; pre : Value was created with make-player. +; post : The renderer list is updated when discovery finishes. +; result : #t when discovery started, #f when it was already running. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-discover! value) + (let ((can-start? + (with-state-lock + value + (λ () + (if (player-discovering? value) + #f + (begin + (set-player-discovering?! value #t) + #t)))))) + (when can-start? + (thread + (λ () + (with-handlers + ((exn:fail? + (λ (exception) + (set-error! value (exn-message exception))))) + (let ((found (discover-renderers))) + (with-state-lock + value + (λ () + (set-player-renderers! + value + (cons (renderer "local" + "Dit apparaat" + 'local + #f) + found)) + (set-player-error! value #f))))) + (with-state-lock + value + (λ () + (set-player-discovering?! value #f)))))) + can-start?)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Stop playback and release all player resources. +; pre : Value was created with make-player. +; post : The active backend is closed; repeated calls are harmless. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (player-close! value) + (call-with-semaphore + (player-command-lock value) + (λ () + (unless (player-closed? value) + (close-backend! value) + (with-state-lock + value + (λ () + (set-player-closed?! value #t))))))) + +(module+ test + (require rackunit + racket/file) + + (define root + (make-temporary-file "rkt-web-player-~a" 'directory)) + + (dynamic-wind + void + (λ () + (make-directory (build-path root "Album")) + (let* ((libraries (make-music-libraries (list root))) + (example-player (make-player libraries)) + (initial-state + (player-state->jsexpr example-player))) + (check-equal? (hash-ref initial-state 'state) "stopped") + (check-equal? (hash-ref initial-state 'rendererId) "local") + (check-equal? (length (hash-ref initial-state 'libraries)) 1) + (check-equal? + (length (hash-ref (hash-ref initial-state 'browser) 'entries)) + 1) + (check-equal? (length (hash-ref initial-state 'tracks)) 0) + + (define browsed-state + (player-command! + example-player + "browse" + (hasheq 'index 0))) + + (check-equal? + (hash-ref (hash-ref browsed-state 'browser) 'path) + '("Album")) + (check-true + (hash-ref (hash-ref browsed-state 'browser) 'canGoUp)) + + (define root-state + (player-command! example-player "up" (hasheq))) + + (check-equal? + (hash-ref (hash-ref root-state 'browser) 'path) + '()) + + (define repeat-state + (player-command! + example-player + "repeat" + (hasheq 'mode "all"))) + + (check-equal? (hash-ref repeat-state 'repeat) "all") + + (define tab-state + (player-command! + example-player + "tab-add" + (hasheq))) + + (check-equal? (length (hash-ref tab-state 'tabs)) 2) + (check-equal? (hash-ref tab-state 'currentTab) 1) + + (define renamed-state + (player-command! + example-player + "tab-rename" + (hasheq 'index 1 'name "Favorieten"))) + + (check-equal? + (hash-ref (list-ref (hash-ref renamed-state 'tabs) 1) 'name) + "Favorieten") + + (define deleted-state + (player-command! + example-player + "tab-delete" + (hasheq 'index 1))) + + (check-equal? (length (hash-ref deleted-state 'tabs)) 1) + (check-exn exn:fail? + (λ () + (player-command! + example-player + "unknown" + (hasheq)))) + (player-close! example-player))) + (λ () + (delete-directory/files root)))) diff --git a/private/server.rkt b/private/server.rkt new file mode 100644 index 0000000..6fb967e --- /dev/null +++ b/private/server.rkt @@ -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))) diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..63cf742 --- /dev/null +++ b/public/app.js @@ -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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a97be72 --- /dev/null +++ b/public/index.html @@ -0,0 +1,131 @@ + + + + + + + RKT Web Player + + + +
+
+
+ RKT + WEB PLAYER +
+ +
+ + + +
+
+ +
+
+ + + + +
+ +
+ +
+ 00:00:00 + / + 00:00:00 +
+
+ + + +
+ +
+ + +
+
+
+ +
+ +
+
+ AFSPEELLIJST + 0 tracks +
+ +
+ +
+

Deze afspeellijst is leeg.

+ Gebruik bij een track of map. +
+ +
+ + + + + + + + + + + +
#TITELALBUMDUURActies
+
+
+
+ +
+
+ — bit + — kHz + — kanalen + + +
+

Verbinden…

+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..a333a91 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example new file mode 100644 index 0000000..b051fdb --- /dev/null +++ b/rkt-web-player.ini.example @@ -0,0 +1,9 @@ +[server] +listen-ip=127.0.0.1 +port=8080 + +[player] +dlna-port=8734 + +[library] +paths= diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl new file mode 100644 index 0000000..c70f2a8 --- /dev/null +++ b/scribblings/rkt-web-player.scrbl @@ -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. +}