const { invoke } = window.__TAURI__.core; const { getCurrentWindow } = window.__TAURI__.window; // In Tauri, the WebView may block insecure (http) images as mixed-content. // We can optionally fetch such images via backend and render as data: URLs. const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core); // State let stations = []; let currentIndex = 0; let isPlaying = false; let currentMode = 'local'; // 'local' | 'cast' let currentCastDevice = null; let currentCastTransport = null; // 'tap' | 'proxy' | 'direct' | null // Local playback is handled natively by the Tauri backend (player_* commands). // The WebView is a control surface only. let localPlayerPollId = null; // UI Elements const stationNameEl = document.getElementById('station-name'); const stationSubtitleEl = document.getElementById('station-subtitle'); const nowPlayingEl = document.getElementById('now-playing'); const nowArtistEl = document.getElementById('now-artist'); const nowTitleEl = document.getElementById('now-title'); const statusTextEl = document.getElementById('status-text'); const statusDotEl = document.querySelector('.status-dot'); const engineBadgeEl = document.getElementById('engine-badge'); const playBtn = document.getElementById('play-btn'); const iconPlay = document.getElementById('icon-play'); const iconStop = document.getElementById('icon-stop'); const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); const volumeSlider = document.getElementById('volume-slider'); const volumeValue = document.getElementById('volume-value'); const castBtn = document.getElementById('cast-toggle-btn'); const castOverlay = document.getElementById('cast-overlay'); const closeOverlayBtn = document.getElementById('close-overlay'); const deviceListEl = document.getElementById('device-list'); const coverflowStageEl = document.getElementById('artwork-coverflow-stage'); const coverflowPrevBtn = document.getElementById('artwork-prev'); const coverflowNextBtn = document.getElementById('artwork-next'); const artworkPlaceholder = document.querySelector('.artwork-placeholder'); const logoTextEl = document.querySelector('.station-logo-text'); const logoImgEl = document.getElementById('station-logo-img'); function toHttpsIfHttp(url) { if (!url || typeof url !== 'string') return ''; return url.startsWith('http://') ? ('https://' + url.slice('http://'.length)) : url; } function uniqueNonEmpty(urls) { const out = []; const seen = new Set(); for (const u of urls) { if (!u || typeof u !== 'string') continue; const trimmed = u.trim(); if (!trimmed) continue; if (seen.has(trimmed)) continue; seen.add(trimmed); out.push(trimmed); } return out; } function setImgWithFallback(imgEl, urls, onFinalError) { let dataFallbackUrls = []; // Backward compatible signature; allow passing { dataFallbackUrls } as 4th param. // (Implemented below via arguments inspection.) if (arguments.length >= 4 && arguments[3] && typeof arguments[3] === 'object') { const opt = arguments[3]; if (Array.isArray(opt.dataFallbackUrls)) dataFallbackUrls = opt.dataFallbackUrls; } const candidates = uniqueNonEmpty(urls); let i = 0; let dataIdx = 0; let triedData = false; if (!imgEl || candidates.length === 0) { if (imgEl) { imgEl.onload = null; imgEl.onerror = null; imgEl.src = ''; } if (onFinalError) onFinalError(); return; } const tryNext = () => { if (i >= candidates.length) { // If direct loads failed and we're in Tauri, try fetching via backend and set as data URL. if (runningInTauri && !triedData && dataFallbackUrls && dataFallbackUrls.length > 0) { triedData = true; const dataCandidates = uniqueNonEmpty(dataFallbackUrls); const tryData = () => { if (dataIdx >= dataCandidates.length) { if (onFinalError) onFinalError(); return; } const u = dataCandidates[dataIdx++]; invoke('fetch_image_data_url', { url: u }) .then((dataUrl) => { // Once we have a data URL, we can stop the fallback chain. imgEl.src = dataUrl; }) .catch(() => tryData()); }; tryData(); return; } if (onFinalError) onFinalError(); return; } const nextUrl = candidates[i++]; imgEl.src = nextUrl; }; imgEl.onload = () => { // keep last successful src }; imgEl.onerror = () => { tryNext(); }; // Some CDNs block referrers; this can improve logo load reliability. try { imgEl.referrerPolicy = 'no-referrer'; } catch (e) {} tryNext(); } // Global error handlers to avoid silent white screen and show errors in UI window.addEventListener('error', (ev) => { try { console.error('Uncaught error', ev.error || ev.message || ev); if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.error && ev.error.message ? ev.error.message : ev.message || 'Unknown'); } catch (e) { /* ignore */ } }); window.addEventListener('unhandledrejection', (ev) => { try { console.error('Unhandled rejection', ev.reason); if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.reason && ev.reason.message ? ev.reason.message : String(ev.reason)); } catch (e) { /* ignore */ } }); // Editor elements const editBtn = document.getElementById('edit-stations-btn'); const editorOverlay = document.getElementById('editor-overlay'); const editorCloseBtn = document.getElementById('editor-close-btn'); const editorListEl = document.getElementById('editor-list'); const addStationForm = document.getElementById('add-station-form'); const usTitle = document.getElementById('us_title'); const usUrl = document.getElementById('us_url'); const usLogo = document.getElementById('us_logo'); const usWww = document.getElementById('us_www'); const usId = document.getElementById('us_id'); const usIndex = document.getElementById('us_index'); // Init async function init() { try { // Helpful debug information for release builds so we can compare parity with dev. console.group && console.group('RadioCast init'); console.log('runningInTauri:', runningInTauri); try { console.log('location:', location.href); } catch (_) {} try { console.log('userAgent:', navigator.userAgent); } catch (_) {} try { console.log('platform:', navigator.platform); } catch (_) {} try { console.log('RADIO_DEBUG_DEVTOOLS flag:', localStorage.getItem('RADIO_DEBUG_DEVTOOLS')); } catch (_) {} // Always try to read build stamp if present (bundled by build scripts). try { const resp = await fetch('/build-info.json', { cache: 'no-store' }); if (resp && resp.ok) { const bi = await resp.json(); console.log('build-info:', bi); } else { console.log('build-info: not present'); } } catch (e) { console.log('build-info: failed to read'); } restoreSavedVolume(); await loadStations(); try { console.log('stations loaded:', Array.isArray(stations) ? stations.length : typeof stations); } catch (_) {} setupEventListeners(); ensureArtworkPointerFallback(); updateUI(); updateEngineBadge(); // Optionally open devtools in release builds for debugging parity with `tauri dev`. // Enable by setting `localStorage.setItem('RADIO_DEBUG_DEVTOOLS', '1')` or by creating // `src/build-info.json` with { debug: true } at build time (the `build:devlike` script does this). try { let shouldOpen = false; try { if (localStorage && localStorage.getItem && localStorage.getItem('RADIO_DEBUG_DEVTOOLS') === '1') shouldOpen = true; } catch (_) {} // Build-time flag file (created by tools/write-build-flag.js when running `build`/`build:devlike`). try { const resp = await fetch('/build-info.json', { cache: 'no-store' }); if (resp && resp.ok) { const bi = await resp.json(); if (bi && bi.debug) shouldOpen = true; } } catch (_) {} if (shouldOpen) { try { const w = getCurrentWindow(); if (w && typeof w.openDevTools === 'function') { w.openDevTools(); console.log('Opened devtools via build-info/localStorage flag'); } } catch (e) { console.warn('Failed to open devtools:', e); } } } catch (e) { /* ignore */ } console.groupEnd && console.groupEnd(); } catch (e) { console.error('Error during init', e); if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e)); } } function updateEngineBadge() { if (!engineBadgeEl) return; // In this app: // - Local playback uses the native backend (FFmpeg decode + CPAL output). // - Cast mode plays via Chromecast. const kind = currentMode === 'cast' ? 'cast' : 'ffmpeg'; const label = kind === 'cast' ? 'CAST' : 'FFMPEG'; const title = kind === 'cast' ? 'Google Cast playback' : 'Native playback (FFmpeg)'; const iconSvg = kind === 'cast' ? `` : ``; engineBadgeEl.innerHTML = `${iconSvg}${label}`; engineBadgeEl.title = title; engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html'); engineBadgeEl.classList.add(`engine-${kind}`); } // Volume persistence function saveVolumeToStorage(val) { try { localStorage.setItem('volume', String(val)); } catch (e) { /* ignore */ } } function getSavedVolume() { try { const v = localStorage.getItem('volume'); if (!v) return null; const n = Number(v); if (Number.isFinite(n) && n >= 0 && n <= 100) return n; return null; } catch (e) { return null; } } function restoreSavedVolume() { const saved = getSavedVolume(); if (saved !== null && volumeSlider) { volumeSlider.value = String(saved); volumeValue.textContent = `${saved}%`; const decimals = saved / 100; // Keep backend player volume in sync (best-effort). invoke('player_set_volume', { volume: decimals }).catch(() => {}); // If currently in cast mode and a device is selected, propagate volume if (currentMode === 'cast' && currentCastDevice) { invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }).catch(()=>{}); } } } function stopLocalPlayerStatePolling() { if (localPlayerPollId) { try { clearInterval(localPlayerPollId); } catch (e) {} localPlayerPollId = null; } } function startLocalPlayerStatePolling() { stopLocalPlayerStatePolling(); // Polling keeps the existing UI in sync with native buffering/reconnect. localPlayerPollId = setInterval(async () => { try { if (!isPlaying || currentMode !== 'local') return; const st = await invoke('player_get_state'); if (!st || !statusTextEl || !statusDotEl) return; const status = String(st.status || '').toLowerCase(); if (status === 'buffering') { statusTextEl.textContent = 'Buffering...'; statusDotEl.style.backgroundColor = 'var(--text-muted)'; } else if (status === 'playing') { statusTextEl.textContent = 'Playing'; statusDotEl.style.backgroundColor = 'var(--success)'; } else if (status === 'error') { statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error'; statusDotEl.style.backgroundColor = 'var(--danger)'; // Backend is no longer playing; reflect that in UX. isPlaying = false; stopLocalPlayerStatePolling(); updateUI(); } else if (status === 'stopped' || status === 'idle') { isPlaying = false; stopLocalPlayerStatePolling(); updateUI(); } else { // idle/stopped: keep UI consistent with our isPlaying flag } } catch (e) { // Don't spam; just surface a minimal indicator. try { if (statusTextEl) statusTextEl.textContent = 'Error'; } catch (_) {} } }, 600); } async function loadStations() { try { // stop any existing pollers before reloading stations stopCurrentSongPollers(); const resp = await fetch('/stations.json'); const raw = await resp.json(); // Normalize station objects so the rest of the app can rely on `name` and `url`. stations = raw .map((s) => { // If already in the old format, keep as-is if (s.name && s.url) return s; const name = s.title || s.id || s.name || 'Unknown'; // Prefer liveAudio, fall back to liveVideo or any common fields const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || ''; return { id: s.id || name, name, url, logo: s.logo || s.poster || '', enabled: typeof s.enabled === 'boolean' ? s.enabled : true, raw: s, }; }) // Filter out disabled stations and those without a stream URL .filter((s) => s.enabled !== false && s.url && s.url.length > 0); // Load user-defined stations from localStorage and merge const user = loadUserStations(); const userNormalized = user .map((s) => { const name = s.title || s.name || s.id || 'UserStation'; const url = s.url || s.liveAudio || s.liveVideo || ''; return { id: s.id || `user-${name.replace(/\s+/g, '-')}`, name, url, logo: s.logo || '', enabled: typeof s.enabled === 'boolean' ? s.enabled : true, raw: s, _user: true, }; }) .filter((s) => s.url && s.url.length > 0); // Append user stations after file stations stations = stations.concat(userNormalized); // Debug: report how many stations we have after loading try { console.debug('loadStations: loaded stations count:', stations.length); } catch (e) {} if (stations.length > 0) { // Try to restore last selected station by id const lastId = getLastStationId(); if (lastId) { const found = stations.findIndex(s => s.id === lastId); if (found >= 0) currentIndex = found; else currentIndex = 0; } else { currentIndex = 0; } console.debug('loadStations: loading station index', currentIndex); loadStation(currentIndex); renderCoverflow(); // start polling for currentSong endpoints (if any) startCurrentSongPollers(); } } catch (e) { console.error('Failed to load stations', e); statusTextEl.textContent = 'Error loading stations'; } } // --- Coverflow UI (3D-ish station cards like your reference image) --- let coverflowPointerId = null; let coverflowStartX = 0; let coverflowLastX = 0; let coverflowAccum = 0; let coverflowMoved = false; let coverflowWheelLock = false; function renderCoverflow() { try { if (!coverflowStageEl) return; coverflowStageEl.innerHTML = ''; stations.forEach((s, idx) => { const item = document.createElement('div'); item.className = 'coverflow-item'; item.dataset.idx = String(idx); const rawLogoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || ''; const fallbackLabel = (s && s.name ? String(s.name) : '?').trim(); item.title = fallbackLabel; if (rawLogoUrl) { const img = document.createElement('img'); img.alt = `${s.name} logo`; // Try https first (avoids mixed-content blocks), then fall back to original. const candidates = [ toHttpsIfHttp(rawLogoUrl), rawLogoUrl, ]; setImgWithFallback(img, candidates, () => { item.innerHTML = ''; item.classList.add('fallback'); item.textContent = fallbackLabel; }, { dataFallbackUrls: [rawLogoUrl] }); item.appendChild(img); } else { item.classList.add('fallback'); item.textContent = fallbackLabel; } // Click a card: if it's not selected, select it. // Double-click the selected card to open the full station grid. item.addEventListener('click', async (ev) => { if (coverflowMoved) return; const idxClicked = Number(item.dataset.idx); if (idxClicked !== currentIndex) { await setStationByIndex(idxClicked); } }); item.addEventListener('dblclick', (ev) => { const idxClicked = Number(item.dataset.idx); if (idxClicked === currentIndex) openStationsOverlay(); }); coverflowStageEl.appendChild(item); }); wireCoverflowInteractions(); updateCoverflowTransforms(); } catch (e) { console.debug('renderCoverflow failed', e); } } function wireCoverflowInteractions() { try { const host = document.getElementById('artwork-coverflow'); if (!host) return; // Buttons // IMPORTANT: prevent the coverflow drag handler (pointer capture) from swallowing button clicks. if (coverflowPrevBtn) { coverflowPrevBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} }; coverflowPrevBtn.onclick = (ev) => { try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {} setStationByIndex((currentIndex - 1 + stations.length) % stations.length); }; } if (coverflowNextBtn) { coverflowNextBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} }; coverflowNextBtn.onclick = (ev) => { try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {} setStationByIndex((currentIndex + 1) % stations.length); }; } // Pointer drag (mouse/touch) host.onpointerdown = (ev) => { if (!stations || stations.length <= 1) return; // If the user clicked the arrow buttons, let the button handler run. // Otherwise pointer capture can prevent the click from reaching the button. try { if (ev.target && ev.target.closest && ev.target.closest('.coverflow-arrow')) return; } catch (e) {} coverflowPointerId = ev.pointerId; coverflowStartX = ev.clientX; coverflowLastX = ev.clientX; coverflowAccum = 0; coverflowMoved = false; try { host.setPointerCapture(ev.pointerId); } catch (e) {} }; host.onpointermove = (ev) => { if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; const dx = ev.clientX - coverflowLastX; coverflowLastX = ev.clientX; if (Math.abs(ev.clientX - coverflowStartX) > 6) coverflowMoved = true; // Accumulate movement; change station when threshold passed. coverflowAccum += dx; const threshold = 36; if (coverflowAccum >= threshold) { coverflowAccum = 0; setStationByIndex((currentIndex - 1 + stations.length) % stations.length); } else if (coverflowAccum <= -threshold) { coverflowAccum = 0; setStationByIndex((currentIndex + 1) % stations.length); } }; host.onpointerup = (ev) => { if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; coverflowPointerId = null; // reset moved flag after click would have fired setTimeout(() => { coverflowMoved = false; }, 0); try { host.releasePointerCapture(ev.pointerId); } catch (e) {} }; host.onpointercancel = (ev) => { coverflowPointerId = null; coverflowMoved = false; }; // Wheel: next/prev with debounce host.onwheel = (ev) => { if (!stations || stations.length <= 1) return; if (coverflowWheelLock) return; const delta = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; if (Math.abs(delta) < 6) return; ev.preventDefault(); coverflowWheelLock = true; if (delta > 0) setStationByIndex((currentIndex + 1) % stations.length); else setStationByIndex((currentIndex - 1 + stations.length) % stations.length); setTimeout(() => { coverflowWheelLock = false; }, 160); }; } catch (e) { console.debug('wireCoverflowInteractions failed', e); } } function updateCoverflowTransforms() { try { if (!coverflowStageEl) return; const items = coverflowStageEl.querySelectorAll('.coverflow-item'); const n = stations ? stations.length : 0; if (n <= 0) return; const maxVisible = 3; items.forEach((el) => { const idx = Number(el.dataset.idx); // Treat the station list as circular so the coverflow loops infinitely. // This makes the "previous" of index 0 be the last station, etc. let offset = idx - currentIndex; const half = Math.floor(n / 2); if (offset > half) offset -= n; if (offset < -half) offset += n; if (Math.abs(offset) > maxVisible) { el.style.opacity = '0'; el.style.pointerEvents = 'none'; el.style.transform = 'translate(-50%, -50%) scale(0.6)'; return; } const abs = Math.abs(offset); const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1); const translateX = dir * (abs * 78); const translateZ = -abs * 70; const rotateY = dir * (-28 * abs); const scale = 1 - abs * 0.12; const opacity = 1 - abs * 0.18; const zIndex = 100 - abs; el.style.opacity = String(opacity); el.style.zIndex = String(zIndex); el.style.pointerEvents = 'auto'; el.style.transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`; if (offset === 0) el.classList.add('selected'); else el.classList.remove('selected'); }); } catch (e) { console.debug('updateCoverflowTransforms failed', e); } } async function setStationByIndex(idx) { if (idx < 0 || idx >= stations.length) return; const wasPlaying = isPlaying; if (wasPlaying) await stop(); currentIndex = idx; saveLastStationId(stations[currentIndex].id); loadStation(currentIndex); updateCoverflowTransforms(); if (wasPlaying) await play(); } // --- Current Song Polling --- const currentSongPollers = new Map(); // stationId -> { intervalId, timeoutId } // Attempt to discover the radio.svet24 asset query token used by lastsongs URLs. // This fetches a stable asset JS (as used on the site) and extracts the value // between `?` and `")` using a JS-friendly regex. It caches the token on the // `station._assetToken` property so pollers can append it to lastsongs requests. async function getAssetTokenForStation(station) { try { if (!station) return null; if (station._assetToken) return station._assetToken; // Primary known asset location used on the site const assetUrl = 'https://radio.svet24.si/assets/index-IMUbJO1D.js'; let body = null; try { // Use backend proxy to avoid CORS body = await invoke('fetch_url', { url: assetUrl }); } catch (e) { // If fetching the known asset fails, try the station's homepage as a fallback try { const home = station.raw && (station.raw.www || station.raw.homepage || station.raw.url); if (home) body = await invoke('fetch_url', { url: String(home) }); } catch (e2) { // swallow fallback errors } } if (!body) return null; // Extract the token: grep -oP '\?\K[^"]+(?="\))' equivalence in JS // We'll capture the group after the question mark and before the '"))' sequence. const m = body.match(/\?([^"\)]+)(?="\))/); if (m && m[1]) { const token = m[1].replace(/^\?/, ''); station._assetToken = token; console.debug('getAssetTokenForStation: found token for', station.id || station.name, token); return token; } console.debug('getAssetTokenForStation: token not found in fetched asset for', station.id || station.name); return null; } catch (e) { console.debug('getAssetTokenForStation failed', e && e.message ? e.message : e); return null; } } function stopCurrentSongPollers() { for (const entry of currentSongPollers.values()) { try { if (entry && entry.intervalId) clearInterval(entry.intervalId); } catch (e) {} try { if (entry && entry.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {} } currentSongPollers.clear(); } function startCurrentSongPollers() { // Clear existing (we only run poller for the currently selected station) stopCurrentSongPollers(); const idx = currentIndex; const s = stations[idx]; if (!s) return; // Prefer explicit `currentSong` endpoint, fall back to `lastSongs` endpoint let url = s.raw && (s.raw.currentSong || s.raw.lastSongs); if (url && typeof url === 'string' && url.length > 0) { const baseUrl = String(url); // Create a wrapper that computes the effective fetch URL each time so that // any token discovered asynchronously on the station object is applied // without restarting the poller. const doFetch = () => { try { let fetchUrl = baseUrl; if (/lastsongsxml/i.test(fetchUrl)) { const token = s._assetToken || null; if (token) { fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + token; } else { // temporary cache-busting param while token discovery runs fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + Date.now(); // start background retrieval of token for future requests getAssetTokenForStation(s).catch(() => {}); } } fetchAndStoreCurrentSong(s, idx, fetchUrl); } catch (e) { console.debug('doFetch wrapper error for station', s.id || idx, e); } }; // fetch immediately and then every 10s doFetch(); const iid = setInterval(doFetch, 10000); // save baseUrl for potential restart and track interval/timeout s._lastSongsBaseUrl = baseUrl; currentSongPollers.set(s.id || idx, { intervalId: iid, timeoutId: null }); } } async function fetchAndStoreCurrentSong(station, idx, url) { try { let data = null; // If the URL is remote (http/https), use the backend `fetch_url` to bypass CORS. let rawBody = null; if (/^https?:\/\//i.test(url)) { rawBody = await invoke('fetch_url', { url }); } else { const resp = await fetch(url, { cache: 'no-store' }); rawBody = await resp.text(); } // Debug: show fetched body length (avoid huge dumps) console.debug('fetchAndStoreCurrentSong fetched', url, 'len=', rawBody ? String(rawBody.length) : 'null'); // Try to parse JSON. Some endpoints double-encode JSON as a string (i.e. // the response is a JSON string containing escaped JSON). Handle both // cases: direct object, or string that needs a second parse. try { if (!rawBody) { data = null; } else { const first = JSON.parse(rawBody); if (typeof first === 'string') { // e.g. "{\"title\":...}" try { data = JSON.parse(first); } catch (e2) { // If second parse fails, attempt to unescape common backslashes and reparse const unescaped = first.replace(/\\"/g, '"').replace(/\\n/g, '').replace(/\\/g, ''); try { data = JSON.parse(unescaped); } catch (e3) { console.debug('Failed second-stage parse for', url, e3); data = null; } } } else { data = first; } } } catch (e) { console.debug('Failed to parse JSON from', url, e); data = null; } // Normalise different shapes to a currentSong object if possible let now = null; if (data) { if (data.currentSong && (data.currentSong.artist || data.currentSong.title)) { now = { artist: data.currentSong.artist || '', title: data.currentSong.title || '' }; } else if (Array.isArray(data.lastSongs) && data.lastSongs.length > 0) { const first = data.lastSongs[0]; if (first && (first.artist || first.title)) now = { artist: first.artist || '', title: first.title || '' }; } else if (data.artist || data.title) { now = { artist: data.artist || '', title: data.title || '' }; } else if (data.title && !data.artist) { // Some stations use `title` only now = { artist: '', title: data.title }; } } if (now) { station.currentSongInfo = now; // update UI if this is the currently loaded station if (idx === currentIndex) updateNowPlayingUI(); // If we have timing info (from the provider), schedule a single-shot // refresh to align with the next song instead of polling continuously. try { const key = station.id || idx; // prefer the provider currentSong object if available const providerCS = data && data.currentSong ? data.currentSong : null; const startStr = providerCS && providerCS.playTimeStartSec ? providerCS.playTimeStartSec : (providerCS && providerCS.playTimeStart ? providerCS.playTimeStart : null); const lengthStr = providerCS && providerCS.playTimeLengthSec ? providerCS.playTimeLengthSec : (providerCS && providerCS.playTimeLength ? providerCS.playTimeLength : null); if (startStr && lengthStr) { // parse start time `HH:MM:SS` (or `H:MM:SS`) and length `M:SS` or `MM:SS` const nowDate = new Date(); const parts = startStr.split(':').map(p=>Number(p)); if (parts.length >= 2) { const startDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), parts[0], parts[1], parts[2]||0, 0); // adjust if start appears to be on previous day (large positive diff) const deltaStart = startDate.getTime() - nowDate.getTime(); if (deltaStart > 12*3600*1000) startDate.setDate(startDate.getDate() - 1); if (deltaStart < -12*3600*1000) startDate.setDate(startDate.getDate() + 1); const lenParts = lengthStr.split(':').map(p=>Number(p)); let lenSec = 0; if (lenParts.length === 3) lenSec = lenParts[0]*3600 + lenParts[1]*60 + lenParts[2]; else if (lenParts.length === 2) lenSec = lenParts[0]*60 + lenParts[1]; else lenSec = Number(lenParts[0]) || 0; const endMs = startDate.getTime() + (lenSec * 1000); const msUntilEnd = endMs - nowDate.getTime(); if (msUntilEnd > 1000) { // Clear existing interval and timeouts for this station const entry = currentSongPollers.get(key); if (entry && entry.intervalId) { try { clearInterval(entry.intervalId); } catch (e) {} } if (entry && entry.timeoutId) { try { clearTimeout(entry.timeoutId); } catch (e) {} } // Schedule a one-shot fetch at song end. After firing, restart pollers const timeoutId = setTimeout(async () => { try { // re-fetch using the same effective URL (may include token) await fetchAndStoreCurrentSong(station, idx, url); } catch (e) { console.debug('scheduled fetch failed for', key, e); } finally { // If still on this station, restart the regular poller if (currentIndex === idx) startCurrentSongPollers(); } }, msUntilEnd + 250); currentSongPollers.set(key, { intervalId: null, timeoutId }); console.debug('Scheduled next fetch for', key, 'in ms=', msUntilEnd); } } } } catch (e) { console.debug('Failed scheduling next-song fetch', e); } } } catch (e) { // ignore fetch errors silently (network/CORS) but keep console for debugging console.debug('currentSong fetch failed for', url, e.message || e); } } function updateNowPlayingUI() { const station = stations[currentIndex]; if (!station) return; if (nowPlayingEl && nowArtistEl && nowTitleEl) { if (station.currentSongInfo && station.currentSongInfo.artist && station.currentSongInfo.title) { nowArtistEl.textContent = station.currentSongInfo.artist; nowTitleEl.textContent = station.currentSongInfo.title; nowPlayingEl.classList.remove('hidden'); } else { nowArtistEl.textContent = ''; nowTitleEl.textContent = ''; nowPlayingEl.classList.add('hidden'); } } // keep subtitle for mode/status stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream'; } // --- User Stations (localStorage) --- function loadUserStations() { try { const raw = localStorage.getItem('userStations'); if (!raw) return []; return JSON.parse(raw); } catch (e) { console.error('Error reading user stations', e); return []; } } function saveUserStations(arr) { try { localStorage.setItem('userStations', JSON.stringify(arr || [])); } catch (e) { console.error('Error saving user stations', e); } } function openEditorOverlay() { renderUserStationsList(); editorOverlay.classList.remove('hidden'); editorOverlay.setAttribute('aria-hidden', 'false'); } function closeEditorOverlay() { editorOverlay.classList.add('hidden'); editorOverlay.setAttribute('aria-hidden', 'true'); // clear form addStationForm.reset(); usIndex.value = ''; } function renderUserStationsList() { const list = loadUserStations(); editorListEl.innerHTML = ''; if (!list || list.length === 0) { editorListEl.innerHTML = '