import { loadPersistedRadioStations, loadRadioStations } from './radio/loadRadioStations.ts'; import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts'; import { getLastManagedCatalogSource, loadManagedStations, loadPersistedManagedStations, } from './radio/loadManagedStations.ts'; import { clearPlayerPersistence, deletePersistedUserStationById, getPersistenceBackend, getPersistedSelectedRadioCountryCodes, getPlayerPersistenceSnapshot, getPersistedCastBothMode, getPersistedFavoriteStationIds, getPersistedLastStationCountry, getPersistedLastStationId, getPersistedRecentStationHistory, getPersistedStationSongHistory, getPersistedStationHealth, getPersistedStationUsageCounts, getPersistedUserStations, getPersistedVolume, hydratePlayerPersistence, importPlayerPersistence, persistCastBothMode, persistFavoriteStationIds, persistLastExportedAt, persistLastImportedAt, persistLastStationCountry, persistLastStationId, persistSelectedRadioCountryCodes, persistRecentStationHistory, persistStationSongHistory, persistStationHealth, persistStationUsageCounts, persistVolume, upsertPersistedUserStation, } from './storage/playerPersistence.ts'; import { clearPersistedManagedStationCatalog } from './storage/stationCatalogPersistence.ts'; import { ARTWORK_SHINE_EFFECTS, ICON_SPARKLE_EFFECTS, IMPORT_EXPORT_SCHEMA, IMPORT_EXPORT_VERSION, PLAYBACK_START_TIMEOUT_MS, RADIO_BROWSER_COUNTRIES_API_ENDPOINT, RADIO_PLACEHOLDER_LOGO, RECENT_STATION_HISTORY_LIMIT, SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE, SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE, SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE, SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE, SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE, STATION_LIBRARY_PAGE_SIZE, } from './player/constants.js'; import { playerDom } from './player/domRefs.js'; import { applyStationTheme, getMetadataFetchUrl, getStationHomepage, getStationMetadataUrl, isMobileInstallViewport, isMobileViewport, isStandalonePwa, normalizeStationRecord, prefersReducedMotion, setImgWithFallback, toHttpsIfHttp, uniqueNonEmpty, } from './player/stationHelpers.js'; // Web version of RadioPlayer — HTML5 Audio + Google Cast Web Sender SDK. // ── Audio engine ────────────────────────────────────────────────────────────── const audio = new Audio(); audio.preload = 'none'; // ── Cast state ──────────────────────────────────────────────────────────────── // 'local' = HTML5 audio, 'cast' = Google Cast session active, 'airplay' = AirPlay route selected let castMode = 'local'; let castContext = null; // cast.framework.CastContext instance let castPlayerController = null; // cast.framework.RemotePlayerController let castInitialized = false; let airPlayAvailable = false; // When true, local audio also plays alongside the Cast session. let castBothMode = false; let deferredInstallPrompt = null; // State let stations = []; let currentIndex = 0; let isPlaying = false; let isMuted = false; let currentVolume = 0.8; // 0–1 float let stationLibraryTab = 'all'; let stationLibraryQuery = ''; let stationLibraryCategory = 'all'; let stationLibraryCountry = 'all'; let stationLibrarySort = 'recommended'; let stationLibraryLanguage = 'all'; let stationLibraryCodec = 'all'; let stationLibraryBitrate = 'all'; let stationLibraryHealthyOnly = false; let selectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes); let draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes); let availableRadioCountries = radioCountries.map((country) => ({ ...country, stationCount: null, pinned: country.code === managedCountryCode, })); let availableRadioCountriesLoaded = false; let availableRadioCountriesRequest = null; let countrySelectionQuery = ''; let countrySelectionSaving = false; let stationLibraryPage = 0; let stationLibraryPageTotal = 1; let stationCatalogState = 'idle'; let stationCatalogError = ''; let playbackError = ''; let sleepTimerTimeoutId = null; let sleepTimerDeadline = null; let sleepTimerSelectionMinutes = 0; let wakeAlarmTimeoutId = null; let wakeAlarmDeadline = null; let wakeAlarmSelectionMinutes = 0; let sessionCountdownIntervalId = null; let playbackStartupAttemptId = 0; let playbackStartupTimeoutId = null; let activeLocalPlaybackAttempt = null; let stationCountryFilterOpen = false; let artworkShineTimeoutId = null; let artworkShineClearTimeoutId = null; let stationTitleRafId = null; let catalogSyncStatusTimeoutId = null; let catalogSyncHistoryTimeoutId = null; let managedCatalogStatusTimeoutId = null; let iconSparkleTimeoutId = null; const STATION_SONG_HISTORY_LIMIT = 8; const { stationNameEl, stationSubtitleEl, stationHealthSummaryEl, stationHealthDetailEl, sessionSchedulerBtn, sessionSchedulerBadge, sessionSummaryBtn, sessionSummaryText, sleepTimerStatusEl, wakeAlarmStatusEl, sleepTimerButtons, wakeAlarmButtons, songHistoryPanelEl, songHistoryListEl, songHistoryClearBtn, managedCatalogStatusEl, catalogSyncStatusEl, catalogSyncStatusTextEl, catalogSyncStatusTimeEl, catalogSyncHistoryEl, nowPlayingEl, nowArtistEl, nowTitleEl, statusTextEl, statusDotEl, engineBadgeEl, playBtn, iconPlay, iconStop, prevBtn, nextBtn, volumeSlider, volumeValue, muteBtn, iconVolume, iconMuted, castOverlay, closeOverlayBtn, deviceListEl, coverflowStageEl, coverflowPrevBtn, coverflowNextBtn, artworkPlaceholder, logoTextEl, logoImgEl, stationLibraryEl, playerLayoutEl, stationLibraryListEl, stationLibrarySummaryEl, stationSearchInput, stationLibraryCloseBtn, stationLibraryFiltersToggleBtn, clearManagedCatalogBtn, stationCategoryListEl, stationCountryFilterWrapEl, stationCountryFilterBtn, stationCountryFilterMenu, stationCountryFilterText, stationCountryFilterFlag, stationAdvancedFiltersBtn, stationAdvancedFiltersOverlay, stationAdvancedFiltersCloseBtn, stationAdvancedFiltersDoneBtn, countrySelectionOverlay, countrySelectionSearchInput, countrySelectionSummaryEl, countrySelectionEmptyEl, countrySelectionListEl, countrySelectionDefaultsBtn, countrySelectionAllBtn, countrySelectionSaveBtn, countrySelectionCancelBtn, stationSortBtn, stationSortText, stationSortMenu, stationSortWrapEl, stationLibraryPaginationEl, stationLibraryPagePrevBtn, stationLibraryPageNextBtn, stationLibraryPageInfo, stationLanguageFilterWrapEl, stationLanguageFilterBtn, stationLanguageFilterText, stationLanguageFilterMenu, stationCodecFilterWrapEl, stationCodecFilterBtn, stationCodecFilterText, stationCodecFilterMenu, stationBitrateFilterWrapEl, stationBitrateFilterBtn, stationBitrateFilterText, stationBitrateFilterMenu, stationHealthFilterBtn, stationHealthFilterText, stationTabBtns, installPromptBannerEl, installPromptActionBtn, installPromptDismissBtn, editBtn, stationsListBtn, shortcutHelpBtn, installAppBtn, castBtn, castBtnCastIcon, castBtnAirPlayIcon, editorOverlay, sessionSchedulerOverlay, sessionSchedulerCloseBtn, sessionSchedulerDoneBtn, shortcutHelpOverlay, shortcutHelpCloseBtn, editorCloseBtn, editorListEl, editorPersistenceNoteEl, editorPersistenceBackendEl, editorBackupActivityNoteEl, exportUserDataBtn, importUserDataBtn, importUserDataInput, resetUserDataBtn, resetUserDataBackupCheckbox, addStationForm, usTitle, usUrl, usLogo, usWww, usId, castOutputRow, castOutputBtn, castOutputText, } = playerDom; let stationSortOpen = false; let stationLanguageFilterOpen = false; let stationCodecFilterOpen = false; let stationBitrateFilterOpen = false; let stationAdvancedFiltersOpen = false; let stationLibraryFiltersCollapsed = false; const radioCountryByCode = new Map(radioCountries.map((country) => [country.code, country])); const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code])); const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name])); // ── Utilities ──────────────────────────────────────────────────────────────── async function lockPortraitOrientation() { if (!isMobileViewport()) return; if (!isStandalonePwa()) return; if (!screen?.orientation?.lock) return; try { await screen.orientation.lock('portrait'); } catch (e) { // Some browsers require a user gesture or simply do not allow locking. console.debug('Portrait orientation lock not available:', e); } } function hideInstallPromptUI() { installAppBtn?.classList.add('hidden'); installPromptBannerEl?.classList.add('hidden'); } function showInstallPromptUI() { if (!deferredInstallPrompt) return; if (isMobileInstallViewport()) { installPromptBannerEl?.classList.remove('hidden'); } else { installAppBtn?.classList.remove('hidden'); } } function clearArtworkShineTimers() { if (artworkShineTimeoutId) { clearTimeout(artworkShineTimeoutId); artworkShineTimeoutId = null; } if (artworkShineClearTimeoutId) { clearTimeout(artworkShineClearTimeoutId); artworkShineClearTimeoutId = null; } if (artworkPlaceholder) { artworkPlaceholder.classList.remove(...ARTWORK_SHINE_EFFECTS); } } function scheduleArtworkShine() { if (!artworkPlaceholder || prefersReducedMotion()) return; const trigger = () => { artworkShineTimeoutId = null; clearArtworkShineTimers(); if (!artworkPlaceholder || prefersReducedMotion()) return; const effect = ARTWORK_SHINE_EFFECTS[Math.floor(Math.random() * ARTWORK_SHINE_EFFECTS.length)]; const effectDuration = 1800 + Math.floor(Math.random() * 900); artworkPlaceholder.classList.add(effect); artworkShineClearTimeoutId = window.setTimeout(() => { artworkPlaceholder.classList.remove(effect); artworkShineClearTimeoutId = null; }, effectDuration); const nextDelay = 7000 + Math.floor(Math.random() * 15000); artworkShineTimeoutId = window.setTimeout(trigger, nextDelay); }; clearArtworkShineTimers(); trigger(); } function triggerSparkleOnSelected() { if (prefersReducedMotion() || !coverflowStageEl) return; const item = coverflowStageEl.querySelector('.coverflow-item.selected'); if (!item) return; const effect = ICON_SPARKLE_EFFECTS[Math.floor(Math.random() * ICON_SPARKLE_EFFECTS.length)]; item.classList.remove(...ICON_SPARKLE_EFFECTS); void item.offsetWidth; item.classList.add(effect); window.setTimeout(() => item.classList.remove(effect), 1600); } function scheduleIconSparkle() { if (prefersReducedMotion()) return; const trigger = () => { iconSparkleTimeoutId = null; if (prefersReducedMotion() || !coverflowStageEl) { iconSparkleTimeoutId = window.setTimeout(trigger, 6000 + Math.floor(Math.random() * 10000)); return; } const items = Array.from(coverflowStageEl.querySelectorAll('.coverflow-item:not(.selected)')); if (items.length > 0) { const item = items[Math.floor(Math.random() * items.length)]; const effect = ICON_SPARKLE_EFFECTS[Math.floor(Math.random() * ICON_SPARKLE_EFFECTS.length)]; item.classList.remove(...ICON_SPARKLE_EFFECTS); // Force reflow so re-adding same class restarts animation void item.offsetWidth; item.classList.add(effect); window.setTimeout(() => item.classList.remove(effect), 1600); } iconSparkleTimeoutId = window.setTimeout(trigger, 4000 + Math.floor(Math.random() * 8000)); }; if (iconSparkleTimeoutId) clearTimeout(iconSparkleTimeoutId); iconSparkleTimeoutId = window.setTimeout(trigger, 3000 + Math.floor(Math.random() * 5000)); } // ── Global error handlers ──────────────────────────────────────────────────── window.addEventListener('error', (ev) => { try { console.error('Uncaught error', ev.error || ev.message || ev); if (statusTextEl) statusTextEl.textContent = 'Error: ' + (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?.message ?? String(ev.reason)); } catch (e) { /* ignore */ } }); // ── Audio event wiring ─────────────────────────────────────────────────────── audio.addEventListener('waiting', () => { if (!isPlaying) return; if (statusTextEl) statusTextEl.textContent = 'Buffering...'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; }); audio.addEventListener('playing', () => { clearPlaybackStartupTimeout(); completeLocalPlaybackAttemptAsSuccess(); playbackError = ''; if (statusTextEl) statusTextEl.textContent = 'Playing'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; isPlaying = true; updateUI(); }); audio.addEventListener('stalled', () => { if (!isPlaying) return; if (statusTextEl) statusTextEl.textContent = 'Reconnecting...'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; }); audio.addEventListener('error', () => { clearPlaybackStartupTimeout(); const err = audio.error; const msg = err ? audioErrorMessage(err.code) : 'Stream error'; const station = stations[currentIndex]; completeLocalPlaybackAttemptAsFailure(station, msg, { audioErrorCode: err?.code ?? null }); playbackError = getPlaybackFailureMessage(station, msg); isPlaying = false; updateUI(); }); audio.addEventListener('pause', () => { if (!audio.ended) { clearPlaybackStartupTimeout(); } }); audio.addEventListener('emptied', () => { clearPlaybackStartupTimeout(); }); audio.addEventListener('ended', () => { // Live streams shouldn't end; if they do, try to reconnect. if (isPlaying) { const url = audio.src; setTimeout(() => { audio.src = url; audio.load(); audio.play().catch(() => {}); }, 2000); } }); function audioErrorMessage(code) { switch (code) { case 1: return 'Playback aborted'; case 2: return 'Network error'; case 3: return 'Decode error'; case 4: return 'Stream not supported'; default: return 'Stream error'; } } function createDefaultStationHealth() { return { attempts: 0, successes: 0, failures: 0, lastAttemptedAt: null, lastSucceededAt: null, lastFailedAt: null, lastFailureReason: null, timeoutCount: 0, lastProbeMillis: null, }; } function loadRecentStationHistory() { return getPersistedRecentStationHistory(); } function saveRecentStationHistory(history) { persistRecentStationHistory(Array.isArray(history) ? history : []); } function loadStationSongHistory() { return getPersistedStationSongHistory(); } function saveStationSongHistory(history) { persistStationSongHistory(history && typeof history === 'object' ? history : {}); } function loadStationHealth() { return getPersistedStationHealth(); } function saveStationHealth(health) { persistStationHealth(health && typeof health === 'object' ? health : {}); } function recordRecentStationPlay(station) { if (!station?.id) return; const nextHistory = [ { stationId: station.id, playedAt: new Date().toISOString(), country: getStationCountry(station) || null, }, ...loadRecentStationHistory(), ].slice(0, RECENT_STATION_HISTORY_LIMIT); saveRecentStationHistory(nextHistory); } function recordSongHistory(station, songInfo) { if (!station?.id || !songInfo?.artist || !songInfo?.title) return; const history = loadStationSongHistory(); const nextEntry = { artist: String(songInfo.artist).trim(), title: String(songInfo.title).trim(), seenAt: new Date().toISOString(), }; if (!nextEntry.artist || !nextEntry.title) return; const currentHistory = Array.isArray(history[station.id]) ? history[station.id] : []; const dedupedHistory = currentHistory.filter((entry) => ( String(entry?.artist || '').trim().toLowerCase() !== nextEntry.artist.toLowerCase() || String(entry?.title || '').trim().toLowerCase() !== nextEntry.title.toLowerCase() )); history[station.id] = [nextEntry, ...dedupedHistory].slice(0, STATION_SONG_HISTORY_LIMIT); saveStationSongHistory(history); } function classifyPlaybackFailure(station, detail, options = {}) { const originalUrl = typeof station?.url === 'string' ? station.url.trim() : ''; const upgradedUrl = toHttpsIfHttp(originalUrl) || originalUrl; const detailText = String(detail || '').toLowerCase(); if (window.location.protocol === 'https:' && originalUrl.startsWith('http://') && upgradedUrl !== originalUrl) { return 'insecure-stream-blocked'; } if (options.isTimeout) { return 'startup-timeout'; } if (options.audioErrorCode === 4 || detailText.includes('not supported')) { return 'unsupported-format'; } if ( options.audioErrorCode === 2 || detailText.includes('network') || detailText.includes('server responded') || detailText.includes('unable to start playback') || detailText.includes('certificate') || detailText.includes('tls') ) { return 'tls-or-network-error'; } return 'unknown'; } function beginLocalPlaybackAttempt(station, attemptId, probeMillis = null) { if (!station?.id) { activeLocalPlaybackAttempt = null; return; } const health = loadStationHealth(); const nextHealth = { ...createDefaultStationHealth(), ...(health[station.id] || {}) }; nextHealth.attempts += 1; nextHealth.lastAttemptedAt = new Date().toISOString(); if (Number.isFinite(probeMillis) && probeMillis >= 0) { nextHealth.lastProbeMillis = probeMillis; } health[station.id] = nextHealth; saveStationHealth(health); activeLocalPlaybackAttempt = { attemptId, stationId: station.id, probeMillis: Number.isFinite(probeMillis) && probeMillis >= 0 ? probeMillis : null, settled: false, }; } function completeLocalPlaybackAttemptAsSuccess() { const attempt = activeLocalPlaybackAttempt; if (!attempt || attempt.settled || attempt.attemptId !== playbackStartupAttemptId) return; const health = loadStationHealth(); const nextHealth = { ...createDefaultStationHealth(), ...(health[attempt.stationId] || {}) }; nextHealth.successes += 1; nextHealth.lastSucceededAt = new Date().toISOString(); if (Number.isFinite(attempt.probeMillis) && attempt.probeMillis >= 0) { nextHealth.lastProbeMillis = attempt.probeMillis; } health[attempt.stationId] = nextHealth; saveStationHealth(health); attempt.settled = true; } function completeLocalPlaybackAttemptAsFailure(station, detail, options = {}) { const stationId = station?.id || activeLocalPlaybackAttempt?.stationId; if (!stationId) { activeLocalPlaybackAttempt = null; return; } const attempt = activeLocalPlaybackAttempt; if (attempt?.settled) return; const health = loadStationHealth(); const nextHealth = { ...createDefaultStationHealth(), ...(health[stationId] || {}) }; nextHealth.failures += 1; nextHealth.lastFailedAt = new Date().toISOString(); nextHealth.lastFailureReason = classifyPlaybackFailure(station, detail, options); if (options.isTimeout) { nextHealth.timeoutCount += 1; } health[stationId] = nextHealth; saveStationHealth(health); if (attempt) { attempt.settled = true; } } function getPlaybackFailureMessage(station, detail = 'Stream error') { const stationTitle = station ? getStationTitle(station) : 'this station'; const originalUrl = typeof station?.url === 'string' ? station.url.trim() : ''; const upgradedUrl = toHttpsIfHttp(originalUrl) || originalUrl; const isSecurePage = window.location.protocol === 'https:'; const isAutoUpgradedHttpStream = originalUrl.startsWith('http://') && upgradedUrl !== originalUrl; if (isSecurePage && isAutoUpgradedHttpStream) { return `Unable to play ${stationTitle}. This station only exposes an insecure HTTP stream or a misconfigured HTTPS certificate, so browsers block it on this secure site.`; } return `Unable to play ${stationTitle}. ${detail}.`; } function clearPlaybackStartupTimeout() { if (playbackStartupTimeoutId) { clearTimeout(playbackStartupTimeoutId); playbackStartupTimeoutId = null; } } function armPlaybackStartupTimeout(station, attemptId) { clearPlaybackStartupTimeout(); playbackStartupTimeoutId = window.setTimeout(() => { if (attemptId !== playbackStartupAttemptId) return; if (audio.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA || !audio.paused) return; audio.pause(); completeLocalPlaybackAttemptAsFailure(station, 'The stream did not respond in time', { isTimeout: true }); playbackError = getPlaybackFailureMessage(station, 'The stream did not respond in time'); isPlaying = false; updateUI(); }, PLAYBACK_START_TIMEOUT_MS); } function downloadJsonFile(filename, data) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(objectUrl); } function updateEditorPersistenceInfo() { const backend = getPersistenceBackend(); if (editorPersistenceBackendEl) { editorPersistenceBackendEl.textContent = backend === 'indexeddb' ? 'IndexedDB' : 'LocalStorage fallback'; } if (editorPersistenceNoteEl) { editorPersistenceNoteEl.title = backend === 'indexeddb' ? 'RadioPlayer is using IndexedDB for local persistence.' : 'IndexedDB is unavailable, so RadioPlayer is using localStorage fallback.'; } } function formatBackupTimestamp(value) { if (!value) return null; try { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }).format(new Date(value)); } catch (error) { return null; } } function updateEditorBackupActivityInfo() { if (!editorBackupActivityNoteEl) return; const snapshot = getPlayerPersistenceSnapshot(); const parts = []; const lastExportedAt = formatBackupTimestamp(snapshot.lastExportedAt); const lastImportedAt = formatBackupTimestamp(snapshot.lastImportedAt); if (lastExportedAt) { parts.push(`Last export: ${lastExportedAt}`); } if (lastImportedAt) { parts.push(`Last import: ${lastImportedAt}`); } editorBackupActivityNoteEl.textContent = parts.length > 0 ? parts.join(' | ') : 'No backup activity yet.'; } function buildBackupSummary(snapshotLike) { const customStationCount = Array.isArray(snapshotLike?.userStations) ? snapshotLike.userStations.length : 0; const favoriteCount = Array.isArray(snapshotLike?.favoriteStationIds) ? snapshotLike.favoriteStationIds.filter(Boolean).length : 0; const recentStationCount = Array.isArray(snapshotLike?.recentStationHistory) ? new Set(snapshotLike.recentStationHistory.map((entry) => entry?.stationId).filter(Boolean)).size : snapshotLike?.stationUsageCounts && typeof snapshotLike.stationUsageCounts === 'object' && !Array.isArray(snapshotLike.stationUsageCounts) ? Object.values(snapshotLike.stationUsageCounts).filter((value) => Number(value) > 0).length : 0; const countryFilter = typeof snapshotLike?.lastStationCountry === 'string' && snapshotLike.lastStationCountry.trim().length > 0 ? snapshotLike.lastStationCountry : null; const hasLastStation = typeof snapshotLike?.lastStationId === 'string' && snapshotLike.lastStationId.trim().length > 0; const notes = [ `${customStationCount} custom station${customStationCount === 1 ? '' : 's'}`, `${favoriteCount} favorite${favoriteCount === 1 ? '' : 's'}`, `${recentStationCount} recent station entr${recentStationCount === 1 ? 'y' : 'ies'}`, ]; if (countryFilter) { notes.push(`country filter: ${countryFilter}`); } if (hasLastStation) { notes.push('restores last selected station'); } return { customStationCount, favoriteCount, recentStationCount, countryFilter, hasLastStation, notes, }; } function formatBackupSummary(summary) { if (!summary || !Array.isArray(summary.notes) || summary.notes.length === 0) { return 'No summary available.'; } return summary.notes.join(', '); } function createBackupPayload(reason = 'manual') { const exportedAt = new Date().toISOString(); persistLastExportedAt(exportedAt); updateEditorBackupActivityInfo(); const data = getPlayerPersistenceSnapshot(); return { app: 'RadioPlayer', schema: IMPORT_EXPORT_SCHEMA, version: 1, exportedAt, reason, backend: getPersistenceBackend(), summary: buildBackupSummary(data), data, }; } function downloadBackupPayload(payload) { const timestamp = payload.exportedAt.replace(/[:.]/g, '-'); const suffix = payload.reason === 'pre-reset' ? '-before-reset' : ''; downloadJsonFile(`radioplayer-local-data-${timestamp}${suffix}.json`, payload); } function parseImportedUserDataPayload(text) { const parsed = JSON.parse(text); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('Invalid backup file format'); } if ('data' in parsed || 'version' in parsed || 'app' in parsed || 'schema' in parsed) { if (parsed.app !== 'RadioPlayer') { throw new Error('Backup file does not belong to RadioPlayer'); } if ('schema' in parsed && parsed.schema !== IMPORT_EXPORT_SCHEMA) { throw new Error('Unsupported backup schema'); } if (parsed.version !== IMPORT_EXPORT_VERSION) { throw new Error('Unsupported backup version'); } if (!('data' in parsed) || !parsed.data || typeof parsed.data !== 'object' || Array.isArray(parsed.data)) { throw new Error('Backup file is missing data'); } return { data: parsed.data, exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : null, summary: parsed.summary && typeof parsed.summary === 'object' && !Array.isArray(parsed.summary) ? parsed.summary : buildBackupSummary(parsed.data), }; } return { data: parsed, exportedAt: null, summary: buildBackupSummary(parsed), }; } async function refreshPersistedState(statusMessage) { if (isPlaying) { await stop(); } restoreSavedVolume(); restoreCastBothMode(); restoreLastStationCountry(); updateEditorPersistenceInfo(); updateEditorBackupActivityInfo(); await loadStations(); renderUserStationsList(); updateUI(); if (statusTextEl && statusMessage) { statusTextEl.textContent = statusMessage; } } function handleExportUserData() { const payload = createBackupPayload('manual'); downloadBackupPayload(payload); if (statusTextEl) { statusTextEl.textContent = 'Local data exported'; } } async function handleImportUserDataFile(file) { if (!file) return; try { const text = await file.text(); const payload = parseImportedUserDataPayload(text); const exportedAt = formatBackupTimestamp(payload.exportedAt); const summaryText = formatBackupSummary(payload.summary); const confirmed = window.confirm( `Import this RadioPlayer backup${exportedAt ? ` from ${exportedAt}` : ''}?\n\n${summaryText}`, ); if (!confirmed) { if (importUserDataInput) importUserDataInput.value = ''; return; } await importPlayerPersistence(payload.data); persistLastImportedAt(new Date().toISOString()); if (importUserDataInput) importUserDataInput.value = ''; await refreshPersistedState('Local data imported'); } catch (error) { console.error('Import failed', error); if (importUserDataInput) importUserDataInput.value = ''; if (statusTextEl) statusTextEl.textContent = error?.message || 'Import failed'; } } async function handleResetUserData() { const shouldBackupBeforeReset = resetUserDataBackupCheckbox?.checked !== false; const confirmed = window.confirm('Delete all locally stored RadioPlayer settings, favourites, usage history, and custom stations on this device?'); if (!confirmed) return; if (shouldBackupBeforeReset) { downloadBackupPayload(createBackupPayload('pre-reset')); } await clearPlayerPersistence(); if (importUserDataInput) importUserDataInput.value = ''; await refreshPersistedState(shouldBackupBeforeReset ? 'Local data backed up and cleared' : 'Local data cleared'); } // ── Volume ─────────────────────────────────────────────────────────────────── function saveVolumeToStorage(val) { persistVolume(val); } function getSavedVolume() { const value = getPersistedVolume(); return Number.isFinite(value) ? value : null; } function restoreSavedVolume() { const saved = getSavedVolume(); const vol = saved !== null ? saved : 80; if (volumeSlider) volumeSlider.value = String(vol); if (volumeValue) volumeValue.textContent = `${vol}%`; currentVolume = vol / 100; audio.volume = currentVolume; } // ── Station persistence ─────────────────────────────────────────────────────── function saveLastStationId(id) { if (id) persistLastStationId(id); } function getLastStationId() { return getPersistedLastStationId(); } function saveLastStationCountry(country) { if (country) persistLastStationCountry(country); } function getLastStationCountry() { return getPersistedLastStationCountry(); } function restoreLastStationCountry() { const savedCountry = getLastStationCountry(); stationLibraryCountry = savedCountry || 'all'; } function normalizeSelectedRadioCountryCodes(codes) { const normalized = Array.isArray(codes) ? codes : Array.from(codes || []); const uniqueCodes = Array.from(new Set( normalized .map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : '')) .filter((entry) => /^[A-Z]{2}$/.test(entry)), )); if (!uniqueCodes.includes(managedCountryCode)) { uniqueCodes.unshift(managedCountryCode); } return uniqueCodes.length > 0 ? uniqueCodes : [...defaultSelectedRadioCountryCodes]; } function getSelectedRadioCountryCodes() { return normalizeSelectedRadioCountryCodes(selectedRadioCountryCodes); } function restoreSelectedRadioCountryCodes() { selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(getPersistedSelectedRadioCountryCodes())); draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes()); } function getAvailableRadioCountryNameByCode(countryCode) { const code = String(countryCode || '').trim().toUpperCase(); if (!code) return ''; return availableRadioCountries.find((country) => country.code === code)?.name || radioCountryNameByCode.get(code) || radioCountryByCode.get(code)?.name || code; } function mergeAvailableRadioCountries(countries) { const mergedCountries = new Map(); radioCountries.forEach((country) => { mergedCountries.set(country.code, { name: country.name, code: country.code, stationCount: null, pinned: country.code === managedCountryCode, }); }); countries.forEach((country) => { const code = String(country?.code || '').trim().toUpperCase(); if (!/^[A-Z]{2}$/.test(code)) return; const existing = mergedCountries.get(code); mergedCountries.set(code, { name: String(country?.name || existing?.name || code).trim() || code, code, stationCount: Number.isFinite(country?.stationCount) && Number(country.stationCount) >= 0 ? Number(country.stationCount) : existing?.stationCount ?? null, pinned: code === managedCountryCode, }); }); availableRadioCountries = Array.from(mergedCountries.values()).sort((left, right) => { if (left.code === managedCountryCode) return -1; if (right.code === managedCountryCode) return 1; return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); }); } async function ensureAvailableRadioCountriesLoaded() { if (availableRadioCountriesLoaded) { return availableRadioCountries; } if (availableRadioCountriesRequest) { return availableRadioCountriesRequest; } availableRadioCountriesRequest = (async () => { try { const response = await fetch(RADIO_BROWSER_COUNTRIES_API_ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json', }, mode: 'cors', }); if (!response.ok) { throw new Error(`Failed loading countries: ${response.status}`); } const payload = await response.json(); if (!Array.isArray(payload)) { throw new Error('Invalid country list response'); } const remoteCountries = payload .map((entry) => ({ code: String(entry?.iso_3166_1 || '').trim().toUpperCase(), name: String(entry?.name || '').trim(), stationCount: Number(entry?.stationcount), })) .filter((entry) => /^[A-Z]{2}$/.test(entry.code) && entry.name.length > 0); mergeAvailableRadioCountries(remoteCountries); } catch (error) { console.debug('Falling back to bundled country list', error); mergeAvailableRadioCountries([]); } finally { availableRadioCountriesLoaded = true; availableRadioCountriesRequest = null; } return availableRadioCountries; })(); return availableRadioCountriesRequest; } function renderCountrySelectionOverlay() { if (!countrySelectionListEl || !countrySelectionSummaryEl || !countrySelectionEmptyEl) return; if (countrySelectionSaveBtn) { countrySelectionSaveBtn.disabled = countrySelectionSaving; countrySelectionSaveBtn.textContent = countrySelectionSaving ? 'Saving...' : 'Save'; } if (countrySelectionDefaultsBtn) countrySelectionDefaultsBtn.disabled = countrySelectionSaving; if (countrySelectionAllBtn) countrySelectionAllBtn.disabled = countrySelectionSaving; if (countrySelectionCancelBtn) countrySelectionCancelBtn.disabled = countrySelectionSaving; const normalizedQuery = countrySelectionQuery.trim().toLowerCase(); const entries = availableRadioCountries.filter((country) => { if (!normalizedQuery) return true; return country.name.toLowerCase().includes(normalizedQuery) || country.code.toLowerCase().includes(normalizedQuery); }); const selectedCount = normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes).length; const loadingLabel = availableRadioCountriesLoaded ? '' : ' Loading available countries...'; countrySelectionSummaryEl.textContent = countrySelectionSaving ? 'Saving country selection...' : `${selectedCount} countries enabled.${loadingLabel}`; countrySelectionEmptyEl.classList.toggle('hidden', entries.length > 0); countrySelectionListEl.innerHTML = ''; if (entries.length === 0) { return; } entries.forEach((country) => { const item = document.createElement('li'); item.className = 'device country-selection-item'; const row = document.createElement('label'); row.className = 'country-selection-option'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = country.code === managedCountryCode || draftSelectedRadioCountryCodes.has(country.code); checkbox.disabled = country.code === managedCountryCode || countrySelectionSaving; checkbox.addEventListener('change', () => { if (checkbox.checked) { draftSelectedRadioCountryCodes.add(country.code); } else { draftSelectedRadioCountryCodes.delete(country.code); } renderCountrySelectionOverlay(); }); const flag = document.createElement('span'); flag.className = 'country-selection-flag'; flag.setAttribute('aria-hidden', 'true'); flag.textContent = country.code === managedCountryCode ? '🇸🇮' : countryCodeToFlagEmoji(country.code); const copy = document.createElement('div'); copy.className = 'country-selection-copy'; const title = document.createElement('div'); title.className = 'device-main'; title.textContent = country.code === managedCountryCode ? `${country.name} (managed)` : country.name; const sub = document.createElement('div'); sub.className = 'device-sub'; if (country.code === managedCountryCode) { sub.textContent = 'Always enabled managed catalog'; } else if (Number.isFinite(country.stationCount) && country.stationCount > 0) { sub.textContent = `${country.code} • ${country.stationCount.toLocaleString()} stations available`; } else { sub.textContent = country.code; } copy.appendChild(title); copy.appendChild(sub); row.appendChild(checkbox); row.appendChild(flag); row.appendChild(copy); item.appendChild(row); countrySelectionListEl.appendChild(item); }); } async function openCountrySelectionOverlay() { if (!countrySelectionOverlay) return; draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes()); countrySelectionQuery = ''; countrySelectionSaving = false; if (countrySelectionSearchInput) { countrySelectionSearchInput.value = ''; } renderCountrySelectionOverlay(); countrySelectionOverlay.classList.remove('hidden'); countrySelectionOverlay.setAttribute('aria-hidden', 'false'); closeStationCountryFilter(); void ensureAvailableRadioCountriesLoaded().then(() => { renderCountrySelectionOverlay(); }); window.setTimeout(() => countrySelectionSearchInput?.focus(), 30); } function closeCountrySelectionOverlay() { if (!countrySelectionOverlay) return; countrySelectionOverlay.classList.add('hidden'); countrySelectionOverlay.setAttribute('aria-hidden', 'true'); countrySelectionSaving = false; } function getNormalizedStationCountryCode(station) { const directCode = typeof station?.countryCode === 'string' ? station.countryCode.trim().toUpperCase() : ''; if (/^[A-Z]{2}$/.test(directCode)) return directCode; const countryValue = typeof station?.country === 'string' ? station.country.trim() : ''; if (/^[A-Z]{2}$/.test(countryValue)) return countryValue.toUpperCase(); return ''; } function isEnabledRadioCountryStation(station) { const countryCode = getNormalizedStationCountryCode(station); if (!countryCode) return true; return getSelectedRadioCountryCodes().includes(countryCode); } async function sendSelectedCountriesToServiceWorker({ syncNow = false } = {}) { if (!('serviceWorker' in navigator)) { return { ok: false, skipped: true }; } const registration = await navigator.serviceWorker.ready.catch(() => null); const target = navigator.serviceWorker.controller || registration?.active || registration?.waiting; if (!target) { return { ok: false, skipped: true }; } return new Promise((resolve) => { const channel = new MessageChannel(); const timeoutId = window.setTimeout(() => { resolve({ ok: false, timedOut: true }); }, syncNow ? 25000 : 8000); channel.port1.onmessage = (event) => { clearTimeout(timeoutId); resolve(event.data || { ok: true }); }; target.postMessage({ type: SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE, countryCodes: getSelectedRadioCountryCodes(), syncNow, }, [channel.port2]); }); } async function saveCountrySelection() { if (countrySelectionSaving) return; countrySelectionSaving = true; renderCountrySelectionOverlay(); try { selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes)); persistSelectedRadioCountryCodes(selectedRadioCountryCodes); await sendSelectedCountriesToServiceWorker({ syncNow: true }); await loadStations(); if (statusTextEl) { statusTextEl.textContent = 'Country selection updated'; } closeCountrySelectionOverlay(); } catch (error) { console.error('Saving country selection failed', error); if (statusTextEl) { statusTextEl.textContent = 'Unable to refresh selected countries'; } } finally { countrySelectionSaving = false; renderCountrySelectionOverlay(); } } // ── castBothMode persistence & UI ──────────────────────────────────────────── function saveCastBothMode(val) { persistCastBothMode(Boolean(val)); } function restoreCastBothMode() { castBothMode = getPersistedCastBothMode(); } function updateCastOutputToggleUI() { if (!castOutputRow || !castOutputBtn || !castOutputText) return; if (castMode === 'cast') { castOutputRow.classList.remove('hidden'); } else { castOutputRow.classList.add('hidden'); return; } if (castBothMode) { castOutputBtn.setAttribute('aria-pressed', 'true'); castOutputText.textContent = 'Cast + Local'; castOutputBtn.title = 'Currently: Cast + This computer — click to cast only'; } else { castOutputBtn.setAttribute('aria-pressed', 'false'); castOutputText.textContent = 'Cast only'; castOutputBtn.title = 'Currently: Cast only — click to also play on this computer'; } } function toggleCastBothMode() { castBothMode = !castBothMode; saveCastBothMode(castBothMode); updateCastOutputToggleUI(); if (castMode !== 'cast' || !isPlaying) return; if (castBothMode) { // Start local playback in parallel playLocal(); } else { // Stop local audio, keep Cast going audio.pause(); audio.src = ''; } } function supportsAirPlay() { return typeof audio.webkitShowPlaybackTargetPicker === 'function'; } function isRemoteOutputActive() { return castMode === 'cast' || castMode === 'airplay'; } function updateAirPlaySubtitle() { const station = stations[currentIndex]; if (!stationSubtitleEl || !station) return; const detailSuffix = getStationDetails(station); stationSubtitleEl.textContent = detailSuffix ? `AirPlay output • ${detailSuffix}` : 'AirPlay output'; } function syncAirPlayState() { const isWireless = Boolean(audio.webkitCurrentPlaybackTargetIsWireless); if (isWireless) { castMode = 'airplay'; if (statusTextEl && isPlaying) statusTextEl.textContent = 'Playing via AirPlay'; updateAirPlaySubtitle(); } else if (castMode === 'airplay') { castMode = 'local'; } updateEngineBadge(); updateCastButtonUI(); updateCastOutputToggleUI(); updateUI(); } function initAirPlay() { try { if ('disableRemotePlayback' in audio) { audio.disableRemotePlayback = false; } } catch (e) { console.debug('disableRemotePlayback not configurable:', e); } if (!supportsAirPlay()) { airPlayAvailable = false; updateCastButtonUI(); return; } airPlayAvailable = true; audio.addEventListener('webkitplaybacktargetavailabilitychanged', (event) => { airPlayAvailable = event.availability === 'available'; if (!airPlayAvailable && castMode === 'airplay') { castMode = 'local'; updateEngineBadge(); updateCastOutputToggleUI(); updateUI(); } updateCastButtonUI(); }); audio.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => { syncAirPlayState(); }); syncAirPlayState(); } // ── User Stations (IndexedDB snapshot) ────────────────────────────────────── function loadUserStations() { return getPersistedUserStations(); } // ── Station library, favourites, quick picks ──────────────────────────────── function loadFavoriteStationIds() { return getPersistedFavoriteStationIds(); } function saveFavoriteStationIds(ids) { persistFavoriteStationIds(ids); } function loadStationUsageCounts() { return getPersistedStationUsageCounts(); } function saveStationUsageCounts(counts) { persistStationUsageCounts(counts || {}); } function incrementStationUsage(station) { if (!station?.id) return; const counts = loadStationUsageCounts(); counts[station.id] = Math.max(0, Number(counts[station.id]) || 0) + 1; saveStationUsageCounts(counts); } function getRecentStationActivityById() { const recentActivityById = new Map(); loadRecentStationHistory().forEach((entry) => { if (!entry?.stationId || recentActivityById.has(entry.stationId)) { return; } const playedAtMs = Date.parse(entry.playedAt); recentActivityById.set(entry.stationId, Number.isFinite(playedAtMs) ? playedAtMs : 0); }); return recentActivityById; } function getStationHealthScore(station, stationHealth) { if (!station?.id) return 0.5; const health = stationHealth?.[station.id]; if (!health) return 0.5; const attempts = Math.max(0, Number(health.attempts) || 0); const successes = Math.max(0, Number(health.successes) || 0); const failures = Math.max(0, Number(health.failures) || 0); const timeoutCount = Math.max(0, Number(health.timeoutCount) || 0); const successRate = attempts > 0 ? successes / attempts : 0.5; const failurePressure = attempts > 0 ? failures / attempts : 0; const timeoutPenalty = Math.min(timeoutCount * 0.08, 0.3); const failurePenalty = Math.min(failurePressure * 0.35, 0.35); return Math.max(0, Math.min(1, successRate - timeoutPenalty - failurePenalty)); } function getRecommendedStationScore(station, context) { const healthScore = getStationHealthScore(station, context.stationHealth); const usageCount = Math.max(0, Number(context.counts?.[station.id]) || 0); const usageBoost = Math.min(usageCount / 20, 0.18); const recentPlayedAt = context.recentActivityById.get(station.id) || 0; const recentBoost = recentPlayedAt > 0 ? 0.12 : 0; const favoriteBoost = context.favourites.has(station.id) ? 0.2 : 0; return healthScore + usageBoost + recentBoost + favoriteBoost; } function getStationProxySupportDetail(station) { const originalUrl = typeof station?.url === 'string' ? station.url.trim() : ''; const upgradedUrl = toHttpsIfHttp(originalUrl) || originalUrl; const isSecurePage = window.location.protocol === 'https:'; if (isSecurePage && originalUrl.startsWith('http://') && upgradedUrl !== originalUrl) { return 'This stream is still HTTP-only on a secure site, so playback will need proxy support.'; } return null; } function createStationInfoIndicator(detail, variant = '') { const indicator = document.createElement('span'); indicator.className = `station-info-indicator${variant ? ` ${variant}` : ''}`; indicator.textContent = 'i'; indicator.title = detail; indicator.setAttribute('aria-label', detail); indicator.setAttribute('role', 'img'); indicator.setAttribute('tabindex', '0'); return indicator; } function buildStationLibraryInfoDetail(station, { healthBadge = null, proxySupportDetail = null, useCount = 0 } = {}) { const lines = []; const stationTags = getStationTags(station); const subtitle = getStationSubtitle(station); if (healthBadge?.detail) { lines.push(`${healthBadge.label}: ${healthBadge.detail}`); } if (proxySupportDetail) { lines.push(`Proxy: ${proxySupportDetail}`); } if (stationTags.length > 0) { lines.push(`Tags: ${stationTags.join(', ')}`); } if (subtitle) { lines.push(`Info: ${subtitle}`); } if (useCount > 0) { lines.push(`Plays: ${useCount}`); } return lines.join('\n'); } function updateManagedCatalogStatus(source = 'unknown') { if (!managedCatalogStatusEl) return; if (managedCatalogStatusTimeoutId) { clearTimeout(managedCatalogStatusTimeoutId); managedCatalogStatusTimeoutId = null; } const sourceLabels = { remote: 'Managed catalog: backend', 'cached-remote': 'Managed catalog: cached backend', bundled: 'Managed catalog: bundled fallback', }; const label = sourceLabels[source]; if (!label) { managedCatalogStatusEl.textContent = ''; managedCatalogStatusEl.classList.add('hidden'); return; } managedCatalogStatusEl.textContent = label; managedCatalogStatusEl.classList.remove('hidden'); managedCatalogStatusTimeoutId = window.setTimeout(() => { managedCatalogStatusEl.textContent = ''; managedCatalogStatusEl.classList.add('hidden'); managedCatalogStatusTimeoutId = null; }, 3500); } function formatCatalogSyncTime(value) { const parsed = value ? new Date(value) : new Date(); if (Number.isNaN(parsed.getTime())) { return ''; } return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', }).format(parsed); } function renderCatalogSyncHistory(record) { if (!catalogSyncHistoryEl) { return; } if (catalogSyncHistoryTimeoutId) { clearTimeout(catalogSyncHistoryTimeoutId); catalogSyncHistoryTimeoutId = null; } const parsed = record?.syncedAt ? new Date(record.syncedAt) : null; const formattedTime = parsed && !Number.isNaN(parsed.getTime()) ? new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }).format(parsed) : ''; if (!record || !formattedTime) { catalogSyncHistoryEl.textContent = ''; catalogSyncHistoryEl.classList.add('hidden'); return; } const label = record.kind === 'managed' ? 'Managed catalog' : 'Stations'; catalogSyncHistoryEl.textContent = `Last background refresh: ${label} · ${formattedTime}`; catalogSyncHistoryEl.classList.remove('hidden'); catalogSyncHistoryTimeoutId = window.setTimeout(() => { catalogSyncHistoryEl.textContent = ''; catalogSyncHistoryEl.classList.add('hidden'); catalogSyncHistoryTimeoutId = null; }, 3500); } function persistCatalogSyncHistory(kind, syncedAt) { const normalizedSyncedAt = syncedAt && !Number.isNaN(new Date(syncedAt).getTime()) ? new Date(syncedAt).toISOString() : new Date().toISOString(); renderCatalogSyncHistory({ kind, syncedAt: normalizedSyncedAt }); } function compactCatalogSyncError(error) { if (typeof error !== 'string') { return ''; } const compact = error.replace(/\s+/g, ' ').trim(); if (!compact) { return ''; } return compact.length > 72 ? `${compact.slice(0, 69)}...` : compact; } function showCatalogSyncStatus(message, syncedAt = null, tone = 'info', detail = '') { if (!catalogSyncStatusEl || !message) return; if (catalogSyncStatusTimeoutId) { clearTimeout(catalogSyncStatusTimeoutId); catalogSyncStatusTimeoutId = null; } const compactDetail = compactCatalogSyncError(detail); const visibleMessage = compactDetail ? `${message}: ${compactDetail}` : message; const accessibleMessage = detail ? `${message}: ${detail}` : message; if (catalogSyncStatusTextEl) { catalogSyncStatusTextEl.textContent = visibleMessage; } else { catalogSyncStatusEl.textContent = visibleMessage; } catalogSyncStatusEl.title = accessibleMessage; catalogSyncStatusEl.setAttribute('aria-label', accessibleMessage); if (catalogSyncStatusTimeEl) { const formattedTime = formatCatalogSyncTime(syncedAt); catalogSyncStatusTimeEl.textContent = formattedTime; catalogSyncStatusTimeEl.dateTime = syncedAt && !Number.isNaN(new Date(syncedAt).getTime()) ? new Date(syncedAt).toISOString() : new Date().toISOString(); } catalogSyncStatusEl.classList.toggle('is-warning', tone === 'warning'); catalogSyncStatusEl.classList.remove('hidden'); catalogSyncStatusTimeoutId = window.setTimeout(() => { if (catalogSyncStatusTextEl) { catalogSyncStatusTextEl.textContent = ''; } else { catalogSyncStatusEl.textContent = ''; } if (catalogSyncStatusTimeEl) { catalogSyncStatusTimeEl.textContent = ''; catalogSyncStatusTimeEl.removeAttribute('datetime'); } catalogSyncStatusEl.removeAttribute('title'); catalogSyncStatusEl.removeAttribute('aria-label'); catalogSyncStatusEl.classList.remove('is-warning'); catalogSyncStatusEl.classList.add('hidden'); catalogSyncStatusTimeoutId = null; }, 3500); } function getStationHealthBadge(station, stationHealth = loadStationHealth()) { const health = stationHealth?.[station?.id]; if (!health || Number(health.attempts) <= 0) { return null; } const score = getStationHealthScore(station, stationHealth); const lastFailureAt = health.lastFailedAt ? Date.parse(health.lastFailedAt) : 0; const lastSuccessAt = health.lastSucceededAt ? Date.parse(health.lastSucceededAt) : 0; if (health.failures > 0 && lastFailureAt >= lastSuccessAt) { return { label: 'Unstable', tone: 'warning', detail: 'Recent playback attempts have failed for this station.', }; } if (health.successes > 0 && score >= 0.7) { return { label: 'Healthy', tone: 'healthy', detail: 'Recent playback attempts have been successful.', }; } if (health.failures > 0 || score < 0.55) { return { label: 'Unstable', tone: 'warning', detail: 'This station has mixed or degraded playback results.', }; } return null; } function getStationHealthDetail(station, stationHealth = loadStationHealth()) { const badge = getStationHealthBadge(station, stationHealth); if (!badge) return null; if (badge.tone !== 'warning') { return null; } const health = stationHealth?.[station?.id]; switch (health?.lastFailureReason) { case 'startup-timeout': return 'Recent failure: the stream did not respond in time.'; case 'tls-or-network-error': return 'Recent failure: network or TLS negotiation failed.'; case 'unsupported-format': return 'Recent failure: the browser could not play this stream format.'; case 'insecure-stream-blocked': return 'Recent failure: the browser blocked an insecure stream on this secure site.'; case 'unknown': default: return 'Recent failure: playback was unstable or ended with an unknown stream error.'; } } function updateActiveStationHealthSummary(station) { if (!stationHealthSummaryEl || !station || castMode !== 'local') { stationHealthSummaryEl?.classList.add('hidden'); stationHealthDetailEl?.classList.add('hidden'); if (stationHealthSummaryEl) { stationHealthSummaryEl.replaceChildren(); stationHealthSummaryEl.textContent = ''; stationHealthSummaryEl.className = 'station-health-summary hidden'; stationHealthSummaryEl.removeAttribute('title'); stationHealthSummaryEl.removeAttribute('aria-label'); } if (stationHealthDetailEl) { stationHealthDetailEl.textContent = ''; stationHealthDetailEl.className = 'station-health-detail hidden'; } return; } const stationHealth = loadStationHealth(); const proxySupportDetail = getStationProxySupportDetail(station); const healthBadge = getStationHealthBadge(station, stationHealth); if (proxySupportDetail) { stationHealthSummaryEl.className = 'station-health-summary station-health-summary-info'; stationHealthSummaryEl.textContent = 'Proxy support'; stationHealthSummaryEl.title = proxySupportDetail; stationHealthSummaryEl.setAttribute('aria-label', proxySupportDetail); if (stationHealthDetailEl) { stationHealthDetailEl.className = 'station-health-detail hidden'; stationHealthDetailEl.textContent = ''; } return; } if (!healthBadge) { stationHealthSummaryEl.className = 'station-health-summary hidden'; stationHealthSummaryEl.replaceChildren(); stationHealthSummaryEl.textContent = ''; stationHealthSummaryEl.removeAttribute('title'); stationHealthSummaryEl.removeAttribute('aria-label'); if (stationHealthDetailEl) { stationHealthDetailEl.className = 'station-health-detail hidden'; stationHealthDetailEl.textContent = ''; } return; } stationHealthSummaryEl.className = `station-health-summary station-health-summary-${healthBadge.tone}`; stationHealthSummaryEl.replaceChildren(); stationHealthSummaryEl.textContent = healthBadge.label; stationHealthSummaryEl.title = healthBadge.detail; stationHealthSummaryEl.setAttribute('aria-label', healthBadge.detail); if (stationHealthDetailEl) { const detail = getStationHealthDetail(station, stationHealth); if (detail) { stationHealthDetailEl.className = `station-health-detail station-health-detail-${healthBadge.tone}`; stationHealthDetailEl.textContent = detail; } else { stationHealthDetailEl.className = 'station-health-detail hidden'; stationHealthDetailEl.textContent = ''; } } } function sortStationEntries(entries, context) { const sortedEntries = entries.slice(); sortedEntries.sort((left, right) => { const leftTitle = getStationTitle(left.station); const rightTitle = getStationTitle(right.station); const leftRecentPlayedAt = context.recentActivityById.get(left.station.id) || 0; const rightRecentPlayedAt = context.recentActivityById.get(right.station.id) || 0; const leftUsageCount = Math.max(0, Number(context.counts?.[left.station.id]) || 0); const rightUsageCount = Math.max(0, Number(context.counts?.[right.station.id]) || 0); const leftFavorite = context.favourites.has(left.station.id) ? 1 : 0; const rightFavorite = context.favourites.has(right.station.id) ? 1 : 0; switch (stationLibrarySort) { case 'name': return leftTitle.localeCompare(rightTitle, undefined, { sensitivity: 'base' }) || left.index - right.index; case 'recent': return rightRecentPlayedAt - leftRecentPlayedAt || rightUsageCount - leftUsageCount || left.index - right.index; case 'favorites': return rightFavorite - leftFavorite || leftTitle.localeCompare(rightTitle, undefined, { sensitivity: 'base' }) || left.index - right.index; case 'health': { const leftHealthScore = getStationHealthScore(left.station, context.stationHealth); const rightHealthScore = getStationHealthScore(right.station, context.stationHealth); return rightHealthScore - leftHealthScore || leftTitle.localeCompare(rightTitle, undefined, { sensitivity: 'base' }) || left.index - right.index; } case 'recommended': default: { const leftRecommendedScore = getRecommendedStationScore(left.station, context); const rightRecommendedScore = getRecommendedStationScore(right.station, context); return rightRecommendedScore - leftRecommendedScore || leftTitle.localeCompare(rightTitle, undefined, { sensitivity: 'base' }) || left.index - right.index; } } }); return sortedEntries; } function getStationTitle(station) { return station?.name || station?.title || station?.id || 'Station'; } function isMobileTitleViewport() { return window.matchMedia('(max-width: 760px)').matches; } function renderStationTitle(title, shouldScroll = false) { if (!stationNameEl) return; stationNameEl.replaceChildren(); if (!shouldScroll) { stationNameEl.classList.remove('station-title-marquee'); const plainTitle = document.createElement('span'); plainTitle.className = 'station-title-text'; plainTitle.textContent = title; stationNameEl.appendChild(plainTitle); return; } stationNameEl.classList.add('station-title-marquee'); const track = document.createElement('span'); track.className = 'station-title-track'; const firstCopy = document.createElement('span'); firstCopy.className = 'station-title-copy'; firstCopy.textContent = title; const secondCopy = document.createElement('span'); secondCopy.className = 'station-title-copy'; secondCopy.setAttribute('aria-hidden', 'true'); secondCopy.textContent = title; track.append(firstCopy, secondCopy); stationNameEl.appendChild(track); } function updateStationTitleLayout(title) { if (!stationNameEl) return; const titleText = String(title || '').trim() || 'Station'; if (stationTitleRafId) { cancelAnimationFrame(stationTitleRafId); stationTitleRafId = null; } renderStationTitle(titleText, false); if (!isMobileTitleViewport()) return; stationTitleRafId = window.requestAnimationFrame(() => { stationTitleRafId = null; if (!stationNameEl || !isMobileTitleViewport()) return; if (stationNameEl.scrollWidth > stationNameEl.clientWidth + 2) { renderStationTitle(titleText, true); } }); } function getStationCountry(station) { return station?.countryCode || station?.country || station?.region || ''; } function getStationTags(station) { return Array.isArray(station?.tags) ? station.tags.filter(Boolean) : []; } function getStationTechnicalLabel(station) { const parts = []; if (station?.codec) parts.push(String(station.codec).toUpperCase()); if (Number.isFinite(station?.bitrate) && station.bitrate > 0) parts.push(`${station.bitrate} kbps`); return parts.join(' • '); } function getStationLanguageValues(station) { const rawLanguages = [ station?.language, station?.languagecodes, station?.languages, station?.raw?.language, station?.raw?.languages, ].flatMap((value) => { if (Array.isArray(value)) return value; if (typeof value === 'string') return value.split(/[;,/|]/g); return []; }); return Array.from(new Set( rawLanguages .map((value) => String(value || '').trim()) .filter(Boolean) .map((value) => value.replace(/\s+/g, ' ')), )); } function getStationPrimaryLanguage(station) { return getStationLanguageValues(station)[0] || ''; } function getStationCodecValue(station) { return String(station?.codec || '').trim().toUpperCase(); } function getStationBitrateBucket(station) { const bitrate = Number(station?.bitrate); if (!Number.isFinite(bitrate) || bitrate <= 0) return 'unknown'; if (bitrate <= 96) return 'low'; if (bitrate < 192) return 'mid'; return 'high'; } function getStationDetails(station) { return [ getCountryDisplayName(getStationCountry(station)), getStationPrimaryLanguage(station), getStationTechnicalLabel(station), ].filter(Boolean).join(' • '); } function getStationSearchText(station) { return [ getStationTitle(station), getStationCountry(station), getStationSubtitle(station), getStationPrimaryLanguage(station), getStationTechnicalLabel(station), getStationCategory(station), ...getStationTags(station), ].join(' ').toLowerCase(); } function getStationLogoUrl(station) { return station?.logo || station?.poster || station?.raw?.assets?.logo || station?.raw?.logo || station?.raw?.assets?.poster || station?.raw?.poster || ''; } function getStationLogoCandidates(station) { const logoUrl = getStationLogoUrl(station); return uniqueNonEmpty([ toHttpsIfHttp(logoUrl), logoUrl, RADIO_PLACEHOLDER_LOGO, ]); } function getStationSubtitle(station) { return station?.slogan || station?.raw?.slogan || station?.raw?.defaultText || ''; } function getStationCategory(station) { if (station?._user) return 'Custom'; if (station?.category) return station.category; const raw = station?.raw || {}; const explicit = raw.category || raw.genre || raw.format || raw.type; if (explicit) return String(explicit); const text = `${station?.id || ''} ${station?.name || ''} ${raw.title || ''} ${raw.slogan || ''} ${getStationTags(station).join(' ')}`.toLowerCase(); if (/news|talk|politic|speech|discussion/.test(text)) return 'Talk'; if (/jazz|blues|soul/.test(text)) return 'Jazz'; if (/classical|orchestra|symphony|opera/.test(text)) return 'Classical'; if (/electronic|dance|house|techno|trance|edm/.test(text)) return 'Electronic'; if (/ambient|chill|downtempo/.test(text)) return 'Ambient'; if (/rock|metal/.test(text)) return 'Rock'; if (/folk|veseljak|zvoki|narod|country/.test(text)) return 'Folk'; if (/aktual|pop|hit|dance|top/.test(text)) return 'Pop'; if (/80|90|70|retro|gold|old/.test(text)) return 'Retro'; if (/maribor|celje|kranj|triglav|velenje|korosk|koper|regional/.test(text)) return 'Regional'; return 'General'; } function getStationEntries() { return stations.map((station, index) => ({ station, index })); } function getCategoryNames() { const entries = getStationEntries().filter(({ station }) => stationLibraryCountry === 'all' || getStationCountry(station) === stationLibraryCountry); return Array.from(new Set(entries.map(({ station }) => getStationCategory(station)))).sort((a, b) => a.localeCompare(b)); } function getCountryNames() { return Array.from(new Set(stations.map(getStationCountry).filter(Boolean))).sort((a, b) => a.localeCompare(b)); } function getLanguageOptions() { return Array.from(new Set( stations.flatMap((station) => getStationLanguageValues(station)), )).sort((a, b) => a.localeCompare(b)); } function getCodecOptions() { return Array.from(new Set( stations.map((station) => getStationCodecValue(station)).filter(Boolean), )).sort((a, b) => a.localeCompare(b)); } function getCountryDisplayName(countryValue) { const value = String(countryValue || '').trim(); if (!value) return ''; if (value === 'all') return 'All countries'; if (value === 'SI') return 'Slovenia (managed)'; if (/^[A-Z]{2}$/i.test(value)) { const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase()); if (countryName) return countryName; } return value; } function getCountryFilterDisplayName(countryValue) { const value = String(countryValue || '').trim(); if (!value) return ''; if (value === 'all') return 'All countries'; if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)'; if (/^[A-Z]{2}$/i.test(value)) { const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase()); if (countryName) return countryName; } return value; } function getCountryCodeFromValue(countryValue) { const value = String(countryValue || '').trim(); if (!value || value === 'all') return ''; if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase(); return radioCountryCodeByName.get(value) || availableRadioCountries.find((country) => country.name === value)?.code || ''; } function resetStationLibraryPage() { stationLibraryPage = 0; } function goToPreviousStationLibraryPage() { if (stationLibraryPage <= 0) return; stationLibraryPage -= 1; renderStationLibrary(); } function goToNextStationLibraryPage() { if (stationLibraryPage >= stationLibraryPageTotal - 1) return; stationLibraryPage += 1; renderStationLibrary(); } function countryCodeToFlagEmoji(countryCode) { const code = String(countryCode || '').trim().toUpperCase(); if (!/^[A-Z]{2}$/.test(code)) return '🌐'; return String.fromCodePoint(...code.split('').map((char) => 127397 + char.charCodeAt(0))); } function renderLibrarySelectOptions({ wrapEl, btnEl, menuEl, textEl, open, value, fallbackLabel, options, onSelect }) { if (!btnEl || !menuEl || !textEl) return; const currentLabel = options.find((option) => option.value === value)?.label ?? fallbackLabel; textEl.textContent = currentLabel; btnEl.setAttribute('aria-expanded', open ? 'true' : 'false'); wrapEl?.classList.toggle('open', open); menuEl.classList.toggle('open', open); if (!open) { menuEl.innerHTML = ''; return; } menuEl.innerHTML = ''; options.forEach((option) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'library-select-option' + (option.value === value ? ' active' : ''); button.setAttribute('role', 'option'); button.setAttribute('aria-selected', option.value === value ? 'true' : 'false'); const text = document.createElement('span'); text.className = 'library-select-option-text'; text.textContent = option.label; button.appendChild(text); button.addEventListener('click', () => onSelect(option.value)); menuEl.appendChild(button); }); } function syncStationLibraryMetadataFilters() { const languageOptions = getLanguageOptions(); if (stationLibraryLanguage !== 'all' && !languageOptions.includes(stationLibraryLanguage)) { stationLibraryLanguage = 'all'; } renderLibrarySelectOptions({ wrapEl: stationLanguageFilterWrapEl, btnEl: stationLanguageFilterBtn, menuEl: stationLanguageFilterMenu, textEl: stationLanguageFilterText, open: stationLanguageFilterOpen, value: stationLibraryLanguage, fallbackLabel: 'All languages', options: [ { value: 'all', label: 'All languages' }, ...languageOptions.map((language) => ({ value: language, label: language })), ], onSelect: (nextValue) => { stationLibraryLanguage = nextValue; stationLanguageFilterOpen = false; resetStationLibraryPage(); renderStationLibrary(); }, }); const codecOptions = getCodecOptions(); if (stationLibraryCodec !== 'all' && !codecOptions.includes(stationLibraryCodec)) { stationLibraryCodec = 'all'; } renderLibrarySelectOptions({ wrapEl: stationCodecFilterWrapEl, btnEl: stationCodecFilterBtn, menuEl: stationCodecFilterMenu, textEl: stationCodecFilterText, open: stationCodecFilterOpen, value: stationLibraryCodec, fallbackLabel: 'All codecs', options: [ { value: 'all', label: 'All codecs' }, ...codecOptions.map((codec) => ({ value: codec, label: codec })), ], onSelect: (nextValue) => { stationLibraryCodec = nextValue; stationCodecFilterOpen = false; resetStationLibraryPage(); renderStationLibrary(); }, }); renderLibrarySelectOptions({ wrapEl: stationBitrateFilterWrapEl, btnEl: stationBitrateFilterBtn, menuEl: stationBitrateFilterMenu, textEl: stationBitrateFilterText, open: stationBitrateFilterOpen, value: stationLibraryBitrate, fallbackLabel: 'Any bitrate', options: [ { value: 'all', label: 'Any bitrate' }, { value: 'low', label: 'Up to 96 kbps' }, { value: 'mid', label: '97-191 kbps' }, { value: 'high', label: '192+ kbps' }, ], onSelect: (nextValue) => { stationLibraryBitrate = nextValue; stationBitrateFilterOpen = false; resetStationLibraryPage(); renderStationLibrary(); }, }); if (stationHealthFilterBtn) { stationHealthFilterBtn.setAttribute('aria-pressed', stationLibraryHealthyOnly ? 'true' : 'false'); stationHealthFilterBtn.classList.toggle('active', stationLibraryHealthyOnly); } if (stationHealthFilterText) { stationHealthFilterText.textContent = stationLibraryHealthyOnly ? 'Works well recently' : 'All stations'; } } function getCountryFlag(countryName) { if (!countryName || countryName === 'all') return '🌐'; const countryCode = getCountryCodeFromValue(countryName); return countryCode ? countryCodeToFlagEmoji(countryCode) : '🏳️'; } function toggleStationCountryFilter(forceOpen) { stationCountryFilterOpen = typeof forceOpen === 'boolean' ? forceOpen : !stationCountryFilterOpen; renderStationLibrary(); } function closeStationCountryFilter() { if (!stationCountryFilterOpen) return; stationCountryFilterOpen = false; renderStationLibrary(); } const SORT_OPTIONS = [ { value: 'recommended', label: 'Recommended' }, { value: 'name', label: 'Name' }, { value: 'recent', label: 'Recently played' }, { value: 'favorites', label: 'Favorites first' }, { value: 'health', label: 'Healthiest' }, ]; function closeLanguageFilter() { if (!stationLanguageFilterOpen) return; stationLanguageFilterOpen = false; syncStationLibraryMetadataFilters(); } function closeCodecFilter() { if (!stationCodecFilterOpen) return; stationCodecFilterOpen = false; syncStationLibraryMetadataFilters(); } function closeBitrateFilter() { if (!stationBitrateFilterOpen) return; stationBitrateFilterOpen = false; syncStationLibraryMetadataFilters(); } function openAdvancedFiltersOverlay() { if (!stationAdvancedFiltersOverlay) return; stationAdvancedFiltersOpen = true; stationAdvancedFiltersOverlay.classList.remove('hidden'); stationAdvancedFiltersOverlay.setAttribute('aria-hidden', 'false'); } function closeAdvancedFiltersOverlay() { if (!stationAdvancedFiltersOverlay) return; stationAdvancedFiltersOpen = false; stationAdvancedFiltersOverlay.classList.add('hidden'); stationAdvancedFiltersOverlay.setAttribute('aria-hidden', 'true'); closeSortFilter(); closeLanguageFilter(); closeCodecFilter(); closeBitrateFilter(); } function closeSortFilter() { if (!stationSortOpen) return; stationSortOpen = false; renderSortOptions(); } function renderSortOptions() { if (!stationSortBtn || !stationSortMenu || !stationSortText) return; const current = SORT_OPTIONS.find((o) => o.value === stationLibrarySort) ?? SORT_OPTIONS[0]; stationSortText.textContent = current.label; stationSortBtn.setAttribute('aria-expanded', stationSortOpen ? 'true' : 'false'); stationSortWrapEl?.classList.toggle('open', stationSortOpen); stationSortMenu.classList.toggle('open', stationSortOpen); if (!stationSortOpen) { stationSortMenu.innerHTML = ''; return; } stationSortMenu.innerHTML = ''; SORT_OPTIONS.forEach(({ value, label }) => { const opt = document.createElement('button'); opt.type = 'button'; opt.className = 'library-select-option' + (stationLibrarySort === value ? ' active' : ''); opt.setAttribute('role', 'option'); opt.setAttribute('aria-selected', stationLibrarySort === value ? 'true' : 'false'); const text = document.createElement('span'); text.className = 'library-select-option-text'; text.textContent = label; opt.appendChild(text); opt.addEventListener('click', () => { stationLibrarySort = value; stationSortOpen = false; resetStationLibraryPage(); renderSortOptions(); renderStationLibrary(); }); stationSortMenu.appendChild(opt); }); } function renderCountryFilterOptions() { if (!stationCountryFilterMenu || !stationCountryFilterBtn || !stationCountryFilterText) return; const countries = getCountryNames(); if (stationCatalogState === 'ready' && stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) { stationLibraryCountry = 'all'; saveLastStationCountry('all'); resetStationLibraryPage(); } stationCountryFilterBtn.setAttribute('aria-expanded', stationCountryFilterOpen ? 'true' : 'false'); stationCountryFilterText.textContent = getCountryFilterDisplayName(stationLibraryCountry); if (stationCountryFilterFlag) { stationCountryFilterFlag.textContent = getCountryFlag(stationLibraryCountry); } stationCountryFilterWrapEl?.classList.toggle('open', stationCountryFilterOpen); stationCountryFilterMenu.innerHTML = ''; stationCountryFilterMenu.classList.toggle('open', stationCountryFilterOpen); const addOption = (label, value) => { const option = document.createElement('button'); option.type = 'button'; option.className = 'library-select-option' + (stationLibraryCountry === value ? ' active' : ''); option.setAttribute('role', 'option'); option.setAttribute('aria-selected', stationLibraryCountry === value ? 'true' : 'false'); const flag = document.createElement('span'); flag.className = 'library-select-option-flag'; flag.setAttribute('aria-hidden', 'true'); flag.textContent = getCountryFlag(value); const text = document.createElement('span'); text.className = 'library-select-option-text'; text.textContent = label; option.appendChild(flag); option.appendChild(text); option.addEventListener('click', () => { stationLibraryCountry = value; stationLibraryCategory = 'all'; saveLastStationCountry(value); resetStationLibraryPage(); stationCountryFilterOpen = false; renderStationLibrary(); }); stationCountryFilterMenu.appendChild(option); }; addOption('All countries', 'all'); const orderedCountries = countries .slice() .sort((left, right) => { const leftPriority = left.toUpperCase() === 'SI' ? -1 : 0; const rightPriority = right.toUpperCase() === 'SI' ? -1 : 0; if (leftPriority !== rightPriority) return leftPriority - rightPriority; return getCountryFilterDisplayName(left).localeCompare(getCountryFilterDisplayName(right)); }); orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country)); const manageButton = document.createElement('button'); manageButton.type = 'button'; manageButton.className = 'library-select-manage-btn'; manageButton.textContent = 'Choose countries...'; manageButton.addEventListener('click', () => { void openCountrySelectionOverlay(); }); stationCountryFilterMenu.appendChild(manageButton); } function getFilteredStationEntries() { const favourites = loadFavoriteStationIds(); const counts = loadStationUsageCounts(); const recentActivityById = getRecentStationActivityById(); const stationHealth = loadStationHealth(); const query = stationLibraryQuery.trim().toLowerCase(); let entries = getStationEntries(); if (stationLibraryTab === 'favourites') { entries = entries.filter(({ station }) => favourites.has(station.id)); } else if (stationLibraryTab === 'recent') { entries = entries .map((entry) => ({ ...entry, count: Number(counts[entry.station.id]) || 0, recentPlayedAt: recentActivityById.get(entry.station.id) || 0, })) .filter((entry) => entry.recentPlayedAt > 0 || entry.count > 0) .sort((a, b) => b.recentPlayedAt - a.recentPlayedAt || b.count - a.count || a.index - b.index); } if (stationLibraryCountry !== 'all') { entries = entries.filter(({ station }) => getStationCountry(station) === stationLibraryCountry); } if (stationLibraryTab === 'categories' && stationLibraryCategory !== 'all') { entries = entries.filter(({ station }) => getStationCategory(station) === stationLibraryCategory); } if (stationLibraryLanguage !== 'all') { entries = entries.filter(({ station }) => getStationLanguageValues(station).includes(stationLibraryLanguage)); } if (stationLibraryCodec !== 'all') { entries = entries.filter(({ station }) => getStationCodecValue(station) === stationLibraryCodec); } if (stationLibraryBitrate !== 'all') { entries = entries.filter(({ station }) => getStationBitrateBucket(station) === stationLibraryBitrate); } if (stationLibraryHealthyOnly) { entries = entries.filter(({ station }) => getStationHealthScore(station, stationHealth) >= 0.7); } if (query) { entries = entries.filter(({ station }) => getStationSearchText(station).includes(query)); } return sortStationEntries(entries, { favourites, counts, recentActivityById, stationHealth, }); } function getQuickPickEntries() { const favourites = loadFavoriteStationIds(); const entries = getStationEntries(); const favEntries = entries.filter(({ station }) => favourites.has(station.id)); if (favEntries.length > 0) return favEntries; const counts = loadStationUsageCounts(); return entries .map((entry) => ({ ...entry, count: Number(counts[entry.station.id]) || 0 })) .sort((a, b) => { if (b.count !== a.count) return b.count - a.count; return a.index - b.index; }) .slice(0, 10); } function setStationLibraryTab(tab) { stationLibraryTab = tab || 'all'; if (stationLibraryTab !== 'categories') stationLibraryCategory = 'all'; resetStationLibraryPage(); renderStationLibrary(); } function toggleFavoriteStation(station) { if (!station?.id) return; const favourites = loadFavoriteStationIds(); if (favourites.has(station.id)) favourites.delete(station.id); else favourites.add(station.id); saveFavoriteStationIds(favourites); renderStationLibrary({ preserveScroll: true }); renderCoverflow(); } function makeStationLogo(station, className, fallbackClassName) { const wrap = document.createElement('div'); const title = getStationTitle(station); const img = document.createElement('img'); img.className = className; img.alt = `${title} logo`; img.referrerPolicy = 'no-referrer'; setImgWithFallback(img, getStationLogoCandidates(station), () => { wrap.innerHTML = ''; const fallback = document.createElement('div'); fallback.className = fallbackClassName; fallback.textContent = title.charAt(0).toUpperCase(); wrap.appendChild(fallback); }); wrap.appendChild(img); return wrap.firstElementChild || wrap; } function renderCategoryChips() { if (!stationCategoryListEl) return; stationCategoryListEl.innerHTML = ''; const chips = ['all', ...getCategoryNames()]; if (stationLibraryCategory !== 'all' && !chips.includes(stationLibraryCategory)) { stationLibraryCategory = 'all'; } chips.forEach((category) => { const btn = document.createElement('button'); btn.className = 'category-chip' + (stationLibraryCategory === category ? ' active' : ''); btn.type = 'button'; btn.textContent = category === 'all' ? 'All categories' : category; btn.addEventListener('click', () => { stationLibraryCategory = category; resetStationLibraryPage(); renderStationLibrary(); }); stationCategoryListEl.appendChild(btn); }); } function renderStationLibrary(options = {}) { try { if (!stationLibraryListEl) return; const preserveScroll = options?.preserveScroll === true; const previousScrollTop = preserveScroll ? stationLibraryListEl.scrollTop : 0; const restoreScrollPosition = () => { if (!preserveScroll) return; stationLibraryListEl.scrollTop = previousScrollTop; }; stationLibraryListEl.innerHTML = ''; if (!preserveScroll) { stationLibraryListEl.scrollTop = 0; } stationLibraryEl?.classList.toggle('show-categories', stationLibraryTab === 'categories'); stationTabBtns.forEach((btn) => { const active = btn.dataset.stationTab === stationLibraryTab; btn.classList.toggle('active', active); btn.setAttribute('aria-selected', active ? 'true' : 'false'); }); renderCountryFilterOptions(); renderSortOptions(); syncStationLibraryMetadataFilters(); renderCategoryChips(); if (stationCatalogState === 'loading') { if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Loading stations...'; if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1'; if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true; if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true; stationLibraryPaginationEl?.classList.add('hidden'); const empty = document.createElement('li'); empty.className = 'library-empty'; empty.textContent = 'Loading the local radio catalog...'; stationLibraryListEl.appendChild(empty); restoreScrollPosition(); return; } if (stationCatalogState === 'error') { if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Unable to load stations'; if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1'; if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true; if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true; stationLibraryPaginationEl?.classList.add('hidden'); const empty = document.createElement('li'); empty.className = 'library-empty'; empty.textContent = stationCatalogError || 'The local station catalog could not be loaded.'; stationLibraryListEl.appendChild(empty); restoreScrollPosition(); return; } if (!stations.length) { if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'No stations available'; if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1'; if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true; if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true; stationLibraryPaginationEl?.classList.add('hidden'); const empty = document.createElement('li'); empty.className = 'library-empty'; empty.textContent = 'The local station catalog is empty.'; stationLibraryListEl.appendChild(empty); restoreScrollPosition(); return; } const favourites = loadFavoriteStationIds(); const counts = loadStationUsageCounts(); const entries = getFilteredStationEntries(); const tabLabel = stationLibraryTab === 'favourites' ? 'favourite' : stationLibraryTab === 'recent' ? 'recent' : 'available'; if (stationLibrarySummaryEl) { const countryLabel = stationLibraryCountry === 'all' ? '' : ` in ${getCountryDisplayName(stationLibraryCountry)}`; stationLibrarySummaryEl.textContent = `${entries.length} ${tabLabel} station${entries.length === 1 ? '' : 's'}${countryLabel}`; } const totalPages = Math.max(1, Math.ceil(entries.length / STATION_LIBRARY_PAGE_SIZE)); stationLibraryPageTotal = totalPages; stationLibraryPaginationEl?.classList.toggle('hidden', totalPages <= 1); if (stationLibraryPage >= totalPages) { stationLibraryPage = totalPages - 1; } const pageStart = stationLibraryPage * STATION_LIBRARY_PAGE_SIZE; const pageEntries = entries.slice(pageStart, pageStart + STATION_LIBRARY_PAGE_SIZE); if (entries.length === 0) { if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1'; if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true; if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true; const empty = document.createElement('li'); empty.className = 'library-empty'; empty.textContent = stationLibraryTab === 'favourites' ? 'No favourites yet. Use the star button to build your quick list.' : 'No stations match this filter.'; stationLibraryListEl.appendChild(empty); restoreScrollPosition(); return; } if (stationLibraryPageInfo) { stationLibraryPageInfo.textContent = `Page ${stationLibraryPage + 1} of ${totalPages}`; } if (stationLibraryPagePrevBtn) { stationLibraryPagePrevBtn.disabled = stationLibraryPage <= 0; stationLibraryPagePrevBtn.setAttribute('aria-disabled', stationLibraryPage <= 0 ? 'true' : 'false'); } if (stationLibraryPageNextBtn) { stationLibraryPageNextBtn.disabled = stationLibraryPage >= totalPages - 1; stationLibraryPageNextBtn.setAttribute('aria-disabled', stationLibraryPage >= totalPages - 1 ? 'true' : 'false'); } const stationHealth = loadStationHealth(); pageEntries.forEach(({ station, index, count }) => { const title = getStationTitle(station); const healthBadge = getStationHealthBadge(station, stationHealth); const proxySupportDetail = getStationProxySupportDetail(station); const li = document.createElement('li'); const row = document.createElement('div'); row.className = 'library-station' + (index === currentIndex ? ' current' : ''); row.setAttribute('role', 'button'); row.setAttribute('tabindex', '0'); row.setAttribute('aria-current', index === currentIndex ? 'true' : 'false'); row.title = title; row.appendChild(makeStationLogo(station, 'library-station-logo', 'library-station-fallback')); const copy = document.createElement('div'); copy.className = 'library-station-copy'; const titleRow = document.createElement('div'); titleRow.className = 'library-station-title-row'; const titleEl = document.createElement('div'); titleEl.className = 'library-station-title'; titleEl.textContent = title; titleRow.appendChild(titleEl); const meta = document.createElement('div'); meta.className = 'library-station-meta'; const country = document.createElement('span'); country.className = 'library-station-country'; country.textContent = getCountryDisplayName(getStationCountry(station)) || getStationCategory(station); const tech = document.createElement('span'); tech.className = 'library-station-tech'; tech.textContent = getStationTechnicalLabel(station) || getStationCategory(station); meta.appendChild(country); meta.appendChild(tech); copy.appendChild(titleRow); copy.appendChild(meta); const useCount = Number(counts[station.id] ?? count) || 0; const infoDetail = buildStationLibraryInfoDetail(station, { healthBadge, proxySupportDetail, useCount, }); if (infoDetail) { titleRow.appendChild(createStationInfoIndicator(infoDetail, 'station-info-indicator-inline station-info-indicator-neutral')); } row.appendChild(copy); const favBtn = document.createElement('span'); favBtn.className = 'favorite-btn' + (favourites.has(station.id) ? ' active' : ''); favBtn.setAttribute('role', 'button'); favBtn.setAttribute('aria-pressed', favourites.has(station.id) ? 'true' : 'false'); favBtn.setAttribute('tabindex', '0'); favBtn.setAttribute('aria-label', favourites.has(station.id) ? `Remove ${title} from favourites` : `Add ${title} to favourites`); favBtn.textContent = favourites.has(station.id) ? '★' : '☆'; favBtn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); toggleFavoriteStation(station); }); favBtn.addEventListener('keydown', (ev) => { if (ev.key !== 'Enter' && ev.key !== ' ') return; ev.preventDefault(); ev.stopPropagation(); toggleFavoriteStation(station); }); row.appendChild(favBtn); row.addEventListener('click', async () => { await activateStationByIndex(index); }); row.addEventListener('keydown', async (ev) => { if (ev.key !== 'Enter' && ev.key !== ' ') return; ev.preventDefault(); await activateStationByIndex(index); }); li.appendChild(row); stationLibraryListEl.appendChild(li); }); restoreScrollPosition(); } catch (e) { console.debug('renderStationLibrary failed', e); } } function openStationLibrary() { if (!stationLibraryEl) { openStationsOverlay(); return; } renderStationLibrary(); const mobileDrawer = window.matchMedia('(max-width: 1100px)').matches; if (mobileDrawer) { playerLayoutEl?.classList.remove('library-collapsed'); stationLibraryEl.classList.add('open'); document.body.classList.add('library-open'); } else { playerLayoutEl?.classList.remove('library-collapsed'); } setTimeout(() => { if (stationLibraryFiltersCollapsed) stationLibraryFiltersToggleBtn?.focus(); else stationSearchInput?.focus(); }, 30); } function closeStationLibrary() { const mobileDrawer = window.matchMedia('(max-width: 1100px)').matches; if (mobileDrawer) { stationLibraryEl?.classList.remove('open'); document.body.classList.remove('library-open'); } else { playerLayoutEl?.classList.add('library-collapsed'); } } function toggleStationLibrary() { if (!stationLibraryEl) { openStationsOverlay(); return; } const mobileDrawer = window.matchMedia('(max-width: 1100px)').matches; if (mobileDrawer) { if (stationLibraryEl.classList.contains('open')) closeStationLibrary(); else openStationLibrary(); return; } if (playerLayoutEl?.classList.contains('library-collapsed')) openStationLibrary(); else closeStationLibrary(); } function applyStationLibraryFiltersState() { if (!stationLibraryEl || !stationLibraryFiltersToggleBtn) return; stationLibraryEl.classList.toggle('filters-collapsed', stationLibraryFiltersCollapsed); stationLibraryFiltersToggleBtn.setAttribute('aria-expanded', stationLibraryFiltersCollapsed ? 'false' : 'true'); const label = stationLibraryFiltersCollapsed ? 'Expand search and filters' : 'Collapse search and filters'; stationLibraryFiltersToggleBtn.setAttribute('aria-label', label); stationLibraryFiltersToggleBtn.title = label; } function toggleStationLibraryFilters(forceCollapsed) { stationLibraryFiltersCollapsed = typeof forceCollapsed === 'boolean' ? forceCollapsed : !stationLibraryFiltersCollapsed; if (stationLibraryFiltersCollapsed) { closeStationCountryFilter(); closeSortFilter(); closeLanguageFilter(); closeCodecFilter(); closeBitrateFilter(); } applyStationLibraryFiltersState(); } async function moveQuickPick(delta) { const entries = getQuickPickEntries(); if (!entries.length) return; const currentQuickIndex = entries.findIndex(({ index }) => index === currentIndex); let nextQuickIndex; if (currentQuickIndex >= 0) { nextQuickIndex = (currentQuickIndex + delta + entries.length) % entries.length; } else { nextQuickIndex = delta > 0 ? 0 : entries.length - 1; } await setStationByIndex(entries[nextQuickIndex].index); } async function activateStationByIndex(idx) { const stationChanged = idx !== currentIndex; await setStationByIndex(idx); if (!isPlaying || stationChanged) { await play(); } } function buildMergedStations(raw, managedRaw) { const normalizedRaw = raw .filter((station) => isEnabledRadioCountryStation(station)) .map((station) => normalizeStationRecord(station)) .filter((station) => station.enabled !== false && station.url && station.url.length > 0); const normalizedManaged = managedRaw .map((station) => normalizeStationRecord(station)) .filter((station) => station.enabled !== false && station.url && station.url.length > 0); const userNormalized = loadUserStations() .map((station) => normalizeStationRecord(station, true)) .filter((station) => station.url && station.url.length > 0); const mergedStations = []; const seenStationIds = new Set(); const seenStreamUrls = new Set(); const pushStation = (station) => { if (!station?.id || !station?.url) return; if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return; seenStationIds.add(station.id); seenStreamUrls.add(station.url); mergedStations.push(station); }; normalizedManaged.forEach(pushStation); normalizedRaw.forEach(pushStation); userNormalized.forEach(pushStation); return mergedStations; } function applyLoadedStations(mergedStations, { preferredStationId = null } = {}) { stations = mergedStations; updateManagedCatalogStatus(getLastManagedCatalogSource()); stationCatalogState = stations.length > 0 ? 'ready' : 'empty'; if (stations.length > 0) { const fallbackStationId = preferredStationId || getLastStationId(); if (fallbackStationId) { const foundIndex = stations.findIndex((station) => station.id === fallbackStationId); currentIndex = foundIndex >= 0 ? foundIndex : 0; } else { currentIndex = 0; } loadStation(currentIndex); renderCoverflow(); renderStationLibrary(); startCurrentSongPollers(); } else { renderCoverflow(); renderStationLibrary(); } } // ── Load stations ──────────────────────────────────────────────────────────── async function loadStations() { try { stationCatalogState = 'loading'; stationCatalogError = ''; resetStationLibraryPage(); renderStationLibrary(); stopCurrentSongPollers(); const persistedRadioPromise = loadPersistedRadioStations().catch(() => null); const persistedManagedPromise = loadPersistedManagedStations().catch(() => null); const liveRadioPromise = loadRadioStations(); const liveManagedPromise = loadManagedStations().catch(() => []); const [persistedRaw, persistedManaged] = await Promise.all([ persistedRadioPromise, persistedManagedPromise, ]); const hasPersistedCatalog = (persistedRaw?.length ?? 0) > 0 || (persistedManaged?.length ?? 0) > 0; if (hasPersistedCatalog) { applyLoadedStations(buildMergedStations(persistedRaw ?? [], persistedManaged ?? [])); console.debug('loadStations: hydrated from persisted station catalog'); } try { const [raw, managedRaw] = await Promise.all([liveRadioPromise, liveManagedPromise]); const preferredStationId = hasPersistedCatalog ? stations[currentIndex]?.id ?? null : null; applyLoadedStations(buildMergedStations(raw, managedRaw), { preferredStationId }); lastManagedCatalogLoadTime = Date.now(); console.debug('loadStations: loaded', stations.length, 'stations'); console.debug('managed catalog source:', getLastManagedCatalogSource()); } catch (refreshError) { if (!hasPersistedCatalog) { throw refreshError; } console.warn('Failed to refresh station catalogs, continuing with persisted data.', refreshError); stationCatalogError = ''; if (statusTextEl) { statusTextEl.textContent = 'Ready'; } } } catch (e) { console.error('Failed to load stations', e); stations = []; updateManagedCatalogStatus('unknown'); stationCatalogState = 'error'; stationCatalogError = e?.message || 'Error loading the local station catalog.'; renderCoverflow(); renderStationLibrary(); if (statusTextEl) statusTextEl.textContent = 'Unable to load stations'; } } // ── Coverflow ──────────────────────────────────────────────────────────────── 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 = ''; const quickPickEntries = getQuickPickEntries(); quickPickEntries.forEach(({ station: s, index: idx }) => { const item = document.createElement('div'); item.className = 'coverflow-item'; item.dataset.idx = String(idx); item.setAttribute('role', 'button'); item.setAttribute('tabindex', '0'); const fallbackLabel = (s?.name ?? '?').trim(); item.title = fallbackLabel; item.setAttribute('aria-label', `Select ${fallbackLabel}`); const rawLogoUrl = getStationLogoUrl(s); if (rawLogoUrl) { const img = document.createElement('img'); img.alt = `${s.name} logo`; setImgWithFallback(img, [toHttpsIfHttp(rawLogoUrl), rawLogoUrl], () => { item.innerHTML = ''; item.classList.add('fallback'); item.textContent = fallbackLabel; }); item.appendChild(img); } else { item.classList.add('fallback'); item.textContent = fallbackLabel; } item.addEventListener('click', async (ev) => { ev.preventDefault(); ev.stopPropagation(); if (coverflowMoved) return; const idxClicked = Number(item.dataset.idx); if (idxClicked !== currentIndex) await activateStationByIndex(idxClicked); else openStationLibrary(); }); item.addEventListener('keydown', async (ev) => { if (ev.key !== 'Enter' && ev.key !== ' ') return; ev.preventDefault(); const idxSelected = Number(item.dataset.idx); if (idxSelected !== currentIndex) await activateStationByIndex(idxSelected); else openStationLibrary(); }); item.addEventListener('dblclick', () => { if (Number(item.dataset.idx) === currentIndex) openStationLibrary(); }); coverflowStageEl.appendChild(item); }); wireCoverflowInteractions(); updateCoverflowTransforms(); } catch (e) { console.debug('renderCoverflow failed', e); } } function wireCoverflowInteractions() { try { const host = document.getElementById('artwork-coverflow'); if (!host) return; if (coverflowPrevBtn) { coverflowPrevBtn.onpointerdown = (ev) => ev.stopPropagation(); coverflowPrevBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); moveQuickPick(-1); }; } if (coverflowNextBtn) { coverflowNextBtn.onpointerdown = (ev) => ev.stopPropagation(); coverflowNextBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); moveQuickPick(1); }; } host.onpointerdown = (ev) => { if (getQuickPickEntries().length <= 1) return; if (ev.target?.closest?.('.coverflow-arrow')) return; 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; coverflowAccum += dx; const threshold = 36; if (coverflowAccum >= threshold) { coverflowAccum = 0; moveQuickPick(-1); } else if (coverflowAccum <= -threshold) { coverflowAccum = 0; moveQuickPick(1); } }; host.onpointerup = (ev) => { if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; coverflowPointerId = null; setTimeout(() => { coverflowMoved = false; }, 0); try { host.releasePointerCapture(ev.pointerId); } catch (e) {} }; host.onpointercancel = () => { coverflowPointerId = null; coverflowMoved = false; }; host.onwheel = (ev) => { if (getQuickPickEntries().length <= 1 || 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) moveQuickPick(1); else moveQuickPick(-1); 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 = items.length; if (n <= 0) return; let selectedRailIndex = Array.from(items).findIndex((el) => Number(el.dataset.idx) === currentIndex); if (selectedRailIndex < 0) selectedRailIndex = 0; const stageWidth = coverflowStageEl.clientWidth || 320; const isMobile = window.matchMedia('(max-width: 760px)').matches; const isNarrow = window.matchMedia('(max-width: 380px)').matches; const spacing = isMobile ? Math.min(78, Math.max(62, stageWidth / 3.25)) : Math.min(116, Math.max(94, stageWidth / 3.1)); const depth = isMobile ? 26 : 36; const rotation = isMobile ? 0 : 8; const scaleStep = isMobile ? 0.08 : 0.1; const maxVisible = isMobile ? 1 : isNarrow ? 1 : Math.max(1, Math.min(4, Math.floor((stageWidth - 120) / (spacing * 0.95)))); items.forEach((el, railIndex) => { const idx = Number(el.dataset.idx); let offset = railIndex - selectedRailIndex; const half = Math.floor(n / 2); if (n > 2 && offset > half) offset -= n; if (n > 2 && offset < -half) offset += n; el.dataset.offset = String(offset); el.setAttribute('aria-current', idx === currentIndex ? 'true' : 'false'); if (Math.abs(offset) > maxVisible) { el.classList.toggle('selected', idx === currentIndex); el.style.opacity = '0'; el.style.pointerEvents = 'none'; el.style.transform = `translate(-50%, -50%) translateX(${Math.sign(offset || 1) * (stageWidth / 2)}px) scale(0.72)`; return; } const abs = Math.abs(offset); const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1); const isAdjacent = abs === 1; el.style.opacity = String(1 - abs * 0.2); el.style.zIndex = String(100 - abs); el.style.pointerEvents = 'auto'; el.style.transform = `translate(-50%, -50%) translateX(${dir * abs * spacing}px) translateZ(${-abs * depth}px) rotateY(${dir * -rotation * abs}deg) scale(${isAdjacent ? 0.88 : 1 - abs * scaleStep})`; if (idx === currentIndex) 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(); playbackError = ''; currentIndex = idx; saveLastStationId(stations[currentIndex].id); incrementStationUsage(stations[currentIndex]); recordRecentStationPlay(stations[currentIndex]); loadStation(currentIndex); renderCoverflow(); renderStationLibrary({ preserveScroll: true }); updateCoverflowTransforms(); if (wasPlaying) await play(); } // ── Current Song Polling ───────────────────────────────────────────────────── const currentSongPollers = new Map(); function stopCurrentSongPollers() { for (const entry of currentSongPollers.values()) { try { if (entry?.intervalId) clearInterval(entry.intervalId); } catch (e) {} try { if (entry?.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {} } currentSongPollers.clear(); } function startCurrentSongPollers() { stopCurrentSongPollers(); const s = stations[currentIndex]; if (!s) return; const url = getMetadataFetchUrl(getStationMetadataUrl(s)); if (!url || typeof url !== 'string' || url.length === 0) return; const doFetch = () => { fetchAndStoreCurrentSong(s, currentIndex, url); }; doFetch(); const iid = setInterval(doFetch, 10000); currentSongPollers.set(s.id || currentIndex, { intervalId: iid, timeoutId: null }); } async function fetchAndStoreCurrentSong(station, idx, url) { try { let rawBody = null; try { const resp = await fetch(url, { cache: 'no-store' }); rawBody = await resp.text(); } catch (e) { // CORS or network error — silently skip; now-playing simply won't display. return; } if (!rawBody) return; let data = null; try { const first = JSON.parse(rawBody); data = typeof first === 'string' ? JSON.parse(first) : first; } catch (e) { return; } let now = null; if (data) { if (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?.artist || first?.title) now = { artist: first.artist || '', title: first.title || '' }; } else if (data.artist || data.title) { now = { artist: data.artist || '', title: data.title || '' }; } } if (now) { const previousArtist = String(station.currentSongInfo?.artist || '').trim().toLowerCase(); const previousTitle = String(station.currentSongInfo?.title || '').trim().toLowerCase(); station.currentSongInfo = now; if (previousArtist !== String(now.artist || '').trim().toLowerCase() || previousTitle !== String(now.title || '').trim().toLowerCase()) { recordSongHistory(station, now); } if (idx === currentIndex) updateNowPlayingUI(); // If provider gives timing info, schedule a single-shot refresh at song end. try { const key = station.id || idx; const providerCS = data?.currentSong ?? null; const startStr = providerCS?.playTimeStartSec ?? providerCS?.playTimeStart ?? null; const lengthStr = providerCS?.playTimeLengthSec ?? providerCS?.playTimeLength ?? null; if (startStr && lengthStr) { const nowDate = new Date(); const parts = startStr.split(':').map(Number); if (parts.length >= 2) { const startDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), parts[0], parts[1], parts[2] || 0, 0); const deltaStart = startDate.getTime() - nowDate.getTime(); if (deltaStart > 12 * 3600000) startDate.setDate(startDate.getDate() - 1); if (deltaStart < -12 * 3600000) startDate.setDate(startDate.getDate() + 1); const lenParts = lengthStr.split(':').map(Number); 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 msUntilEnd = startDate.getTime() + lenSec * 1000 - nowDate.getTime(); if (msUntilEnd > 1000) { const entry = currentSongPollers.get(key); if (entry?.intervalId) try { clearInterval(entry.intervalId); } catch (e) {} if (entry?.timeoutId) try { clearTimeout(entry.timeoutId); } catch (e) {} const timeoutId = setTimeout(async () => { try { await fetchAndStoreCurrentSong(station, idx, url); } catch (e) { /* ignore */ } finally { if (currentIndex === idx) startCurrentSongPollers(); } }, msUntilEnd + 250); currentSongPollers.set(key, { intervalId: null, timeoutId }); } } } } catch (e) { console.debug('Failed scheduling next-song fetch', e); } } } catch (e) { console.debug('currentSong fetch failed for', url, e.message || e); } } function formatRelativeTimer(deadline) { if (!deadline) return 'Off'; const diffMs = deadline - Date.now(); if (diffMs <= 0) return 'Now'; const totalSeconds = Math.ceil(diffMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return seconds > 0 ? `In ${hours}h ${minutes}m ${seconds}s` : `In ${hours}h ${minutes}m`; } if (minutes > 0) { return `In ${minutes}m ${seconds}s`; } return `In ${seconds}s`; } function stopSessionCountdownTicker() { if (!sessionCountdownIntervalId) return; clearInterval(sessionCountdownIntervalId); sessionCountdownIntervalId = null; } function refreshSessionCountdownUI() { updateSleepTimerUI(); updateWakeAlarmUI(); } function ensureSessionCountdownTicker() { const hasActiveSession = Boolean(sleepTimerDeadline || wakeAlarmDeadline); if (!hasActiveSession) { stopSessionCountdownTicker(); return; } if (sessionCountdownIntervalId) return; sessionCountdownIntervalId = window.setInterval(() => { refreshSessionCountdownUI(); if (!sleepTimerDeadline && !wakeAlarmDeadline) { stopSessionCountdownTicker(); } }, 1000); } function buildSessionSummaryText() { const sleepText = sleepTimerDeadline ? `Sleep ${formatRelativeTimer(sleepTimerDeadline).toLowerCase()}` : 'Sleep off'; const wakeText = wakeAlarmDeadline ? `Wake ${formatRelativeTimer(wakeAlarmDeadline).toLowerCase()}` : 'Wake off'; return `${sleepText} · ${wakeText}`; } function updateSleepTimerUI() { if (sleepTimerStatusEl) { sleepTimerStatusEl.textContent = sleepTimerDeadline ? `Playback stops ${formatRelativeTimer(sleepTimerDeadline).toLowerCase()}.` : 'No sleep timer set.'; } sleepTimerButtons?.forEach((button) => { const minutes = Number(button.getAttribute('data-sleep-minutes')); button.classList.toggle('active', minutes > 0 && minutes === sleepTimerSelectionMinutes); }); const hasActiveSession = Boolean(sleepTimerDeadline || wakeAlarmDeadline); sessionSchedulerBtn?.classList.toggle('session-active', hasActiveSession); sessionSummaryBtn?.setAttribute('aria-expanded', sessionSchedulerOverlay && !sessionSchedulerOverlay.classList.contains('hidden') ? 'true' : 'false'); if (sessionSchedulerBadge) { sessionSchedulerBadge.classList.toggle('hidden', !hasActiveSession); } if (sessionSummaryText) { sessionSummaryText.textContent = buildSessionSummaryText(); } } function updateWakeAlarmUI() { if (wakeAlarmStatusEl) { wakeAlarmStatusEl.textContent = wakeAlarmDeadline ? `Playback resumes ${formatRelativeTimer(wakeAlarmDeadline).toLowerCase()}.` : 'No wake alarm set.'; } wakeAlarmButtons?.forEach((button) => { const minutes = Number(button.getAttribute('data-wake-minutes')); button.classList.toggle('active', minutes > 0 && minutes === wakeAlarmSelectionMinutes); }); const hasActiveSession = Boolean(sleepTimerDeadline || wakeAlarmDeadline); sessionSchedulerBtn?.classList.toggle('session-active', hasActiveSession); if (sessionSchedulerBadge) { sessionSchedulerBadge.classList.toggle('hidden', !hasActiveSession); } if (sessionSummaryText) { sessionSummaryText.textContent = buildSessionSummaryText(); } sessionSummaryBtn?.setAttribute('aria-expanded', sessionSchedulerOverlay && !sessionSchedulerOverlay.classList.contains('hidden') ? 'true' : 'false'); } function clearSleepTimer() { if (sleepTimerTimeoutId) clearTimeout(sleepTimerTimeoutId); sleepTimerTimeoutId = null; sleepTimerDeadline = null; sleepTimerSelectionMinutes = 0; updateSleepTimerUI(); ensureSessionCountdownTicker(); } function clearWakeAlarm() { if (wakeAlarmTimeoutId) clearTimeout(wakeAlarmTimeoutId); wakeAlarmTimeoutId = null; wakeAlarmDeadline = null; wakeAlarmSelectionMinutes = 0; updateWakeAlarmUI(); ensureSessionCountdownTicker(); } function scheduleSleepTimer(minutes) { clearSleepTimer(); if (!Number.isFinite(minutes) || minutes <= 0) return; sleepTimerSelectionMinutes = minutes; sleepTimerDeadline = Date.now() + minutes * 60000; sleepTimerTimeoutId = window.setTimeout(async () => { clearSleepTimer(); await stop(); if (statusTextEl) statusTextEl.textContent = 'Sleep timer finished'; }, minutes * 60000); updateSleepTimerUI(); ensureSessionCountdownTicker(); } function scheduleWakeAlarm(minutes) { clearWakeAlarm(); if (!Number.isFinite(minutes) || minutes <= 0) return; wakeAlarmSelectionMinutes = minutes; wakeAlarmDeadline = Date.now() + minutes * 60000; wakeAlarmTimeoutId = window.setTimeout(async () => { clearWakeAlarm(); try { await play(); if (statusTextEl) statusTextEl.textContent = 'Wake alarm started playback'; } catch (error) { if (statusTextEl) statusTextEl.textContent = 'Wake alarm is ready'; } }, minutes * 60000); updateWakeAlarmUI(); ensureSessionCountdownTicker(); } function openSessionSchedulerOverlay() { if (!sessionSchedulerOverlay) return; sessionSchedulerOverlay.classList.remove('hidden'); sessionSchedulerOverlay.setAttribute('aria-hidden', 'false'); sessionSummaryBtn?.setAttribute('aria-expanded', 'true'); } function closeSessionSchedulerOverlay() { if (!sessionSchedulerOverlay) return; sessionSchedulerOverlay.classList.add('hidden'); sessionSchedulerOverlay.setAttribute('aria-hidden', 'true'); sessionSummaryBtn?.setAttribute('aria-expanded', 'false'); } function renderSongHistory() { if (!songHistoryPanelEl || !songHistoryListEl) return; const station = stations[currentIndex]; const historyMap = loadStationSongHistory(); const entries = station?.id ? (historyMap[station.id] || []) : []; songHistoryListEl.innerHTML = ''; songHistoryPanelEl.classList.toggle('hidden', entries.length === 0); if (!entries.length) return; entries.forEach((entry) => { const item = document.createElement('li'); item.className = 'song-history-item'; const title = document.createElement('div'); title.className = 'song-history-track'; title.textContent = entry.title; const meta = document.createElement('div'); meta.className = 'song-history-meta'; meta.textContent = `${entry.artist} · ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(entry.seenAt))}`; item.append(title, meta); songHistoryListEl.appendChild(item); }); } function updateNowPlayingUI() { const station = stations[currentIndex]; if (!station) return; if (nowPlayingEl && nowArtistEl && nowTitleEl) { if (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'); } } if (stationSubtitleEl) stationSubtitleEl.textContent = getStationDetails(station) || 'Live stream'; renderSongHistory(); } // ── Load station UI ─────────────────────────────────────────────────────────── function loadStation(index) { if (index < 0 || index >= stations.length) return; const station = stations[index]; applyStationTheme(station); updateStationTitleLayout(station.name); if (stationSubtitleEl) stationSubtitleEl.textContent = getStationDetails(station) || 'Live stream'; updateActiveStationHealthSummary(station); if (nowPlayingEl) nowPlayingEl.classList.add('hidden'); if (nowArtistEl) nowArtistEl.textContent = ''; if (nowTitleEl) nowTitleEl.textContent = ''; renderSongHistory(); try { if (logoTextEl && station.name) { logoTextEl.textContent = String(station.name).trim(); logoTextEl.classList.add('logo-name'); } if (logoImgEl) { logoImgEl.classList.add('hidden'); if (logoTextEl) logoTextEl.classList.remove('hidden'); setImgWithFallback(logoImgEl, getStationLogoCandidates(station), () => { logoImgEl.classList.add('hidden'); if (logoTextEl) logoTextEl.classList.remove('hidden'); }); logoImgEl.onload = () => { logoImgEl.classList.remove('hidden'); if (logoTextEl) logoTextEl.classList.add('hidden'); }; } } catch (e) { /* non-fatal */ } try { updateCoverflowTransforms(); } catch (e) {} try { startCurrentSongPollers(); } catch (e) {} window.setTimeout(triggerSparkleOnSelected, 120); } function setupServiceWorkerCatalogRefreshListener() { if (!('serviceWorker' in navigator)) { return; } navigator.serviceWorker.addEventListener('message', (event) => { const messageType = event.data?.type; if ( messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE && messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE && messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE && messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE ) { return; } if (messageType === SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE) { showCatalogSyncStatus( 'Background station sync failed', event.data?.failedAt ?? null, 'warning', event.data?.error ?? '', ); return; } if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE) { showCatalogSyncStatus( 'Managed catalog refresh failed', event.data?.failedAt ?? null, 'warning', event.data?.error ?? '', ); return; } if (stationCatalogState === 'loading') { return; } void loadStations().then(() => { if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE) { persistCatalogSyncHistory('managed', new Date().toISOString()); showCatalogSyncStatus('Managed catalog refreshed in background'); return; } persistCatalogSyncHistory('stations', event.data?.meta?.syncedAt ?? null); showCatalogSyncStatus('Stations updated in background', event.data?.meta?.syncedAt ?? null); }).catch(() => { // Keep current UI if the refresh fails. }); }); } // ── Google Cast initialisation ──────────────────────────────────────────────── // Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback). function initCast() { if (castInitialized) { updateCastButtonUI(); return; } try { castInitialized = true; castContext = cast.framework.CastContext.getInstance(); castContext.setOptions({ receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, }); const remotePlayer = new cast.framework.RemotePlayer(); castPlayerController = new cast.framework.RemotePlayerController(remotePlayer); // Sync volume slider with Cast session volume castPlayerController.addEventListener( cast.framework.RemotePlayerEventType.VOLUME_LEVEL_CHANGED, () => { if (castMode !== 'cast') return; const vol = Math.round(remotePlayer.volumeLevel * 100); if (volumeSlider) volumeSlider.value = String(vol); if (volumeValue) volumeValue.textContent = `${vol}%`; currentVolume = remotePlayer.volumeLevel; } ); // Track Cast session state changes castContext.addEventListener( cast.framework.CastContextEventType.SESSION_STATE_CHANGED, (ev) => { const SS = cast.framework.SessionState; if (ev.sessionState === SS.SESSION_STARTED || ev.sessionState === SS.SESSION_RESUMED) { castMode = 'cast'; updateEngineBadge(); updateCastButtonUI(); updateCastOutputToggleUI(); if (isPlaying) { // Hand off to Cast; local audio continues only in castBothMode if (!castBothMode) { audio.pause(); audio.src = ''; } castPlayCurrent(); } } else if (ev.sessionState === SS.SESSION_ENDED || ev.sessionState === SS.SESSION_START_FAILED) { castMode = 'local'; updateEngineBadge(); updateCastButtonUI(); updateCastOutputToggleUI(); updateUI(); } } ); console.log('Cast SDK initialised'); updateCastButtonUI(); } catch (e) { castInitialized = false; console.warn('Cast init failed:', e); updateCastButtonUI(); } } // The Cast SDK calls this global when it is ready. window['__onGCastApiAvailable'] = (isAvailable) => { if (isAvailable) initCast(); else updateCastButtonUI(); }; if (window.cast?.framework && window.chrome?.cast) { initCast(); } initAirPlay(); function updateCastButtonUI() { if (!castBtn) return; const sdkReady = !!castContext; const airPlayReady = airPlayAvailable || supportsAirPlay(); const outputReady = sdkReady || airPlayReady; const remoteActive = isRemoteOutputActive(); const isAirPlayActive = castMode === 'airplay'; castBtn.classList.toggle('cast-ready', outputReady); castBtn.classList.toggle('cast-active', remoteActive); castBtn.classList.toggle('cast-airplay-active', isAirPlayActive); castBtn.setAttribute('aria-pressed', remoteActive ? 'true' : 'false'); const label = castMode === 'cast' ? 'Casting' : castMode === 'airplay' ? 'AirPlay' : airPlayReady && !sdkReady ? 'AirPlay' : sdkReady && !airPlayReady ? 'Cast' : 'Output'; const title = sdkReady ? (airPlayReady ? 'Cast or AirPlay to device' : 'Cast to device') : (airPlayReady ? 'AirPlay to device' : 'Cast and AirPlay are not available'); castBtn.title = remoteActive ? `${label} active` : title; castBtn.setAttribute('aria-label', remoteActive ? `${label} active` : title); castBtnCastIcon?.classList.toggle('hidden', isAirPlayActive); castBtnAirPlayIcon?.classList.toggle('hidden', !isAirPlayActive); } async function requestCastSession() { if (!castContext && supportsAirPlay()) { try { audio.webkitShowPlaybackTargetPicker(); if (statusTextEl) statusTextEl.textContent = 'Choose an AirPlay device'; } catch (e) { console.debug('AirPlay picker request failed:', e); if (statusTextEl) statusTextEl.textContent = 'AirPlay not available'; } finally { updateCastButtonUI(); } return; } if (!castContext) { if (statusTextEl) statusTextEl.textContent = 'Cast and AirPlay are not available'; updateCastButtonUI(); return; } try { await castContext.requestSession(); } catch (e) { console.debug('Cast session request failed:', e); } finally { updateCastButtonUI(); } } async function castPlayCurrent() { try { const session = castContext?.getCurrentSession(); if (!session) return; const station = stations[currentIndex]; if (!station) return; const streamUrl = toHttpsIfHttp(station.url) || station.url; const contentType = streamUrl.includes('.ogg') ? 'audio/ogg' : 'audio/mpeg'; const mediaInfo = new chrome.cast.media.MediaInfo(streamUrl, contentType); mediaInfo.streamType = chrome.cast.media.StreamType.LIVE; const meta = new chrome.cast.media.MusicTrackMediaMetadata(); meta.title = station.name; meta.artist = station.currentSongInfo?.artist ?? ''; meta.songName = station.currentSongInfo?.title ?? ''; if (station.logo) meta.images = [new chrome.cast.Image(toHttpsIfHttp(station.logo) || station.logo)]; mediaInfo.metadata = meta; const request = new chrome.cast.media.LoadRequest(mediaInfo); await session.loadMedia(request); isPlaying = true; if (statusTextEl) statusTextEl.textContent = 'Casting...'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; if (stationSubtitleEl) { const detailSuffix = getStationDetails(station); stationSubtitleEl.textContent = detailSuffix ? `Casting to ${session.getCastDevice().friendlyName} • ${detailSuffix}` : `Casting to ${session.getCastDevice().friendlyName}`; } updateUI(); } catch (e) { console.error('Cast play failed:', e); if (statusTextEl) statusTextEl.textContent = 'Cast error — playing locally'; castMode = 'local'; updateEngineBadge(); playLocal(); } } function updateEngineBadge() { if (!engineBadgeEl) return; if (castMode === 'cast') { engineBadgeEl.className = 'engine-badge engine-cast'; engineBadgeEl.title = 'Google Cast playback'; engineBadgeEl.innerHTML = `CAST`; } else if (castMode === 'airplay') { engineBadgeEl.className = 'engine-badge engine-airplay'; engineBadgeEl.title = 'AirPlay playback'; engineBadgeEl.innerHTML = `AIRPLAY`; } else { engineBadgeEl.className = 'engine-badge engine-html'; engineBadgeEl.title = 'HTML5 Audio playback'; engineBadgeEl.innerHTML = `HTML5`; } } // ── Playback ────────────────────────────────────────────────────────────────── async function playLocal() { const station = stations[currentIndex]; if (!station) return; playbackStartupAttemptId += 1; const attemptId = playbackStartupAttemptId; playbackError = ''; if (statusTextEl) statusTextEl.textContent = 'Buffering...'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; const streamUrl = toHttpsIfHttp(station.url) || station.url; beginLocalPlaybackAttempt(station, attemptId, null); audio.src = streamUrl; audio.volume = isMuted ? 0 : currentVolume; audio.load(); armPlaybackStartupTimeout(station, attemptId); try { await audio.play(); if (attemptId !== playbackStartupAttemptId) return; isPlaying = true; updateUI(); } catch (e) { if (attemptId !== playbackStartupAttemptId) return; clearPlaybackStartupTimeout(); completeLocalPlaybackAttemptAsFailure(station, 'Unable to start playback', {}); console.warn('audio.play() failed:', e.message); playbackError = e?.name === 'NotAllowedError' ? 'Playback was blocked until you interact with the page.' : getPlaybackFailureMessage(station, 'Unable to start playback'); isPlaying = false; updateUI(); } } async function play() { const station = stations[currentIndex]; if (!station) return; triggerSparkleOnSelected(); if (castMode === 'cast') { // Only kill local audio if NOT in both-mode if (!castBothMode) { audio.pause(); audio.src = ''; } await castPlayCurrent(); // In both-mode also start local playback if (castBothMode) { playLocal(); // intentionally not awaited — fire-and-forget alongside cast } } else { await playLocal(); } } async function stop() { playbackStartupAttemptId += 1; clearPlaybackStartupTimeout(); activeLocalPlaybackAttempt = null; playbackError = ''; if (castMode === 'cast') { try { const session = castContext?.getCurrentSession(); const media = session?.getMediaSession(); if (media) { media.stop(new chrome.cast.media.StopRequest(), () => {}, () => {}); } } catch (e) { console.warn('Cast stop error:', e); } } // Always stop local audio audio.pause(); audio.src = ''; isPlaying = false; updateUI(); } async function togglePlay() { if (isPlaying) await stop(); else await play(); } async function playNext() { if (stations.length === 0) return; await setStationByIndex((currentIndex + 1) % stations.length); } async function playPrev() { if (stations.length === 0) return; await setStationByIndex((currentIndex - 1 + stations.length) % stations.length); } function updateUI() { const station = stations[currentIndex]; if (isPlaying) { if (iconPlay) iconPlay.classList.add('hidden'); if (iconStop) iconStop.classList.remove('hidden'); if (playBtn) playBtn.classList.add('playing'); if (statusTextEl && statusTextEl.textContent === 'Ready') statusTextEl.textContent = 'Playing'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; if (stationSubtitleEl && castMode === 'local' && station) stationSubtitleEl.textContent = getStationDetails(station) || 'Live stream'; } else { if (iconPlay) iconPlay.classList.remove('hidden'); if (iconStop) iconStop.classList.add('hidden'); if (playBtn) playBtn.classList.remove('playing'); if (playbackError) { if (statusTextEl) statusTextEl.textContent = ''; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--danger)'; } else { if (statusTextEl) statusTextEl.textContent = stationCatalogState === 'loading' ? 'Loading stations...' : 'Ready'; if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; } if (stationSubtitleEl && station && castMode === 'local') stationSubtitleEl.textContent = getStationDetails(station) || 'Live stream'; } if (station) updateActiveStationHealthSummary(station); updateEngineBadge(); updateCastOutputToggleUI(); updateCastButtonUI(); } // ── Volume / Mute ───────────────────────────────────────────────────────────── function handleVolumeInput() { const val = Number(volumeSlider.value); if (volumeValue) volumeValue.textContent = `${val}%`; currentVolume = val / 100; if (!isMuted) audio.volume = currentVolume; // Sync volume to active Cast session if (castMode === 'cast') { try { const session = castContext?.getCurrentSession(); if (session) session.setVolume(currentVolume, () => {}, () => {}); } catch (e) { /* ignore */ } } saveVolumeToStorage(val); } function toggleMute() { isMuted = !isMuted; audio.volume = isMuted ? 0 : currentVolume; if (iconVolume) iconVolume.classList.toggle('hidden', isMuted); if (iconMuted) iconMuted.classList.toggle('hidden', !isMuted); } function openShortcutHelpOverlay() { if (!shortcutHelpOverlay) return; shortcutHelpOverlay.classList.remove('hidden'); shortcutHelpOverlay.setAttribute('aria-hidden', 'false'); } function closeShortcutHelpOverlay() { if (!shortcutHelpOverlay) return; shortcutHelpOverlay.classList.add('hidden'); shortcutHelpOverlay.setAttribute('aria-hidden', 'true'); } // ── Stations overlay ────────────────────────────────────────────────────────── async function openStationsOverlay() { if (!castOverlay || !deviceListEl) return; castOverlay.classList.remove('hidden'); castOverlay.setAttribute('aria-hidden', 'false'); deviceListEl.classList.add('stations-grid'); deviceListEl.innerHTML = ''; const titleEl = document.getElementById('deviceTitle'); if (titleEl) titleEl.textContent = 'Stations'; if (!stations || stations.length === 0) { deviceListEl.classList.remove('stations-grid'); if (stationCatalogState === 'loading') { deviceListEl.innerHTML = '
  • Loading stations
    Reading the local radio catalog
  • '; } else if (stationCatalogState === 'error') { deviceListEl.innerHTML = `
  • Stations unavailable
    ${escapeHtml(stationCatalogError || 'Unable to load local radio data.')}
  • `; } else { deviceListEl.innerHTML = '
  • No stations found
    The local radio catalog is empty
  • '; } return; } const stationHealth = loadStationHealth(); for (let idx = 0; idx < stations.length; idx++) { const s = stations[idx]; const li = document.createElement('li'); const logoUrl = getStationLogoUrl(s); const title = s.name || s.title || s.id || 'Station'; const subtitle = getStationDetails(s) || getStationHomepage(s) || s.id || ''; li.className = 'station-card' + (currentIndex === idx ? ' selected' : ''); const left = document.createElement('div'); left.className = 'station-card-left'; if (logoUrl) { const img = document.createElement('img'); img.className = 'station-card-logo'; img.alt = `${title} logo`; img.referrerPolicy = 'no-referrer'; const fallback = document.createElement('div'); fallback.className = 'station-card-fallback'; fallback.textContent = title.charAt(0).toUpperCase(); img.onerror = () => { left.replaceChild(fallback, img); }; img.src = toHttpsIfHttp(logoUrl) || logoUrl || RADIO_PLACEHOLDER_LOGO; left.appendChild(img); } else { const img = document.createElement('img'); img.className = 'station-card-logo'; img.alt = `${title} logo`; img.src = RADIO_PLACEHOLDER_LOGO; left.appendChild(img); } 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; const healthBadge = getStationHealthBadge(s, stationHealth); body.appendChild(tEl); body.appendChild(sEl); if (healthBadge) { const badgeEl = document.createElement('div'); badgeEl.className = `station-card-health station-card-health-${healthBadge.tone}`; badgeEl.textContent = healthBadge.label; badgeEl.title = healthBadge.detail; body.appendChild(badgeEl); } li.appendChild(left); li.appendChild(body); li.onclick = async () => { closeCastOverlay(); await setStationByIndex(idx); try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } }; deviceListEl.appendChild(li); } } function closeCastOverlay() { if (!castOverlay) return; castOverlay.classList.add('hidden'); castOverlay.setAttribute('aria-hidden', 'true'); const titleEl = document.getElementById('deviceTitle'); if (titleEl) titleEl.textContent = 'Stations'; if (deviceListEl) deviceListEl.classList.remove('stations-grid'); } // ── Editor overlay ──────────────────────────────────────────────────────────── function openEditorOverlay() { updateEditorPersistenceInfo(); updateEditorBackupActivityInfo(); renderUserStationsList(); if (editorOverlay) { editorOverlay.classList.remove('hidden'); editorOverlay.setAttribute('aria-hidden', 'false'); } } function closeEditorOverlay() { if (editorOverlay) { editorOverlay.classList.add('hidden'); editorOverlay.setAttribute('aria-hidden', 'true'); } if (addStationForm) addStationForm.reset(); if (usId) usId.value = ''; } function renderUserStationsList() { const list = loadUserStations(); if (!editorListEl) return; editorListEl.innerHTML = ''; if (!list || list.length === 0) { editorListEl.innerHTML = '
  • No user stations
    Add your stream using the form below
  • '; return; } list.forEach((s) => { const li = document.createElement('li'); li.className = 'device'; const main = s.title || s.name || s.id || 'User Station'; const sub = s.url || s.streams?.audio || ''; const stationId = String(s.id || ''); li.innerHTML = `
    ${escapeHtml(main)}
    ${escapeHtml(sub)}
    `; editorListEl.appendChild(li); }); editorListEl.querySelectorAll('.edit-btn').forEach((b) => { b.addEventListener('click', () => editUserStation(b.getAttribute('data-station-id'))); }); editorListEl.querySelectorAll('.delete-btn').forEach((b) => { b.addEventListener('click', () => deleteUserStation(b.getAttribute('data-station-id'))); }); } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function editUserStation(stationId) { const list = loadUserStations(); const s = list.find((station) => String(station?.id || '') === String(stationId || '')); if (!s) return; if (usTitle) usTitle.value = s.title || s.name || ''; if (usUrl) usUrl.value = s.url || s.streams?.audio || s.liveAudio || ''; if (usLogo) usLogo.value = s.logo || s.assets?.logo || ''; if (usWww) usWww.value = s.website || s.www || ''; if (usId) usId.value = s.id || ''; } function deleteUserStation(stationId) { void deletePersistedUserStationById(stationId).then(() => { void loadStations(); renderUserStationsList(); }); } addStationForm?.addEventListener('submit', (e) => { e.preventDefault(); // Validate URL before saving const urlValue = usUrl?.value.trim() ?? ''; try { new URL(urlValue); } catch (_) { if (statusTextEl) statusTextEl.textContent = 'Invalid stream URL'; return; } const station = { id: usId?.value || `user-${Date.now()}`, name: usTitle?.value.trim() ?? '', category: 'Custom', country: '', language: '', website: usWww?.value.trim() ?? '', assets: { logo: usLogo?.value.trim() ?? '', }, streams: { audio: urlValue, }, enabled: true, }; void upsertPersistedUserStation(station).then(() => { renderUserStationsList(); void loadStations(); if (addStationForm) addStationForm.reset(); if (usId) usId.value = ''; }); }); // ── Artwork pointer fallback ────────────────────────────────────────────────── function ensureArtworkPointerFallback() { try { if (artworkPlaceholder) artworkPlaceholder.style.cursor = 'pointer'; } catch (e) { /* ignore */ } } // ── Event listeners ─────────────────────────────────────────────────────────── function setupEventListeners() { playBtn?.addEventListener('click', togglePlay); prevBtn?.addEventListener('click', playPrev); nextBtn?.addEventListener('click', playNext); volumeSlider?.addEventListener('input', handleVolumeInput); muteBtn?.addEventListener('click', toggleMute); stationLibraryPagePrevBtn?.addEventListener('click', goToPreviousStationLibraryPage); stationLibraryPageNextBtn?.addEventListener('click', goToNextStationLibraryPage); closeOverlayBtn?.addEventListener('click', closeCastOverlay); castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); }); editBtn?.addEventListener('click', openEditorOverlay); stationsListBtn?.addEventListener('click', toggleStationLibrary); installAppBtn?.addEventListener('click', promptInstallApp); castBtn?.addEventListener('click', requestCastSession); editorCloseBtn?.addEventListener('click', closeEditorOverlay); countrySelectionCancelBtn?.addEventListener('click', closeCountrySelectionOverlay); countrySelectionOverlay?.addEventListener('click', (e) => { if (e.target === countrySelectionOverlay) closeCountrySelectionOverlay(); }); countrySelectionSearchInput?.addEventListener('input', () => { countrySelectionQuery = countrySelectionSearchInput.value || ''; renderCountrySelectionOverlay(); }); countrySelectionDefaultsBtn?.addEventListener('click', () => { draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes); renderCountrySelectionOverlay(); }); countrySelectionAllBtn?.addEventListener('click', async () => { await ensureAvailableRadioCountriesLoaded(); draftSelectedRadioCountryCodes = new Set(availableRadioCountries.map((country) => country.code)); renderCountrySelectionOverlay(); }); countrySelectionSaveBtn?.addEventListener('click', () => { void saveCountrySelection(); }); exportUserDataBtn?.addEventListener('click', handleExportUserData); importUserDataBtn?.addEventListener('click', () => importUserDataInput?.click()); importUserDataInput?.addEventListener('change', () => { const [file] = importUserDataInput.files || []; void handleImportUserDataFile(file); }); resetUserDataBtn?.addEventListener('click', () => { void handleResetUserData(); }); stationAdvancedFiltersBtn?.addEventListener('click', openAdvancedFiltersOverlay); stationAdvancedFiltersCloseBtn?.addEventListener('click', closeAdvancedFiltersOverlay); stationAdvancedFiltersDoneBtn?.addEventListener('click', closeAdvancedFiltersOverlay); sessionSchedulerBtn?.addEventListener('click', openSessionSchedulerOverlay); sessionSummaryBtn?.addEventListener('click', openSessionSchedulerOverlay); sessionSchedulerCloseBtn?.addEventListener('click', closeSessionSchedulerOverlay); sessionSchedulerDoneBtn?.addEventListener('click', closeSessionSchedulerOverlay); shortcutHelpBtn?.addEventListener('click', openShortcutHelpOverlay); shortcutHelpCloseBtn?.addEventListener('click', closeShortcutHelpOverlay); songHistoryClearBtn?.addEventListener('click', () => { const station = stations[currentIndex]; if (!station?.id) return; const history = loadStationSongHistory(); delete history[station.id]; saveStationSongHistory(history); renderSongHistory(); }); stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary); stationLibraryFiltersToggleBtn?.addEventListener('click', () => { toggleStationLibraryFilters(); }); clearManagedCatalogBtn?.addEventListener('click', async () => { try { await clearPersistedManagedStationCatalog(); showCatalogSyncStatus('Cleared persisted managed catalog'); await loadStations(); } catch (e) { console.error('clearManagedCatalog failed', e); showCatalogSyncStatus('Failed to clear managed catalog', null, 'warning', e?.message || ''); } }); stationCountryFilterBtn?.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); closeSortFilter(); closeLanguageFilter(); closeCodecFilter(); closeBitrateFilter(); toggleStationCountryFilter(); }); stationSearchInput?.addEventListener('input', () => { stationLibraryQuery = stationSearchInput.value || ''; resetStationLibraryPage(); renderStationLibrary(); }); stationLanguageFilterBtn?.addEventListener('click', (event) => { event.stopPropagation(); stationLanguageFilterOpen = !stationLanguageFilterOpen; closeStationCountryFilter(); closeSortFilter(); closeCodecFilter(); closeBitrateFilter(); syncStationLibraryMetadataFilters(); }); stationCodecFilterBtn?.addEventListener('click', (event) => { event.stopPropagation(); stationCodecFilterOpen = !stationCodecFilterOpen; closeStationCountryFilter(); closeSortFilter(); closeLanguageFilter(); closeBitrateFilter(); syncStationLibraryMetadataFilters(); }); stationBitrateFilterBtn?.addEventListener('click', (event) => { event.stopPropagation(); stationBitrateFilterOpen = !stationBitrateFilterOpen; closeStationCountryFilter(); closeSortFilter(); closeLanguageFilter(); closeCodecFilter(); syncStationLibraryMetadataFilters(); }); stationHealthFilterBtn?.addEventListener('click', () => { stationLibraryHealthyOnly = !stationLibraryHealthyOnly; resetStationLibraryPage(); renderStationLibrary(); }); sleepTimerButtons?.forEach((button) => { button.addEventListener('click', () => { scheduleSleepTimer(Number(button.getAttribute('data-sleep-minutes'))); }); }); wakeAlarmButtons?.forEach((button) => { button.addEventListener('click', () => { scheduleWakeAlarm(Number(button.getAttribute('data-wake-minutes'))); }); }); stationSortBtn?.addEventListener('click', (e) => { e.stopPropagation(); stationSortOpen = !stationSortOpen; closeStationCountryFilter(); closeLanguageFilter(); closeCodecFilter(); closeBitrateFilter(); renderSortOptions(); }); stationTabBtns.forEach((btn) => { btn.addEventListener('click', () => setStationLibraryTab(btn.dataset.stationTab || 'all')); }); artworkPlaceholder?.addEventListener('click', openStationLibrary); castOutputBtn?.addEventListener('click', toggleCastBothMode); installPromptActionBtn?.addEventListener('click', promptInstallApp); installPromptDismissBtn?.addEventListener('click', hideInstallPromptUI); window.addEventListener('resize', () => { updateCoverflowTransforms(); updateStationTitleLayout(getStationTitle(stations[currentIndex])); }); document.addEventListener('click', (ev) => { if (stationCountryFilterOpen && !stationCountryFilterWrapEl?.contains(ev.target)) { closeStationCountryFilter(); } if (stationSortOpen && !stationSortWrapEl?.contains(ev.target)) { closeSortFilter(); } if (stationLanguageFilterOpen && !stationLanguageFilterWrapEl?.contains(ev.target)) { closeLanguageFilter(); } if (stationCodecFilterOpen && !stationCodecFilterWrapEl?.contains(ev.target)) { closeCodecFilter(); } if (stationBitrateFilterOpen && !stationBitrateFilterWrapEl?.contains(ev.target)) { closeBitrateFilter(); } if (stationAdvancedFiltersOverlay && ev.target === stationAdvancedFiltersOverlay) { closeAdvancedFiltersOverlay(); } if (sessionSchedulerOverlay && ev.target === sessionSchedulerOverlay) { closeSessionSchedulerOverlay(); } }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (stationCountryFilterOpen && e.code === 'Escape') { e.preventDefault(); closeStationCountryFilter(); return; } if (stationSortOpen && e.code === 'Escape') { e.preventDefault(); closeSortFilter(); return; } if (stationLanguageFilterOpen && e.code === 'Escape') { e.preventDefault(); closeLanguageFilter(); return; } if (stationCodecFilterOpen && e.code === 'Escape') { e.preventDefault(); closeCodecFilter(); return; } if (stationBitrateFilterOpen && e.code === 'Escape') { e.preventDefault(); closeBitrateFilter(); return; } if (stationAdvancedFiltersOpen && e.code === 'Escape') { e.preventDefault(); closeAdvancedFiltersOverlay(); return; } if (countrySelectionOverlay && !countrySelectionOverlay.classList.contains('hidden') && e.code === 'Escape') { e.preventDefault(); closeCountrySelectionOverlay(); return; } if (shortcutHelpOverlay && !shortcutHelpOverlay.classList.contains('hidden') && e.code === 'Escape') { e.preventDefault(); closeShortcutHelpOverlay(); return; } if (sessionSchedulerOverlay && !sessionSchedulerOverlay.classList.contains('hidden') && e.code === 'Escape') { e.preventDefault(); closeSessionSchedulerOverlay(); return; } if ((e.key === '?' || (e.shiftKey && e.code === 'Slash'))) { e.preventDefault(); openShortcutHelpOverlay(); return; } if (e.code === 'Space') { e.preventDefault(); togglePlay(); } else if (e.code === 'ArrowRight') playNext(); else if (e.code === 'ArrowLeft') playPrev(); else if (e.code === 'KeyM') toggleMute(); else if (e.code === 'Escape') { closeStationLibrary(); closeCastOverlay(); closeEditorOverlay(); closeShortcutHelpOverlay(); closeSessionSchedulerOverlay(); } }); applyStationLibraryFiltersState(); updateSleepTimerUI(); updateWakeAlarmUI(); } async function promptInstallApp() { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); try { await deferredInstallPrompt.userChoice; } catch (e) { /* ignore */ } deferredInstallPrompt = null; hideInstallPromptUI(); } // ── Media Session API (for OS media controls / lock screen) ────────────────── function updateMediaSession() { if (!('mediaSession' in navigator)) return; const station = stations[currentIndex]; if (!station) return; try { navigator.mediaSession.metadata = new MediaMetadata({ title: station.name, artist: station.currentSongInfo?.artist ?? '', album: 'Live Radio', artwork: station.logo ? [{ src: toHttpsIfHttp(station.logo), sizes: '512x512' }] : [], }); navigator.mediaSession.setActionHandler('play', () => play()); navigator.mediaSession.setActionHandler('pause', () => stop()); navigator.mediaSession.setActionHandler('previoustrack', () => playPrev()); navigator.mediaSession.setActionHandler('nexttrack', () => playNext()); } catch (e) { /* ignore */ } } // ── Init ────────────────────────────────────────────────────────────────────── let hasInitialized = false; async function init() { if (hasInitialized) return; hasInitialized = true; try { console.group('RadioPlayer init'); console.log('location:', location.href); console.log('userAgent:', navigator.userAgent); console.groupEnd(); await hydratePlayerPersistence(); restoreSavedVolume(); restoreCastBothMode(); restoreLastStationCountry(); restoreSelectedRadioCountryCodes(); await loadStations(); setupEventListeners(); setupServiceWorkerCatalogRefreshListener(); void sendSelectedCountriesToServiceWorker(); lockPortraitOrientation(); ensureArtworkPointerFallback(); scheduleArtworkShine(); scheduleIconSparkle(); updateUI(); // Update Media Session when station or song changes audio.addEventListener('playing', updateMediaSession); } catch (e) { console.error('Error during init', e); if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e?.message ?? String(e)); } } window.addEventListener('beforeinstallprompt', (event) => { event.preventDefault(); deferredInstallPrompt = event; showInstallPromptUI(); }); window.addEventListener('appinstalled', () => { deferredInstallPrompt = null; hideInstallPromptUI(); }); window.addEventListener('orientationchange', () => { lockPortraitOrientation(); }); // Refresh the managed catalog when the page returns to the foreground after // being hidden for at least 1 hour. This is the cross-browser fallback for // browsers that don't support periodicSync (e.g. iOS Safari, Firefox). const MANAGED_CATALOG_REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour let lastManagedCatalogLoadTime = 0; document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; const now = Date.now(); if (now - lastManagedCatalogLoadTime >= MANAGED_CATALOG_REFRESH_INTERVAL_MS) { void loadStations(); } }); // Runtime debug helpers (use from console) window.__rp_inspectManagedCatalog = async function() { try { const persisted = await loadPersistedManagedStations(); console.log('Persisted managed catalog:', persisted); console.log('In-memory stations count:', stations.length); return { persisted, stations }; } catch (e) { console.error('inspectManagedCatalog failed', e); } }; window.__rp_clearManagedCatalog = async function() { try { await clearPersistedManagedStationCatalog(); console.log('Cleared persisted managed catalog. Call loadStations() to reload catalogs.'); } catch (e) { console.error('clearManagedCatalog failed', e); } }; window.__rp_refreshManagedCatalog = async function() { try { await clearPersistedManagedStationCatalog(); await loadManagedStations(); await loadStations(); console.log('Refreshed managed catalog from backend and reloaded stations.'); } catch (e) { console.error('refreshManagedCatalog failed', e); } }; export function initPlayer() { return init(); }