radieo/stream/index.html
Pierre-Olivier Mercier c12e522fee stream: link the now-playing title to its source page
For yt-dlp tracks the locator is the original web page, so pass it as a
url annotation, carry it through the stream metadata and history, and
turn the track title into a link back to that page (both live and in the
history). Subsonic ids are opaque and stay plain text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:14:50 +08:00

245 lines
11 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>
<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;
}
.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; }
.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>
<main class="card">
<div class="logo" id="stationName"></div>
<div class="np-label"><span class="dot"></span>en cours</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;
const titleEl = document.getElementById("title");
const artistEl = document.getElementById("artist");
const player = document.getElementById("player");
// 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(); }
});
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;
document.title = t ? (a ? `${t}${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
} 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();
setInterval(() => { poll(); pollHistory(); }, 5000);
</script>
</body>
</html>