Deze afspeellijst is leeg.
+ Gebruik + bij een track of map. +| # | +TITEL | +ALBUM | +DUUR | +Acties | +
|---|
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 (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)))) 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-ci + #:key renderer-name))) + +(define (json-number data key [default #f]) + (let ((value (hash-ref data key default))) + (if (number? value) value default))) + +(define (json-string data key [default #f]) + (let ((value (hash-ref data key default))) + (if (string? value) value default))) + +(define (perform-library-command! value command data) + (let ((index (json-number data 'index #f))) + (cond + ((string=? command "library") + (let* ((id (json-string data 'id #f)) + (library (and id (library-by-id value id)))) + (unless library + (raise-arguments-error + 'player-command! + "music library does not exist" + "id" id)) + (refresh-browser! value library '()))) + ((string=? command "browse") + (let ((entry (entry-by-index value index))) + (unless (and entry + (eq? (browser-entry-kind entry) 'container)) + (raise-arguments-error + 'player-command! + "library entry is not a directory" + "index" index)) + (refresh-browser! + value + (current-library value) + (browser-entry-relative-path entry)))) + ((string=? command "up") + (unless (null? (player-browser-path value)) + (refresh-browser! + value + (current-library value) + (drop-right (player-browser-path value) 1)))) + ((string=? command "item-play") + (let ((tracks (selected-entry-tracks value index))) + (when (null? tracks) + (raise-arguments-error + 'player-command! + "selected item contains no supported audio")) + (replace-tracks! value tracks) + (play-index! value 0))) + ((string=? command "item-add") + (append-tracks! + value + (selected-entry-tracks value index)))))) + +(define (perform-playlist-command! value command data) + (let ((index (json-number data 'index #f))) + (cond + ((string=? command "track-remove") + (drop-track! value index)) + ((string=? command "track-move") + (move-track! value + (json-number data 'from #f) + (json-number data 'to #f))) + ((string=? command "playlist-clear") + (replace-tracks! value '())) + ((string=? command "tab-add") + (stop-playback! value) + (add-tab! value)) + ((string=? command "tab-select") + (select-tab! value index)) + ((string=? command "tab-rename") + (rename-tab! value index + (or (json-string data 'name #f) ""))) + ((string=? command "tab-delete") + (delete-tab! value index))))) + +(define (perform-command! value command data) + (cond + ((member command '("library" "browse" "up" + "item-play" "item-add")) + (perform-library-command! value command data)) + ((member command '("track-remove" "track-move" + "playlist-clear" "tab-add" + "tab-select" "tab-rename" + "tab-delete")) + (perform-playlist-command! value command data)) + ((string=? command "play") + (play-index! + value + (or (json-number data 'index #f) + (player-current-index value) + 0))) + ((string=? command "pause") + (let ((backend (ensure-backend! value))) + (if (eq? (player-backend-kind value) 'local) + (audio-pause! backend #t) + (dlna-player-pause! backend)))) + ((string=? command "resume") + (let ((backend (ensure-backend! value))) + (if (eq? (player-backend-kind value) 'local) + (audio-pause! backend #f) + (dlna-player-resume! backend)))) + ((string=? command "stop") + (stop-playback! value)) + ((string=? command "next") + (let ((index (next-index value 1))) + (if index + (play-index! value index) + (stop-playback! value)))) + ((string=? command "previous") + (let ((index (next-index value -1))) + (when index + (play-index! value index)))) + ((string=? command "seek") + (let ((percentage (json-number data 'percentage #f))) + (unless percentage + (raise-arguments-error + 'player-command! + "seek requires a numeric percentage")) + (let ((backend (ensure-backend! value))) + (if (eq? (player-backend-kind value) 'local) + (audio-seek! backend percentage) + (dlna-player-seek-percentage! backend percentage))))) + ((string=? command "volume") + (let ((percentage (json-number data 'value #f))) + (unless percentage + (raise-arguments-error + 'player-command! + "volume requires a numeric value")) + (let* ((clamped (min 100 (max 0 percentage))) + (backend (ensure-backend! value))) + (if (eq? (player-backend-kind value) 'local) + (let ((logical-volume (/ clamped 100.0))) + (audio-volume! + backend + (* 100.0 logical-volume logical-volume))) + (dlna-player-volume! backend clamped)) + (with-state-lock + value + (λ () + (set-player-volume! value clamped)))))) + ((string=? command "repeat") + (let* ((name (json-string data 'mode "off")) + (mode (string->symbol 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 @@ + + +
+ + + +Deze afspeellijst is leeg.
+ Gebruik + bij een track of map. +| # | +TITEL | +ALBUM | +DUUR | +Acties | +
|---|