radieo/stream/index.html
Pierre-Olivier Mercier ef1a19504e
All checks were successful
continuous-integration/drone/push Build is passing
stream: add a synthwave CRT scan sweep behind the card
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:06:15 +08:00

504 lines
24 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%);
}
/* Ambiance cathodique synthwave, purement décorative (au-dessus de tout,
mais transparent aux clics). Deux couches superposées :
- .scanlines : fines lignes horizontales fixes, comme la trame d'un
vieux moniteur, à peine visibles ;
- .scanbeam : une bande lumineuse qui balaie l'écran de haut en bas en
boucle, évoquant le rafraîchissement d'un tube cathodique. */
.crt { position: fixed; inset: 0; z-index: 0; pointer-events: none;
overflow: hidden; }
.scanlines {
position: absolute; inset: 0; opacity: .35;
background: repeating-linear-gradient(
180deg, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
rgba(0,0,0,.25) 3px, rgba(0,0,0,0) 4px);
}
.scanbeam {
position: absolute; left: 0; right: 0; top: 0; height: 22vh;
background: linear-gradient(
180deg, rgba(155,140,255,0) 0%,
rgba(155,140,255,.06) 45%, rgba(214,153,255,.14) 50%,
rgba(155,140,255,.06) 55%, rgba(155,140,255,0) 100%);
mix-blend-mode: screen;
animation: scan 16s linear infinite;
}
/* Le faisceau part au-dessus du cadre et repasse sous le bas, pour un
balayage continu sans saut visible. */
@keyframes scan {
0% { transform: translateY(-22vh); }
100% { transform: translateY(100vh); }
}
@media (prefers-reduced-motion: reduce) {
.scanbeam { animation: none; opacity: 0; }
}
.card {
position: relative; z-index: 1;
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; }
/* Onglets « glossy » pour basculer entre file d'attente et historique :
une barre pilule au fond translucide, l'onglet actif surligné par un
dégradé lumineux et un léger reflet en haut pour l'effet vernis. */
.tabs { margin-top: 1.75rem; text-align: left; }
.tab-bar {
display: flex; gap: .35rem; padding: .3rem;
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08);
border-radius: 12px; box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
}
.tab-btn {
flex: 1; position: relative; overflow: hidden; cursor: pointer;
padding: .55rem .7rem; font-size: .7rem; font-weight: 600;
letter-spacing: .15em; text-transform: uppercase; color: #8b849c;
background: transparent; border: 1px solid transparent; border-radius: 9px;
transition: color .2s, background .2s, box-shadow .2s, border-color .2s;
}
.tab-btn:hover { color: #cfc9de; }
.tab-btn.active {
color: #f2f0f7;
background: linear-gradient(180deg, rgba(155,140,255,.45), rgba(123,110,220,.22));
border-color: rgba(155,140,255,.5);
box-shadow: inset 0 1px 0 rgba(255,255,255,.35),
0 4px 14px rgba(123,110,220,.35);
}
/* Reflet vitreux : une bande claire sur la moitié haute de l'onglet actif. */
.tab-btn.active::before {
content: ""; position: absolute; inset: 0 0 50%;
background: linear-gradient(180deg, rgba(255,255,255,.28), transparent);
pointer-events: none;
}
.tab-panel { margin-top: 1rem; text-align: left; }
.tab-panel[hidden] { display: none; }
.history, .queue { text-align: left; }
.history ul, .queue ul { list-style: none; margin: 0; padding: 0; }
.history li, .queue 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, .queue .h-title { color: #e8e4f2; font-size: .92rem; }
.history .h-title a, .queue .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
.history .h-title a:hover, .queue .h-title a:hover { color: #9b8cff; text-decoration: underline; }
.history .h-artist, .queue .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
.history .empty, .queue .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
/* File d'attente : rang discret devant chaque titre à venir. */
.queue li { padding: .45rem 0; }
.queue .q-item { display: flex; align-items: baseline; gap: .6rem; }
.queue .q-num { color: #6b6480; font-size: .8rem; font-variant-numeric: tabular-nums;
min-width: 1.2em; text-align: right; }
.queue .q-meta { flex: 1; min-width: 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>
<div class="crt" aria-hidden="true"><div class="scanlines"></div><div class="scanbeam"></div></div>
<main class="card">
<div class="logo" id="stationName"></div>
<div class="np-label"><span class="dot"></span><span id="npLabel">Connexion en cours</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>
<div class="tabs">
<div class="tab-bar" role="tablist">
<button class="tab-btn active" id="tabHistory" type="button" role="tab" aria-selected="true" aria-controls="panelHistory">Historique</button>
<button class="tab-btn" id="tabQueue" type="button" role="tab" aria-selected="false" aria-controls="panelQueue">File d'attente</button>
</div>
<section class="tab-panel history" id="panelHistory" role="tabpanel" aria-labelledby="tabHistory">
<ul id="historyList"></ul>
</section>
<section class="tab-panel queue" id="panelQueue" role="tabpanel" aria-labelledby="tabQueue" hidden>
<ul id="queueList"></ul>
</section>
</div>
</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.isFinite(Number(s.prefetch)) ? Number(s.prefetch) : -1;
if (prefetch >= 0 && ready >= prefetch) {
bufferFull = true;
npLabel.textContent = "en cours";
} else if (prefetch >= 0) {
npLabel.textContent = `Préchargement ${ready}/${prefetch}`;
} else {
npLabel.textContent = "Connexion en cours";
}
} 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 */ }
}
// File d'attente : les prochains morceaux déjà préchargés par le daemon
// d'ingestion, relayés par le stream via /queue (le plus proche en tête).
// La liste est courte (bornée par PREFETCH) et peut être vide au démarrage.
const queueList = document.getElementById("queueList");
async function pollQueue() {
try {
const r = await fetch("/queue", { cache: "no-store" });
const items = await r.json();
const next = Array.isArray(items) ? items : [];
if (next.length === 0) {
queueList.innerHTML = '<li class="empty">—</li>';
return;
}
queueList.innerHTML = next.map((m, i) => {
const t = escapeHtml((m.title || "").trim() || "—");
const a = (m.artist || "").trim();
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
// Titre cliquable vers la page d'origine (yt-dlp) quand elle existe.
const u = (m.url || "").trim();
const titleHtml = u
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
: t;
const meta = `<div class="q-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
return `<li><div class="q-item"><span class="q-num">${i + 1}</span>${meta}</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(); pollQueue(); }, 900);
});
// Onglets File d'attente / Historique : l'historique est affiché par
// défaut. On bascule l'état actif et la visibilité des panneaux ; les deux
// continuent d'être rafraîchis en arrière-plan, seul l'affichage change.
const tabs = [
{ btn: document.getElementById("tabHistory"), panel: document.getElementById("panelHistory") },
{ btn: document.getElementById("tabQueue"), panel: document.getElementById("panelQueue") },
];
tabs.forEach(({ btn }, i) => {
btn.addEventListener("click", () => {
tabs.forEach(({ btn: b, panel: p }, j) => {
const on = i === j;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
p.hidden = !on;
});
});
});
poll();
pollHistory();
pollQueue();
pollStatus();
setInterval(() => { poll(); pollHistory(); pollQueue(); pollStatus(); }, 5000);
</script>
</body>
</html>