feat: persist station sync catalogs and status
This commit is contained in:
+73
-4
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// This value is rewritten automatically before each build so deployed clients
|
||||
// refresh to the newest shell and cached assets.
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316';
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777474420392';
|
||||
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
|
||||
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
|
||||
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
|
||||
@@ -377,10 +377,23 @@ async function refreshManagedCatalogCache() {
|
||||
const response = await fetch(url, { cache: 'no-store' });
|
||||
if (isCacheableResponse(response)) {
|
||||
await managedCatalogCache.put(url, response);
|
||||
return { updated: true, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
updated: false,
|
||||
error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
|
||||
};
|
||||
} catch {
|
||||
// Network unavailable — keep existing cache as-is.
|
||||
}
|
||||
|
||||
return { updated: false, error: 'Managed catalog request failed.' };
|
||||
}
|
||||
|
||||
async function notifyClients(message) {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
await Promise.allSettled(clients.map((client) => client.postMessage(message)));
|
||||
}
|
||||
|
||||
async function respondWithManagedCatalog(request) {
|
||||
@@ -530,7 +543,23 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag !== STATION_SYNC_TAG) return;
|
||||
event.waitUntil(syncRadioStations('background-sync'));
|
||||
event.waitUntil(
|
||||
syncRadioStations('background-sync')
|
||||
.then((meta) => notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'background-sync',
|
||||
meta,
|
||||
}))
|
||||
.catch(async (error) => {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-update-failed',
|
||||
reason: 'background-sync',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
@@ -544,6 +573,14 @@ self.addEventListener('message', (event) => {
|
||||
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
||||
: null;
|
||||
|
||||
if (syncResult) {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'country-selection-update',
|
||||
meta: syncResult,
|
||||
});
|
||||
}
|
||||
|
||||
event.ports?.[0]?.postMessage({
|
||||
ok: true,
|
||||
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
||||
@@ -560,11 +597,43 @@ self.addEventListener('message', (event) => {
|
||||
|
||||
self.addEventListener('periodicsync', (event) => {
|
||||
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
||||
event.waitUntil(syncRadioStations('periodic-sync'));
|
||||
event.waitUntil(
|
||||
syncRadioStations('periodic-sync')
|
||||
.then((meta) => notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'periodic-sync',
|
||||
meta,
|
||||
}))
|
||||
.catch(async (error) => {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-update-failed',
|
||||
reason: 'periodic-sync',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
||||
event.waitUntil(refreshManagedCatalogCache());
|
||||
event.waitUntil(
|
||||
refreshManagedCatalogCache().then((result) => {
|
||||
if (!result.updated) {
|
||||
return notifyClients({
|
||||
type: 'managed-catalog-update-failed',
|
||||
reason: 'periodic-sync',
|
||||
error: result.error || 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return notifyClients({
|
||||
type: 'managed-catalog-updated',
|
||||
reason: 'periodic-sync',
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -199,6 +199,11 @@ function TrackInfo() {
|
||||
</span>
|
||||
</div>
|
||||
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
|
||||
<div id="catalog-sync-status" className="catalog-sync-status hidden" aria-live="polite">
|
||||
<span id="catalog-sync-status-text" />
|
||||
<time id="catalog-sync-status-time" className="catalog-sync-status-time" />
|
||||
</div>
|
||||
<div id="catalog-sync-history" className="catalog-sync-history hidden" aria-live="polite" />
|
||||
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
|
||||
<span className="cast-output-label">Output:</span>
|
||||
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
|
||||
|
||||
+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();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
|
||||
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||
|
||||
import {
|
||||
loadPersistedManagedStationCatalog,
|
||||
persistManagedStationCatalog,
|
||||
} from '../storage/stationCatalogPersistence.js';
|
||||
|
||||
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
||||
|
||||
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
||||
@@ -44,10 +49,26 @@ function setLastManagedCatalogSource(source: ManagedCatalogSource) {
|
||||
lastManagedCatalogSource = source;
|
||||
}
|
||||
|
||||
function normalizeManagedCatalogSource(source: unknown): ManagedCatalogSource {
|
||||
return source === 'remote' || source === 'cached-remote' || source === 'bundled'
|
||||
? source
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
||||
return lastManagedCatalogSource;
|
||||
}
|
||||
|
||||
export async function loadPersistedManagedStations(): Promise<unknown[] | null> {
|
||||
const persisted = await loadPersistedManagedStationCatalog();
|
||||
if (!persisted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLastManagedCatalogSource(normalizeManagedCatalogSource(persisted.source));
|
||||
return normalizeManagedStationsPayload(persisted.stations);
|
||||
}
|
||||
|
||||
export async function loadManagedStations(): Promise<unknown[]> {
|
||||
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
||||
@@ -92,6 +113,7 @@ export async function loadManagedStations(): Promise<unknown[]> {
|
||||
throw new Error(`Failed to load managed stations: ${response.status}`);
|
||||
}
|
||||
|
||||
const stations = await response.json();
|
||||
return normalizeManagedStationsPayload(stations);
|
||||
const stations = normalizeManagedStationsPayload(await response.json());
|
||||
await persistManagedStationCatalog(stations, getLastManagedCatalogSource());
|
||||
return stations;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { RadioStation } from './radioTypes.js';
|
||||
import {
|
||||
loadPersistedRadioStationCatalog,
|
||||
persistRadioStationCatalog,
|
||||
} from '../storage/stationCatalogPersistence.js';
|
||||
|
||||
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
||||
|
||||
@@ -21,6 +25,11 @@ async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPersistedRadioStations(): Promise<RadioStation[] | null> {
|
||||
const persisted = await loadPersistedRadioStationCatalog();
|
||||
return Array.isArray(persisted) ? persisted as RadioStation[] : null;
|
||||
}
|
||||
|
||||
export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`;
|
||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`;
|
||||
@@ -43,5 +52,7 @@ export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||
}
|
||||
|
||||
const stations = (await response.json()) as RadioStation[];
|
||||
return Array.isArray(stations) ? stations : [];
|
||||
const normalizedStations = Array.isArray(stations) ? stations : [];
|
||||
await persistRadioStationCatalog(normalizedStations);
|
||||
return normalizedStations;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
|
||||
type StationCatalogKey = 'radio-browser' | 'managed';
|
||||
|
||||
type StationCatalogRecord = {
|
||||
key: StationCatalogKey;
|
||||
stations: unknown[];
|
||||
source: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
interface StationCatalogDb extends DBSchema {
|
||||
catalogs: {
|
||||
key: StationCatalogKey;
|
||||
value: StationCatalogRecord;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'radioplayer-station-catalogs';
|
||||
const DB_VERSION = 1;
|
||||
const LOCAL_STORAGE_KEYS = {
|
||||
radioBrowser: 'radioplayer.stationCatalog.radioBrowser',
|
||||
managed: 'radioplayer.stationCatalog.managed',
|
||||
};
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<StationCatalogDb> | null> | null = null;
|
||||
|
||||
function getDb() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<StationCatalogDb>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('catalogs')) {
|
||||
db.createObjectStore('catalogs', { keyPath: 'key' });
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn('Station catalog IndexedDB unavailable, using localStorage fallback.', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalStorageKey(key: StationCatalogKey) {
|
||||
return key === 'managed' ? LOCAL_STORAGE_KEYS.managed : LOCAL_STORAGE_KEYS.radioBrowser;
|
||||
}
|
||||
|
||||
function sanitizeStations(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function sanitizeSource(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeCatalogRecord(key: StationCatalogKey, value: unknown): StationCatalogRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Partial<StationCatalogRecord>;
|
||||
return {
|
||||
key,
|
||||
stations: sanitizeStations(record.stations),
|
||||
source: sanitizeSource(record.source),
|
||||
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
|
||||
? record.updatedAt
|
||||
: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function readCatalogFromLocalStorage(key: StationCatalogKey): StationCatalogRecord | null {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = storage.getItem(getLocalStorageKey(key));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeCatalogRecord(key, JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCatalogToLocalStorage(record: StationCatalogRecord) {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(getLocalStorageKey(record.key), JSON.stringify(record));
|
||||
} catch {
|
||||
// Ignore quota/write failures and rely on IndexedDB when available.
|
||||
}
|
||||
}
|
||||
|
||||
async function readCatalog(key: StationCatalogKey): Promise<StationCatalogRecord | null> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return readCatalogFromLocalStorage(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await db.get('catalogs', key);
|
||||
return sanitizeCatalogRecord(key, record);
|
||||
} catch {
|
||||
return readCatalogFromLocalStorage(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCatalog(key: StationCatalogKey, stations: unknown[], source: string | null = null) {
|
||||
const record: StationCatalogRecord = {
|
||||
key,
|
||||
stations: sanitizeStations(stations),
|
||||
source: sanitizeSource(source),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeCatalogToLocalStorage(record);
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.put('catalogs', record);
|
||||
} catch {
|
||||
// Keep the localStorage mirror if IndexedDB writes fail.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPersistedRadioStationCatalog(): Promise<unknown[] | null> {
|
||||
const record = await readCatalog('radio-browser');
|
||||
return record ? record.stations : null;
|
||||
}
|
||||
|
||||
export async function persistRadioStationCatalog(stations: unknown[]) {
|
||||
await writeCatalog('radio-browser', stations);
|
||||
}
|
||||
|
||||
export async function loadPersistedManagedStationCatalog(): Promise<{ stations: unknown[]; source: string | null } | null> {
|
||||
const record = await readCatalog('managed');
|
||||
return record
|
||||
? {
|
||||
stations: record.stations,
|
||||
source: record.source,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function persistManagedStationCatalog(stations: unknown[], source: string | null) {
|
||||
await writeCatalog('managed', stations, source);
|
||||
}
|
||||
@@ -1585,6 +1585,61 @@ header {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.catalog-sync-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-3-rgb), 0.1));
|
||||
color: var(--text-main);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.catalog-sync-status.is-warning {
|
||||
border-color: rgba(255, 112, 135, 0.34);
|
||||
background: linear-gradient(135deg, rgba(255, 112, 135, 0.18), rgba(255, 180, 92, 0.12));
|
||||
}
|
||||
|
||||
.catalog-sync-status-time {
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.catalog-sync-status-time:not(:empty)::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.catalog-sync-status::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.7);
|
||||
}
|
||||
|
||||
.catalog-sync-status.is-warning::before {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 10px rgba(255, 112, 135, 0.7);
|
||||
}
|
||||
|
||||
.catalog-sync-history {
|
||||
margin-top: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
Reference in New Issue
Block a user