Files
Noxely/server/noxely_server/web/index.html
T
tuxgyverandClaude Sonnet 5 5b5e90247f Onglet Radio sur le serveur, bouton de synchronisation sur le mobile (2.5.0)
Le serveur gagne un onglet Radio : les stations de l'annuaire, écoutables en
direct dans le navigateur ou enregistrables — jusqu'à l'arrêt ou pour une durée
choisie —, puis réécoutables depuis l'interface. Quatre enregistrements en
parallèle au plus ; si le flux tombe, il est rouvert et le fichier continue.
Les enregistrements rejoignent la bibliothèque : le mobile les reçoit.

Un redémarrage du service clôt proprement les captures en cours, et celles
restées « en cours » après un plantage sont récupérées au démarrage suivant.
La copie de flux sans blocs ICY, propre au guet, est extraite pour être
partagée.

Côté mobile, la synchronisation manuelle n'était qu'un petit lien dans l'onglet
Connexion : elle a maintenant un vrai bouton dans l'onglet Synchronisation.
Deux passages ne se chevauchent plus, et une erreur inattendue s'affiche au lieu
de fermer l'application.

Le code Kotlin n'a pas été compilé sur cette machine (pas de JDK) : il a été
relu, mais reste à construire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
2026-09-20 22:39:59 +02:00

663 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<!--
Noxely — collecte côté serveur, écoute hors connexion côté mobile.
Copyright © 2026 Johnny (tuxgyver)
Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier
selon les termes de la GNU General Public License, version 3 ou ultérieure,
telle que publiée par la Free Software Foundation.
Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE.
Voir le fichier LICENSE à la racine du dépôt pour le texte complet.
-->
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noxely — serveur</title>
<style>
:root { --bg:#faf7ff; --card:#fff; --ink:#1c1b22; --muted:#5c5670; --accent:#6b4eff;
--mint:#11a894; --line:#e6e0f0; }
@media (prefers-color-scheme: dark) {
:root { --bg:#120d1c; --card:#1b1033; --ink:#e8e0f0; --muted:#a79fbb; --line:#2f2743; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,sans-serif; }
header { padding:18px 20px; border-bottom:1px solid var(--line); display:flex;
align-items:center; gap:12px; flex-wrap:wrap; }
h1 { font-size:19px; margin:0; }
main { max-width:920px; margin:0 auto; padding:20px; }
.tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:18px; }
.tabs button { background:none; border:1px solid var(--line); border-radius:999px;
padding:7px 15px; color:var(--muted); cursor:pointer; font-size:14px; }
.tabs button[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:12px 14px; margin-bottom:8px; display:flex; gap:12px; align-items:center; }
.card .grow { flex:1; min-width:0; }
.card b { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.card small { color:var(--muted); }
input, select { padding:9px 12px; border:1px solid var(--line); border-radius:10px;
background:var(--card); color:var(--ink); font-size:14px; }
input[type=text] { flex:1; min-width:160px; }
button.go { background:var(--accent); color:#fff; border:none; border-radius:10px;
padding:9px 16px; cursor:pointer; font-size:14px; }
button.ghost { background:none; border:1px solid var(--line); border-radius:10px;
padding:7px 12px; cursor:pointer; color:var(--muted); }
.row { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
.pill { font-size:12px; padding:3px 9px; border-radius:999px; background:var(--line); color:var(--muted); }
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
/* Page « À propos » : en-tête d'identité figé, corps en sous-onglets. */
.about-head { display:flex; gap:16px; align-items:center; margin-bottom:16px; }
.about-logo { width:80px; height:80px; border-radius:22px; background:var(--accent);
display:grid; place-items:center; font-size:40px; flex:none; }
.about-head h2 { margin:0 0 2px; font-size:20px; }
.about-head p { margin:0 0 8px; color:var(--muted); font-size:14px; }
.pill.accent { background:var(--accent); color:#fff; }
.subtabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:14px; }
.subtabs button { background:none; border:1px solid var(--line); border-radius:999px;
padding:5px 12px; color:var(--muted); cursor:pointer; font-size:13px; }
.subtabs button[aria-selected="true"] { border-color:var(--accent); color:var(--accent); }
.about-body { max-height:none; }
.about-body .card { display:block; }
.about-body ul { margin:0; padding-left:20px; }
.about-body li { margin:5px 0; }
.tech { width:100%; border-collapse:collapse; }
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
.tech td:first-child { color:var(--muted); width:38%; }
[hidden] { display:none !important; }
.card button { flex:none; }
#audio { flex:2; min-width:220px; max-width:100%; }
h3.section { font-size:15px; margin:20px 0 10px; }
.log h4 { margin:0 0 4px; font-size:15px; }
</style>
</head>
<body>
<header>
<h1>🎧 Noxely — serveur</h1>
<span id="status" class="pill">…</span>
<span class="grow" style="flex:1"></span>
<button class="go" onclick="openPairing()">📱 Appairer</button>
</header>
<main>
<div class="tabs">
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
<button onclick="show('radio')" aria-selected="false" id="t-radio">📻 Radio</button>
<button onclick="show('heard')" aria-selected="false" id="t-heard">📈 Écoutes</button>
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
<button onclick="show('settings')" aria-selected="false" id="t-settings">⚙️ Réglages</button>
<button onclick="show('about')" aria-selected="false" id="t-about">ℹ️ À propos</button>
</div>
<section id="p-search">
<div class="row">
<select id="catalog"></select>
<input type="text" id="q" placeholder="Titre, artiste, album…" onkeydown="if(event.key==='Enter')doSearch()">
<button class="go" onclick="doSearch()">Chercher</button>
</div>
<div id="results"></div>
</section>
<section id="p-watch" hidden>
<div class="row">
<input type="text" id="watchQuery" placeholder="Morceau à guetter sur toutes les stations"
onkeydown="if(event.key==='Enter')addWatch()">
<button class="go" onclick="addWatch()">Guetter</button>
<button class="ghost" onclick="sweep()">Balayer maintenant</button>
</div>
<div id="watches"></div>
</section>
<section id="p-radio" hidden>
<div class="row">
<select id="radioGenre" onchange="loadStations()"><option value="">Tous les genres</option></select>
<input type="text" id="radioName" placeholder="Nom de la station"
onkeydown="if(event.key==='Enter')loadStations()">
<button class="go" onclick="loadStations()">Chercher</button>
<select id="radioMinutes" title="Durée d'un enregistrement">
<option value="">Enregistrer jusqu'à l'arrêt</option>
<option value="15">Enregistrer 15 min</option>
<option value="30">Enregistrer 30 min</option>
<option value="60">Enregistrer 1 h</option>
<option value="120">Enregistrer 2 h</option>
</select>
</div>
<div class="card" id="radioPlayer" hidden>
<div class="grow"><b id="playerTitle"></b><small id="playerNote"></small></div>
<audio id="audio" controls></audio>
<button class="ghost" onclick="stopPlayer()" title="Fermer le lecteur">×</button>
</div>
<h3 class="section">⏺ En cours d'enregistrement</h3>
<div id="radioActive"></div>
<h3 class="section">🎙️ Enregistrements</h3>
<div id="radioRecordings"></div>
<h3 class="section">📻 Stations</h3>
<div id="radioStations"></div>
</section>
<section id="p-heard" hidden>
<div class="row">
<select id="rotationWindow" onchange="loadHeard()">
<option value="50">50 dernières remontées</option>
<option value="100" selected>100 dernières remontées</option>
<option value="200">200 dernières remontées</option>
<option value="500">500 dernières remontées</option>
</select>
<input type="text" id="heardQuery" placeholder="Chercher dans l'historique"
onkeydown="if(event.key==='Enter')loadHeard()">
<button class="ghost" onclick="loadHeard()">Actualiser</button>
</div>
<h3 class="section">🔥 En rotation</h3>
<div id="rotation"></div>
<h3 class="section">🕘 Historique</h3>
<div id="heard"></div>
</section>
<section id="p-queue" hidden><div id="queue"></div></section>
<section id="p-library" hidden><div id="library"></div></section>
<section id="p-settings" hidden>
<div class="card" style="display:block">
<b>Jeton d'accès</b>
<small>Rempli tout seul quand cette page est ouverte depuis la machine du serveur.
Depuis un autre poste, collez celui que donne <code>make server-token</code>.</small>
<div class="row" style="margin-top:12px">
<input type="password" id="token" placeholder="Jeton" style="flex:1">
<button class="go" onclick="saveToken()">Enregistrer</button>
</div>
</div>
<div id="serverInfo"></div>
</section>
<section id="p-about" hidden>
<div class="about-head">
<div class="about-logo">🎧</div>
<div>
<h2 id="aboutName">Noxely</h2>
<p id="aboutTagline"></p>
<div class="row" style="margin:0" id="aboutBadges"></div>
</div>
</div>
<div class="subtabs" id="aboutTabs"></div>
<div class="about-body" id="aboutBody"></div>
</section>
<section id="p-devices" hidden>
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
<div id="devices"></div>
</section>
</main>
<script src="/about.js"></script>
<script>
const $ = (id) => document.getElementById(id);
let token = localStorage.getItem('noxely-token') || '';
$('token').value = token;
function saveToken() {
token = $('token').value.trim();
localStorage.setItem('noxely-token', token);
refreshAll();
}
async function api(path, options = {}) {
const response = await fetch('/api' + path, {
...options,
headers: { 'X-Noxely-Token': token, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).detail || response.status);
return response.json();
}
function show(name) {
for (const tab of ['search', 'watch', 'radio', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
$('p-' + tab).hidden = tab !== name;
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
}
if (name === 'queue') loadQueue();
if (name === 'library') loadLibrary();
if (name === 'watch') loadWatches();
if (name === 'radio') loadRadio();
if (name === 'heard') loadHeard();
if (name === 'devices') loadDevices();
if (name === 'settings') loadServerInfo();
if (name === 'about') loadAbout();
}
async function refreshAll() {
try {
const health = await fetch('/api/health').then((r) => r.json());
$('status').textContent = health.name + ' ' + health.version;
$('status').className = 'pill ok';
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
if (!token) {
$('status').textContent = 'jeton requis → Réglages';
$('status').className = 'pill warn';
}
try {
const { catalogs, genres } = await api('/catalogs');
$('catalog').innerHTML = Object.entries(catalogs)
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
$('radioGenre').innerHTML = '<option value="">Tous les genres</option>' +
genres.map((g) => `<option value="${esc(g)}">${esc(g)}</option>`).join('');
} catch {}
}
async function doSearch() {
const q = $('q').value.trim(); if (!q) return;
$('results').innerHTML = '<div class="empty">Recherche…</div>';
try {
const { results } = await api(`/search?catalog=${$('catalog').value}&q=${encodeURIComponent(q)}`);
$('results').innerHTML = results.length ? results.map((r, i) => `
<div class="card">
<div class="grow"><b>${esc(r.title)}</b><small>${
r.catalog ? '<span class="pill">' + esc(r.catalog) + '</span> ' : ''}${
esc(r.artist)} · ${r.kind}${
r.size ? ' · ~' + (r.size / 1e6).toFixed(0) + ' Mo' : ''}</small></div>
<button class="ghost" onclick='openTracks(${i})'>Pistes</button>
</div>`).join('') : '<div class="empty">Aucun résultat</div>';
window._results = results;
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function openTracks(index) {
const item = window._results[index];
$('results').innerHTML = '<div class="empty">Lecture des pistes…</div>';
try {
// En recherche « toutes sources », c'est le catalogue du résultat qui compte, pas celui du menu.
const catalog = item.catalog || $('catalog').value;
const { tracks } = await api(`/tracks?catalog=${catalog}&id=${
encodeURIComponent(item.id)}&title=${encodeURIComponent(item.title)}&kind=${item.kind}`);
window._catalog = catalog;
window._tracks = tracks;
const total = tracks.reduce((sum, t) => sum + (t.size || 0), 0);
$('results').innerHTML = `
<div class="row"><button class="ghost" onclick="doSearch()">← Résultats</button>
<button class="go" onclick="queueAll()">Tout mettre en file${
total ? ' (' + (total / 1e6).toFixed(0) + ' Mo)' : ''}</button></div>` +
tracks.map((t, i) => `
<div class="card">
<div class="grow"><b>${esc(t.title)}</b><small>${
t.size ? (t.size / 1e6).toFixed(1) + ' Mo' : 'taille inconnue'}</small></div>
<button class="ghost" onclick="queueOne(${i})">Mettre en file</button>
</div>`).join('');
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
const queueTracks = async (tracks) => {
const { queued } = await api('/queue', { method: 'POST',
body: JSON.stringify({ tracks, catalog: window._catalog || $('catalog').value }) });
alert(queued + ' piste(s) en file de téléchargement');
};
const queueOne = (i) => queueTracks([window._tracks[i]]);
const queueAll = () => queueTracks(window._tracks);
async function loadQueue() {
try {
const { items } = await api('/queue');
$('queue').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${m.state}${m.error ? ' — ' + esc(m.error) : ''}</small></div></div>`).join('')
: '<div class="empty">File vide</div>';
} catch (e) { $('queue').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function loadLibrary() {
try {
const { items } = await api('/library?since=0');
$('library').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${(m.size / 1e6).toFixed(1)} Mo · ${esc(m.source)}</small></div>
<button class="ghost" onclick="removeMedia('${m.id}')">Supprimer</button></div>`).join('')
: '<div class="empty">Rien de prêt pour le mobile</div>';
} catch (e) { $('library').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function removeMedia(id) {
if (!confirm('Supprimer définitivement ce média du serveur ?')) return;
await api('/media/' + id, { method: 'DELETE' });
loadLibrary();
}
async function loadWatches() {
try {
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
const scan = last && last.stations
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus, ` +
`${last.heard || 0} nouveau(x) dans l'historique`
: 'en attente d\'un guet';
$('watches').innerHTML =
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
<span class="pill ok">actif</span></div>` +
(recording ? `<div class="card"><div class="grow">
<b>Enregistrement en cours</b><small>${esc(recording)}</small></div></div>` : '') +
(watches.length ? watches.map((w) => `
<div class="card"><div class="grow"><b>${esc(w.query)}</b><small>${
w.found_at ? 'capté sur ' + esc(w.found_on || '') + ' — ' + esc(w.found_title || '')
: w.scans + ' balayage(s)'}</small></div>
<span class="pill ${w.found_at ? 'ok' : ''}">${w.found_at ? 'trouvé' : 'en attente'}</span>
<button class="ghost" onclick="removeWatch('${w.id}')">×</button></div>`).join('')
: '<div class="empty">Aucun guet</div>');
} catch (e) { $('watches').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function addWatch() {
const query = $('watchQuery').value.trim(); if (!query) return;
await api('/watches', { method: 'POST', body: JSON.stringify({ query }) });
$('watchQuery').value = '';
loadWatches();
}
const removeWatch = async (id) => { await api('/watches/' + id, { method: 'DELETE' }); loadWatches(); };
async function sweep() {
$('watches').innerHTML = '<div class="empty">Balayage des stations…</div>';
try {
const r = await api('/watches/sweep', { method: 'POST' });
alert(r.matched ? `Trouvé sur ${r.station} : ${r.matched}`
: `${r.stations} stations écoutées, ${r.titles} titres lus, rien de guetté`);
} catch (e) { alert(String(e)); }
loadWatches();
}
const clock = (ms) => new Date(ms).toLocaleString('fr-FR',
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
async function loadHeard() {
const last = $('rotationWindow').value;
const query = $('heardQuery').value.trim();
try {
const [rot, list] = await Promise.all([
api('/rotation?last=' + last + '&top=15'),
api('/heard?limit=100' + (query ? '&q=' + encodeURIComponent(query) : '')),
]);
const label = (t) => t.artist ? `${esc(t.artist)} — ${esc(t.title)}` : esc(t.title);
const stations = (n) => n.length > 2 ? `${n.length} stations` : esc(n.join(', '));
$('rotation').innerHTML = rot.tracks.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${rot.window} remontée(s) analysée(s)</div>` +
rot.tracks.map((t) => `
<div class="card"><div class="grow"><b>${label(t)}</b>
<small>${stations(t.stations)} · dernier passage ${clock(t.last_heard)}</small></div>
<span class="pill ${t.plays > 1 ? 'ok' : ''}">×${t.plays}</span></div>`).join('') +
(rot.artists.length ? `<h3 class="section">🎤 Artistes</h3>` + rot.artists.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.artist)}</b>
<small>${a.tracks} titre(s) · ${a.stations.length} station(s)</small></div>
<span class="pill ${a.plays > 1 ? 'ok' : ''}">×${a.plays}</span></div>`).join('') : '')
: `<div class="empty">${list.history
? 'Rien d\'entendu pour l\'instant — le premier tour de balayage peut prendre une minute.'
: 'L\'historique est désactivé (NOXELY_HISTORY=0).'}</div>`;
$('heard').innerHTML = list.items.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${list.total} ligne(s) au total</div>` +
list.items.map((h) => `
<div class="card"><div class="grow"><b>${h.artist ? esc(h.artist) + ' — ' : ''}${esc(h.title)}</b>
<small>${esc(h.station)} · ${clock(h.heard_at)}</small></div></div>`).join('')
: '<div class="empty">Aucun résultat</div>';
} catch (e) {
$('rotation').innerHTML = `<div class="empty">${esc(String(e))}</div>`;
$('heard').innerHTML = '';
}
}
// --- Radio : stations, écoute, enregistrement ---------------------------
let radioStations = [];
let radioItems = [];
let radioTimer = null;
let playerUrl = null;
const fmtSize = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + ' Mo' : Math.round(n / 1e3) + ' Ko';
const fmtDuration = (ms) => {
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))} s`;
const min = Math.round(ms / 60000);
return min >= 60 ? `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}` : `${min} min`;
};
function loadRadio() {
loadRecordings();
if (!radioStations.length) loadStations();
}
async function loadStations() {
$('radioStations').innerHTML = '<div class="empty">Chargement des stations…</div>';
const params = new URLSearchParams();
if ($('radioGenre').value) params.set('genre', $('radioGenre').value);
if ($('radioName').value.trim()) params.set('name', $('radioName').value.trim());
try {
radioStations = (await api('/radio/stations?' + params)).stations;
$('radioStations').innerHTML = radioStations.length ? radioStations.map((s, i) => `
<div class="card"><div class="grow"><b>${esc(s.name)}</b>
<small>${esc([s.country, s.codec, s.bitrate ? s.bitrate + ' kb/s' : ''].filter(Boolean).join(' · '))}</small></div>
<button class="ghost" onclick="listenLive(${i})">▶ Écouter</button>
<button class="go" onclick="recordStation(${i})">⏺ Enregistrer</button></div>`).join('')
: '<div class="empty">Aucune station</div>';
} catch (e) {
$('radioStations').innerHTML = `<div class="empty">Annuaire indisponible : ${esc(e.message || e)}</div>`;
}
}
function play(title, note, src, isBlob) {
if (playerUrl) URL.revokeObjectURL(playerUrl);
playerUrl = isBlob ? src : null;
$('radioPlayer').hidden = false;
$('playerTitle').textContent = title;
$('playerNote').textContent = note;
const audio = $('audio');
audio.onerror = () => { $('playerNote').textContent = 'Lecture impossible dans ce navigateur'; };
audio.src = src;
audio.play().catch(() => {});
}
function stopPlayer() {
const audio = $('audio');
audio.pause();
audio.removeAttribute('src');
audio.load();
if (playerUrl) URL.revokeObjectURL(playerUrl);
playerUrl = null;
$('radioPlayer').hidden = true;
}
const listenLive = (i) => play(radioStations[i].name, 'En direct', radioStations[i].url, false);
async function playRecording(i) {
const item = radioItems[i];
$('radioPlayer').hidden = false;
$('playerTitle').textContent = item.title;
$('playerNote').textContent = 'Chargement du fichier…';
try {
const response = await fetch('/api/media/' + item.id + '/file', { headers: { 'X-Noxely-Token': token } });
if (!response.ok) throw new Error(response.status);
play(item.title, 'Enregistrement', URL.createObjectURL(await response.blob()), true);
} catch (e) { $('playerNote').textContent = 'Lecture impossible : ' + (e.message || e); }
}
async function recordStation(i) {
const s = radioStations[i];
const minutes = $('radioMinutes').value;
try {
await api('/radio/record', { method: 'POST', body: JSON.stringify({
name: s.name, url: s.url, codec: s.codec, minutes: minutes ? Number(minutes) : null }) });
loadRecordings();
} catch (e) { alert(e.message || e); }
}
async function stopRecording(id) {
await api('/radio/record/' + id + '/stop', { method: 'POST' });
setTimeout(loadRecordings, 1500);
}
async function deleteRecording(i) {
if (!confirm('Supprimer définitivement « ' + radioItems[i].title + ' » du serveur ?')) return;
await api('/media/' + radioItems[i].id, { method: 'DELETE' });
loadRecordings();
}
async function loadRecordings() {
clearTimeout(radioTimer);
try {
const { active, items, max_minutes: max } = await api('/radio/recordings');
radioItems = items;
const running = new Set(active.map((a) => a.media_id));
$('radioActive').innerHTML = active.length ? active.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.station)}</b>
<small>depuis ${clock(a.started_at)} · ${fmtSize(a.written)} · s'arrête seul après ${a.limit_minutes} min (max ${max})</small></div>
<span class="pill warn">● en cours</span>
<button class="go" onclick="stopRecording('${a.media_id}')">■ Arrêter</button></div>`).join('')
: '<div class="empty" style="padding:10px">Aucun enregistrement en cours</div>';
const shown = items.map((m, i) => [m, i]).filter(([m]) => !running.has(m.id));
$('radioRecordings').innerHTML = shown.length ? shown.map(([m, i]) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${clock(m.created_at)}${m.size ? ' · ' + fmtSize(m.size) : ''}${
m.duration_ms ? ' · ' + fmtDuration(m.duration_ms) : ''}${m.error ? ' · ' + esc(m.error) : ''}</small></div>
${m.state === 'ready' ? `<button class="ghost" onclick="playRecording(${i})">▶ Écouter</button>`
: `<span class="pill ${m.state === 'failed' ? 'warn' : ''}">${m.state === 'failed' ? 'échec' : 'en cours'}</span>`}
<button class="ghost" onclick="deleteRecording(${i})" title="Supprimer">×</button></div>`).join('')
: '<div class="empty" style="padding:10px">Aucun enregistrement — choisis une station ci-dessous</div>';
// Tant que quelque chose s'enregistre, la taille et l'état se rafraîchissent d'eux-mêmes.
if (!$('p-radio').hidden && (active.length || items.some((m) => m.state === 'downloading'))) {
radioTimer = setTimeout(loadRecordings, 4000);
}
} catch (e) {
$('radioActive').innerHTML = `<div class="empty">${esc(e.message || e)}</div>`;
}
}
async function loadServerInfo() {
try {
const health = await fetch('/api/health').then((r) => r.json());
const { devices } = await api('/devices');
$('serverInfo').innerHTML = `
<div class="card"><div class="grow"><b>${esc(health.name)} ${esc(health.version)}</b>
<small>${devices.length} appareil(s) appairé(s)</small></div>
<span class="pill ok">en service</span></div>`;
} catch (e) {
$('serverInfo').innerHTML = `<div class="card"><div class="grow"><b>Jeton requis</b>
<small>${esc(String(e))}</small></div><span class="pill warn">non authentifié</span></div>`;
}
}
async function loadDevices() {
try {
const { devices } = await api('/devices');
$('devices').innerHTML = devices.length ? devices.map((d) => `
<div class="card"><div class="grow"><b>${esc(d.name)}</b><small>appairé le ${
new Date(d.created_at).toLocaleString('fr-FR')}${
d.last_seen ? ' · vu le ' + new Date(d.last_seen).toLocaleString('fr-FR')
: ' · jamais synchronisé'}</small></div>
<button class="ghost" onclick="revokeDevice('${d.id}','${esc(d.name)}')">Révoquer</button></div>`).join('')
: '<div class="empty">Aucun appareil appairé</div>';
} catch (e) { $('devices').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function revokeDevice(id, name) {
if (!confirm(`Révoquer « ${name} » ? Il ne pourra plus se synchroniser tant qu'il n'est pas réappairé.`)) return;
await api('/devices/' + id, { method: 'DELETE' });
loadDevices();
}
async function openPairing() {
try {
const { seconds } = await api('/pair/open', { method: 'POST' });
alert(`Appairage ouvert pour ${seconds} secondes.\n\n` +
`Sur le téléphone : Réglages ▸ Connexion ▸ Appairer.\n` +
`La fenêtre se referme dès qu'un appareil s'est servi.`);
} catch (e) { alert('Appairage impossible : ' + e); }
}
/* ── À propos ──────────────────────────────────────────────────────────────
Le contenu vient d'about.js, la version de /api/health et le journal du
CHANGELOG.md du dépôt : rien n'est recopié ici, donc rien ne peut diverger. */
let aboutTab = 'features';
const ABOUT_TABS = [
['features', '⭐ Fonctions'], ['guarantees', '🛡️ Garanties'],
['stack', '🧱 Technique'], ['log', '🕓 Journal'], ['author', '👤 Auteur'],
];
async function loadAbout() {
$('aboutName').textContent = ABOUT.name;
$('aboutTagline').textContent = ABOUT.tagline;
let version = '—', live = false;
try {
const health = await fetch('/api/health').then((r) => r.json());
version = health.version; live = true;
} catch {}
$('aboutBadges').innerHTML =
`<span class="pill accent">v${esc(version)}</span>` +
`<span class="pill">${esc(ABOUT.license)}</span>` +
`<span class="pill ${live ? 'ok' : 'warn'}">${live ? 'en service' : 'injoignable'}</span>` +
`<span class="pill ok">4 catalogues libres</span>`;
$('aboutTabs').innerHTML = ABOUT_TABS.map(([id, label]) =>
`<button onclick="showAbout('${id}')" aria-selected="${id === aboutTab}">${label}</button>`).join('');
renderAbout();
}
function showAbout(id) { aboutTab = id; loadAbout(); }
async function renderAbout() {
const body = $('aboutBody');
if (aboutTab === 'features') {
body.innerHTML = ABOUT.features.map(([title, text]) =>
`<div class="card"><b>${esc(title)}</b><small>${esc(text)}</small></div>`).join('');
} else if (aboutTab === 'guarantees') {
body.innerHTML = `<div class="card"><small>${esc(ABOUT.guaranteesIntro)}</small>
<ul style="margin-top:10px">${ABOUT.guarantees.map((g) => `<li>${esc(g)}</li>`).join('')}</ul></div>`;
} else if (aboutTab === 'stack') {
body.innerHTML = `<div class="card"><table class="tech">${ABOUT.stack.map(([layer, tools]) =>
`<tr><td>${esc(layer)}</td><td>${esc(tools)}</td></tr>`).join('')}</table></div>`;
} else if (aboutTab === 'author') {
body.innerHTML = `<div class="card" style="display:flex;align-items:center">
<div class="about-logo" style="width:52px;height:52px;border-radius:16px;font-size:24px">${
esc(ABOUT.author[0])}</div>
<div class="grow" style="margin-left:14px">
<b>${esc(ABOUT.author)}</b><small>${esc(ABOUT.authorRole)}</small>
<small style="user-select:text;display:block;margin-top:6px">${esc(ABOUT.repository)}</small>
</div></div>
<div class="card"><small>${esc(ABOUT.authorship)}</small>
<small style="display:block;margin-top:6px">${esc(ABOUT.copyright)} — ${esc(ABOUT.license)}</small></div>`;
} else {
body.innerHTML = '<div class="empty">Lecture du journal…</div>';
try {
const text = await fetch('/api/changelog').then((r) => r.text());
body.innerHTML = renderChangelog(text) || '<div class="empty">Journal vide</div>';
} catch { body.innerHTML = '<div class="empty">Journal indisponible</div>'; }
}
}
/* Le CHANGELOG n'emploie que des titres « ## », des puces « - » et du gras :
on n'interprète que cela, plutôt que d'embarquer un moteur Markdown. */
function renderChangelog(text) {
const bold = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b style="display:inline">$1</b>');
const entries = [];
for (const line of text.split('\n')) {
if (line.startsWith('## ')) entries.push({ title: line.slice(3).trim(), items: [] });
else if (line.startsWith('- ') && entries.length) entries[entries.length - 1].items.push(line.slice(2).trim());
}
return entries.map((e) => `<div class="card log"><h4>${bold(e.title)}</h4>
<ul>${e.items.map((i) => `<li>${bold(i)}</li>`).join('')}</ul></div>`).join('');
}
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Consultée depuis la machine du serveur, la page se remplit seule.
(async () => {
if (!token) {
try {
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
} catch {}
}
refreshAll();
})();
setInterval(() => {
if (!$('p-queue').hidden) loadQueue();
if (!$('p-watch').hidden) loadWatches();
}, 5000);
</script>
</body>
</html>