2026-03-16 19:19:22 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* diora — radio player
|
|
|
|
|
|
* Handles playback, SSE metadata, search, station management, and affiliate links.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// State
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let currentStation = null; // { url, name, id } | null
|
|
|
|
|
|
let currentTrack = '';
|
|
|
|
|
|
let sseSource = null;
|
|
|
|
|
|
let isPlaying = false;
|
|
|
|
|
|
let currentPlayId = null;
|
|
|
|
|
|
|
|
|
|
|
|
const audio = new Audio();
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// DOM helpers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
|
|
|
|
|
|
|
|
function getCsrfToken() {
|
|
|
|
|
|
const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken='));
|
|
|
|
|
|
return cookie ? cookie.split('=')[1] : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDateTime(iso) {
|
|
|
|
|
|
if (!iso) return '';
|
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
|
const pad = n => String(n).padStart(2, '0');
|
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} `
|
|
|
|
|
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
|
return String(str)
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Play / Stop
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function playStation(url, name, stationId) {
|
|
|
|
|
|
stopPlayback(false);
|
|
|
|
|
|
|
|
|
|
|
|
currentStation = { url, name, id: stationId || null };
|
|
|
|
|
|
isPlaying = true;
|
|
|
|
|
|
|
|
|
|
|
|
audio.src = url;
|
|
|
|
|
|
const volSlider = document.getElementById('volume');
|
2026-03-16 21:07:12 +01:00
|
|
|
|
if (volSlider) audio.volume = volSlider.value / 255;
|
2026-03-16 19:19:22 +01:00
|
|
|
|
audio.play().catch(() => {
|
|
|
|
|
|
// Browser may block autoplay; the user needs to interact first
|
|
|
|
|
|
console.warn('Audio play blocked by browser policy.');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('now-playing-station').textContent = name;
|
|
|
|
|
|
$('now-playing-track').textContent = '';
|
2026-03-16 20:38:08 +01:00
|
|
|
|
$('play-stop-btn').style.display = '';
|
2026-03-16 19:19:22 +01:00
|
|
|
|
$('play-stop-btn').textContent = '⏹ Stop';
|
|
|
|
|
|
$('play-stop-btn').classList.add('playing');
|
|
|
|
|
|
$('save-station-btn').style.display = '';
|
|
|
|
|
|
|
|
|
|
|
|
startMetadataSSE(url);
|
|
|
|
|
|
startPlaySession(name, url);
|
2026-03-16 20:57:07 +01:00
|
|
|
|
maybeShowDonationHint(url, name);
|
2026-03-16 19:19:22 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopPlayback(clearStation = true) {
|
|
|
|
|
|
audio.pause();
|
|
|
|
|
|
audio.src = '';
|
|
|
|
|
|
isPlaying = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (sseSource) {
|
|
|
|
|
|
sseSource.close();
|
|
|
|
|
|
sseSource = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$('play-stop-btn').textContent = '▶ Play';
|
|
|
|
|
|
$('play-stop-btn').classList.remove('playing');
|
|
|
|
|
|
$('save-station-btn').style.display = 'none';
|
|
|
|
|
|
$('affiliate-section').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
stopPlaySession();
|
|
|
|
|
|
|
|
|
|
|
|
if (clearStation) {
|
|
|
|
|
|
currentStation = null;
|
|
|
|
|
|
currentTrack = '';
|
|
|
|
|
|
$('now-playing-station').textContent = '— no station —';
|
|
|
|
|
|
$('now-playing-track').textContent = '';
|
2026-03-16 20:38:08 +01:00
|
|
|
|
$('play-stop-btn').style.display = 'none';
|
2026-03-16 19:19:22 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function togglePlayStop() {
|
|
|
|
|
|
if (isPlaying) {
|
|
|
|
|
|
stopPlayback(true);
|
|
|
|
|
|
} else if (currentStation) {
|
|
|
|
|
|
playStation(currentStation.url, currentStation.name, currentStation.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Play session tracking
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function startPlaySession(stationName, stationUrl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/play/start/', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
|
|
|
|
body: JSON.stringify({station_name: stationName, station_url: stationUrl})
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
currentPlayId = data.play_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function stopPlaySession() {
|
|
|
|
|
|
if (!currentPlayId) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/radio/play/stop/', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
|
|
|
|
body: JSON.stringify({play_id: currentPlayId})
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
currentPlayId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
|
|
|
|
if (currentPlayId) {
|
|
|
|
|
|
navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Recommendations
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRecommendations() {
|
|
|
|
|
|
const container = document.getElementById('recommendations');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/recommendations/');
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!data.recommendations.length) {
|
|
|
|
|
|
container.innerHTML = '<p class="muted">Play more stations to get recommendations.</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const label = data.context;
|
|
|
|
|
|
let html = `<p class="recommendations-context">Based on your ${label} listening:</p><ul class="recommendations-list">`;
|
|
|
|
|
|
for (const r of data.recommendations) {
|
|
|
|
|
|
html += `<li>
|
|
|
|
|
|
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
|
|
|
|
|
|
▶ ${escapeHtml(r.station_name)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="muted">${r.play_count}×</span>
|
|
|
|
|
|
</li>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</ul>';
|
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeAttr(s) {
|
|
|
|
|
|
return String(s).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Volume
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-03-16 21:07:12 +01:00
|
|
|
|
function setVolume(val) {
|
|
|
|
|
|
val = Math.max(0, Math.min(255, Math.round(val)));
|
|
|
|
|
|
audio.volume = val / 255;
|
|
|
|
|
|
localStorage.setItem('diora_volume', val);
|
|
|
|
|
|
const slider = $('volume');
|
|
|
|
|
|
const numInput = $('volume-num');
|
|
|
|
|
|
if (slider) slider.value = val;
|
|
|
|
|
|
if (numInput) numInput.value = val;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const volSliderEl = document.getElementById('volume');
|
|
|
|
|
|
if (volSliderEl) {
|
|
|
|
|
|
['input', 'change', 'mousemove', 'touchmove'].forEach(evt =>
|
|
|
|
|
|
volSliderEl.addEventListener(evt, function () { setVolume(this.value); })
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const volNumEl = document.getElementById('volume-num');
|
|
|
|
|
|
if (volNumEl) {
|
|
|
|
|
|
volNumEl.addEventListener('change', function () { setVolume(this.value); });
|
|
|
|
|
|
volNumEl.addEventListener('input', function () { setVolume(this.value); });
|
2026-03-16 21:20:31 +01:00
|
|
|
|
volNumEl.addEventListener('click', function () { this.select(); });
|
2026-03-16 21:07:12 +01:00
|
|
|
|
}
|
2026-03-16 19:19:22 +01:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// SSE metadata
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function startMetadataSSE(streamUrl) {
|
|
|
|
|
|
if (sseSource) { sseSource.close(); sseSource = null; }
|
|
|
|
|
|
|
|
|
|
|
|
const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl);
|
|
|
|
|
|
sseSource = new EventSource(endpoint);
|
|
|
|
|
|
|
|
|
|
|
|
sseSource.onmessage = function (e) {
|
|
|
|
|
|
let data;
|
|
|
|
|
|
try { data = JSON.parse(e.data); } catch (_) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
|
console.warn('SSE stream ended:', data.error);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.track && data.track !== currentTrack) {
|
|
|
|
|
|
currentTrack = data.track;
|
|
|
|
|
|
updateNowPlayingUI(data.track);
|
|
|
|
|
|
recordTrack(currentStation ? currentStation.name : '', data.track);
|
|
|
|
|
|
fetchAffiliateLinks(data.track);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
sseSource.onerror = function () {
|
|
|
|
|
|
// Connection dropped; the browser will attempt to reconnect automatically
|
|
|
|
|
|
console.warn('SSE connection error, browser will retry.');
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateNowPlayingUI(track) {
|
|
|
|
|
|
$('now-playing-track').textContent = track;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Record track
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function recordTrack(stationName, track) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/record/', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'X-CSRFToken': getCsrfToken(),
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ station_name: stationName, track, scrobble: true }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
addHistoryRow(stationName, track);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('recordTrack error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addHistoryRow(stationName, track) {
|
|
|
|
|
|
const tbody = $('history-tbody');
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Remove the "no history" placeholder row if present
|
|
|
|
|
|
const emptyRow = $('history-empty-row');
|
|
|
|
|
|
if (emptyRow) emptyRow.remove();
|
|
|
|
|
|
|
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
tr.innerHTML = `
|
|
|
|
|
|
<td class="history-time">${escapeHtml(formatDateTime(now))}</td>
|
|
|
|
|
|
<td>${escapeHtml(stationName)}</td>
|
|
|
|
|
|
<td>${escapeHtml(track)}</td>
|
|
|
|
|
|
<td></td>
|
2026-03-16 20:16:30 +01:00
|
|
|
|
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Remove">✕</button></td>
|
2026-03-16 19:19:22 +01:00
|
|
|
|
`;
|
|
|
|
|
|
tbody.insertBefore(tr, tbody.firstChild);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 20:16:30 +01:00
|
|
|
|
async function deleteHistoryEntry(id, btn) {
|
|
|
|
|
|
const tr = btn.closest('tr');
|
|
|
|
|
|
if (!id) { tr.remove(); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/radio/history/${id}/delete/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'X-CSRFToken': getCsrfToken()},
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) tr.remove();
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 19:19:22 +01:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Affiliate links
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchAffiliateLinks(track) {
|
|
|
|
|
|
const section = $('affiliate-section');
|
2026-03-16 20:47:02 +01:00
|
|
|
|
if (section && section.dataset.disabled) return;
|
2026-03-16 19:19:22 +01:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track));
|
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
const itunes = data.itunes_data || {};
|
|
|
|
|
|
|
|
|
|
|
|
$('affiliate-track-name').textContent = itunes.name || track;
|
|
|
|
|
|
$('affiliate-artist-name').textContent = itunes.artist || '';
|
|
|
|
|
|
$('affiliate-album-name').textContent = itunes.album || '';
|
|
|
|
|
|
|
|
|
|
|
|
const artEl = $('affiliate-artwork');
|
|
|
|
|
|
if (itunes.artwork) {
|
|
|
|
|
|
artEl.src = itunes.artwork;
|
|
|
|
|
|
artEl.style.display = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
artEl.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const amzLink = $('affiliate-amazon-link');
|
|
|
|
|
|
if (data.amazon_url) {
|
|
|
|
|
|
amzLink.href = data.amazon_url;
|
|
|
|
|
|
amzLink.style.display = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
amzLink.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = 'flex';
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('fetchAffiliateLinks error:', err);
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Search (radio-browser.info)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function doSearch() {
|
|
|
|
|
|
const query = $('search-input').value.trim();
|
|
|
|
|
|
if (!query) return;
|
|
|
|
|
|
|
|
|
|
|
|
const statusEl = $('search-status');
|
|
|
|
|
|
const tableEl = $('search-results-table');
|
|
|
|
|
|
const tbody = $('search-results-body');
|
|
|
|
|
|
|
|
|
|
|
|
statusEl.textContent = 'Searching…';
|
|
|
|
|
|
tableEl.style.display = 'none';
|
|
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`;
|
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
|
|
const stations = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (!stations.length) {
|
|
|
|
|
|
statusEl.textContent = 'No stations found.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
statusEl.textContent = `${stations.length} result(s)`;
|
|
|
|
|
|
tableEl.style.display = '';
|
|
|
|
|
|
const curated = document.getElementById('curated-lists');
|
|
|
|
|
|
if (curated) curated.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
stations.forEach(st => {
|
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
const safeName = escapeHtml(st.name || '');
|
|
|
|
|
|
const safeUrl = escapeHtml(st.url_resolved || st.url || '');
|
|
|
|
|
|
const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : '');
|
|
|
|
|
|
const safeCC = escapeHtml(st.countrycode || st.country || '');
|
|
|
|
|
|
const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', '));
|
|
|
|
|
|
|
|
|
|
|
|
tr.innerHTML = `
|
|
|
|
|
|
<td title="${safeName}">${safeName}</td>
|
|
|
|
|
|
<td>${safeBr}</td>
|
|
|
|
|
|
<td>${safeCC}</td>
|
|
|
|
|
|
<td title="${escapeHtml(st.tags || '')}">${safeTags}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button class="btn btn-sm btn-play"
|
|
|
|
|
|
onclick='searchPlay(${JSON.stringify(safeUrl)}, ${JSON.stringify(safeName)}, ${JSON.stringify({
|
|
|
|
|
|
name: st.name,
|
|
|
|
|
|
url: st.url_resolved || st.url,
|
|
|
|
|
|
bitrate: st.bitrate ? String(st.bitrate) : '',
|
|
|
|
|
|
country: st.country || '',
|
|
|
|
|
|
tags: st.tags || '',
|
|
|
|
|
|
favicon_url: st.favicon || '',
|
|
|
|
|
|
})})'>
|
|
|
|
|
|
▶ Play
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
`;
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
statusEl.textContent = 'Search failed: ' + err.message;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function searchPlay(url, name, stationData) {
|
|
|
|
|
|
// Store station data on the window so saveCurrentStation() can use it
|
|
|
|
|
|
window._pendingStationData = stationData;
|
|
|
|
|
|
playStation(url, name, null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Save current station
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function saveCurrentStation() {
|
|
|
|
|
|
if (!currentStation) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Use the rich data from search results if available, otherwise minimal data
|
|
|
|
|
|
const data = window._pendingStationData || {
|
|
|
|
|
|
name: currentStation.name,
|
|
|
|
|
|
url: currentStation.url,
|
|
|
|
|
|
bitrate: '',
|
|
|
|
|
|
country: '',
|
|
|
|
|
|
tags: '',
|
|
|
|
|
|
favicon_url: '',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await saveStation(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveStation(station) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/save/', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'X-CSRFToken': getCsrfToken(),
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify(station),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (res.status === 401) {
|
|
|
|
|
|
alert('Please log in to save stations.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.ok) {
|
|
|
|
|
|
if (data.created) {
|
|
|
|
|
|
addSavedRow({ id: data.id, ...station, is_favorite: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('saveStation error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addSavedRow(station) {
|
|
|
|
|
|
const tbody = $('saved-tbody');
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
const emptyRow = $('saved-empty-row');
|
|
|
|
|
|
if (emptyRow) emptyRow.remove();
|
|
|
|
|
|
|
|
|
|
|
|
// Check for duplicate
|
|
|
|
|
|
if (document.getElementById(`saved-row-${station.id}`)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
tr.id = `saved-row-${station.id}`;
|
|
|
|
|
|
tr.dataset.id = station.id;
|
|
|
|
|
|
tr.dataset.url = station.url;
|
|
|
|
|
|
tr.dataset.name = station.name;
|
|
|
|
|
|
|
|
|
|
|
|
const safeName = escapeHtml(station.name || '');
|
|
|
|
|
|
const safeBr = escapeHtml(station.bitrate || '');
|
|
|
|
|
|
const safeCC = escapeHtml(station.country || '');
|
|
|
|
|
|
const safeUrl = escapeHtml(station.url || '');
|
|
|
|
|
|
|
|
|
|
|
|
tr.innerHTML = `
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button class="btn-icon fav-btn${station.is_favorite ? ' active' : ''}"
|
|
|
|
|
|
onclick="toggleFav(${station.id}, this)"
|
|
|
|
|
|
title="Toggle favorite">★</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="station-name-cell">${safeName}</td>
|
|
|
|
|
|
<td>${safeBr}</td>
|
|
|
|
|
|
<td>${safeCC}</td>
|
|
|
|
|
|
<td class="notes-cell" onclick="editNotes(${station.id}, this.textContent.trim())" title="" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button class="btn btn-sm"
|
|
|
|
|
|
onclick="playStation('${safeUrl}', '${safeName}', ${station.id})">
|
|
|
|
|
|
▶ Play
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger"
|
|
|
|
|
|
onclick="removeStation(${station.id})">
|
|
|
|
|
|
Remove
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
`;
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Remove station
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleFav(pk) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/radio/favorite/${pk}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'X-CSRFToken': getCsrfToken() },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Flip the star button state
|
|
|
|
|
|
const btn = document.querySelector(`#saved-row-${pk} .fav-btn`);
|
|
|
|
|
|
if (btn) btn.classList.toggle('active', data.is_favorite);
|
|
|
|
|
|
|
|
|
|
|
|
// Re-sort rows in the DOM: favorites first, then alphabetically
|
|
|
|
|
|
const tbody = $('saved-tbody');
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
|
|
|
|
|
|
rows.sort((a, b) => {
|
|
|
|
|
|
const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1;
|
|
|
|
|
|
const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1;
|
|
|
|
|
|
if (aFav !== bFav) return aFav - bFav;
|
|
|
|
|
|
return a.dataset.name.localeCompare(b.dataset.name);
|
|
|
|
|
|
});
|
|
|
|
|
|
rows.forEach(row => tbody.appendChild(row));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('toggleFav error', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeStation(pk) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/radio/remove/${pk}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'X-CSRFToken': getCsrfToken() },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const row = $(`saved-row-${pk}`);
|
|
|
|
|
|
if (row) row.remove();
|
|
|
|
|
|
|
|
|
|
|
|
const tbody = $('saved-tbody');
|
|
|
|
|
|
if (tbody && tbody.querySelectorAll('tr').length === 0) {
|
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
tr.id = 'saved-empty-row';
|
|
|
|
|
|
tr.innerHTML = '<td colspan="7" class="empty-msg">No saved stations yet.</td>';
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('removeStation error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Toggle favorite
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleFav(pk, btnEl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/radio/favorite/${pk}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'X-CSRFToken': getCsrfToken() },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.is_favorite) {
|
|
|
|
|
|
btnEl.classList.add('active');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
btnEl.classList.remove('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('toggleFav error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Focus Timer
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const TIMER_WORK = 25 * 60;
|
|
|
|
|
|
const TIMER_BREAK = 5 * 60;
|
|
|
|
|
|
let timerSeconds = TIMER_WORK;
|
|
|
|
|
|
let timerRunning = false;
|
|
|
|
|
|
let timerIsBreak = false;
|
|
|
|
|
|
let timerInterval = null;
|
|
|
|
|
|
|
|
|
|
|
|
function timerTick() {
|
|
|
|
|
|
timerSeconds--;
|
|
|
|
|
|
renderTimer();
|
|
|
|
|
|
if (timerSeconds <= 0) {
|
|
|
|
|
|
clearInterval(timerInterval);
|
|
|
|
|
|
timerInterval = null;
|
|
|
|
|
|
timerRunning = false;
|
|
|
|
|
|
if (!timerIsBreak) {
|
|
|
|
|
|
// work session ended → start break
|
|
|
|
|
|
timerIsBreak = true;
|
|
|
|
|
|
recordFocusSession();
|
|
|
|
|
|
timerSeconds = TIMER_BREAK;
|
|
|
|
|
|
showTimerNotification('Break time! 5 minutes.');
|
|
|
|
|
|
// auto-pause playback during break
|
|
|
|
|
|
if (audio.src && !audio.paused) audio.pause();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// break ended → reset to work
|
|
|
|
|
|
timerIsBreak = false;
|
|
|
|
|
|
timerSeconds = TIMER_WORK;
|
|
|
|
|
|
showTimerNotification('Break over. Back to work.');
|
|
|
|
|
|
}
|
|
|
|
|
|
renderTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleTimer() {
|
|
|
|
|
|
if (timerRunning) {
|
|
|
|
|
|
clearInterval(timerInterval);
|
|
|
|
|
|
timerInterval = null;
|
|
|
|
|
|
timerRunning = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if ('Notification' in window && Notification.permission === 'default') {
|
|
|
|
|
|
Notification.requestPermission();
|
|
|
|
|
|
}
|
|
|
|
|
|
timerRunning = true;
|
|
|
|
|
|
timerInterval = setInterval(timerTick, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
renderTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetTimer() {
|
|
|
|
|
|
clearInterval(timerInterval);
|
|
|
|
|
|
timerInterval = null;
|
|
|
|
|
|
timerRunning = false;
|
|
|
|
|
|
timerIsBreak = false;
|
|
|
|
|
|
timerSeconds = TIMER_WORK;
|
|
|
|
|
|
renderTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderTimer() {
|
|
|
|
|
|
const m = String(Math.floor(timerSeconds / 60)).padStart(2, '0');
|
|
|
|
|
|
const s = String(timerSeconds % 60).padStart(2, '0');
|
|
|
|
|
|
const display = $('timer-display');
|
|
|
|
|
|
const btn = $('timer-toggle-btn');
|
|
|
|
|
|
const label = $('timer-phase-label');
|
|
|
|
|
|
if (display) display.textContent = `${m}:${s}`;
|
|
|
|
|
|
if (btn) btn.textContent = timerRunning ? '⏸' : '▶';
|
|
|
|
|
|
if (label) label.textContent = timerIsBreak ? 'break' : 'focus';
|
|
|
|
|
|
// colour the display red when break
|
|
|
|
|
|
if (display) display.style.color = timerIsBreak ? '#e63946' : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showTimerNotification(msg) {
|
|
|
|
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
|
|
|
|
new Notification('diora', { body: msg });
|
|
|
|
|
|
}
|
|
|
|
|
|
// also flash in the timer label
|
|
|
|
|
|
const label = $('timer-phase-label');
|
|
|
|
|
|
if (label) { label.textContent = msg; setTimeout(() => renderTimer(), 3000); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Focus session recording
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function recordFocusSession() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/radio/focus/record/', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
station_name: currentStation ? currentStation.name : '',
|
|
|
|
|
|
duration_minutes: 25,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
loadFocusStats();
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadFocusStats() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/focus/stats/');
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const widget = document.getElementById('focus-today-widget');
|
|
|
|
|
|
if (widget) {
|
|
|
|
|
|
if (data.today_sessions > 0) {
|
|
|
|
|
|
widget.textContent = `Today: ${data.today_sessions} session${data.today_sessions !== 1 ? 's' : ''} · ${data.today_minutes} min`;
|
|
|
|
|
|
widget.style.display = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
widget.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// populate focus tab
|
|
|
|
|
|
const tbody = document.getElementById('focus-tbody');
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
|
|
if (!data.sessions.length) {
|
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
data.sessions.forEach(s => {
|
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'});
|
|
|
|
|
|
tr.innerHTML = `<td>${dt}</td><td>${escapeHtml(s.station_name || '—')}</td><td>${s.duration_minutes} min</td>`;
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Do Not Disturb / focus mode
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let dndActive = false;
|
|
|
|
|
|
|
|
|
|
|
|
function toggleDNDLight() {
|
|
|
|
|
|
document.body.classList.toggle('dnd-dark');
|
|
|
|
|
|
const btn = $('dnd-light-btn');
|
|
|
|
|
|
if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleDND() {
|
|
|
|
|
|
dndActive = !dndActive;
|
|
|
|
|
|
if (!dndActive) document.body.classList.remove('dnd-dark');
|
|
|
|
|
|
document.body.classList.toggle('dnd-mode', dndActive);
|
|
|
|
|
|
const btn = $('dnd-btn');
|
|
|
|
|
|
if (btn) btn.classList.toggle('active', dndActive);
|
|
|
|
|
|
if (dndActive) {
|
|
|
|
|
|
const el = document.documentElement;
|
|
|
|
|
|
if (el.requestFullscreen) el.requestFullscreen();
|
|
|
|
|
|
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen();
|
|
|
|
|
|
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Exit DND on Escape (browser also exits fullscreen on Escape, so sync state)
|
|
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
|
|
|
|
if (!document.fullscreenElement && dndActive) {
|
|
|
|
|
|
dndActive = false;
|
|
|
|
|
|
document.body.classList.remove('dnd-mode');
|
|
|
|
|
|
const btn = $('dnd-btn');
|
|
|
|
|
|
if (btn) btn.classList.remove('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.key === 'Escape' && dndActive) toggleDND();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Mood / genre tag filter
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const MOOD_TAGS = [
|
|
|
|
|
|
{ label: '🎯 Focus', tag: 'ambient' },
|
|
|
|
|
|
{ label: '☕ Lo-fi', tag: 'lofi' },
|
|
|
|
|
|
{ label: '🎷 Jazz', tag: 'jazz' },
|
|
|
|
|
|
{ label: '🎻 Classical', tag: 'classical' },
|
|
|
|
|
|
{ label: '🌧 Ambient', tag: 'ambient drone' },
|
|
|
|
|
|
{ label: '🤘 Metal', tag: 'metal' },
|
|
|
|
|
|
{ label: '🎉 Electronic', tag: 'electronic' },
|
|
|
|
|
|
{ label: '📻 Talk', tag: 'talk' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function initMoodChips() {
|
|
|
|
|
|
const container = $('mood-chips');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
MOOD_TAGS.forEach(({ label, tag }) => {
|
|
|
|
|
|
const btn = document.createElement('button');
|
|
|
|
|
|
btn.className = 'mood-chip';
|
|
|
|
|
|
btn.textContent = label;
|
|
|
|
|
|
btn.onclick = () => {
|
|
|
|
|
|
const input = $('search-input');
|
|
|
|
|
|
if (input) input.value = tag;
|
|
|
|
|
|
doSearch();
|
|
|
|
|
|
};
|
|
|
|
|
|
container.appendChild(btn);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Curated station lists
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const CURATED_LISTS = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'focus',
|
|
|
|
|
|
label: '🎯 Focus',
|
|
|
|
|
|
stations: [
|
|
|
|
|
|
{ name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' },
|
|
|
|
|
|
{ name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' },
|
|
|
|
|
|
{ name: 'Nightride FM', url: 'https://stream.nightride.fm/nightride.mp3' },
|
|
|
|
|
|
{ name: 'Nightride FM Chillsynth', url: 'https://stream.nightride.fm/chillsynth.mp3' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'lofi',
|
|
|
|
|
|
label: '☕ Lo-fi / Chill',
|
|
|
|
|
|
stations: [
|
|
|
|
|
|
{ name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' },
|
|
|
|
|
|
{ name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' },
|
|
|
|
|
|
{ name: 'dublab DE', url: 'https://dublabde.out.airtime.pro/dublabde_a' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'dark',
|
|
|
|
|
|
label: '🌑 Dark / Industrial',
|
|
|
|
|
|
stations: [
|
|
|
|
|
|
{ name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' },
|
|
|
|
|
|
{ name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' },
|
|
|
|
|
|
{ name: 'Radio Caprice Industrial', url: 'http://79.120.39.202:9095/' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'classical',
|
|
|
|
|
|
label: '🎻 Classical',
|
|
|
|
|
|
stations: [
|
|
|
|
|
|
{ name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' },
|
|
|
|
|
|
{ name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' },
|
|
|
|
|
|
{ name: 'Deutschlandfunk Kultur', url: 'https://st02.sslstream.dlf.de/dlf/02/high/aac/stream.aac?aggregator=web' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function initCuratedLists() {
|
|
|
|
|
|
const container = document.getElementById('curated-lists');
|
|
|
|
|
|
if (!container) return;
|
2026-03-16 21:16:28 +01:00
|
|
|
|
|
|
|
|
|
|
if (INITIAL_FEATURED && INITIAL_FEATURED.length) {
|
|
|
|
|
|
const section = document.createElement('div');
|
|
|
|
|
|
section.className = 'curated-section';
|
|
|
|
|
|
section.innerHTML = `<div class="curated-label">★ Featured</div>`;
|
|
|
|
|
|
const ul = document.createElement('ul');
|
|
|
|
|
|
ul.className = 'curated-stations';
|
|
|
|
|
|
INITIAL_FEATURED.forEach(s => {
|
|
|
|
|
|
const li = document.createElement('li');
|
|
|
|
|
|
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
|
|
|
|
|
|
<span class="curated-name">${escapeHtml(s.name)}</span>
|
|
|
|
|
|
${s.description ? `<span class="muted" style="font-size:0.78rem">${escapeHtml(s.description)}</span>` : ''}`;
|
|
|
|
|
|
ul.appendChild(li);
|
|
|
|
|
|
});
|
|
|
|
|
|
section.appendChild(ul);
|
|
|
|
|
|
container.appendChild(section);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 19:19:22 +01:00
|
|
|
|
CURATED_LISTS.forEach(list => {
|
|
|
|
|
|
const section = document.createElement('div');
|
|
|
|
|
|
section.className = 'curated-section';
|
|
|
|
|
|
section.innerHTML = `<div class="curated-label">${list.label}</div>`;
|
|
|
|
|
|
const ul = document.createElement('ul');
|
|
|
|
|
|
ul.className = 'curated-stations';
|
|
|
|
|
|
list.stations.forEach(s => {
|
|
|
|
|
|
const li = document.createElement('li');
|
|
|
|
|
|
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
|
|
|
|
|
|
<span class="curated-name">${escapeHtml(s.name)}</span>`;
|
|
|
|
|
|
ul.appendChild(li);
|
|
|
|
|
|
});
|
|
|
|
|
|
section.appendChild(ul);
|
|
|
|
|
|
container.appendChild(section);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 20:57:07 +01:00
|
|
|
|
// Donation hint
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const DONATION_HINT_THRESHOLD = 10;
|
|
|
|
|
|
const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
|
|
|
|
|
|
|
|
function maybeShowDonationHint(stationUrl, stationName) {
|
|
|
|
|
|
const station = INITIAL_SAVED.find(s => s.url === stationUrl);
|
|
|
|
|
|
if (!station || station.play_count < DONATION_HINT_THRESHOLD) return;
|
|
|
|
|
|
|
|
|
|
|
|
const key = `diora_donation_hint_${stationUrl}`;
|
|
|
|
|
|
const last = parseInt(localStorage.getItem(key) || '0', 10);
|
|
|
|
|
|
if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return;
|
|
|
|
|
|
|
|
|
|
|
|
const existing = document.getElementById('donation-hint');
|
|
|
|
|
|
if (existing) existing.remove();
|
|
|
|
|
|
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.id = 'donation-hint';
|
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
|
<span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot — consider supporting them ❤️</span>
|
|
|
|
|
|
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss">✕</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(el);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => dismissDonationHint(stationUrl), 12000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dismissDonationHint(stationUrl) {
|
|
|
|
|
|
localStorage.setItem(`diora_donation_hint_${stationUrl}`, Date.now());
|
|
|
|
|
|
const el = document.getElementById('donation-hint');
|
|
|
|
|
|
if (el) { el.classList.add('hiding'); setTimeout(() => el.remove(), 400); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 19:19:22 +01:00
|
|
|
|
// Station notes
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function editNotes(pk, current) {
|
|
|
|
|
|
const note = prompt('Station note:', current || '');
|
|
|
|
|
|
if (note === null) return; // cancelled
|
|
|
|
|
|
fetch(`/radio/notes/${pk}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
|
|
|
|
|
body: JSON.stringify({ notes: note }),
|
|
|
|
|
|
}).then(r => {
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
const cell = document.querySelector(`#saved-row-${pk} .notes-cell`);
|
|
|
|
|
|
if (cell) cell.textContent = note;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Tabs
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function showTab(name) {
|
|
|
|
|
|
const panels = ['search', 'saved', 'history', 'focus'];
|
|
|
|
|
|
panels.forEach(p => {
|
|
|
|
|
|
const panel = $(`tab-${p}`);
|
|
|
|
|
|
if (panel) panel.style.display = (p === name) ? '' : 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.tab-btn').forEach((btn, i) => {
|
|
|
|
|
|
btn.classList.toggle('active', panels[i] === name);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (name === 'saved') {
|
|
|
|
|
|
loadRecommendations();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Service Worker
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
|
|
|
|
window.addEventListener('load', () => {
|
|
|
|
|
|
navigator.serviceWorker.register('/static/js/sw.js').catch(err => {
|
|
|
|
|
|
console.warn('Service worker registration failed:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// M3U import
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function importM3U(input) {
|
|
|
|
|
|
const file = input.files[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
const status = document.getElementById('import-status');
|
|
|
|
|
|
status.textContent = 'Importing…';
|
|
|
|
|
|
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append('file', file);
|
|
|
|
|
|
form.append('csrfmiddlewaretoken', getCsrfToken());
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/radio/import/', { method: 'POST', body: form });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.ok) {
|
|
|
|
|
|
status.textContent = `✓ ${data.added} added, ${data.skipped} already saved`;
|
|
|
|
|
|
if (data.added > 0) location.reload();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
status.textContent = `Error: ${data.error}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
status.textContent = 'Upload failed';
|
|
|
|
|
|
}
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Contrast scheme
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// Accent palette — ordered by preference. Algorithm picks the one with the
|
|
|
|
|
|
// highest WCAG contrast ratio against the detected background luminance.
|
|
|
|
|
|
const ACCENT_PALETTE = [
|
|
|
|
|
|
{ base: '#e63946', hover: '#ff4d58' }, // red
|
|
|
|
|
|
{ base: '#ff9500', hover: '#ffaa33' }, // orange
|
|
|
|
|
|
{ base: '#f1c40f', hover: '#f9d439' }, // yellow
|
|
|
|
|
|
{ base: '#2ecc71', hover: '#4ee88a' }, // green
|
|
|
|
|
|
{ base: '#00b4d8', hover: '#33c7e5' }, // cyan
|
|
|
|
|
|
{ base: '#4361ee', hover: '#6d84f4' }, // blue
|
|
|
|
|
|
{ base: '#c77dff', hover: '#d89fff' }, // purple
|
|
|
|
|
|
{ base: '#ff6b9d', hover: '#ff8fb5' }, // pink
|
|
|
|
|
|
{ base: '#ffffff', hover: '#cccccc' }, // white (last resort)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function _linearise(c) {
|
|
|
|
|
|
c /= 255;
|
|
|
|
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
function _luminance(r, g, b) {
|
|
|
|
|
|
return 0.2126 * _linearise(r) + 0.7152 * _linearise(g) + 0.0722 * _linearise(b);
|
|
|
|
|
|
}
|
|
|
|
|
|
function _contrast(l1, l2) {
|
|
|
|
|
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
function _hexRgb(hex) {
|
|
|
|
|
|
return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function analyzeBackground(url) {
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
|
img.crossOrigin = 'anonymous';
|
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
canvas.width = 64; canvas.height = 64;
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
ctx.drawImage(img, 0, 0, 64, 64);
|
|
|
|
|
|
const data = ctx.getImageData(0, 0, 64, 64).data;
|
|
|
|
|
|
let tR = 0, tG = 0, tB = 0, tBt601 = 0;
|
|
|
|
|
|
const n = data.length / 4;
|
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
|
|
|
|
tR += data[i]; tG += data[i+1]; tB += data[i+2];
|
|
|
|
|
|
tBt601 += 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2];
|
|
|
|
|
|
}
|
|
|
|
|
|
resolve({
|
|
|
|
|
|
bright: (tBt601 / n) > 127,
|
|
|
|
|
|
bgLuminance: _luminance(tR/n, tG/n, tB/n),
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
img.onerror = () => resolve({ bright: false, bgLuminance: 0 });
|
|
|
|
|
|
img.src = url;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pickBestAccent(bgLuminance) {
|
|
|
|
|
|
let best = ACCENT_PALETTE[0], bestRatio = 0;
|
|
|
|
|
|
for (const entry of ACCENT_PALETTE) {
|
|
|
|
|
|
const [r, g, b] = _hexRgb(entry.base);
|
|
|
|
|
|
const ratio = _contrast(bgLuminance, _luminance(r, g, b));
|
|
|
|
|
|
if (ratio > bestRatio) { bestRatio = ratio; best = entry; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyAccent(entry) {
|
|
|
|
|
|
const root = document.documentElement;
|
|
|
|
|
|
root.style.setProperty('--accent', entry.base);
|
|
|
|
|
|
root.style.setProperty('--accent-hover', entry.hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setScheme(bright) {
|
|
|
|
|
|
document.body.classList.toggle('bright-bg', bright);
|
|
|
|
|
|
const btn = document.getElementById('contrast-toggle');
|
|
|
|
|
|
if (btn) btn.style.opacity = bright ? '1' : '0.5';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleContrast() {
|
|
|
|
|
|
setScheme(!document.body.classList.contains('bright-bg'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Init
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
(function init() {
|
|
|
|
|
|
// Populate saved stations from server-side context if available
|
|
|
|
|
|
if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) {
|
|
|
|
|
|
// The server already renders saved stations in the template; nothing extra needed.
|
|
|
|
|
|
// But if JS-rendered saved tab were needed we'd call addSavedRow here.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Restore persisted volume, fall back to slider default
|
|
|
|
|
|
const volSlider = $('volume');
|
|
|
|
|
|
if (volSlider) {
|
|
|
|
|
|
const saved = localStorage.getItem('diora_volume');
|
|
|
|
|
|
const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10);
|
2026-03-16 21:07:12 +01:00
|
|
|
|
setVolume(vol);
|
2026-03-16 19:19:22 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load recommendations on page load
|
|
|
|
|
|
loadRecommendations();
|
|
|
|
|
|
|
|
|
|
|
|
// Initialise focus timer display
|
|
|
|
|
|
renderTimer();
|
|
|
|
|
|
|
|
|
|
|
|
// Initialise mood/genre chips
|
|
|
|
|
|
initMoodChips();
|
|
|
|
|
|
|
|
|
|
|
|
// Initialise curated station lists
|
|
|
|
|
|
initCuratedLists();
|
|
|
|
|
|
|
|
|
|
|
|
// Show curated lists again when search input is cleared
|
|
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
|
|
|
|
if (searchInput) {
|
|
|
|
|
|
searchInput.addEventListener('input', function () {
|
|
|
|
|
|
if (this.value === '') {
|
|
|
|
|
|
const curated = document.getElementById('curated-lists');
|
|
|
|
|
|
if (curated) curated.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load focus session stats
|
|
|
|
|
|
loadFocusStats();
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-detect wallpaper brightness + best accent colour
|
|
|
|
|
|
const bgUrl = document.body.dataset.bg;
|
|
|
|
|
|
if (bgUrl) {
|
|
|
|
|
|
analyzeBackground(bgUrl).then(({ bright, bgLuminance }) => {
|
|
|
|
|
|
setScheme(bright);
|
|
|
|
|
|
applyAccent(pickBestAccent(bgLuminance));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|