271 lines
12 KiB
HTML
271 lines
12 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;
|
|
}
|
|
.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><span id="npLabel">Préchargement</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;
|
|
|
|
const titleEl = document.getElementById("title");
|
|
const artistEl = document.getElementById("artist");
|
|
const npLabel = document.getElementById("npLabel");
|
|
const player = document.getElementById("player");
|
|
|
|
// 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(); }
|
|
});
|
|
|
|
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) => (
|
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|