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
This commit is contained in:
1 parent
7810bef8a3
commit
5b5e90247f
15 files changed
+593
-48
No files matched your search
@@ -66,6 +66,9 @@
|
||||
.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>
|
||||
@@ -81,6 +84,7 @@
|
||||
<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>
|
||||
@@ -108,6 +112,33 @@
|
||||
<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()">
|
||||
@@ -182,13 +213,14 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
for (const tab of ['search', 'watch', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||
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();
|
||||
@@ -206,9 +238,11 @@ async function refreshAll() {
|
||||
$('status').className = 'pill warn';
|
||||
}
|
||||
try {
|
||||
const { catalogs } = await api('/catalogs');
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -366,6 +400,132 @@ async function loadHeard() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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());
|
||||
|
||||
Reference in new issue
Block a user