Files
RadioPlayerWeb/src/player.js
T
2026-07-03 21:05:07 +02:00

4473 lines
153 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; // 01 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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 16.1A5 5 0 0 1 5.9 20"/><path d="M2 12.05A9 9 0 0 1 9.95 20"/><path d="M2 8V6a14 14 0 0 1 14 14h-2"/></svg><span>CAST</span>`;
} else if (castMode === 'airplay') {
engineBadgeEl.className = 'engine-badge engine-airplay';
engineBadgeEl.title = 'AirPlay playback';
engineBadgeEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 17h14"/><path d="M7 7h10a2 2 0 0 1 2 2v6"/><path d="M5 15V9a2 2 0 0 1 2-2"/><path d="m12 20 3-3h-6l3 3Z"/></svg><span>AIRPLAY</span>`;
} else {
engineBadgeEl.className = 'engine-badge engine-html';
engineBadgeEl.title = 'HTML5 Audio playback';
engineBadgeEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 15V9"/><path d="M8 19V5"/><path d="M12 16V8"/><path d="M16 18V6"/><path d="M20 15V9"/></svg><span>HTML5</span>`;
}
}
// ── 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 = '<li class="device"><div class="device-main">Loading stations</div><div class="device-sub">Reading the local radio catalog</div></li>';
} else if (stationCatalogState === 'error') {
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Stations unavailable</div><div class="device-sub">${escapeHtml(stationCatalogError || 'Unable to load local radio data.')}</div></li>`;
} else {
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">The local radio catalog is empty</div></li>';
}
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 = '<li class="device"><div class="device-main">No user stations</div><div class="device-sub">Add your stream using the form below</div></li>';
return;
}
list.forEach((s) => {
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 = `<div class="editor-station-row">
<div class="editor-station-copy">
<div class="device-main">${escapeHtml(main)}</div>
<div class="device-sub">${escapeHtml(sub)}</div>
</div>
<div class="editor-station-actions">
<button data-station-id="${escapeHtml(stationId)}" class="btn edit-btn">Edit</button>
<button data-station-id="${escapeHtml(stationId)}" class="btn delete-btn">Delete</button>
</div>
</div>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();
}