761 lines
24 KiB
JavaScript
761 lines
24 KiB
JavaScript
const { invoke } = window.__TAURI__.core;
|
|
const { getCurrentWindow } = window.__TAURI__.window;
|
|
|
|
// State
|
|
let stations = [];
|
|
let currentIndex = 0;
|
|
let isPlaying = false;
|
|
let currentMode = 'local'; // 'local' | 'cast'
|
|
let currentCastDevice = null;
|
|
const audio = new Audio();
|
|
|
|
// UI Elements
|
|
const stationNameEl = document.getElementById('station-name');
|
|
const stationSubtitleEl = document.getElementById('station-subtitle');
|
|
const nowPlayingEl = document.getElementById('now-playing');
|
|
const nowArtistEl = document.getElementById('now-artist');
|
|
const nowTitleEl = document.getElementById('now-title');
|
|
const statusTextEl = document.getElementById('status-text');
|
|
const statusDotEl = document.querySelector('.status-dot');
|
|
const playBtn = document.getElementById('play-btn');
|
|
const iconPlay = document.getElementById('icon-play');
|
|
const iconStop = document.getElementById('icon-stop');
|
|
const prevBtn = document.getElementById('prev-btn');
|
|
const nextBtn = document.getElementById('next-btn');
|
|
const volumeSlider = document.getElementById('volume-slider');
|
|
const volumeValue = document.getElementById('volume-value');
|
|
const castBtn = document.getElementById('cast-toggle-btn');
|
|
const castOverlay = document.getElementById('cast-overlay');
|
|
const closeOverlayBtn = document.getElementById('close-overlay');
|
|
const deviceListEl = document.getElementById('device-list');
|
|
const logoTextEl = document.querySelector('.station-logo-text');
|
|
const logoImgEl = document.getElementById('station-logo-img');
|
|
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
|
// Editor elements
|
|
const editBtn = document.getElementById('edit-stations-btn');
|
|
const editorOverlay = document.getElementById('editor-overlay');
|
|
const editorCloseBtn = document.getElementById('editor-close-btn');
|
|
const editorListEl = document.getElementById('editor-list');
|
|
const addStationForm = document.getElementById('add-station-form');
|
|
const usTitle = document.getElementById('us_title');
|
|
const usUrl = document.getElementById('us_url');
|
|
const usLogo = document.getElementById('us_logo');
|
|
const usWww = document.getElementById('us_www');
|
|
const usId = document.getElementById('us_id');
|
|
const usIndex = document.getElementById('us_index');
|
|
|
|
// Init
|
|
async function init() {
|
|
restoreSavedVolume();
|
|
await loadStations();
|
|
setupEventListeners();
|
|
updateUI();
|
|
}
|
|
|
|
// Volume persistence
|
|
function saveVolumeToStorage(val) {
|
|
try {
|
|
localStorage.setItem('volume', String(val));
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function getSavedVolume() {
|
|
try {
|
|
const v = localStorage.getItem('volume');
|
|
if (!v) return null;
|
|
const n = Number(v);
|
|
if (Number.isFinite(n) && n >= 0 && n <= 100) return n;
|
|
return null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
function restoreSavedVolume() {
|
|
const saved = getSavedVolume();
|
|
if (saved !== null && volumeSlider) {
|
|
volumeSlider.value = String(saved);
|
|
volumeValue.textContent = `${saved}%`;
|
|
const decimals = saved / 100;
|
|
audio.volume = decimals;
|
|
// If currently in cast mode and a device is selected, propagate volume
|
|
if (currentMode === 'cast' && currentCastDevice) {
|
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }).catch(()=>{});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadStations() {
|
|
try {
|
|
// stop any existing pollers before reloading stations
|
|
stopCurrentSongPollers();
|
|
const resp = await fetch('stations.json');
|
|
const raw = await resp.json();
|
|
|
|
// Normalize station objects so the rest of the app can rely on `name` and `url`.
|
|
stations = raw
|
|
.map((s) => {
|
|
// If already in the old format, keep as-is
|
|
if (s.name && s.url) return s;
|
|
|
|
const name = s.title || s.id || s.name || 'Unknown';
|
|
// Prefer liveAudio, fall back to liveVideo or any common fields
|
|
const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || '';
|
|
|
|
return {
|
|
id: s.id || name,
|
|
name,
|
|
url,
|
|
logo: s.logo || s.poster || '',
|
|
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
|
raw: s,
|
|
};
|
|
})
|
|
// Filter out disabled stations and those without a stream URL
|
|
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
|
|
|
// Load user-defined stations from localStorage and merge
|
|
const user = loadUserStations();
|
|
const userNormalized = user
|
|
.map((s) => {
|
|
const name = s.title || s.name || s.id || 'UserStation';
|
|
const url = s.url || s.liveAudio || s.liveVideo || '';
|
|
return {
|
|
id: s.id || `user-${name.replace(/\s+/g, '-')}`,
|
|
name,
|
|
url,
|
|
logo: s.logo || '',
|
|
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
|
raw: s,
|
|
_user: true,
|
|
};
|
|
})
|
|
.filter((s) => s.url && s.url.length > 0);
|
|
|
|
// Append user stations after file stations
|
|
stations = stations.concat(userNormalized);
|
|
|
|
if (stations.length > 0) {
|
|
// Try to restore last selected station by id
|
|
const lastId = getLastStationId();
|
|
if (lastId) {
|
|
const found = stations.findIndex(s => s.id === lastId);
|
|
if (found >= 0) currentIndex = found;
|
|
else currentIndex = 0;
|
|
} else {
|
|
currentIndex = 0;
|
|
}
|
|
|
|
loadStation(currentIndex);
|
|
// start polling for currentSong endpoints (if any)
|
|
startCurrentSongPollers();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load stations', e);
|
|
statusTextEl.textContent = 'Error loading stations';
|
|
}
|
|
}
|
|
|
|
// --- Current Song Polling ---
|
|
const currentSongPollers = new Map(); // stationId -> intervalId
|
|
|
|
function stopCurrentSongPollers() {
|
|
for (const id of currentSongPollers.values()) {
|
|
clearInterval(id);
|
|
}
|
|
currentSongPollers.clear();
|
|
}
|
|
|
|
function startCurrentSongPollers() {
|
|
// Clear existing
|
|
stopCurrentSongPollers();
|
|
|
|
stations.forEach((s, idx) => {
|
|
const url = s.raw && s.raw.currentSong;
|
|
if (url && typeof url === 'string' && url.length > 0) {
|
|
// fetch immediately and then every 10s
|
|
fetchAndStoreCurrentSong(s, idx, url);
|
|
const iid = setInterval(() => fetchAndStoreCurrentSong(s, idx, url), 10000);
|
|
currentSongPollers.set(s.id || idx, iid);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function fetchAndStoreCurrentSong(station, idx, url) {
|
|
try {
|
|
let data = null;
|
|
try {
|
|
const resp = await fetch(url, { cache: 'no-store' });
|
|
const ct = resp.headers.get('content-type') || '';
|
|
if (ct.includes('application/json')) {
|
|
data = await resp.json();
|
|
} else {
|
|
const txt = await resp.text();
|
|
try { data = JSON.parse(txt); } catch (e) { data = null; }
|
|
}
|
|
} catch (fetchErr) {
|
|
// Possibly blocked by CORS — fall back to backend fetch via Tauri invoke
|
|
try {
|
|
const body = await invoke('fetch_url', { url });
|
|
try { data = JSON.parse(body); } catch (e) { data = null; }
|
|
} catch (invokeErr) {
|
|
console.debug('Both fetch and backend fetch failed for', url, fetchErr, invokeErr);
|
|
data = null;
|
|
}
|
|
}
|
|
|
|
if (data && (data.artist || data.title)) {
|
|
station.currentSongInfo = { artist: data.artist || '', title: data.title || '' };
|
|
// update UI if this is the currently loaded station
|
|
if (idx === currentIndex) updateNowPlayingUI();
|
|
}
|
|
} catch (e) {
|
|
// ignore fetch errors silently (network/CORS) but keep console for debugging
|
|
console.debug('currentSong fetch failed for', url, e.message || e);
|
|
}
|
|
}
|
|
|
|
function updateNowPlayingUI() {
|
|
const station = stations[currentIndex];
|
|
if (!station) return;
|
|
|
|
if (nowPlayingEl && nowArtistEl && nowTitleEl) {
|
|
if (station.currentSongInfo && station.currentSongInfo.artist && station.currentSongInfo.title) {
|
|
nowArtistEl.textContent = station.currentSongInfo.artist;
|
|
nowTitleEl.textContent = station.currentSongInfo.title;
|
|
nowPlayingEl.classList.remove('hidden');
|
|
} else {
|
|
nowArtistEl.textContent = '';
|
|
nowTitleEl.textContent = '';
|
|
nowPlayingEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// keep subtitle for mode/status
|
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
|
}
|
|
|
|
// --- User Stations (localStorage) ---
|
|
function loadUserStations() {
|
|
try {
|
|
const raw = localStorage.getItem('userStations');
|
|
if (!raw) return [];
|
|
return JSON.parse(raw);
|
|
} catch (e) {
|
|
console.error('Error reading user stations', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveUserStations(arr) {
|
|
try {
|
|
localStorage.setItem('userStations', JSON.stringify(arr || []));
|
|
} catch (e) {
|
|
console.error('Error saving user stations', e);
|
|
}
|
|
}
|
|
|
|
function openEditorOverlay() {
|
|
renderUserStationsList();
|
|
editorOverlay.classList.remove('hidden');
|
|
editorOverlay.setAttribute('aria-hidden', 'false');
|
|
}
|
|
|
|
function closeEditorOverlay() {
|
|
editorOverlay.classList.add('hidden');
|
|
editorOverlay.setAttribute('aria-hidden', 'true');
|
|
// clear form
|
|
addStationForm.reset();
|
|
usIndex.value = '';
|
|
}
|
|
|
|
function renderUserStationsList() {
|
|
const list = loadUserStations();
|
|
editorListEl.innerHTML = '';
|
|
if (!list || list.length === 0) {
|
|
editorListEl.innerHTML = '<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?
|
|
}
|
|
|
|
function loadStation(index) {
|
|
if (index < 0 || index >= stations.length) return;
|
|
const station = stations[index];
|
|
|
|
stationNameEl.textContent = station.name;
|
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
|
// clear now playing when loading a new station; will be updated by poller if available
|
|
if (nowPlayingEl) nowPlayingEl.classList.add('hidden');
|
|
if (nowArtistEl) nowArtistEl.textContent = '';
|
|
if (nowTitleEl) nowTitleEl.textContent = '';
|
|
|
|
// Update Logo Text (First letter or number)
|
|
// Simple heuristic: if name has a number, use it, else first letter
|
|
// If station has a logo URL, show the image; otherwise show the text fallback
|
|
if (station.logo && station.logo.length > 0) {
|
|
// Verify the logo exists before showing it
|
|
checkImageExists(station.logo).then((exists) => {
|
|
if (exists) {
|
|
logoImgEl.src = station.logo;
|
|
logoImgEl.classList.remove('hidden');
|
|
logoTextEl.classList.add('hidden');
|
|
} else {
|
|
logoImgEl.src = '';
|
|
logoImgEl.classList.add('hidden');
|
|
logoTextEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback to single-letter/logo text
|
|
logoImgEl.src = '';
|
|
logoImgEl.classList.add('hidden');
|
|
const numberMatch = station.name.match(/\d+/);
|
|
if (numberMatch) {
|
|
logoTextEl.textContent = numberMatch[0];
|
|
} else {
|
|
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
|
}
|
|
logoTextEl.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Check if an image URL is reachable and valid
|
|
function checkImageExists(url, timeout = 6000) {
|
|
return new Promise((resolve) => {
|
|
if (!url) return resolve(false);
|
|
try {
|
|
const img = new Image();
|
|
let timedOut = false;
|
|
const t = setTimeout(() => {
|
|
timedOut = true;
|
|
img.src = ''; // stop load
|
|
resolve(false);
|
|
}, timeout);
|
|
|
|
img.onload = () => {
|
|
if (!timedOut) {
|
|
clearTimeout(t);
|
|
resolve(true);
|
|
}
|
|
};
|
|
img.onerror = () => {
|
|
if (!timedOut) {
|
|
clearTimeout(t);
|
|
resolve(false);
|
|
}
|
|
};
|
|
// Bypass caching oddities by assigning after handlers
|
|
img.src = url;
|
|
} catch (e) {
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function togglePlay() {
|
|
if (isPlaying) {
|
|
await stop();
|
|
} else {
|
|
await play();
|
|
}
|
|
}
|
|
|
|
async function play() {
|
|
const station = stations[currentIndex];
|
|
if (!station) return;
|
|
|
|
statusTextEl.textContent = 'Buffering...';
|
|
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
|
|
|
|
if (currentMode === 'local') {
|
|
audio.src = station.url;
|
|
audio.volume = volumeSlider.value / 100;
|
|
try {
|
|
await audio.play();
|
|
isPlaying = true;
|
|
updateUI();
|
|
} catch (e) {
|
|
console.error('Playback failed', e);
|
|
statusTextEl.textContent = 'Error';
|
|
}
|
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
|
// Cast logic
|
|
try {
|
|
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url });
|
|
isPlaying = true;
|
|
// Sync volume
|
|
const vol = volumeSlider.value / 100;
|
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: vol });
|
|
updateUI();
|
|
} catch (e) {
|
|
console.error('Cast failed', e);
|
|
statusTextEl.textContent = 'Cast Error';
|
|
currentMode = 'local'; // Fallback
|
|
updateUI();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stop() {
|
|
if (currentMode === 'local') {
|
|
audio.pause();
|
|
audio.src = '';
|
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
|
try {
|
|
await invoke('cast_stop', { deviceName: currentCastDevice });
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
isPlaying = false;
|
|
updateUI();
|
|
}
|
|
|
|
async function playNext() {
|
|
if (stations.length === 0) return;
|
|
|
|
// If playing, stop first? Or seamless?
|
|
// For radio, seamless switch requires stop then play new URL
|
|
const wasPlaying = isPlaying;
|
|
|
|
if (wasPlaying) await stop();
|
|
|
|
currentIndex = (currentIndex + 1) % stations.length;
|
|
loadStation(currentIndex);
|
|
|
|
// persist selection
|
|
saveLastStationId(stations[currentIndex].id);
|
|
|
|
if (wasPlaying) await play();
|
|
}
|
|
|
|
async function playPrev() {
|
|
if (stations.length === 0) return;
|
|
|
|
const wasPlaying = isPlaying;
|
|
|
|
if (wasPlaying) await stop();
|
|
|
|
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
|
|
loadStation(currentIndex);
|
|
|
|
// persist selection
|
|
saveLastStationId(stations[currentIndex].id);
|
|
|
|
if (wasPlaying) await play();
|
|
}
|
|
|
|
function updateUI() {
|
|
// Play/Stop Button
|
|
if (isPlaying) {
|
|
iconPlay.classList.add('hidden');
|
|
iconStop.classList.remove('hidden');
|
|
playBtn.classList.add('playing'); // Add pulsing ring animation
|
|
statusTextEl.textContent = 'Playing';
|
|
statusDotEl.style.backgroundColor = 'var(--success)';
|
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
|
} else {
|
|
iconPlay.classList.remove('hidden');
|
|
iconStop.classList.add('hidden');
|
|
playBtn.classList.remove('playing'); // Remove pulsing ring
|
|
statusTextEl.textContent = 'Ready';
|
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
|
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
|
}
|
|
}
|
|
|
|
function handleVolumeInput() {
|
|
const val = volumeSlider.value;
|
|
volumeValue.textContent = `${val}%`;
|
|
const decimals = val / 100;
|
|
|
|
if (currentMode === 'local') {
|
|
audio.volume = decimals;
|
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
|
}
|
|
// persist volume for next sessions
|
|
saveVolumeToStorage(Number(val));
|
|
}
|
|
|
|
// Cast Logic
|
|
async function openCastOverlay() {
|
|
castOverlay.classList.remove('hidden');
|
|
castOverlay.setAttribute('aria-hidden', 'false');
|
|
// ensure cast overlay shows linear list style
|
|
deviceListEl.classList.remove('stations-grid');
|
|
deviceListEl.innerHTML = '<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();
|
|
}
|
|
|
|
if (deviceName) {
|
|
currentMode = 'cast';
|
|
currentCastDevice = deviceName;
|
|
castBtn.style.color = 'var(--success)';
|
|
} else {
|
|
currentMode = 'local';
|
|
currentCastDevice = null;
|
|
castBtn.style.color = 'var(--text-main)';
|
|
}
|
|
|
|
updateUI();
|
|
|
|
// Auto-play if we were playing? Let's stay stopped to be safe/explicit
|
|
// Or auto-play for better UX?
|
|
// Let's prompt user to play.
|
|
}
|
|
|
|
window.addEventListener('DOMContentLoaded', init);
|
|
|
|
// Open overlay and show list of stations (used by menu/hamburger)
|
|
async function openStationsOverlay() {
|
|
castOverlay.classList.remove('hidden');
|
|
castOverlay.setAttribute('aria-hidden', 'false');
|
|
deviceListEl.innerHTML = '<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;
|
|
castBtn.style.color = 'var(--text-main)';
|
|
currentIndex = idx;
|
|
// Remember this selection
|
|
saveLastStationId(stations[idx].id);
|
|
loadStation(currentIndex);
|
|
closeCastOverlay();
|
|
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
|
|
};
|
|
|
|
deviceListEl.appendChild(li);
|
|
}
|
|
}
|