diff --git a/README.md b/README.md index b9cc519..db7aae0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/stream/index.html b/stream/index.html index 47b515f..a9426d2 100644 --- a/stream/index.html +++ b/stream/index.html @@ -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 */ } }