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'); // 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() { restoreSavedVolume(); await loadStations(); setupEventListeners(); updateUI(); } // 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); 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; } 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 function stopCurrentSongPollers() { for (const id of currentSongPollers.values()) { clearInterval(id); } currentSongPollers.clear(); } function startCurrentSongPollers() { // Clear existing stopCurrentSongPollers(); stations.forEach((s, idx) => { const url = s.raw && s.raw.currentSong; if (url && typeof url === 'string' && url.length > 0) { // fetch immediately and then every 10s fetchAndStoreCurrentSong(s, idx, url); const iid = setInterval(() => fetchAndStoreCurrentSong(s, idx, url), 10000); currentSongPollers.set(s.id || idx, iid); } }); } async function fetchAndStoreCurrentSong(station, idx, url) { try { let data = null; try { const resp = await fetch(url, { cache: 'no-store' }); const ct = resp.headers.get('content-type') || ''; if (ct.includes('application/json')) { data = await resp.json(); } else { const txt = await resp.text(); try { data = JSON.parse(txt); } catch (e) { data = null; } } } catch (fetchErr) { // Possibly blocked by CORS — fall back to backend fetch via Tauri invoke try { const body = await invoke('fetch_url', { url }); try { data = JSON.parse(body); } catch (e) { data = null; } } catch (invokeErr) { console.debug('Both fetch and backend fetch failed for', url, fetchErr, invokeErr); data = null; } } if (data && (data.artist || data.title)) { station.currentSongInfo = { artist: data.artist || '', title: data.title || '' }; // update UI if this is the currently loaded station if (idx === currentIndex) updateNowPlayingUI(); } } 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? } 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 Logo Text (First letter or number) // Simple heuristic: if name has a number, use it, else first letter // If station has a logo URL, show the image; otherwise show the text fallback if (station.logo && station.logo.length > 0) { // Verify the logo exists before showing it checkImageExists(station.logo).then((exists) => { if (exists) { logoImgEl.src = station.logo; logoImgEl.classList.remove('hidden'); logoTextEl.classList.add('hidden'); } else { logoImgEl.src = ''; logoImgEl.classList.add('hidden'); logoTextEl.classList.remove('hidden'); } }); } else { // Fallback to single-letter/logo text logoImgEl.src = ''; logoImgEl.classList.add('hidden'); const numberMatch = station.name.match(/\d+/); if (numberMatch) { logoTextEl.textContent = numberMatch[0]; } else { logoTextEl.textContent = station.name.charAt(0).toUpperCase(); } logoTextEl.classList.remove('hidden'); } } // 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') { audio.src = station.url; audio.volume = volumeSlider.value / 100; try { await audio.play(); isPlaying = true; updateUI(); } catch (e) { console.error('Playback failed', e); statusTextEl.textContent = 'Error'; } } else if (currentMode === 'cast' && currentCastDevice) { // Cast logic try { await invoke('cast_play', { deviceName: currentCastDevice, url: station.url }); 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'; currentMode = 'local'; // Fallback updateUI(); } } } async function stop() { if (currentMode === 'local') { audio.pause(); audio.src = ''; } else if (currentMode === 'cast' && currentCastDevice) { try { await invoke('cast_stop', { deviceName: currentCastDevice }); } catch (e) { console.error(e); } } isPlaying = false; 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 wasPlaying = isPlaying; if (wasPlaying) await stop(); currentIndex = (currentIndex + 1) % stations.length; loadStation(currentIndex); // persist selection saveLastStationId(stations[currentIndex].id); if (wasPlaying) await play(); } async function playPrev() { if (stations.length === 0) return; const wasPlaying = isPlaying; if (wasPlaying) await stop(); currentIndex = (currentIndex - 1 + stations.length) % stations.length; loadStation(currentIndex); // persist selection saveLastStationId(stations[currentIndex].id); if (wasPlaying) await play(); } 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)'; stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : '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)'; stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream'; } } function handleVolumeInput() { const val = volumeSlider.value; volumeValue.textContent = `${val}%`; const decimals = val / 100; if (currentMode === 'local') { audio.volume = decimals; } 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(); } if (deviceName) { currentMode = 'cast'; currentCastDevice = deviceName; castBtn.style.color = 'var(--success)'; } else { currentMode = 'local'; currentCastDevice = null; castBtn.style.color = 'var(--text-main)'; } 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. } window.addEventListener('DOMContentLoaded', init); // 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; castBtn.style.color = 'var(--text-main)'; currentIndex = idx; // Remember this selection saveLastStationId(stations[idx].id); loadStation(currentIndex); closeCastOverlay(); try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } }; deviceListEl.appendChild(li); } }