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