DLNA playback

This commit is contained in:
2026-08-27 17:51:39 +02:00
parent 344be19521
commit 69433abee1
3 changed files with 401 additions and 115 deletions
+7 -2
View File
@@ -147,8 +147,13 @@ a more useful perceived volume curve.
Network outputs use `racket-audio-dlna`. The backend controls the chosen media Network outputs use `racket-audio-dlna`. The backend controls the chosen media
renderer and publishes local files over HTTP on the configured DLNA port and renderer and publishes local files over HTTP on the configured DLNA port and
path. Unlike the callback-driven local backend, network playback information is path. After starting a track, the server publishes the following track and sets
refreshed by calling the renderer whenever a state snapshot is requested. it as the renderer's `NextAVTransportURI`. Network playback information is
refreshed whenever a state snapshot is requested. A changed current URI
promotes the prepared playlist index and immediately prepares its successor.
If a renderer does not perform the prepared transition, a confirmed natural
stop advances through a server-driven fallback. Explicit stops and failed play
requests are tracked separately and never trigger that fallback.
Changing the selected renderer closes the existing backend and resets playback Changing the selected renderer closes the existing backend and resets playback
state. The replacement backend remains lazy and is created only when it is state. The replacement backend remains lazy and is created only when it is
+8
View File
@@ -88,6 +88,14 @@ wordt pas geïnitialiseerd bij het eerste afspeelcommando. De knop naast de
uitvoerkeuze start UPnP-discovery; gevonden Sonos-zones worden als logische uitvoerkeuze start UPnP-discovery; gevonden Sonos-zones worden als logische
groepen aangeboden en niet nogmaals als losse UPnP-renderers. groepen aangeboden en niet nogmaals als losse UPnP-renderers.
Voor UPnP- en Sonos-weergave publiceert de server ook de volgende playlisttrack
en stelt deze vooraf in als `NextAVTransportURI`. Een renderer die dit
ondersteunt kan daardoor zelf zonder serverronde naar de volgende track
overgaan. De server volgt de nieuwe URI en playlistindex. Renderers zonder
betrouwbare next-ondersteuning krijgen na het natuurlijke trackeinde een
servergestuurde fallback. Een expliciet stopcommando start nooit de volgende
track.
Playlisttabs kunnen worden toegevoegd, geselecteerd, hernoemd door dubbel te Playlisttabs kunnen worden toegevoegd, geselecteerd, hernoemd door dubbel te
klikken en verwijderd. Tracks kunnen worden afgespeeld, verwijderd en met klikken en verwijderd. Tracks kunnen worden afgespeeld, verwijderd en met
drag-and-drop verplaatst. De tabs zijn in deze versie alleen in het geheugen drag-and-drop verplaatst. De tabs zijn in deze versie alleen in het geheugen
+386 -113
View File
@@ -47,6 +47,18 @@
(id [name #:mutable] [tracks #:mutable]) (id [name #:mutable] [tracks #:mutable])
#:transparent) #:transparent)
(struct network-playback
([current-uri #:mutable]
[prepared-uri #:mutable]
[prepared-index #:mutable]
[playing-seen? #:mutable]
[progress-seen? #:mutable]
[failure-active? #:mutable]
[stop-requested? #:mutable]
[stopped-polls #:mutable]
[play-request-ms #:mutable])
#:transparent)
(struct player (struct player
(libraries (libraries
allowed-agent-ids allowed-agent-ids
@@ -74,9 +86,11 @@
[error #:mutable] [error #:mutable]
[discovering? #:mutable] [discovering? #:mutable]
[closed? #:mutable] [closed? #:mutable]
[network-monitor #:mutable]
state-lock state-lock
command-lock command-lock
local-music-indexes local-music-indexes
network
dlna-port) dlna-port)
#:transparent) #:transparent)
@@ -247,6 +261,94 @@
(set-player-bits! value #f) (set-player-bits! value #f)
(set-player-decoder! value #f)) (set-player-decoder! value #f))
(define (now-ms)
(current-inexact-milliseconds))
(define (reset-network-playback! value)
(define network (player-network value))
(set-network-playback-current-uri! network #f)
(set-network-playback-prepared-uri! network #f)
(set-network-playback-prepared-index! network #f)
(set-network-playback-playing-seen?! network #f)
(set-network-playback-progress-seen?! network #f)
(set-network-playback-failure-active?! network #f)
(set-network-playback-stop-requested?! network #f)
(set-network-playback-stopped-polls! network 0)
(set-network-playback-play-request-ms! network #f))
(define (same-track-file? first second)
(and first
second
(with-handlers ((exn:fail? (lambda (_) #f)))
(equal? (normal-case-path first)
(normal-case-path second)))))
(define (network-info-track-index value info)
(define network (player-network value))
(define info-track (dlna-info-track info))
(define file (and info-track (dlna-track-info-file info-track)))
(define uri (dlna-info-uri info))
(define prepared-index (network-playback-prepared-index network))
(define prepared-uri (network-playback-prepared-uri network))
(cond
((and (valid-track-index? value prepared-index)
(or (same-track-file?
file
(track-file (list-ref (player-tracks value) prepared-index)))
(and (string? uri)
(string? prepared-uri)
(string=? uri prepared-uri))))
prepared-index)
(else
(for/first ((item (in-list (player-tracks value)))
(index (in-naturals))
#:when (same-track-file? file (track-file item)))
index))))
(define (prepare-next-network-track! value)
(define backend (player-backend value))
(define network (player-network value))
(when (and backend
(member (player-backend-kind value) '(upnp sonos))
(valid-track-index? value (player-current-index value)))
(define index (next-index value 1))
(cond
((not index)
(set-network-playback-prepared-index! network #f)
(set-network-playback-prepared-uri! network #f))
((not (equal? index (network-playback-prepared-index network)))
(with-handlers
((exn:fail?
(lambda (exception)
(set-network-playback-prepared-index! network #f)
(set-network-playback-prepared-uri! network #f)
(warn-web-player
"Could not prepare next DLNA track: ~a"
(exn-message exception)))))
(dlna-player-set-next-file!
backend
(track-file (list-ref (player-tracks value) index)))
(define prepared-info (dlna-player-info backend))
(set-network-playback-prepared-index! network index)
(set-network-playback-prepared-uri!
network
(dlna-info-next-uri prepared-info)))))))
;; Result used when a renderer reports stopped after a play request.
(define (network-stop-decision stop-requested?
playing-seen?
progress-seen?
elapsed-ms
prepared?
stopped-polls)
(cond
(stop-requested? 'requested-stop)
((not playing-seen?) 'none)
((and (not progress-seen?) (< elapsed-ms 5000)) 'wait-for-start)
((not progress-seen?) 'playback-failed)
((and prepared? (<= stopped-polls 1)) 'wait-for-next)
(else 'advance)))
(define (local-state-callback value handle state full-state) (define (local-state-callback value handle state full-state)
(with-state-lock (with-state-lock
value value
@@ -363,6 +465,7 @@
(with-state-lock (with-state-lock
value value
(λ () (λ ()
(reset-network-playback! value)
(set-player-backend! value #f) (set-player-backend! value #f)
(set-player-backend-kind! value #f) (set-player-backend-kind! value #f)
(set-player-state! value 'stopped) (set-player-state! value 'stopped)
@@ -380,6 +483,15 @@
(player-backend value) (player-backend value)
"stop")) "stop"))
(else (else
(with-state-lock
value
(λ ()
(define network (player-network value))
(set-network-playback-stop-requested?! network #t)
(set-network-playback-playing-seen?! network #f)
(set-network-playback-progress-seen?! network #f)
(set-network-playback-failure-active?! network #f)
(set-network-playback-stopped-polls! network 0)))
(dlna-player-stop! (player-backend value))))) (dlna-player-stop! (player-backend value)))))
(with-state-lock (with-state-lock
value value
@@ -447,7 +559,24 @@
(enqueue-agent-command! (enqueue-agent-command!
value backend "prefetch" following-data)))) value backend "prefetch" following-data))))
(else (else
(dlna-player-play! backend (track-file item)))) (dlna-player-play! backend (track-file item))
(let ((info (dlna-player-info backend)))
(with-state-lock
value
(λ ()
(define network (player-network value))
(set-network-playback-current-uri!
network
(dlna-info-uri info))
(set-network-playback-prepared-uri! network #f)
(set-network-playback-prepared-index! network #f)
(set-network-playback-playing-seen?! network #t)
(set-network-playback-progress-seen?! network #f)
(set-network-playback-failure-active?! network #f)
(set-network-playback-stop-requested?! network #f)
(set-network-playback-stopped-polls! network 0)
(set-network-playback-play-request-ms! network (now-ms)))))
(prepare-next-network-track! value)))
(clear-error! value))) (clear-error! value)))
(define (next-index value direction) (define (next-index value direction)
@@ -467,86 +596,172 @@
(if (positive? direction) 0 (- count 1))) (if (positive? direction) 0 (- count 1)))
(else #f))))))) (else #f)))))))
(define (refresh-network-state! value) (define (refresh-agent-state! value)
(when (and (player-backend value) (define reported
(not (eq? (player-backend-kind value) 'local))) (playback-agent-reported-state (player-backend value)))
(with-handlers ;; Keep optimistic command state until playback-changing work is acknowledged.
((exn:fail? (when (and (hash? reported)
(λ (exception) (andmap
(set-error! value (exn-message exception))))) (λ (command)
(if (eq? (player-backend-kind value) 'agent) (string=? (hash-ref command 'action "") "prefetch"))
(let ((reported (playback-agent-commands (player-backend value))))
(playback-agent-reported-state (with-state-lock
(player-backend value)))) value
;; Keep the server's optimistic command state visible until the (λ ()
;; agent acknowledges playback-changing work. Prefetching does not (let ((state (hash-ref reported 'state #f)))
;; change playback state and may continue in the background. (when (string? state)
(when (and (hash? reported)
(andmap
(λ (command)
(string=? (hash-ref command 'action "")
"prefetch"))
(playback-agent-commands
(player-backend value))))
(with-state-lock
value
(λ ()
(let ((state (hash-ref reported 'state #f)))
(when (string? state)
(set-player-state!
value
(normalize-state (string->symbol state)))))
(set-player-position!
value
(or (json-number reported 'position #f) 0))
(set-player-duration!
value
(json-number reported 'duration #f))
(set-player-rate!
value
(json-number reported 'rate #f))
(set-player-channels!
value
(json-number reported 'channels #f))
(set-player-bits!
value
(json-number reported 'bits #f))
(let ((decoder (json-string reported 'format #f)))
(set-player-decoder!
value
(and decoder (string->symbol decoder))))
(let ((volume (json-number reported 'volume #f)))
(when volume
(set-player-volume! value volume)))
(let ((error (json-string reported 'error #f)))
(set-player-error! value error))))))
(let* ((info (dlna-player-info (player-backend value)))
(track-info (dlna-info-track info)))
(with-state-lock
value
(λ ()
(set-player-state! (set-player-state!
value value
(normalize-state (dlna-info-state info))) (normalize-state (string->symbol state)))))
(set-player-position! (set-player-position! value
value (or (json-number reported 'position #f) 0))
(or (dlna-info-position info) 0)) (set-player-duration! value
(set-player-duration! (json-number reported 'duration #f))
value (set-player-rate! value (json-number reported 'rate #f))
(dlna-info-duration info)) (set-player-channels! value (json-number reported 'channels #f))
(set-player-rate! (set-player-bits! value (json-number reported 'bits #f))
value (let ((decoder (json-string reported 'format #f)))
(and track-info (set-player-decoder!
(dlna-track-info-sample-rate track-info))) value
(set-player-channels! (and decoder (string->symbol decoder))))
value (let ((volume (json-number reported 'volume #f)))
(and track-info (when volume (set-player-volume! value volume)))
(dlna-track-info-channels track-info))) (set-player-error! value (json-string reported 'error #f))))))
(set-player-bits! value #f)
(set-player-decoder! value 'dlna) (define (refresh-dlna-state! value)
(when (number? (dlna-info-volume info)) (define info (dlna-player-info (player-backend value)))
(set-player-volume! value (define track-info (dlna-info-track info))
(dlna-info-volume info)))))))))) (define new-state (normalize-state (dlna-info-state info)))
(define position (or (dlna-info-position info) 0))
(define prepare-next? #f)
(define advance? #f)
(define playback-failed? #f)
(with-state-lock
value
(λ ()
(define network (player-network value))
(define uri (dlna-info-uri info))
(when (and (string? uri)
(not (string=? uri ""))
(not (equal? uri (network-playback-current-uri network))))
(set-network-playback-current-uri! network uri)
(set-network-playback-stopped-polls! network 0)
(let ((detected-index (network-info-track-index value info)))
(when (valid-track-index? value detected-index)
(unless (equal? detected-index (player-current-index value))
(set-network-playback-progress-seen?! network #f)
(set-network-playback-failure-active?! network #f)
(set-network-playback-play-request-ms! network (now-ms)))
(set-player-current-index! value detected-index)
(set-network-playback-prepared-index! network #f)
(set-network-playback-prepared-uri! network #f)
(set! prepare-next? #t))))
(when (and (not (network-playback-failure-active? network))
(member new-state '(playing starting paused)))
(when (eq? new-state 'playing)
(set-network-playback-playing-seen?! network #t)
(set-network-playback-stopped-polls! network 0))
(when (and (number? position) (> position 0))
(set-network-playback-progress-seen?! network #t)))
(let ((requested-at (network-playback-play-request-ms network)))
(when (and (network-playback-playing-seen? network)
(not (network-playback-progress-seen? network))
requested-at
(>= (- (now-ms) requested-at) 8000))
(set-network-playback-playing-seen?! network #f)
(set-network-playback-stopped-polls! network 0)
(set-network-playback-failure-active?! network #t)
(set-player-error! value "De DLNA-renderer kon de track niet starten")
(set! playback-failed? #t)))
(when (and (eq? new-state 'stopped)
(not playback-failed?)
(not (network-playback-failure-active? network)))
(when (and (network-playback-playing-seen? network)
(network-playback-progress-seen? network))
(set-network-playback-stopped-polls!
network
(+ 1 (network-playback-stopped-polls network))))
(let* ((requested-at (network-playback-play-request-ms network))
(decision
(network-stop-decision
(network-playback-stop-requested? network)
(network-playback-playing-seen? network)
(network-playback-progress-seen? network)
(if requested-at (- (now-ms) requested-at) 10000)
(exact-nonnegative-integer?
(network-playback-prepared-index network))
(network-playback-stopped-polls network))))
(case decision
((requested-stop)
(set-network-playback-stop-requested?! network #f)
(set-network-playback-stopped-polls! network 0))
((playback-failed)
(set-network-playback-playing-seen?! network #f)
(set-network-playback-stopped-polls! network 0)
(set-network-playback-failure-active?! network #t)
(set-player-error! value "De DLNA-renderer kon de track niet starten"))
((advance)
(set-network-playback-playing-seen?! network #f)
(set-network-playback-stopped-polls! network 0)
(set! advance? #t)))))
(set-player-state!
value
(cond
((or playback-failed?
(network-playback-failure-active? network))
'stopped)
((and (network-playback-playing-seen? network)
(not (network-playback-progress-seen? network)))
'starting)
(else new-state)))
(set-player-position! value position)
(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)))))
(when prepare-next?
(prepare-next-network-track! value))
(when advance?
(let ((index (next-index value 1)))
(if index
(play-index! value index)
(stop-playback! value)))))
(define (refresh-network-state! value)
(call-with-semaphore
(player-command-lock value)
(λ ()
(when (and (player-backend value)
(not (eq? (player-backend-kind value) 'local)))
(with-handlers
((exn:fail?
(λ (exception)
(set-error! value (exn-message exception)))))
(if (eq? (player-backend-kind value) 'agent)
(refresh-agent-state! value)
(refresh-dlna-state! value)))))))
(define (start-network-monitor! value)
(set-player-network-monitor!
value
(thread
(λ ()
(let loop ()
(sleep 1)
(unless (player-closed? value)
(refresh-network-state! value)
(loop)))))))
(define (entry-by-index value index) (define (entry-by-index value index)
(and (exact-nonnegative-integer? index) (and (exact-nonnegative-integer? index)
@@ -1010,7 +1225,12 @@
(with-state-lock (with-state-lock
value value
(λ () (λ ()
(set-player-repeat! value mode))))) (set-player-repeat! value mode)))
(when (member (player-backend-kind value) '(upnp sonos))
;; Replace the renderer's prepared URI when repeat mode changes.
(set-network-playback-prepared-index! (player-network value) #f)
(set-network-playback-prepared-uri! (player-network value) #f)
(prepare-next-network-track! value))))
((string=? command "renderer") ((string=? command "renderer")
(let* ((id (json-string data 'id #f)) (let* ((id (json-string data 'id #f))
(selected (and id (renderer-by-id value id)))) (selected (and id (renderer-by-id value id))))
@@ -1058,36 +1278,41 @@
(browse-library library '()) (browse-library library '())
'())) '()))
(tab (playlist-tab "default" "Default" '()))) (tab (playlist-tab "default" "Default" '())))
(player libraries (define value
(remove-duplicates normalized-agent-ids string=?) (player libraries
'() (remove-duplicates normalized-agent-ids string=?)
(and library (music-library-id library)) '()
'() (and library (music-library-id library))
browser-entries '()
'() browser-entries
(list tab) '()
0 (list tab)
(list (renderer "local" "Server audio output" 'local #f)) 0
"local" (list (renderer "local" "Server audio output" 'local #f))
#f "local"
#f #f
#f #f
'stopped #f
0 'stopped
#f 0
#f #f
#f #f
#f #f
#f #f
50 #f
'off 50
#f 'off
#f #f
#f #f
(make-semaphore 1) #f
(make-semaphore 1) #f
(make-hash) (make-semaphore 1)
dlna-port))) (make-semaphore 1)
(make-hash)
(network-playback #f #f #f #f #f #f #f 0 #f)
dlna-port))
(start-network-monitor! value)
value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Return the complete browser-visible player state. ; goal : Return the complete browser-visible player state.
@@ -1171,8 +1396,10 @@
(λ (exception) (λ (exception)
(set-error! value (exn-message exception)) (set-error! value (exn-message exception))
(raise exception)))) (raise exception))))
(perform-command! value command data)) (perform-command! value command data))))
(player-state->jsexpr value)))) ;; State refresh can itself advance a completed network track and therefore
;; acquires command-lock. Take the snapshot after releasing this command.
(player-state->jsexpr value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Discover UPnP renderers and logical Sonos groups asynchronously. ; goal : Discover UPnP renderers and logical Sonos groups asynchronously.
@@ -1403,12 +1630,35 @@
(with-state-lock (with-state-lock
value value
(λ () (λ ()
(set-player-closed?! value #t))))))) (set-player-closed?! value #t))))))
(let ((monitor (player-network-monitor value)))
(when (and monitor (not (thread-dead? monitor)))
(kill-thread monitor))
(set-player-network-monitor! value #f)))
(module+ test (module+ test
(require rackunit (require rackunit
racket/file) racket/file)
(check-eq?
(network-stop-decision #t #t #t 6000 #f 1)
'requested-stop)
(check-eq?
(network-stop-decision #f #t #f 1000 #f 0)
'wait-for-start)
(check-eq?
(network-stop-decision #f #t #f 6000 #f 0)
'playback-failed)
(check-eq?
(network-stop-decision #f #t #t 6000 #t 1)
'wait-for-next)
(check-eq?
(network-stop-decision #f #t #t 6000 #t 2)
'advance)
(check-eq?
(network-stop-decision #f #t #t 6000 #f 1)
'advance)
(define root (define root
(make-temporary-file "rkt-web-player-~a" 'directory)) (make-temporary-file "rkt-web-player-~a" 'directory))
@@ -1572,6 +1822,29 @@
(list (track first-file "First" "Artist" "Album" 60 "audio/flac") (list (track first-file "First" "Artist" "Album" 60 "audio/flac")
(track second-file "Second" "Artist" "Album" 60 "audio/flac"))) (track second-file "Second" "Artist" "Album" 60 "audio/flac")))
(define test-network (player-network example-player))
(set-network-playback-prepared-index! test-network 1)
(set-network-playback-prepared-uri!
test-network
"http://renderer.test/next.flac")
(check-equal?
(network-info-track-index
example-player
(dlna-info
'playing
(dlna-track-info second-file "Second" "Artist" "Album"
#f #f #f 60 #f #f #f)
"http://renderer.test/current.flac"
#f #f 0 60 25 #f #t))
1)
(check-equal?
(network-info-track-index
example-player
(dlna-info 'playing #f "http://renderer.test/next.flac"
#f #f 0 60 25 #f #t))
1)
(reset-network-playback! example-player)
(player-command! example-player "play" (hasheq 'index 0)) (player-command! example-player "play" (hasheq 'index 0))
(define play-poll (define play-poll
(player-agent-poll! (player-agent-poll!