radieo/stream/index.html
Pierre-Olivier Mercier bfa7cc1046 stream: show the source provider of the current track
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:18:00 +08:00

377 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title id="pageTitle"></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center;
justify-content: center; font-family: system-ui, sans-serif;
background: radial-gradient(circle at 30% 20%, #2a2140, #0d0b14 70%);
color: #f2f0f7;
}
/* Fond façon écran de connexion Navidrome : une image aléatoire tirée de
leur galerie, posée derrière la carte avec un voile sombre pour garder
le texte lisible. Le dégradé du body reste visible tant que l'image
n'est pas chargée (ou en cas d'échec réseau). */
.bg {
position: fixed; inset: 0; z-index: -1; background-size: cover;
background-position: center; opacity: 0; transition: opacity .8s ease;
}
.bg.loaded { opacity: 1; }
.bg::after {
content: ""; position: absolute; inset: 0;
background: radial-gradient(circle at 30% 20%, rgba(20,16,34,.72), rgba(13,11,20,.9) 75%);
}
.card {
width: min(90vw, 420px); padding: 2.5rem 2rem;
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08);
border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.45);
backdrop-filter: blur(8px); text-align: center;
}
.logo { font-size: .8rem; letter-spacing: .35em; text-transform: uppercase;
color: #9b8cff; margin-bottom: 1.75rem; }
.np-label { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
color: #7d768f; margin-bottom: .5rem; }
/* Provenance discrète du morceau : d'où vient la piste (OpenSubsonic,
YouTube…). Ton effacé, glissée à la suite de l'état « en cours ». */
.provider { color: #6b6480; }
.provider::before { content: "·"; margin: 0 .35em; color: #4a4560; }
.title { font-size: 1.55rem; font-weight: 650; line-height: 1.25;
word-wrap: break-word; }
.title a { color: inherit; text-decoration: none; transition: color .15s; }
.title a:hover { color: #9b8cff; text-decoration: underline; }
.artist { margin-top: .35rem; font-size: 1.05rem; color: #b8b2c8; }
audio { width: 100%; margin-top: 2rem; }
.share { display: flex; gap: .5rem; margin-top: 1rem; }
.share input {
flex: 1; min-width: 0; padding: .55rem .7rem; font-size: .85rem;
color: #cfc9de; background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.1); border-radius: 10px;
font-family: ui-monospace, monospace;
}
.share button {
padding: .55rem .9rem; font-size: .8rem; font-weight: 600; cursor: pointer;
color: #f2f0f7; background: rgba(155,140,255,.18);
border: 1px solid rgba(155,140,255,.35); border-radius: 10px;
transition: background .15s;
}
.share button:hover { background: rgba(155,140,255,.3); }
.actions { display: flex; gap: .5rem; margin-top: .75rem; }
.actions button, .actions a {
flex: 1; text-align: center; text-decoration: none;
padding: .6rem .9rem; font-size: .85rem; font-weight: 600; cursor: pointer;
color: #f2f0f7; background: rgba(155,140,255,.18);
border: 1px solid rgba(155,140,255,.35); border-radius: 10px;
transition: background .15s;
}
.actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); }
.actions button:disabled { opacity: .5; cursor: default; }
.history { margin-top: 1.75rem; text-align: left; }
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
.history ul { list-style: none; margin: 0; padding: 0; }
.history li { border-top: 1px solid rgba(255,255,255,.06); }
.history .h-row {
display: flex; align-items: center; gap: .6rem; padding: .45rem 0;
}
.history .h-meta { flex: 1; min-width: 0; }
.history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none;
transition: color .15s; }
.history .h-act:hover { color: #9b8cff; }
.history .h-title { color: #e8e4f2; font-size: .92rem; }
.history .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
.history .h-title a:hover { color: #9b8cff; text-decoration: underline; }
.history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: #4ade80; margin-right: .4rem; vertical-align: middle;
box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(74,222,128,.5); }
70% { box-shadow: 0 0 0 10px rgba(74,222,128,0); }
100% { box-shadow: 0 0 0 0 rgba(74,222,128,0); }
}
</style>
</head>
<body>
<div class="bg" id="bg"></div>
<main class="card">
<div class="logo" id="stationName"></div>
<div class="np-label"><span class="dot"></span><span id="npLabel">Préchargement</span><span id="provider" class="provider" hidden></span></div>
<div class="title" id="title"></div>
<div class="artist" id="artist"></div>
<audio id="player" controls autoplay preload="none"></audio>
<div class="actions">
<button id="skipBtn" type="button">⏭ Suivant</button>
<a id="dlBtn" href="/download" download>⬇ Télécharger</a>
</div>
<div class="share">
<input id="streamUrl" type="text" readonly>
<button id="copyBtn" type="button">Copier</button>
</div>
<section class="history">
<h2>Historique</h2>
<ul id="historyList"></ul>
</section>
</main>
<script>
// Nom de la station, modifiable ici (repris dans le logo et le titre de l'onglet).
const STATION_NAME = "Nemu FM";
document.getElementById("stationName").textContent = "◈ " + STATION_NAME;
document.getElementById("pageTitle").textContent = STATION_NAME;
// Fond aléatoire repris de l'écran de connexion Navidrome : on récupère la
// liste de leur galerie (index.yml, CORS ouvert), on tire un nom au hasard
// et on sert la version .webp depuis leur CDN — même logique que Navidrome,
// qui retire l'extension du nom listé. On précharge l'image avant de
// l'afficher pour éviter tout flash, et on ignore silencieusement les
// échecs (le dégradé du body reste alors le fond).
(async () => {
const BASE = "https://www.navidrome.org/images/";
try {
const r = await fetch(BASE + "index.yml", { cache: "no-store" });
const names = (await r.text())
.split("\n")
.map((l) => l.replace(/^\s*-\s*/, "").trim())
.filter(Boolean);
if (!names.length) return;
const name = names[Math.floor(Math.random() * names.length)];
const url = BASE + name.replace(/\.[^.]+$/, "") + ".webp";
const img = new Image();
img.onload = () => {
const bg = document.getElementById("bg");
bg.style.backgroundImage = `url("${url}")`;
bg.classList.add("loaded");
};
img.src = url;
} catch (e) { /* pas de fond : on garde le dégradé */ }
})();
const titleEl = document.getElementById("title");
const artistEl = document.getElementById("artist");
const npLabel = document.getElementById("npLabel");
const providerEl = document.getElementById("provider");
const player = document.getElementById("player");
// Noms d'affichage des providers d'ingestion (champ `origin` du morceau).
// Inconnu → on réutilise tel quel, faute de mieux.
const PROVIDER_NAMES = {
subsonic: "OpenSubsonic",
ytdlp: "YouTube",
listenbrainz: "ListenBrainz",
// Filet de secours : morceau rejoué depuis le cache local (déjà diffusé),
// quand l'ingest n'a rien à proposer (démarrage, panne…).
cache: "le cache local",
};
// Tant que le buffer de préchargement (PREFETCH côté ingest) n'est pas
// rempli, on affiche « Préchargement N/M » plutôt que « en cours ». L'info
// vient du daemon d'ingestion, relayée par le stream via /ingest/status.
// Dès que le buffer est plein (ready >= prefetch), on bascule définitivement
// sur « en cours » : ensuite le buffer se vide et se remplit en continu, ce
// n'est plus un état de démarrage à signaler.
let bufferFull = false;
async function pollStatus() {
if (bufferFull) return;
try {
const r = await fetch("/ingest/status", { cache: "no-store" });
const s = await r.json();
const ready = Number(s.ready) || 0;
const prefetch = Number(s.prefetch) || 0;
if (prefetch > 0 && ready >= prefetch) {
bufferFull = true;
npLabel.textContent = "en cours";
} else {
npLabel.textContent = `Préchargement ${ready}/${prefetch || "…"}`;
}
} catch (e) { /* keep last known label */ }
}
// Flux « live » : un paramètre anti-cache force le navigateur à se
// (re)connecter au direct au lieu de rejouer un buffer périmé.
const liveUrl = () => "/radio.mp3?t=" + Date.now();
function goLive() {
player.src = liveUrl();
player.load();
player.play().catch(() => {});
}
goLive();
// Reconnexion automatique : si le flux se coupe (serveur redémarré, réseau
// perdu…), on retente avec un délai qui double à chaque échec, plafonné à
// 30 s, pour ne pas marteler un serveur encore indisponible. Le compteur
// repart à zéro dès qu'on rejoue effectivement du son.
const RECONNECT_MIN = 1000, RECONNECT_MAX = 30000;
let reconnectDelay = RECONNECT_MIN;
let reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer || player.paused) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
goLive();
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
}, reconnectDelay);
}
player.addEventListener("error", scheduleReconnect);
player.addEventListener("ended", scheduleReconnect);
player.addEventListener("stalled", scheduleReconnect);
player.addEventListener("playing", () => {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
reconnectDelay = RECONNECT_MIN;
});
// Volume persistant : on restaure le dernier réglage de l'utilisateur,
// avec 40% comme valeur par défaut au tout premier lancement.
const VOLUME_KEY = "radieo-volume";
const savedVolume = parseFloat(localStorage.getItem(VOLUME_KEY));
player.volume = Number.isFinite(savedVolume) ? Math.min(1, Math.max(0, savedVolume)) : 0.4;
player.addEventListener("volumechange", () => {
localStorage.setItem(VOLUME_KEY, player.volume);
});
// Reprendre après une pause = revenir au direct, pas au point bufferisé.
let wasPaused = false;
player.addEventListener("pause", () => { wasPaused = true; });
player.addEventListener("play", () => {
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()));
// « Suivant » saute le morceau courant. « Précédent » le rejoue depuis le
// début, pour toute l'antenne (flux partagé, pas de position par
// auditeur). Brancher les deux est aussi nécessaire sous Android (Chrome),
// qui traite précédent/suivant comme une paire et masque le bouton suivant
// si seul « nexttrack » est défini.
safe(() => media.setActionHandler("nexttrack", () => { skipBtn.click(); }));
safe(() => media.setActionHandler("previoustrack", () => {
fetch("/restart-track", { method: "POST" })
.then(() => setTimeout(() => { poll(); pollHistory(); }, 900))
.catch(() => {});
}));
// 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" });
const m = await r.json();
const t = (m.title || "").trim();
const a = (m.artist || "").trim();
// Le titre devient un lien vers la page d'origine (yt-dlp) quand elle
// est connue ; sinon simple texte.
const u = (m.url || "").trim();
const label = t || "—";
titleEl.innerHTML = u
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
: escapeHtml(label);
artistEl.textContent = a;
// Provenance discrète : nom lisible du provider, masqué s'il est absent.
const origin = (m.origin || "").trim();
if (origin) {
providerEl.textContent = "via " + (PROVIDER_NAMES[origin] || origin);
providerEl.hidden = false;
} else {
providerEl.hidden = true;
}
document.title = t ? (a ? `${t}${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
updateMediaSession(t, a);
} catch (e) { /* keep last known values */ }
}
// Historique des titres passés. Le premier élément renvoyé est le morceau
// en cours (déjà affiché plus haut), on ne montre donc que les précédents.
const historyList = document.getElementById("historyList");
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
async function pollHistory() {
try {
const r = await fetch("/history", { cache: "no-store" });
const items = await r.json();
const past = Array.isArray(items) ? items.slice(1) : [];
if (past.length === 0) {
historyList.innerHTML = '<li class="empty">—</li>';
return;
}
historyList.innerHTML = past.map((m) => {
const t = escapeHtml((m.title || "").trim() || "—");
const a = (m.artist || "").trim();
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
// Le titre porte le lien vers la page d'origine (yt-dlp) quand elle
// existe ; le téléchargement reste une action distincte à côté.
const u = (m.url || "").trim();
const titleHtml = u
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
: t;
const meta = `<div class="h-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
const f = (m.file || "").trim();
const dl = f
? `<a class="h-act" href="/download?file=${encodeURIComponent(f)}" download title="Télécharger">⬇</a>`
: "";
return `<li><div class="h-row">${meta}${dl}</div></li>`;
}).join("");
} catch (e) { /* keep last known values */ }
}
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
const shareUrl = location.origin + "/radio.mp3";
const urlEl = document.getElementById("streamUrl");
const copyBtn = document.getElementById("copyBtn");
urlEl.value = shareUrl;
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch (e) {
urlEl.select();
document.execCommand("copy");
}
const prev = copyBtn.textContent;
copyBtn.textContent = "Copié !";
setTimeout(() => { copyBtn.textContent = prev; }, 1500);
});
// Passer au morceau suivant.
const skipBtn = document.getElementById("skipBtn");
skipBtn.addEventListener("click", async () => {
skipBtn.disabled = true;
try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ }
// Laisser le temps à la bascule, puis rafraîchir l'affichage.
setTimeout(() => { skipBtn.disabled = false; poll(); pollHistory(); }, 900);
});
poll();
pollHistory();
pollStatus();
setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000);
</script>
</body>
</html>