radieo/stream/index.html
Pierre-Olivier Mercier a1f7fc29b3
All checks were successful
continuous-integration/drone/push Build is passing
stream: name a yt-dlp track's provider from its source domain
yt-dlp pulls from many sites, so a fixed "YouTube" label was wrong for
bandcamp, soundcloud, etc. Derive the provider name from the source page's
host instead (www.youtube.com -> YouTube, *.bandcamp.com -> Bandcamp),
falling back to the bare host for unmapped sites. Other origins keep their
fixed PROVIDER_NAMES labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:39:12 +08:00

729 lines
35 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">
<!-- Application installable (PWA). Le manifeste décrit nom, icônes et mode
d'affichage ; les balises Apple font l'équivalent sur iOS, qui n'exploite
pas le manifeste pour l'ajout à l'écran d'accueil. La couleur de thème
teinte la barre système en mode autonome. -->
<link rel="manifest" href="/manifest.webmanifest" id="manifestLink">
<meta name="theme-color" content="#0d0b14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="" id="appleTitle">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<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; }
/* Retrait d'un morceau à venir : croix discrète, virant au rouge tamisé au
survol pour signaler l'action destructive. Bouton nu, aligné sur le titre. */
.queue .q-act { color: #7d768f; font-size: .95rem; line-height: 1;
background: none; border: 0; padding: 0; cursor: pointer;
transition: color .15s; }
.queue .q-act:hover { color: #e08b9b; }
.queue .q-act:disabled { opacity: .5; cursor: default; }
/* Ajout à la file : un input d'URL + bouton, calqués sur le bloc « share ».
Le message de retour s'affiche discrètement sous le formulaire, en rouge
tamisé quand c'est une erreur. */
.enqueue { display: flex; gap: .5rem; margin-bottom: .75rem; }
.enqueue 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;
}
.enqueue 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;
}
.enqueue button:hover { background: rgba(155,140,255,.3); }
.enqueue button:disabled { opacity: .5; cursor: default; }
.enqueue-msg { font-size: .8rem; color: #8ad9a0; margin-bottom: .5rem; }
.enqueue-msg.err { color: #e08b9b; }
.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>
<form class="enqueue" id="enqueueForm">
<input id="enqueueUrl" type="url" inputmode="url"
placeholder="URL yt-dlp (piste, playlist ou album)…" required>
<button type="submit" id="enqueueBtn">Ajouter</button>
</form>
<div class="enqueue-msg" id="enqueueMsg" hidden></div>
<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;
// Le manifeste statique reste générique (« Radieo ») pour être réutilisable ;
// on le régénère ici à partir de STATION_NAME, l'unique réglage de la station,
// afin que l'app installée porte le nom de l'instance. Le manifeste est servi
// en data URL, donc toutes les URLs qu'il contient doivent être absolues.
(async () => {
try {
const link = document.getElementById("manifestLink");
const base = await fetch(link.href, { cache: "no-store" }).then((r) => r.json());
const abs = (p) => new URL(p, location.origin).href;
const manifest = {
...base,
name: STATION_NAME,
short_name: STATION_NAME,
start_url: abs(base.start_url || "/"),
scope: abs(base.scope || "/"),
icons: (base.icons || []).map((ic) => ({ ...ic, src: abs(ic.src) })),
};
const blob = new Blob([JSON.stringify(manifest)], { type: "application/manifest+json" });
link.href = URL.createObjectURL(blob);
} catch (e) { /* on garde le manifeste statique générique */ }
// iOS n'exploite pas le manifeste : on lui donne le nom via sa balise dédiée.
document.getElementById("appleTitle").setAttribute("content", STATION_NAME);
})();
// Enregistrement du service worker : condition pour que la webapp soit
// « installable » sur mobile (Android/Chrome) et pour ouvrir la fenêtre
// autonome depuis le cache. Échec silencieux si non supporté.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
// 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. yt-dlp est absent
// volontairement : il pioche sur de nombreux sites, donc on nomme sa
// provenance d'après le domaine de la page source (voir hostLabel).
const PROVIDER_NAMES = {
subsonic: "OpenSubsonic",
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",
// Morceau mis en file par un auditeur via l'input « Ajouter à la file ».
request: "une demande",
};
// Noms lisibles par domaine, pour les morceaux yt-dlp dont l'origine réelle
// est le site de la page source (www.youtube.com → YouTube). La clé est le
// domaine enregistrable ; les sous-domaines (artiste.bandcamp.com) sont
// ramenés à cette clé par hostLabel.
const HOST_NAMES = {
"youtube.com": "YouTube",
"youtu.be": "YouTube",
"bandcamp.com": "Bandcamp",
"soundcloud.com": "SoundCloud",
"vimeo.com": "Vimeo",
"dailymotion.com": "Dailymotion",
"mixcloud.com": "Mixcloud",
};
// Nom d'affichage d'un hôte : on retire le « www. » puis on remonte de
// sous-domaine en sous-domaine (foo.bar.bandcamp.com → bar.bandcamp.com →
// bandcamp.com) pour retrouver une entrée connue ; à défaut, on renvoie
// l'hôte nu, qui reste une provenance lisible.
function hostLabel(host) {
host = host.replace(/^www\./, "");
let h = host;
while (h) {
if (HOST_NAMES[h]) return HOST_NAMES[h];
const dot = h.indexOf(".");
if (dot < 0) break;
h = h.slice(dot + 1);
}
return host;
}
// 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"; });
}
// --- Scrobbling ListenBrainz ------------------------------------------
// Le serveur détient le token (dans .env) ; le player ne fait que décider
// QUAND un morceau est vraiment « écouté » et le déclencher. Règle : pris
// ~au début (position serveur < 10 s à la découverte) ET réellement écouté
// ≥ 90 % de sa durée (plafonné à 4 min, comme la règle ListenBrainz). Un
// morceau qu'on « next » ou qu'on rejoint en cours n'est donc pas scrobblé.
const SCROBBLE_START_MAX = 10; // s : position max au moment de la découverte
const SCROBBLE_MIN_RATIO = 0.9; // fraction de la durée à écouter
const SCROBBLE_CAP = 240; // s : plafond (4 min) pour les longs morceaux
let sc = null; // état du morceau courant vis-à-vis du scrobble
function scrobble(type) {
if (!sc || !sc.file) return;
const q = "/scrobble?file=" + encodeURIComponent(sc.file) +
(type ? "&type=" + type : "");
fetch(q, { method: "POST" }).catch(() => { /* best effort */ });
}
// Cumul du temps RÉELLEMENT écouté : une seconde par tick tant qu'on joue.
// Une pause renvoie au direct via goLive() (on ne rattrape pas le live), donc
// après une pause on n'atteindra pas les 90 % → pas de scrobble abusif.
setInterval(() => {
if (!sc || player.paused || !sc.music) return;
if (!sc.playingNowSent) { sc.playingNowSent = true; scrobble("playing_now"); }
sc.heard += 1;
if (!sc.scrobbled && sc.duration > 0 && sc.startPos < SCROBBLE_START_MAX &&
sc.heard >= Math.min(sc.duration * SCROBBLE_MIN_RATIO, SCROBBLE_CAP)) {
sc.scrobbled = true;
scrobble(); // listen définitif
}
}, 1000);
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.
// yt-dlp pioche sur de nombreux sites : on nomme sa provenance d'après
// le domaine de la page source (u) plutôt qu'un « YouTube » systématique.
const origin = (m.origin || "").trim();
let provider = "";
if (origin === "ytdlp" && u) {
try { provider = hostLabel(new URL(u).hostname); } catch (e) { /* url invalide */ }
} else if (origin) {
provider = PROVIDER_NAMES[origin] || origin;
}
if (provider) {
providerEl.textContent = "via " + provider;
providerEl.hidden = false;
} else {
providerEl.hidden = true;
}
document.title = t ? (a ? `${t}${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
updateMediaSession(t, a);
// Suivi du morceau courant pour le scrobble. Le jeton `file` identifie le
// passage ; on repart de zéro à chaque nouveau titre.
const file = (m.file || "").trim();
const dur = Number(m.duration) || 0;
const pos = Number(m.position) || 0;
if (file && (!sc || sc.file !== file)) {
sc = { file, duration: dur, startPos: pos, heard: 0,
scrobbled: false, playingNowSent: false, music: !!(t && a) };
} else if (sc && sc.file === file) {
// Les métadonnées peuvent se compléter après coup (durée, tags tardifs).
if (!sc.music && t && a) sc.music = true;
if (sc.duration <= 0 && dur > 0) sc.duration = dur;
}
} 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>`;
// Croix de retrait, portant l'id opaque de l'entrée (fourni par
// /queue). Absent (ancien ingest sans id) → pas de bouton.
const id = (m.id || "").toString();
const rm = id
? `<button class="q-act" type="button" data-id="${escapeHtml(id)}" title="Retirer de la file" aria-label="Retirer de la file">✕</button>`
: "";
return `<li><div class="q-item"><span class="q-num">${i + 1}</span>${meta}${rm}</div></li>`;
}).join("");
} catch (e) { /* keep last known values */ }
}
// Retrait d'un morceau de la file : délégation de clic sur la liste. On POST
// l'id au stream (relayé à l'ingest), puis on rafraîchit la file. Un échec
// (entrée déjà passée entre-temps) est simplement ignoré : le prochain
// rafraîchissement remettra l'affichage d'aplomb.
queueList.addEventListener("click", async (e) => {
const btn = e.target.closest(".q-act");
if (!btn) return;
const id = btn.dataset.id;
if (!id) return;
btn.disabled = true;
try {
await fetch("/dequeue?id=" + encodeURIComponent(id), { method: "POST" });
} catch (err) { /* ignore */ }
pollQueue();
});
// Ajout à la file : on POST l'URL au stream, qui la relaie à l'ingest. Ce
// dernier résout l'URL (piste seule, ou playlist/album entier) et la place
// en file prioritaire — le prochain morceau diffusé sera la demande. On
// affiche un retour bref puis on rafraîchit la file. La résolution d'une
// grosse playlist peut prendre quelques secondes.
const enqueueForm = document.getElementById("enqueueForm");
const enqueueUrl = document.getElementById("enqueueUrl");
const enqueueBtn = document.getElementById("enqueueBtn");
const enqueueMsg = document.getElementById("enqueueMsg");
function showEnqueueMsg(text, ok) {
enqueueMsg.textContent = text;
enqueueMsg.classList.toggle("err", !ok);
enqueueMsg.hidden = false;
}
enqueueForm.addEventListener("submit", async (e) => {
e.preventDefault();
const link = enqueueUrl.value.trim();
if (!link) return;
const prev = enqueueBtn.textContent;
enqueueBtn.disabled = true;
enqueueBtn.textContent = "…";
try {
const r = await fetch("/enqueue?url=" + encodeURIComponent(link), { method: "POST" });
if (r.ok) {
const d = await r.json().catch(() => ({}));
const n = Number(d.queued) || 0;
showEnqueueMsg(n > 1 ? `${n} titres ajoutés à la file` : "1 titre ajouté à la file", true);
enqueueUrl.value = "";
pollQueue();
} else {
showEnqueueMsg("Impossible de résoudre cette URL", false);
}
} catch (err) {
showEnqueueMsg("Erreur réseau", false);
} finally {
enqueueBtn.disabled = false;
enqueueBtn.textContent = prev;
}
});
// 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>