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 = '
  • No user stations
    Add your stream using the form below
  • '; return; } list.forEach((s, idx) => { const li = document.createElement('li'); li.className = 'device'; const main = s.title || s.name || s.id || 'User Station'; const sub = s.url || ''; li.innerHTML = `
    ${main}
    ${sub}
    `; editorListEl.appendChild(li); }); // Attach handlers editorListEl.querySelectorAll('.edit-btn').forEach(b => { b.addEventListener('click', () => { const idx = Number(b.getAttribute('data-idx')); editUserStation(idx); }); }); editorListEl.querySelectorAll('.delete-btn').forEach(b => { b.addEventListener('click', () => { const idx = Number(b.getAttribute('data-idx')); deleteUserStation(idx); }); }); } function editUserStation(idx) { const list = loadUserStations(); const s = list[idx]; if (!s) return; usTitle.value = s.title || s.name || ''; usUrl.value = s.url || s.liveAudio || ''; usLogo.value = s.logo || ''; usWww.value = s.www || s.website || ''; usId.value = s.id || ''; usIndex.value = String(idx); } function deleteUserStation(idx) { const list = loadUserStations(); list.splice(idx, 1); saveUserStations(list); // refresh stations in runtime refreshStationsFromSources(); renderUserStationsList(); } function refreshStationsFromSources() { // reload stations.json and user stations into `stations` array // For simplicity, re-run loadStations() loadStations(); } // Persist last-selected station id between sessions function saveLastStationId(id) { try { if (!id) return; localStorage.setItem('lastStationId', id); } catch (e) { console.error('Failed to save last station id', e); } } function getLastStationId() { try { return localStorage.getItem('lastStationId'); } catch (e) { return null; } } // Handle form submit (add/update) addStationForm && addStationForm.addEventListener('submit', (e) => { e.preventDefault(); const list = loadUserStations(); const station = { id: usId.value || `user-${Date.now()}`, title: usTitle.value.trim(), url: usUrl.value.trim(), logo: usLogo.value.trim(), www: usWww.value.trim(), enabled: true, }; const idx = usIndex.value === '' ? -1 : Number(usIndex.value); if (idx >= 0 && idx < list.length) { list[idx] = station; } else { list.push(station); } saveUserStations(list); renderUserStationsList(); refreshStationsFromSources(); addStationForm.reset(); usIndex.value = ''; }); // Editor button handlers editBtn && editBtn.addEventListener('click', openEditorOverlay); editorCloseBtn && editorCloseBtn.addEventListener('click', closeEditorOverlay); function setupEventListeners() { playBtn.addEventListener('click', togglePlay); prevBtn.addEventListener('click', playPrev); nextBtn.addEventListener('click', playNext); volumeSlider.addEventListener('input', handleVolumeInput); castBtn.addEventListener('click', openCastOverlay); closeOverlayBtn.addEventListener('click', closeCastOverlay); // Close overlay on background click castOverlay.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); }); // Close button document.getElementById('close-btn').addEventListener('click', async () => { const appWindow = getCurrentWindow(); await appWindow.close(); }); // Menu button - explicit functionality or placeholder? // Menu removed — header click opens stations via artwork placeholder // Click artwork to open stations chooser artworkPlaceholder && artworkPlaceholder.addEventListener('click', openStationsOverlay); // Hotkeys? } // If CSS doesn't produce a pointer, this helper forces a pointer when the // mouse is inside the artwork placeholder's bounding rect. This handles // cases where an invisible overlay or ancestor blocks pointer styling. function ensureArtworkPointerFallback() { try { const ap = artworkPlaceholder; if (!ap) return; // Quick inline style fallback (helps when CSS is overridden) try { ap.style.cursor = 'pointer'; } catch (e) {} let active = false; const onMove = (ev) => { try { const r = ap.getBoundingClientRect(); const x = ev.clientX, y = ev.clientY; const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; if (inside && !active) { document.body.style.cursor = 'pointer'; active = true; } else if (!inside && active) { document.body.style.cursor = ''; active = false; } } catch (e) {} }; window.addEventListener('mousemove', onMove); // remove on unload window.addEventListener('beforeunload', () => { try { window.removeEventListener('mousemove', onMove); } catch (e) {} }); } catch (e) { console.debug('ensureArtworkPointerFallback failed', e); } } function loadStation(index) { if (index < 0 || index >= stations.length) return; const station = stations[index]; stationNameEl.textContent = station.name; stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream'; // clear now playing when loading a new station; will be updated by poller if available if (nowPlayingEl) nowPlayingEl.classList.add('hidden'); if (nowArtistEl) nowArtistEl.textContent = ''; if (nowTitleEl) nowTitleEl.textContent = ''; // Update main artwork logo (best-effort). Many station logo URLs are http; try https first. try { if (logoTextEl && station && station.name) { logoTextEl.textContent = String(station.name).trim(); logoTextEl.classList.add('logo-name'); } const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || '')))) || ''; const rawPoster = (station && ((station.raw && station.raw.poster) || station.poster || '')) || ''; if (logoImgEl) { // Show fallback until load completes. logoImgEl.classList.add('hidden'); if (logoTextEl) logoTextEl.classList.remove('hidden'); const candidates = uniqueNonEmpty([ toHttpsIfHttp(rawLogo), rawLogo, toHttpsIfHttp(rawPoster), rawPoster, ]); setImgWithFallback(logoImgEl, candidates, () => { logoImgEl.classList.add('hidden'); if (logoTextEl) logoTextEl.classList.remove('hidden'); }, { dataFallbackUrls: [rawLogo, rawPoster] }); // If something loads successfully, show it. logoImgEl.onload = () => { logoImgEl.classList.remove('hidden'); if (logoTextEl) logoTextEl.classList.add('hidden'); }; } } catch (e) { // non-fatal } // Sync coverflow transforms (if present) try { updateCoverflowTransforms(); } catch (e) {} // When loading a station, ensure only this station's poller runs try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); } } // Check if an image URL is reachable and valid function checkImageExists(url, timeout = 6000) { return new Promise((resolve) => { if (!url) return resolve(false); try { const img = new Image(); let timedOut = false; const t = setTimeout(() => { timedOut = true; img.src = ''; // stop load resolve(false); }, timeout); img.onload = () => { if (!timedOut) { clearTimeout(t); resolve(true); } }; img.onerror = () => { if (!timedOut) { clearTimeout(t); resolve(false); } }; // Bypass caching oddities by assigning after handlers img.src = url; } catch (e) { resolve(false); } }); } async function togglePlay() { if (isPlaying) { await stop(); } else { await play(); } } async function play() { const station = stations[currentIndex]; if (!station) return; statusTextEl.textContent = 'Buffering...'; statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading if (currentMode === 'local') { try { const vol = volumeSlider.value / 100; await invoke('player_set_volume', { volume: vol }).catch(() => {}); await invoke('player_play', { url: station.url }); isPlaying = true; updateUI(); startLocalPlayerStatePolling(); } catch (e) { console.error('Playback failed', e); statusTextEl.textContent = 'Error'; } } else if (currentMode === 'cast' && currentCastDevice) { // Cast logic try { // UX guard: if native playback is currently decoding a different station, // stop it explicitly before starting the cast pipeline (which would otherwise // replace the decoder behind the scenes). try { const st = await invoke('player_get_state'); const nativeActive = st && (st.status === 'playing' || st.status === 'buffering') && st.url; if (nativeActive && st.url !== station.url) { stopLocalPlayerStatePolling(); await invoke('player_stop').catch(() => {}); } } catch (_) { // Ignore: best-effort guard only. } let castUrl = station.url; currentCastTransport = null; try { const res = await invoke('cast_proxy_start', { deviceName: currentCastDevice, url: station.url }); if (res && typeof res === 'object') { castUrl = res.url || station.url; currentCastTransport = res.mode || 'proxy'; } else { // Backward-compat (older backend returned string) castUrl = res || station.url; currentCastTransport = 'proxy'; } } catch (e) { // If proxy cannot start (ffmpeg missing, firewall, etc), fall back to direct station URL. console.warn('Cast proxy start failed; falling back to direct URL', e); currentCastTransport = 'direct'; } await invoke('cast_play', { deviceName: currentCastDevice, url: castUrl }); isPlaying = true; // Sync volume const vol = volumeSlider.value / 100; invoke('cast_set_volume', { deviceName: currentCastDevice, volume: vol }); updateUI(); } catch (e) { console.error('Cast failed', e); statusTextEl.textContent = 'Cast Error (check LAN/firewall)'; await invoke('cast_proxy_stop').catch(() => {}); currentMode = 'local'; // Fallback currentCastTransport = null; updateUI(); } } } async function stop() { if (currentMode === 'local') { stopLocalPlayerStatePolling(); try { await invoke('player_stop'); } catch (e) { console.error(e); } } else if (currentMode === 'cast' && currentCastDevice) { try { await invoke('cast_proxy_stop').catch(() => {}); await invoke('cast_stop', { deviceName: currentCastDevice }); } catch (e) { console.error(e); } } isPlaying = false; if (currentMode !== 'cast') { currentCastTransport = null; } updateUI(); } async function playNext() { if (stations.length === 0) return; // If playing, stop first? Or seamless? // For radio, seamless switch requires stop then play new URL const nextIndex = (currentIndex + 1) % stations.length; await setStationByIndex(nextIndex); } async function playPrev() { if (stations.length === 0) return; const prevIndex = (currentIndex - 1 + stations.length) % stations.length; await setStationByIndex(prevIndex); } function updateUI() { // Play/Stop Button if (isPlaying) { iconPlay.classList.add('hidden'); iconStop.classList.remove('hidden'); playBtn.classList.add('playing'); // Add pulsing ring animation statusTextEl.textContent = 'Playing'; statusDotEl.style.backgroundColor = 'var(--success)'; if (currentMode === 'cast') { const t = currentCastTransport ? ` (${currentCastTransport})` : ''; stationSubtitleEl.textContent = `Casting${t} to ${currentCastDevice}`; } else { stationSubtitleEl.textContent = 'Live Stream'; } } else { iconPlay.classList.remove('hidden'); iconStop.classList.add('hidden'); playBtn.classList.remove('playing'); // Remove pulsing ring statusTextEl.textContent = 'Ready'; statusDotEl.style.backgroundColor = 'var(--text-muted)'; if (currentMode === 'cast') { const t = currentCastTransport ? ` (${currentCastTransport})` : ''; stationSubtitleEl.textContent = `Connected${t} to ${currentCastDevice}`; } else { stationSubtitleEl.textContent = 'Live Stream'; } } updateEngineBadge(); } function handleVolumeInput() { const val = volumeSlider.value; volumeValue.textContent = `${val}%`; const decimals = val / 100; if (currentMode === 'local') { invoke('player_set_volume', { volume: decimals }).catch(() => {}); } else if (currentMode === 'cast' && currentCastDevice) { invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }); } // persist volume for next sessions saveVolumeToStorage(Number(val)); } // Cast Logic async function openCastOverlay() { castOverlay.classList.remove('hidden'); castOverlay.setAttribute('aria-hidden', 'false'); // ensure cast overlay shows linear list style deviceListEl.classList.remove('stations-grid'); deviceListEl.innerHTML = '
  • Scanning...
    Searching for speakers
  • '; try { const devices = await invoke('list_cast_devices'); deviceListEl.innerHTML = ''; // Add "This Computer" option const localLi = document.createElement('li'); localLi.className = 'device' + (currentMode === 'local' ? ' selected' : ''); localLi.innerHTML = '
    This Computer
    Local Playback
    '; localLi.onclick = () => selectCastDevice(null); deviceListEl.appendChild(localLi); if (devices.length > 0) { devices.forEach(d => { const li = document.createElement('li'); li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : ''); li.innerHTML = `
    ${d}
    Google Cast Speaker
    `; li.onclick = () => selectCastDevice(d); deviceListEl.appendChild(li); }); } } catch (e) { deviceListEl.innerHTML = `
  • Error
    ${e}
  • `; } } function closeCastOverlay() { castOverlay.classList.add('hidden'); castOverlay.setAttribute('aria-hidden', 'true'); } async function selectCastDevice(deviceName) { closeCastOverlay(); // If checking same device, do nothing if (deviceName === currentCastDevice) return; // If switching mode, stop current playback if (isPlaying) { await stop(); } // Best-effort cleanup: stop any lingering cast transport when changing device/mode. await invoke('cast_proxy_stop').catch(() => {}); if (deviceName) { currentMode = 'cast'; currentCastDevice = deviceName; castBtn.style.color = 'var(--success)'; // Transport mode gets set on play. currentCastTransport = currentCastTransport || null; } else { currentMode = 'local'; currentCastDevice = null; castBtn.style.color = 'var(--text-main)'; currentCastTransport = null; } updateUI(); // Auto-play if we were playing? Let's stay stopped to be safe/explicit // Or auto-play for better UX? // Let's prompt user to play. } // Best-effort: stop any cast transport when leaving the window. window.addEventListener('beforeunload', () => { try { invoke('cast_proxy_stop'); } catch (_) {} }); window.addEventListener('DOMContentLoaded', init); // Service worker is useful for the PWA, but it can cause confusing caching during // Tauri development because it may serve an older cached `index.html`. if ('serviceWorker' in navigator) { if (runningInTauri) { // Best-effort cleanup so the desktop app doesn't get stuck on an old cached UI. // If we clear anything, do a one-time reload to ensure the new bundled assets are used. (async () => { let changed = false; try { const regs = await navigator.serviceWorker.getRegistrations(); if (regs && regs.length) { await Promise.all(regs.map((r) => r.unregister().catch(() => false))); changed = true; } } catch (_) {} if ('caches' in window) { try { const keys = await caches.keys(); if (keys && keys.length) { await Promise.all(keys.map((k) => caches.delete(k).catch(() => false))); changed = true; } } catch (_) {} } try { if (changed) { const k = '__radiocast_sw_cleared_once'; const already = sessionStorage.getItem(k); if (!already) { sessionStorage.setItem(k, '1'); location.reload(); } } } catch (_) {} })(); } else { // Register Service Worker for PWA installation (non-disruptive) window.addEventListener('load', () => { navigator.serviceWorker.register('sw.js') .then((reg) => console.log('ServiceWorker registered:', reg.scope)) .catch((err) => console.debug('ServiceWorker registration failed:', err)); }); } } // Open overlay and show list of stations (used by menu/hamburger) async function openStationsOverlay() { castOverlay.classList.remove('hidden'); castOverlay.setAttribute('aria-hidden', 'false'); deviceListEl.innerHTML = '
  • Loading...
    Preparing stations
  • '; // If stations not loaded yet, show message if (!stations || stations.length === 0) { deviceListEl.classList.remove('stations-grid'); deviceListEl.innerHTML = '
  • No stations found
    Check your stations.json
  • '; return; } // Render stations as responsive grid of cards (2-3 per row depending on width) deviceListEl.classList.add('stations-grid'); deviceListEl.innerHTML = ''; for (let idx = 0; idx < stations.length; idx++) { const s = stations[idx]; const li = document.createElement('li'); li.className = 'station-card' + (currentIndex === idx ? ' selected' : ''); const logoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || ''; const title = s.name || s.title || s.id || 'Station'; const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || ''); const left = document.createElement('div'); left.className = 'station-card-left'; // Check if logo exists, otherwise show fallback const hasLogo = await checkImageExists(logoUrl); if (hasLogo) { const img = document.createElement('img'); img.className = 'station-card-logo'; img.src = logoUrl; img.alt = `${title} logo`; left.appendChild(img); } else { const fb = document.createElement('div'); fb.className = 'station-card-fallback'; fb.textContent = title.charAt(0).toUpperCase(); left.appendChild(fb); } const body = document.createElement('div'); body.className = 'station-card-body'; const tEl = document.createElement('div'); tEl.className = 'station-card-title'; tEl.textContent = title; const sEl = document.createElement('div'); sEl.className = 'station-card-sub'; sEl.textContent = subtitle; body.appendChild(tEl); body.appendChild(sEl); li.appendChild(left); li.appendChild(body); li.onclick = async () => { currentMode = 'local'; currentCastDevice = null; currentCastTransport = null; castBtn.style.color = 'var(--text-main)'; try { await invoke('cast_proxy_stop'); } catch (_) {} await setStationByIndex(idx); closeCastOverlay(); try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } }; deviceListEl.appendChild(li); } }