diff --git a/private/player.rkt b/private/player.rkt index e7584a3..7573cb4 100644 --- a/private/player.rkt +++ b/private/player.rkt @@ -204,7 +204,9 @@ id action (length (playback-agent-commands agent))) command)))) -(define agent-heartbeat-timeout-seconds 10) +;; Allow mobile network interruptions without discarding the playback session +;; and pending commands while the agent continues playing cached audio. +(define agent-heartbeat-timeout-seconds 120) (define (prune-stale-agents! value) (with-state-lock @@ -1834,6 +1836,46 @@ rackunit racket/file) + (test-case "agent survives a mobile interruption and expires after prolonged silence" + (let* ((app-id (make-string 64 #\c)) + (value (make-player '() #:playlist-keystore #f #:local-output? #f + #:allowed-agent-ids (list app-id)))) + (dynamic-wind + void + (λ () + (player-agent-register! value (hasheq 'appId app-id)) + (let* ((agent (agent-by-id value app-id)) + (session (playback-session-for! value "anonymous")) + (id (agent-renderer-id app-id)) + (command (enqueue-agent-command! value agent "pause"))) + (set-playback-session-selected-id! session id) + (set-playback-session-backend! session agent) + (set-playback-session-backend-kind! session 'agent) + (set-playback-session-state! session 'playing) + (hash-set! (player-renderer-owners value) id session) + ;; A minute without polls must preserve the renderer and its owner. + (set-playback-agent-last-seen! agent (- (current-seconds) 60)) + (prune-stale-agents! value) + (check-eq? (agent-by-id value app-id) agent) + (check-equal? (playback-session-selected-id session) id) + (check-eq? (playback-session-backend session) agent) + (check-eq? (playback-session-state session) 'playing) + (check-eq? (hash-ref (player-renderer-owners value) id #f) session) + ;; Reconnection delivers the pending command and refreshes liveness. + (check-equal? (hash-ref (player-agent-poll! value (hasheq 'appId app-id)) + 'command) + command) + (prune-stale-agents! value) + (check-eq? (agent-by-id value app-id) agent) + (set-playback-agent-last-seen! agent (- (current-seconds) 121)) + (prune-stale-agents! value) + (check-false (agent-by-id value app-id)) + (check-false (renderer-by-id value id)) + (check-false (playback-session-backend session)) + (check-eq? (playback-session-state session) 'stopped) + (check-false (hash-ref (player-renderer-owners value) id #f)))) + (λ () (player-close! value))))) + (test-case "playlist versions cache 1000 tracks and follow user mutations" (let ((value (make-player '() #:playlist-keystore #f #:local-output? #f))) (dynamic-wind diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 18a8455..da8fba3 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -124,6 +124,12 @@ renderers. The server can prefetch the next track to an agent for gapless transitions. A 256-bit application ID identifies the agent; the server's @tt{[playback-agents]} allowlist decides which agents may connect. +The server keeps an agent's playback session and pending commands for up to +two minutes without a status update, allowing brief mobile network outages. +Polling within that period refreshes the connection; after longer silence, +the server removes the agent from the available outputs. Already downloaded +audio can continue playing on the agent during an interruption. + Both agents retain downloaded tracks in a local disk cache with a budget of 1 GB (1,000,000,000 bytes), evicting the least recently used tracks first. Selecting a cached track reuses its local file when the server address, source