1581 lines
55 KiB
JavaScript
1581 lines
55 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();
|
|
});
|
|
|
|
// Listen for cast device discovery events from backend
|
|
if (runningInTauri && window.__TAURI__ && window.__TAURI__.event) {
|
|
window.__TAURI__.event.listen('cast-device-discovered', (event) => {
|
|
console.log('Cast device discovered:', event.payload);
|
|
// If cast overlay is currently open, refresh the device list
|
|
if (!castOverlay.classList.contains('hidden')) {
|
|
refreshCastDeviceList();
|
|
}
|
|
});
|
|
// Notify UI when a device is removed so the list can update
|
|
window.__TAURI__.event.listen('cast-device-removed', (event) => {
|
|
console.log('Cast device removed:', event.payload);
|
|
if (!castOverlay.classList.contains('hidden')) {
|
|
refreshCastDeviceList();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
title: station.title || 'Radio',
|
|
artist: station.slogan || undefined,
|
|
image: station.logo || undefined,
|
|
// Additional metadata hints for receivers
|
|
subtitle: station.slogan || station.name,
|
|
backgroundImage: station.background || station.logo || undefined,
|
|
bgGradient: station.bgGradient || 'linear-gradient(135deg,#5b2d91,#b36cf3)'
|
|
});
|
|
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');
|
|
await refreshCastDeviceList();
|
|
}
|
|
|
|
async function refreshCastDeviceList() {
|
|
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);
|
|
}
|
|
}
|