Files
RadioPlayer/src/main.js
Gregor Klevze 694f335408 tools: add sync-version.js to sync package.json -> Tauri files
- Add tools/sync-version.js script to read root package.json version
  and update src-tauri/tauri.conf.json and src-tauri/Cargo.toml.
- Update only the [package] version line in Cargo.toml to preserve formatting.
- Include JSON read/write helpers and basic error handling/reporting.
2026-01-13 07:21:51 +01:00

1549 lines
54 KiB
JavaScript

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'
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
<path d="M2 8V6a14 14 0 0 1 14 14h-2" />
</svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 15V9" />
<path d="M8 19V5" />
<path d="M12 16V8" />
<path d="M16 18V6" />
<path d="M20 15V9" />
</svg>`;
engineBadgeEl.innerHTML = `${iconSvg}<span>${label}</span>`;
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 = '<li class="device"><div class="device-main">No user stations</div><div class="device-sub">Add your stream using the form below</div></li>';
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 = `<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class=\"device-main\">${main}</div>
<div class=\"device-sub\">${sub}</div>
</div>
<div style=\"display:flex;gap:8px;align-items:center;\">
<button data-idx=\"${idx}\" class=\"btn edit-btn\" style=\"background:#6bd3ff;color:#042\">Edit</button>
<button data-idx=\"${idx}\" class=\"btn delete-btn\" style=\"background:#ff6b6b\">Delete</button>
</div>
</div>`;
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 = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
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 = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
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 = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
li.onclick = () => selectCastDevice(d);
deviceListEl.appendChild(li);
});
}
} catch (e) {
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
}
}
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 = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
// If stations not loaded yet, show message
if (!stations || stations.length === 0) {
deviceListEl.classList.remove('stations-grid');
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
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);
}
}