All checks were successful
continuous-integration/drone/push Build is passing
Wire the Media Session previous-track control to a new POST /restart-track route that requeues the current song from the start for every listener, and keep next-track as skip. Exposing both handlers also makes Android (Chrome) show the skip button, which it hides when only nexttrack is set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
16 KiB
HTML
353 lines
16 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; }
|
|
.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></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 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(); }
|
|
});
|
|
|
|
// 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;
|
|
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) => (
|
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|