stream: expose OS media controls via the Media Session API
All checks were successful
continuous-integration/drone/push Build is passing

Wire the web player into system media controls (MPRIS, Control Center,
lock screen) and keyboard media keys: push track metadata and handle
play/pause/next, with seek neutralized on the live stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-03 19:17:20 +08:00
commit 622210197f
2 changed files with 37 additions and 0 deletions

View file

@ -32,6 +32,10 @@ simultaneous listeners), not for public broadcasting.
- **Built-in web player** at `http://localhost:8000/`: now playing (linked to
its source page), track history, skip button, per-track download, volume
memory, live auto-reconnect, prefetch progress, and a synthwave look.
- **OS media controls**: the player exposes current track metadata and
play/pause/next through the Media Session API, so it wires into system media
controls (MPRIS on Linux, macOS Control Center, Windows, mobile lock screen)
and keyboard media keys.
- **Robust in a container**: Docker healthcheck, graceful shutdown, retries on
transient HTTP errors.

View file

@ -225,6 +225,38 @@
if (wasPaused) { wasPaused = false; goLive(); }
});
// Intégration aux contrôles média du système (MPRIS sous Linux, centre de
// contrôle macOS/Windows, écran verrouillé mobile, touches multimédia du
// clavier) via la Media Session API. On expose les métadonnées du morceau
// courant et on branche play / pause / suivant sur les mêmes actions que
// l'interface. Absente sur quelques navigateurs : on garde un no-op.
const media = navigator.mediaSession;
function updateMediaSession(title, artist) {
if (!media || !window.MediaMetadata) return;
media.metadata = new MediaMetadata({
title: title || STATION_NAME,
artist: artist || "",
album: STATION_NAME,
artwork: [{ src: "/favicon.svg", type: "image/svg+xml" }],
});
}
if (media) {
const safe = (fn) => { try { fn(); } catch (e) { /* action non supportée */ } };
safe(() => media.setActionHandler("play", () => player.play().catch(() => {})));
safe(() => media.setActionHandler("pause", () => player.pause()));
safe(() => media.setActionHandler("stop", () => player.pause()));
// Pas de reprise possible sur un direct : « précédent » comme « suivant »
// passent au morceau suivant côté serveur.
safe(() => media.setActionHandler("nexttrack", () => { skipBtn.click(); }));
// Le direct n'est pas déplaçable : on neutralise les actions de seek pour
// éviter que le système n'affiche des boutons d'avance/recul inopérants.
safe(() => media.setActionHandler("seekbackward", null));
safe(() => media.setActionHandler("seekforward", null));
safe(() => media.setActionHandler("seekto", null));
player.addEventListener("playing", () => { media.playbackState = "playing"; });
player.addEventListener("pause", () => { media.playbackState = "paused"; });
}
async function poll() {
try {
const r = await fetch("/nowplaying", { cache: "no-store" });
@ -240,6 +272,7 @@
: escapeHtml(label);
artistEl.textContent = a;
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
updateMediaSession(t, a);
} catch (e) { /* keep last known values */ }
}