const { invoke } = window.__TAURI__.core; const { getCurrentWindow } = window.__TAURI__.window; // State let stations = []; let currentIndex = 0; let isPlaying = false; let currentMode = 'local'; // 'local' | 'cast' let currentCastDevice = null; const audio = new Audio(); // 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 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 logoTextEl = document.querySelector('.station-logo-text'); const logoImgEl = document.getElementById('station-logo-img'); const artworkPlaceholder = document.querySelector('.artwork-placeholder'); // 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 { restoreSavedVolume(); await loadStations(); setupEventListeners(); ensureArtworkPointerFallback(); updateUI(); } catch (e) { console.error('Error during init', e); if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e)); } } // 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; audio.volume = decimals; // 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(()=>{}); } } } 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); // start polling for currentSong endpoints (if any) startCurrentSongPollers(); } } catch (e) { console.error('Failed to load stations', e); statusTextEl.textContent = 'Error loading stations'; } } // --- 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 = '