feat: persist station sync catalogs and status
This commit is contained in:
+304
-52
@@ -1,6 +1,10 @@
|
||||
import { loadRadioStations } from './radio/loadRadioStations.ts';
|
||||
import { loadPersistedRadioStations, loadRadioStations } from './radio/loadRadioStations.ts';
|
||||
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
|
||||
import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts';
|
||||
import {
|
||||
getLastManagedCatalogSource,
|
||||
loadManagedStations,
|
||||
loadPersistedManagedStations,
|
||||
} from './radio/loadManagedStations.ts';
|
||||
import {
|
||||
clearPlayerPersistence,
|
||||
deletePersistedUserStationById,
|
||||
@@ -83,6 +87,7 @@ let stationCountryFilterOpen = false;
|
||||
let artworkShineTimeoutId = null;
|
||||
let artworkShineClearTimeoutId = null;
|
||||
let stationTitleRafId = null;
|
||||
let catalogSyncStatusTimeoutId = null;
|
||||
|
||||
const STATION_LIBRARY_PAGE_SIZE = 24;
|
||||
const RECENT_STATION_HISTORY_LIMIT = 40;
|
||||
@@ -98,6 +103,10 @@ const stationSubtitleEl = document.getElementById('station-subtitle');
|
||||
const stationHealthSummaryEl = document.getElementById('station-health-summary');
|
||||
const stationHealthDetailEl = document.getElementById('station-health-detail');
|
||||
const managedCatalogStatusEl = document.getElementById('managed-catalog-status');
|
||||
const catalogSyncStatusEl = document.getElementById('catalog-sync-status');
|
||||
const catalogSyncStatusTextEl = document.getElementById('catalog-sync-status-text');
|
||||
const catalogSyncStatusTimeEl = document.getElementById('catalog-sync-status-time');
|
||||
const catalogSyncHistoryEl = document.getElementById('catalog-sync-history');
|
||||
const nowPlayingEl = document.getElementById('now-playing');
|
||||
const nowArtistEl = document.getElementById('now-artist');
|
||||
const nowTitleEl = document.getElementById('now-title');
|
||||
@@ -200,6 +209,11 @@ const IMPORT_EXPORT_VERSION = 1;
|
||||
const PLAYBACK_START_TIMEOUT_MS = 12000;
|
||||
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
|
||||
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
|
||||
const SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE = 'station-catalog-updated';
|
||||
const SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE = 'station-catalog-update-failed';
|
||||
const SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE = 'managed-catalog-updated';
|
||||
const SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE = 'managed-catalog-update-failed';
|
||||
const CATALOG_SYNC_HISTORY_STORAGE_KEY = 'radioplayer.catalogSyncHistory';
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1666,6 +1680,151 @@ function updateManagedCatalogStatus(source = 'unknown') {
|
||||
managedCatalogStatusEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
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 formatCatalogSyncHistoryTime(value) {
|
||||
const parsed = value ? new Date(value) : null;
|
||||
if (!parsed || Number.isNaN(parsed.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function readCatalogSyncHistory() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CATALOG_SYNC_HISTORY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = parsed.kind === 'managed' ? 'managed' : parsed.kind === 'stations' ? 'stations' : null;
|
||||
const syncedAt = typeof parsed.syncedAt === 'string' ? parsed.syncedAt : null;
|
||||
if (!kind || !syncedAt || Number.isNaN(new Date(syncedAt).getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind, syncedAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogSyncHistory(record) {
|
||||
if (!catalogSyncHistoryEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedTime = formatCatalogSyncHistoryTime(record?.syncedAt ?? null);
|
||||
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');
|
||||
}
|
||||
|
||||
function persistCatalogSyncHistory(kind, syncedAt) {
|
||||
const normalizedSyncedAt = syncedAt && !Number.isNaN(new Date(syncedAt).getTime())
|
||||
? new Date(syncedAt).toISOString()
|
||||
: new Date().toISOString();
|
||||
const record = { kind, syncedAt: normalizedSyncedAt };
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(CATALOG_SYNC_HISTORY_STORAGE_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
// Ignore storage failures and still render the in-memory state.
|
||||
}
|
||||
|
||||
renderCatalogSyncHistory(record);
|
||||
}
|
||||
|
||||
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;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function getStationHealthBadge(station, stationHealth = loadStationHealth()) {
|
||||
const health = stationHealth?.[station?.id];
|
||||
if (!health || Number(health.attempts) <= 0) {
|
||||
@@ -2603,6 +2762,63 @@ async function activateStationByIndex(idx) {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -2612,60 +2828,39 @@ async function loadStations() {
|
||||
resetStationLibraryPage();
|
||||
renderStationLibrary();
|
||||
stopCurrentSongPollers();
|
||||
const raw = await loadRadioStations();
|
||||
const managedRaw = await loadManagedStations().catch(() => []);
|
||||
const persistedRadioPromise = loadPersistedRadioStations().catch(() => null);
|
||||
const persistedManagedPromise = loadPersistedManagedStations().catch(() => null);
|
||||
const liveRadioPromise = loadRadioStations();
|
||||
const liveManagedPromise = loadManagedStations().catch(() => []);
|
||||
|
||||
const normalizedRaw = raw
|
||||
.filter((station) => isEnabledRadioCountryStation(station))
|
||||
.map((s) => normalizeStationRecord(s))
|
||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||
const [persistedRaw, persistedManaged] = await Promise.all([
|
||||
persistedRadioPromise,
|
||||
persistedManagedPromise,
|
||||
]);
|
||||
|
||||
const normalizedManaged = managedRaw
|
||||
.map((s) => normalizeStationRecord(s))
|
||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||
const hasPersistedCatalog = (persistedRaw?.length ?? 0) > 0 || (persistedManaged?.length ?? 0) > 0;
|
||||
if (hasPersistedCatalog) {
|
||||
applyLoadedStations(buildMergedStations(persistedRaw ?? [], persistedManaged ?? []));
|
||||
console.debug('loadStations: hydrated from persisted station catalog');
|
||||
}
|
||||
|
||||
const userNormalized = loadUserStations()
|
||||
.map((s) => normalizeStationRecord(s, true))
|
||||
.filter((s) => s.url && s.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);
|
||||
|
||||
stations = mergedStations;
|
||||
updateManagedCatalogStatus(getLastManagedCatalogSource());
|
||||
lastManagedCatalogLoadTime = Date.now();
|
||||
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
|
||||
console.debug('loadStations: loaded', stations.length, 'stations');
|
||||
console.debug('managed catalog source:', getLastManagedCatalogSource());
|
||||
|
||||
if (stations.length > 0) {
|
||||
const lastId = getLastStationId();
|
||||
if (lastId) {
|
||||
const found = stations.findIndex((s) => s.id === lastId);
|
||||
currentIndex = found >= 0 ? found : 0;
|
||||
} else {
|
||||
currentIndex = 0;
|
||||
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';
|
||||
}
|
||||
loadStation(currentIndex);
|
||||
renderCoverflow();
|
||||
renderStationLibrary();
|
||||
startCurrentSongPollers();
|
||||
} else {
|
||||
renderCoverflow();
|
||||
renderStationLibrary();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stations', e);
|
||||
@@ -3053,6 +3248,61 @@ function loadStation(index) {
|
||||
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).
|
||||
@@ -3793,8 +4043,10 @@ async function init() {
|
||||
restoreCastBothMode();
|
||||
restoreLastStationCountry();
|
||||
restoreSelectedRadioCountryCodes();
|
||||
renderCatalogSyncHistory(readCatalogSyncHistory());
|
||||
await loadStations();
|
||||
setupEventListeners();
|
||||
setupServiceWorkerCatalogRefreshListener();
|
||||
void sendSelectedCountriesToServiceWorker();
|
||||
lockPortraitOrientation();
|
||||
ensureArtworkPointerFallback();
|
||||
|
||||
Reference in New Issue
Block a user