356 lines
11 KiB
JavaScript
356 lines
11 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 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');
|
|
|
|
// Init
|
|
async function init() {
|
|
await loadStations();
|
|
setupEventListeners();
|
|
updateUI();
|
|
}
|
|
|
|
async function loadStations() {
|
|
try {
|
|
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);
|
|
|
|
if (stations.length > 0) {
|
|
currentIndex = 0;
|
|
loadStation(currentIndex);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load stations', e);
|
|
statusTextEl.textContent = 'Error loading stations';
|
|
}
|
|
}
|
|
|
|
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?
|
|
// For now just log or maybe show about
|
|
document.getElementById('menu-btn').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';
|
|
|
|
// 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) {
|
|
logoImgEl.src = station.logo;
|
|
logoImgEl.classList.remove('hidden');
|
|
logoTextEl.classList.add('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');
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// Cast Logic
|
|
async function openCastOverlay() {
|
|
castOverlay.classList.remove('hidden');
|
|
castOverlay.setAttribute('aria-hidden', 'false');
|
|
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)
|
|
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.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
|
|
return;
|
|
}
|
|
|
|
deviceListEl.innerHTML = '';
|
|
|
|
stations.forEach((s, idx) => {
|
|
const li = document.createElement('li');
|
|
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
|
|
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
|
|
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
|
|
li.onclick = async () => {
|
|
// Always switch to local playback when selecting from stations menu
|
|
currentMode = 'local';
|
|
currentCastDevice = null;
|
|
castBtn.style.color = 'var(--text-main)';
|
|
|
|
// Select and play
|
|
currentIndex = idx;
|
|
loadStation(currentIndex);
|
|
closeCastOverlay();
|
|
try {
|
|
await play();
|
|
} catch (e) {
|
|
console.error('Failed to play station from menu', e);
|
|
}
|
|
};
|
|
deviceListEl.appendChild(li);
|
|
});
|
|
}
|