Player revamp: harden crossfade race conditions + global decl (audit fixes)
Self-audit of the revamp surface found real bugs, now fixed: - DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the track actually 'ended' fired, onAudioEnded ALSO advanced — two skips. onAudioEnded now bails when npXfadeActive (crossfade owns the advance). - STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the interval running, firing npFinishCrossfade on top of the manual change, and left the second <audio> playing. Added npCancelCrossfade() (clears the timer, tears down the 2nd audio, restores main volume) called at the top of playQueueItem and in handleStop. The fade interval also self-checks npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the legitimate handoff isn't treated as an abort. - stream_start: moved 'global stream_background_task' to function top (it was declared inside an if-block — parsed, but brittle/bad form). web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance unchanged from HEAD.
This commit is contained in:
parent
a2fe3da839
commit
592b68c16c
2 changed files with 36 additions and 3 deletions
|
|
@ -11787,6 +11787,7 @@ def library_radio():
|
||||||
@app.route('/api/stream/start', methods=['POST'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
"""Start streaming a track in the background (per-listener session)."""
|
"""Start streaming a track in the background (per-listener session)."""
|
||||||
|
global stream_background_task
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"success": False, "error": "No track data provided"}), 400
|
return jsonify({"success": False, "error": "No track data provided"}), 400
|
||||||
|
|
@ -11816,7 +11817,6 @@ def stream_start():
|
||||||
fut = stream_executor.submit(_prepare_stream_task, data, sess, sid)
|
fut = stream_executor.submit(_prepare_stream_task, data, sess, sid)
|
||||||
stream_tasks[sid] = fut
|
stream_tasks[sid] = fut
|
||||||
if sid == _DEFAULT_STREAM_SESSION:
|
if sid == _DEFAULT_STREAM_SESSION:
|
||||||
global stream_background_task
|
|
||||||
stream_background_task = fut # keep legacy alias in sync
|
stream_background_task = fut # keep legacy alias in sync
|
||||||
|
|
||||||
return jsonify({"success": True, "message": "Streaming started"})
|
return jsonify({"success": True, "message": "Streaming started"})
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,8 @@ async function handlePlayPause() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
|
// Tear down any in-flight crossfade so its second audio doesn't keep playing.
|
||||||
|
npCancelCrossfade();
|
||||||
// Use new streaming system stop function
|
// Use new streaming system stop function
|
||||||
await stopStream();
|
await stopStream();
|
||||||
clearTrack();
|
clearTrack();
|
||||||
|
|
@ -1206,6 +1208,11 @@ function onAudioEnded() {
|
||||||
currentTimeElement.textContent = '0:00';
|
currentTimeElement.textContent = '0:00';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a crossfade is mid-flight it OWNS the advance to the next track —
|
||||||
|
// bail so we don't double-advance (crossfade's npFinishCrossfade →
|
||||||
|
// playQueueItem already handles it).
|
||||||
|
if (npXfadeActive) return;
|
||||||
|
|
||||||
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
|
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
|
||||||
// Auto-advance to next track if queue has a next item (guard against race conditions)
|
// Auto-advance to next track if queue has a next item (guard against race conditions)
|
||||||
if (npQueue.length > 0 && !npLoadingQueueItem) {
|
if (npQueue.length > 0 && !npLoadingQueueItem) {
|
||||||
|
|
@ -1833,6 +1840,21 @@ function npPunchUpColor(r, g, b) {
|
||||||
const NP_CROSSFADE_SECONDS = 6;
|
const NP_CROSSFADE_SECONDS = 6;
|
||||||
let npXfadeAudio = null;
|
let npXfadeAudio = null;
|
||||||
let npXfadeActive = false;
|
let npXfadeActive = false;
|
||||||
|
let npXfadeTimer = null;
|
||||||
|
let npXfadeMainVol = null; // main-player volume to restore if a crossfade is aborted
|
||||||
|
|
||||||
|
// Abort an in-flight crossfade (manual skip / stop during the fade). Restores
|
||||||
|
// the main player's volume and tears down the second audio element. Safe to
|
||||||
|
// call when no crossfade is active (no-op).
|
||||||
|
function npCancelCrossfade() {
|
||||||
|
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
|
||||||
|
if (npXfadeAudio) { try { npXfadeAudio.pause(); } catch (_) {} npXfadeAudio.src = ''; npXfadeAudio.volume = 0; npXfadeAudio = null; }
|
||||||
|
if (npXfadeActive && audioPlayer && npXfadeMainVol !== null) {
|
||||||
|
audioPlayer.volume = npXfadeMainVol; // undo any partial fade-down
|
||||||
|
}
|
||||||
|
npXfadeActive = false;
|
||||||
|
npXfadeMainVol = null;
|
||||||
|
}
|
||||||
|
|
||||||
function npCrossfadeTick() {
|
function npCrossfadeTick() {
|
||||||
if (!npCrossfadeOn || npXfadeActive || npRepeatMode === 'one') return;
|
if (!npCrossfadeOn || npXfadeActive || npRepeatMode === 'one') return;
|
||||||
|
|
@ -1856,6 +1878,7 @@ function npStartCrossfade(nextIdx, next) {
|
||||||
npXfadeAudio = xa;
|
npXfadeAudio = xa;
|
||||||
|
|
||||||
const targetVol = audioPlayer.volume; // fade the new track up to current level
|
const targetVol = audioPlayer.volume; // fade the new track up to current level
|
||||||
|
npXfadeMainVol = targetVol; // remember to restore on abort
|
||||||
xa.src = `/stream/library-audio?path=${encodeURIComponent(next.file_path)}&t=${Date.now()}`;
|
xa.src = `/stream/library-audio?path=${encodeURIComponent(next.file_path)}&t=${Date.now()}`;
|
||||||
xa.volume = 0;
|
xa.volume = 0;
|
||||||
xa.play().then(() => {
|
xa.play().then(() => {
|
||||||
|
|
@ -1864,13 +1887,16 @@ function npStartCrossfade(nextIdx, next) {
|
||||||
const steps = Math.max(1, Math.floor(fadeMs / step));
|
const steps = Math.max(1, Math.floor(fadeMs / step));
|
||||||
let n = 0;
|
let n = 0;
|
||||||
const startOutVol = audioPlayer.volume;
|
const startOutVol = audioPlayer.volume;
|
||||||
const timer = setInterval(() => {
|
npXfadeTimer = setInterval(() => {
|
||||||
|
// A manual skip/stop may have cancelled us mid-fade.
|
||||||
|
if (!npXfadeActive) { clearInterval(npXfadeTimer); npXfadeTimer = null; return; }
|
||||||
n++;
|
n++;
|
||||||
const t = Math.min(1, n / steps);
|
const t = Math.min(1, n / steps);
|
||||||
audioPlayer.volume = Math.max(0, startOutVol * (1 - t));
|
audioPlayer.volume = Math.max(0, startOutVol * (1 - t));
|
||||||
xa.volume = Math.min(targetVol, targetVol * t);
|
xa.volume = Math.min(targetVol, targetVol * t);
|
||||||
if (t >= 1) {
|
if (t >= 1) {
|
||||||
clearInterval(timer);
|
clearInterval(npXfadeTimer);
|
||||||
|
npXfadeTimer = null;
|
||||||
npFinishCrossfade(nextIdx, targetVol);
|
npFinishCrossfade(nextIdx, targetVol);
|
||||||
}
|
}
|
||||||
}, step);
|
}, step);
|
||||||
|
|
@ -1879,6 +1905,7 @@ function npStartCrossfade(nextIdx, next) {
|
||||||
// the normal 'ended' hard-cut advance handle it.
|
// the normal 'ended' hard-cut advance handle it.
|
||||||
npXfadeActive = false;
|
npXfadeActive = false;
|
||||||
npXfadeAudio = null;
|
npXfadeAudio = null;
|
||||||
|
npXfadeMainVol = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1890,6 +1917,8 @@ function npFinishCrossfade(nextIdx, restoreVol) {
|
||||||
if (xa) { try { xa.pause(); } catch (_) {} xa.src = ''; xa.volume = 0; }
|
if (xa) { try { xa.pause(); } catch (_) {} xa.src = ''; xa.volume = 0; }
|
||||||
npXfadeAudio = null;
|
npXfadeAudio = null;
|
||||||
npXfadeActive = false;
|
npXfadeActive = false;
|
||||||
|
npXfadeMainVol = null;
|
||||||
|
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
|
||||||
if (audioPlayer) audioPlayer.volume = restoreVol;
|
if (audioPlayer) audioPlayer.volume = restoreVol;
|
||||||
// playQueueItem re-points stream_state + reloads audioPlayer for the next
|
// playQueueItem re-points stream_state + reloads audioPlayer for the next
|
||||||
// track; there's a brief silent reload, but the perceived crossfade already
|
// track; there's a brief silent reload, but the perceived crossfade already
|
||||||
|
|
@ -2145,6 +2174,10 @@ function playPreviousInQueue() {
|
||||||
async function playQueueItem(index) {
|
async function playQueueItem(index) {
|
||||||
if (index < 0 || index >= npQueue.length) return;
|
if (index < 0 || index >= npQueue.length) return;
|
||||||
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
|
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
|
||||||
|
// Manual skip / row-click during a crossfade: tear down the stray fade so it
|
||||||
|
// can't fire npFinishCrossfade on top of this change. No-op for the
|
||||||
|
// legitimate handoff (npFinishCrossfade already cleared the flag first).
|
||||||
|
npCancelCrossfade();
|
||||||
npLoadingQueueItem = true;
|
npLoadingQueueItem = true;
|
||||||
npQueueIndex = index;
|
npQueueIndex = index;
|
||||||
const track = npQueue[index];
|
const track = npQueue[index];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue