Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/viewer-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1383,7 +1383,7 @@ ${visitorMessages.BOX_CSS}${showButtons.CSS}</style>
<script>window.__SHOWPILOT__ = ${JSON.stringify(bootstrap)};</script>
<script src="/socket.io/socket.io.js"></script>
${cfg.debug_mic_measure === 1 ? '<script src="/sp-mic.js?v=4"></script>' : ''}
<script src="/rf-compat.js?v=154"></script>
<script src="/rf-compat.js?v=155"></script>
`;
if (html.includes('</body>')) {
html = html.replace('</body>', injection + '</body>');
Expand Down
189 changes: 186 additions & 3 deletions public/rf-compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -4560,6 +4560,9 @@
// a user gesture on iOS, which would break the resume-on-restart
// flow.
try { stopAudio(); } catch {}
// Stay awake for a while (a short stop between shows), then let
// the phone sleep.
try { scheduleKeepAwakeStop(); } catch {}

// Hide the player content. notPlayingMsg has flex:1 so it takes
// the textCol's place. Close (×) stays visible.
Expand All @@ -4582,6 +4585,7 @@
minBtn.style.display = '';
if (ltBtn) ltBtn.style.display = '';
panel.classList.remove('sp-not-playing');
try { setKeepAwake(!userPaused); } catch {}
// If the user has the panel open when the show resumes, get audio
// going. If audioCtx already exists (panel was opened during a
// prior playing window), a syncOnce() picks up the new track.
Expand Down Expand Up @@ -4678,6 +4682,162 @@
let _pendingGestureAudioCtx = null;
let gainNode = null;
let isMuted = false;

// ---- Keep the page awake with the screen off (Android) ----
// Chrome slows or pauses the code of a page you can't see once the tab
// stops playing sound, and the silent gap between songs counts as not
// playing. The code that starts the next song then doesn't run until
// the screen is turned back on. The -80 dBFS keep-alive level below
// (which keeps the speaker stream open) is under the level Chrome counts
// as sound (about -72 dBFS), so while the player is open and not paused
// the same constant level is raised to -60 dBFS: still a steady DC level
// no speaker can reproduce, but enough for Chrome to keep the tab
// running. It drops back to -80 dBFS on Pause, and 10 minutes after the
// show stops, so a phone left on the page can sleep.
// ?keepawake=0 turns this off; ?keepawake=1 turns it on off Android.
const SP_KEEPALIVE_QUIET = 0.0001; // -80 dBFS
const SP_KEEPALIVE_AWAKE = 0.001; // -60 dBFS
const SP_KEEPAWAKE_AFTER_STOP_MS = 10 * 60 * 1000;
let keepAliveSrc = null;
let keepAwakeStopTimer = null;
function spKeepAwakeOn() {
let q = null;
try { q = new URLSearchParams(window.location.search).get('keepawake'); } catch (_) {}
if (q === '0') return false;
if (q === '1') return true;
return /Android/i.test(navigator.userAgent || '');
}
function setKeepAwake(on) {
if (keepAwakeStopTimer) { clearTimeout(keepAwakeStopTimer); keepAwakeStopTimer = null; }
try { if (on) spAnchorPlay(); else spAnchorPause(); } catch (_) {}
if (!keepAliveSrc || !audioCtx) return;
const level = (on && spKeepAwakeOn()) ? SP_KEEPALIVE_AWAKE : SP_KEEPALIVE_QUIET;
// A short ramp, so the level change can't click on analog outputs.
try { keepAliveSrc.offset.setTargetAtTime(level, audioCtx.currentTime, 0.05); }
catch (_) { try { keepAliveSrc.offset.value = level; } catch (_) {} }
try { if (spVerbose) console.log('[ShowPilot] keep awake:', level === SP_KEEPALIVE_AWAKE ? 'on (-60 dBFS)' : 'off (-80 dBFS)'); } catch (_) {}
}
// Let the phone sleep if the show stays stopped.
function scheduleKeepAwakeStop() {
if (keepAwakeStopTimer) clearTimeout(keepAwakeStopTimer);
keepAwakeStopTimer = setTimeout(() => {
keepAwakeStopTimer = null;
setKeepAwake(false);
}, SP_KEEPAWAKE_AFTER_STOP_MS);
}

// ---- Media Session: notification and lock-screen controls ----
// Browsers only show the media notification and lock-screen controls
// for a playing <audio>/<video> element. The player's sound comes from
// Web Audio, which doesn't count, so while listening a looping <audio>
// element of pure silence plays alongside it (the "anchor") and
// navigator.mediaSession carries the song's title, artist and cover,
// plus Play and Pause. The anchor carries no music, so sync is
// unchanged. It follows the keep-awake state: playing while listening,
// paused on Pause and 10 minutes after the show stops.
// On by default where the browser has Media Session, except iPhone and
// iPad for now (not tested there). ?mediasession=0 turns it off;
// ?mediasession=1 turns it on anywhere.
let spAnchor = null;
let spAnchorUrl = null;
let spMsReady = false;
function spMediaSessionOn() {
let q = null;
try { q = new URLSearchParams(window.location.search).get('mediasession'); } catch (_) {}
if (q === '0') return false;
const has = ('mediaSession' in navigator) && typeof Audio !== 'undefined';
if (q === '1') return has;
const ua = navigator.userAgent || '';
const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
return has && !ios;
}
// 10 s of 16-bit mono silence as a WAV, made once.
function spSilentWavUrl() {
if (spAnchorUrl) return spAnchorUrl;
const sr = 8000, n = sr * 10;
const buf = new ArrayBuffer(44 + n * 2);
const v = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
str(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); str(8, 'WAVE');
str(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
str(36, 'data'); v.setUint32(40, n * 2, true);
spAnchorUrl = URL.createObjectURL(new Blob([buf], { type: 'audio/wav' }));
return spAnchorUrl;
}
// Pause from the notification / lock screen. Between songs nothing is
// playing, so the Pause button's own test would read it as Play; set
// the paused state directly instead.
function spPauseFromMediaSession() {
if (userPaused) { spAnchorPause(); return; }
if (currentSource || (htmlAudio && !htmlAudio.paused)) { playBtn.onclick(); return; }
userPaused = true;
trackChangeAt = 0;
setPlayIcon(false);
statusEl.textContent = _pt('Paused');
setKeepAwake(false);
}
function spSetupMediaSession() {
if (spMsReady) return;
spMsReady = true;
const set = (action, fn) => { try { navigator.mediaSession.setActionHandler(action, fn); } catch (_) {} };
set('play', () => { if (userPaused) playBtn.onclick(); else spAnchorPlay(); });
set('pause', () => spPauseFromMediaSession());
// A live show: no seeking or skipping from the phone.
['seekbackward', 'seekforward', 'seekto', 'previoustrack', 'nexttrack'].forEach((a) => set(a, null));
}
// Start (or keep) the anchor playing. The first start has to be inside a
// tap (the Listen button or Play); later starts are allowed because the
// page has been tapped.
function spAnchorPlay() {
if (!spMediaSessionOn() || isAppMode()) return;
try {
if (!spAnchor) {
spAnchor = new Audio();
spAnchor.loop = true;
spAnchor.preload = 'auto';
spAnchor.setAttribute('playsinline', '');
spAnchor.src = spSilentWavUrl();
}
if (spAnchor.paused) {
const p = spAnchor.play();
if (p && p.catch) p.catch((e) => { try { if (spVerbose) console.log('[ShowPilot] media session anchor:', e && e.name); } catch (_) {} });
}
} catch (_) {}
spSetupMediaSession();
try { navigator.mediaSession.playbackState = 'playing'; } catch (_) {}
}
function spAnchorPause() {
try { if (spAnchor && !spAnchor.paused) spAnchor.pause(); } catch (_) {}
if (spMsReady) { try { navigator.mediaSession.playbackState = 'paused'; } catch (_) {} }
}
// Player closed: stop and clear the notification.
function spAnchorStop() {
spAnchorPause();
if (spMsReady) {
try { navigator.mediaSession.metadata = null; } catch (_) {}
try { navigator.mediaSession.playbackState = 'none'; } catch (_) {}
try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (_) {}
}
}
// Song details for the notification. With a length, the notification's
// progress bar follows the song instead of the 10 s anchor.
function spUpdateMediaMetadata(title, artist, imageUrl, durationSec, positionSec) {
if (!spMsReady || typeof MediaMetadata === 'undefined') return;
try {
const artwork = imageUrl ? [{ src: new URL(imageUrl, window.location.href).href }] : [];
navigator.mediaSession.metadata = new MediaMetadata({ title: title || '', artist: artist || '', album: document.title || '', artwork });
} catch (_) {}
try {
if (!navigator.mediaSession.setPositionState) return;
if (durationSec > 0) {
const pos = Math.min(durationSec, Math.max(0, positionSec || 0));
navigator.mediaSession.setPositionState({ duration: durationSec, playbackRate: 1, position: pos });
} else {
navigator.mediaSession.setPositionState();
}
} catch (_) {}
}
let currentBuffer = null; // AudioBuffer of currently-playing track
let currentSource = null; // AudioBufferSourceNode of currently playing
let currentSequence = null;
Expand Down Expand Up @@ -5096,7 +5256,7 @@
measuredDelayMs: outDelayMs,
rawTsGapMs: ts && ts.contextTime > 0 ? +((ctx.currentTime - ts.contextTime) * 1000).toFixed(1) : null,
rawPerfAgeMs: ts && ts.performanceTime > 0 ? +(performance.now() - ts.performanceTime).toFixed(1) : null,
sampleRate: ctx.sampleRate, latencyHint: hint || (spAudioAlt === 'b' ? '12ms' : spAudioAlt === 'a' ? 'interactive' : 'default'), freshStream: spAudioAlt ? 'on (' + spAudioAlt + ')' : 'off', keepAlive: spFreshStreamOn(), state: ctx.state,
sampleRate: ctx.sampleRate, latencyHint: hint || (spAudioAlt === 'b' ? '12ms' : spAudioAlt === 'a' ? 'interactive' : 'default'), freshStream: spAudioAlt ? 'on (' + spAudioAlt + ')' : 'off', keepAlive: spFreshStreamOn(), keepAwake: spKeepAwakeOn(), state: ctx.state,
});
};
tick();
Expand Down Expand Up @@ -5623,6 +5783,8 @@
if ('audioSession' in navigator) {
try { navigator.audioSession.type = 'playback'; } catch {}
}
// Media Session anchor: an <audio> element must start inside a tap.
try { spAnchorPlay(); } catch {}
// If the show isn't currently playing, there's no audio to gate on.
// Skip the location prompt entirely — just open the panel so the
// user sees the "Show isn't playing" message. We'll ask for location
Expand Down Expand Up @@ -5688,12 +5850,15 @@
if (!userPaused && (currentSource || (htmlAudio && !htmlAudio.paused))) {
userPaused = true;
stopAudio();
setKeepAwake(false); // paused on purpose: let the phone sleep
trackChangeAt = 0;
setPlayIcon(false);
statusEl.textContent = _pt('Paused');
return;
}
userPaused = false;
setKeepAwake(true);
if (_showNotPlaying) scheduleKeepAwakeStop();
// Resume inside the tap so iOS allows it.
if (audioCtx && audioCtx.state === 'suspended') {
try { audioCtx.resume().catch(() => {}); } catch {}
Expand Down Expand Up @@ -5894,14 +6059,27 @@
// report, and the switch makes the audio clock jump. A tiny constant
// level (-80 dBFS DC, which speakers can't reproduce) means the
// output is never silent, so the stream stays up all show.
if (spFreshStreamOn() && audioCtx.createConstantSource) {
// The same source keeps the page awake with the screen off while
// listening (see setKeepAwake). An earlier one is stopped first, so
// a second startup() on the same context doesn't stack levels.
if (keepAliveSrc) {
try { keepAliveSrc.stop(); } catch (_) {}
try { keepAliveSrc.disconnect(); } catch (_) {}
keepAliveSrc = null;
}
if ((spFreshStreamOn() || spKeepAwakeOn()) && audioCtx.createConstantSource) {
try {
const keepAlive = audioCtx.createConstantSource();
keepAlive.offset.value = 0.0001;
keepAlive.offset.value = SP_KEEPALIVE_QUIET;
keepAlive.connect(audioCtx.destination);
keepAlive.start();
keepAliveSrc = keepAlive;
} catch (_) {}
}
// Opened before the show starts: stay awake for a while so the
// first song can start with the screen off.
setKeepAwake(!userPaused);
if (_showNotPlaying) scheduleKeepAwakeStop();
// v0.33.203: log device-driven state changes (e.g. Android pausing
// audio on a Bluetooth route/focus change) and try to recover.
try {
Expand Down Expand Up @@ -6263,6 +6441,9 @@
}

if (pendingStartTimeout) { clearTimeout(pendingStartTimeout); pendingStartTimeout = null; }
if (keepAwakeStopTimer) { clearTimeout(keepAwakeStopTimer); keepAwakeStopTimer = null; }
keepAliveSrc = null;
try { spAnchorStop(); } catch (_) {}
if (audioCtx) { try { audioCtx.close(); } catch {} audioCtx = null; gainNode = null; }
rateTrim = 0; rateTrimLastCtx = 0;
currentBuffer = null;
Expand Down Expand Up @@ -6488,6 +6669,7 @@
setupMarquee(titleEl, titleWrap);
setupMarquee(artistEl, artistWrap);
}
spUpdateMediaMetadata(titleEl.textContent, artistEl.textContent, art, 0, 0);
// Song changes are noticed by name; forget the last one so a song
// with audio after this one loads normally.
if (data.playing && data.sequenceName) { currentSequence = null; }
Expand Down Expand Up @@ -6623,6 +6805,7 @@
setupMarquee(titleEl, titleWrap);
setupMarquee(artistEl, artistWrap);
setCover(data.imageUrl);
spUpdateMediaMetadata(titleEl.textContent, artistEl.textContent, data.imageUrl, trackDuration, data.elapsedSec);
// v0.33.246: listener paused — show the new song's details but don't
// load or play it. Play rebuilds from scratch (currentSequence reset).
if (userPaused) {
Expand Down