Some checks failed
continuous-integration/drone/push Build is failing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
601 lines
28 KiB
HTML
601 lines
28 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; }
|
|
/* 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;
|
|
|
|
// 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",
|
|
// Morceau mis en file par un auditeur via l'input « Ajouter à la file ».
|
|
request: "une demande",
|
|
};
|
|
|
|
// 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) => (
|
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|