From fd1bc504a583f50309883198ea0d6a7189c50bae Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Tue, 3 Mar 2026 23:17:01 +0300 Subject: [PATCH] cl-streamer integration fixes: CORS, reconnect, stream config Server-side fixes (stream-server.lisp): - Add CORS preflight (OPTIONS) request handler for browser crossorigin audio - AAC clients start from current buffer position instead of burst to avoid ADTS frame alignment issues that caused browser decode errors - Upgrade client stream error logging from debug to warn for diagnostics - Add send-cors-preflight function with proper Access-Control headers Frontend fixes (stream-player.lisp): - Rewrite reconnect-stream to reuse existing audio element instead of creating a new one, preserving browser user gesture context and preventing NotAllowedError on autoplay after reconnect - Unify stream config: both curated and shuffle channels use same mount points (asteroid.mp3/asteroid.aac) since cl-streamer has a single pipeline - Remove non-existent /asteroid-shuffle.mp3 mount reference that caused 404s and broken pipe cascade when switching to shuffle channel - Map :low quality to same MP3 mount (asteroid-low.mp3 not yet available) Note: Channel selector preserved for future multi-stream support. Recently-played API works correctly; frontend rendering to investigate separately. --- cl-streamer/stream-server.lisp | 30 ++++++- parenscript/stream-player.lisp | 152 +++++++++++---------------------- 2 files changed, 75 insertions(+), 107 deletions(-) diff --git a/cl-streamer/stream-server.lisp b/cl-streamer/stream-server.lisp index c14c491..8fb0377 100644 --- a/cl-streamer/stream-server.lisp +++ b/cl-streamer/stream-server.lisp @@ -122,7 +122,13 @@ :external-format :latin-1))) (handler-case (let* ((request-line (read-line stream)) - (headers (read-http-headers stream))) + (headers (read-http-headers stream)) + (method (first (split-sequence:split-sequence #\Space request-line)))) + ;; Handle CORS preflight + (when (string-equal method "OPTIONS") + (send-cors-preflight stream) + (ignore-errors (usocket:socket-close client-socket)) + (return-from handle-client)) (multiple-value-bind (path wants-meta) (parse-icy-request request-line headers) (let ((mount (gethash path (server-mounts server)))) @@ -178,8 +184,13 @@ (stream (client-stream client)) (chunk-size 4096) (chunk (make-array chunk-size :element-type '(unsigned-byte 8)))) - ;; Start from burst position for fast playback - (setf (client-read-pos client) (buffer-burst-start buffer)) + ;; For MP3, burst recent data for fast playback start. + ;; For AAC, start from current position — AAC requires ADTS frame alignment + ;; and burst data from mid-stream causes browser decode errors. + (setf (client-read-pos client) + (if (string= (mount-content-type mount) "audio/aac") + (buffer-current-pos buffer) + (buffer-burst-start buffer))) (loop while (client-active-p client) do (multiple-value-bind (bytes-read new-pos) (buffer-read-from buffer (client-read-pos client) chunk) @@ -195,7 +206,8 @@ (write-sequence chunk stream :end bytes-read)) (force-output stream)) (error (e) - (log:debug "Client stream error: ~A" e) + (log:warn "Client stream error on ~A: ~A" + (mount-path mount) e) (setf (client-active-p client) nil) (return))))))))) @@ -221,6 +233,16 @@ (incf (client-bytes-since-meta client) bytes-remaining) (setf pos length))))))) +(defun send-cors-preflight (stream) + "Send a CORS preflight response for OPTIONS requests." + (format stream "HTTP/1.1 204 No Content~C~C" #\Return #\Linefeed) + (format stream "Access-Control-Allow-Origin: *~C~C" #\Return #\Linefeed) + (format stream "Access-Control-Allow-Methods: GET, OPTIONS~C~C" #\Return #\Linefeed) + (format stream "Access-Control-Allow-Headers: Origin, Accept, Content-Type, Icy-MetaData, Range~C~C" #\Return #\Linefeed) + (format stream "Access-Control-Max-Age: 86400~C~C" #\Return #\Linefeed) + (format stream "~C~C" #\Return #\Linefeed) + (force-output stream)) + (defun send-404 (stream path) "Send a 404 response for unknown mount points." (format stream "HTTP/1.1 404 Not Found~C~C" #\Return #\Linefeed) diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index d6a76f6..2de2f70 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -149,28 +149,23 @@ ;; ======================================== ;; Get stream configuration for a given channel and quality - ;; Curated channel has multiple quality options, shuffle has only one + ;; With cl-streamer, both channels use the same stream mounts - + ;; channel switching loads a different playlist server-side (defun get-stream-config (stream-base-url channel quality) - (let ((curated-config (ps:create - :aac (ps:create :url (+ stream-base-url "/asteroid.aac") - :type "audio/aac" - :format "AAC 96kbps Stereo" - :mount "asteroid.aac") - :mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3") - :type "audio/mpeg" - :format "MP3 128kbps Stereo" - :mount "asteroid.mp3") - :low (ps:create :url (+ stream-base-url "/asteroid-low.mp3") - :type "audio/mpeg" - :format "MP3 64kbps Stereo" - :mount "asteroid-low.mp3"))) - (shuffle-config (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3") - :type "audio/mpeg" - :format "Shuffle MP3 96kbps" - :mount "asteroid-shuffle.mp3"))) - (if (= channel "shuffle") - shuffle-config - (ps:getprop curated-config quality)))) + (let ((config (ps:create + :aac (ps:create :url (+ stream-base-url "/asteroid.aac") + :type "audio/aac" + :format "AAC 96kbps Stereo" + :mount "asteroid.aac") + :mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3") + :type "audio/mpeg" + :format "MP3 128kbps Stereo" + :mount "asteroid.mp3") + :low (ps:create :url (+ stream-base-url "/asteroid.mp3") + :type "audio/mpeg" + :format "MP3 128kbps Stereo" + :mount "asteroid.mp3")))) + (ps:getprop config quality))) ;; Get current channel from selector or localStorage (defun get-current-channel () @@ -672,101 +667,52 @@ (defvar *reconnect-timeout* nil) (defvar *is-reconnecting* false) - ;; Reconnect stream - recreates audio element to fix wedged state + ;; Reconnect stream - reuses existing audio element to preserve user gesture context (defun reconnect-stream () (ps:chain console (log "Reconnecting stream...")) (show-status "🔄 Reconnecting..." false) - (let* ((container (ps:chain document (query-selector ".persistent-player"))) - (old-audio (ps:chain document (get-element-by-id "persistent-audio"))) + (let* ((audio (ps:chain document (get-element-by-id "persistent-audio"))) + (source (ps:chain document (get-element-by-id "audio-source"))) (stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value)) (stream-channel (get-current-channel)) (stream-quality (get-current-quality)) (config (get-stream-config stream-base-url stream-channel stream-quality))) - (unless (and container old-audio) + (unless audio (show-status "❌ Could not reconnect - reload page" true) (setf *is-reconnecting* false) (return-from reconnect-stream nil)) - ;; Save current volume and muted state - (let ((saved-volume (ps:@ old-audio volume)) - (saved-muted (ps:@ old-audio muted))) - (ps:chain console (log "Saving volume:" saved-volume "muted:" saved-muted)) - - ;; Reset spectrum analyzer if it exists - (when (ps:@ window reset-spectrum-analyzer) - (ps:chain window (reset-spectrum-analyzer))) - - ;; Stop and remove old audio - (ps:chain old-audio (pause)) - (setf (ps:@ old-audio src) "") - (ps:chain old-audio (load)) - - ;; Create new audio element - (let ((new-audio (ps:chain document (create-element "audio")))) - (setf (ps:@ new-audio id) "persistent-audio") - (setf (ps:@ new-audio controls) true) - (setf (ps:@ new-audio preload) "metadata") - (setf (ps:@ new-audio cross-origin) "anonymous") - - ;; Restore volume and muted state - (setf (ps:@ new-audio volume) saved-volume) - (setf (ps:@ new-audio muted) saved-muted) - - ;; Create source - (let ((source (ps:chain document (create-element "source")))) - (setf (ps:@ source id) "audio-source") - (setf (ps:@ source src) (ps:@ config url)) - (setf (ps:@ source type) (ps:@ config type)) - (ps:chain new-audio (append-child source))) - - ;; Replace old audio with new - (ps:chain old-audio (replace-with new-audio)) - - ;; Re-attach event listeners - (attach-audio-listeners new-audio) - - ;; Try to play - reset flag so error handler can catch failures - (setf *is-reconnecting* false) - (set-timeout - (lambda () - (ps:chain new-audio (play) - (then (lambda () - (ps:chain console (log "Reconnected successfully")) - (show-status "✓ Reconnected!" false) - ;; Reinitialize spectrum analyzer - (when (ps:@ window init-spectrum-analyzer) - (set-timeout (lambda () - (ps:chain window (init-spectrum-analyzer))) - 500)) - ;; Also try in content frame - (set-timeout - (lambda () - (ps:try - (let ((content-frame (ps:@ (ps:@ window parent) frames "content-frame"))) - (when (and content-frame (ps:@ content-frame init-spectrum-analyzer)) - (when (ps:@ content-frame reset-spectrum-analyzer) - (ps:chain content-frame (reset-spectrum-analyzer))) - (ps:chain content-frame (init-spectrum-analyzer)) - (ps:chain console (log "Spectrum analyzer reinitialized in content frame")))) - (:catch (e) - (ps:chain console (log "Could not reinit spectrum in content frame:" e))))) - 600))) - (catch (lambda (err) - (ps:chain console (log "Reconnect play failed:" err)) - ;; Retry with exponential backoff - (incf *stream-error-count*) - (if (< *stream-error-count* 5) - (let ((delay (* 2000 *stream-error-count*))) - (show-status (+ "⚠️ Reconnect failed, retrying in " (/ delay 1000) "s...") true) - (setf *is-reconnecting* false) - (setf *reconnect-timeout* - (set-timeout (lambda () (reconnect-stream)) delay))) - (progn - (setf *is-reconnecting* false) - (show-status "❌ Could not reconnect. Click play to try again." true))))))) - 300))))) + (ps:chain console (log "Saving volume:" (ps:@ audio volume) "muted:" (ps:@ audio muted))) + + ;; Reset spectrum analyzer if it exists + (when (ps:@ window reset-spectrum-analyzer) + (ps:chain window (reset-spectrum-analyzer))) + + ;; Reload source on existing element (preserves user gesture context) + (ps:chain audio (pause)) + (if source + ;; Update existing source element + (progn + (setf (ps:@ source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time)))) + (setf (ps:@ source type) (ps:@ config type))) + ;; Create source if missing + (let ((new-source (ps:chain document (create-element "source")))) + (setf (ps:@ new-source id) "audio-source") + (setf (ps:@ new-source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time)))) + (setf (ps:@ new-source type) (ps:@ config type)) + (ps:chain audio (append-child new-source)))) + + ;; Reload and play + (ps:chain audio (load)) + (setf *is-reconnecting* false) + (set-timeout + (lambda () + (ps:chain audio (play) + (catch (lambda (error) + (ps:chain console (log "Reconnect play failed:" error)))))) + 200))) ;; Simple reconnect for popout player (just reload and play) (defun simple-reconnect (audio-element)