/** * 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, '"'); } // --------------------------------------------------------------------------- // 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'); if (volSlider) audio.volume = volSlider.value / 100; 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 = ''; $('play-stop-btn').style.display = ''; $('play-stop-btn').textContent = '⏹ Stop'; $('play-stop-btn').classList.add('playing'); $('save-station-btn').style.display = ''; startMetadataSSE(url); startPlaySession(name, url); } 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 = ''; $('play-stop-btn').style.display = 'none'; } } 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 = '

Play more stations to get recommendations.

'; return; } const label = data.context; let html = `

Based on your ${label} listening:

'; container.innerHTML = html; } catch (e) {} } function escapeAttr(s) { return String(s).replace(/'/g, "\\'").replace(/"/g, '"'); } // --------------------------------------------------------------------------- // Volume // --------------------------------------------------------------------------- document.getElementById('volume').addEventListener('input', function () { audio.volume = this.value / 100; localStorage.setItem('diora_volume', this.value); }); // --------------------------------------------------------------------------- // 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 = ` ${escapeHtml(formatDateTime(now))} ${escapeHtml(stationName)} ${escapeHtml(track)} `; tbody.insertBefore(tr, tbody.firstChild); } 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) {} } // --------------------------------------------------------------------------- // Affiliate links // --------------------------------------------------------------------------- async function fetchAffiliateLinks(track) { const section = $('affiliate-section'); 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 = ` ${safeName} ${safeBr} ${safeCC} ${safeTags} `; 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 = ` ${safeName} ${safeBr} ${safeCC} `; 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 = 'No saved stations yet.'; 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 = 'No focus sessions yet. Start the timer!'; return; } data.sessions.forEach(s => { const tr = document.createElement('tr'); const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'}); tr.innerHTML = `${dt}${escapeHtml(s.station_name || '—')}${s.duration_minutes} min`; 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; CURATED_LISTS.forEach(list => { const section = document.createElement('div'); section.className = 'curated-section'; section.innerHTML = `
${list.label}
`; const ul = document.createElement('ul'); ul.className = 'curated-stations'; list.stations.forEach(s => { const li = document.createElement('li'); li.innerHTML = ` ${escapeHtml(s.name)}`; ul.appendChild(li); }); section.appendChild(ul); container.appendChild(section); }); } // --------------------------------------------------------------------------- // 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); volSlider.value = vol; audio.volume = vol / 100; } // 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)); }); } })();