Add managed catalog sync and player UX improvements

This commit is contained in:
2026-04-29 13:49:16 +02:00
parent b866845b6a
commit c8f8c76e8a
21 changed files with 71429 additions and 148 deletions

View File

@@ -1,6 +1,6 @@
# RadioPlayer
RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. It loads its station catalog from `public/stations.json`, supports custom stations, and includes a built-in updater for refreshing that list from the live Radio.si feed.
RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. It loads its bundled managed catalog from `public/stations.json`, exposes that catalog through a same-origin backend endpoint at `/api/managed-stations.json`, supports custom stations, and includes a built-in updater for refreshing the managed list from the live Radio.si feed.
## Features
@@ -43,9 +43,39 @@ Preview the production build:
npm run preview
```
Run the production build with the bundled same-origin backend endpoint:
```bash
npm run serve:backend
```
Deploy the built frontend plus backend server files to the remote host:
```bash
bash sync.sh
```
## Station Data
The app reads station data from `public/stations.json`.
The app keeps the editorial managed list in `public/stations.json`.
At runtime, the frontend prefers the same-origin managed endpoint:
```text
/api/managed-stations.json
```
That endpoint returns:
```json
{
"schemaVersion": 1,
"updatedAt": "2026-04-29T07:39:58.374Z",
"stations": []
}
```
If the backend endpoint is unavailable, the frontend and service worker fall back to the bundled `stations.json` file.
To refresh the file from the remote source, run:
@@ -59,7 +89,7 @@ That command fetches the latest station list from:
https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d
```
and rewrites `public/stations.json` while preserving the existing JSON structure used by the app.
and rewrites `public/stations.json` while preserving the existing JSON structure used by the app. The backend endpoint reads from that file and wraps it in the runtime envelope shown above.
You can also pass a custom source URL or a custom output path if needed:
@@ -76,6 +106,9 @@ public/
manifest.json
stations.json
sw.js
server/
index.mjs
managedCatalogData.mjs
src/
App.jsx
main.jsx
@@ -90,4 +123,7 @@ scripts/
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point.
- The service worker is registered only in production builds. During development, existing service workers and caches are cleared automatically to avoid stale assets while iterating.
- The updater script uses the remote feed as the source of truth for the station list and writes the merged result into `public/stations.json`.
- Vite dev and preview both expose `/api/managed-stations.json` on the same origin via middleware, and `npm run serve:backend` serves the built app plus the same endpoint from Node.
- `sync.sh` now deploys both the built frontend files and the `server/` runtime so the same-origin backend can be started remotely with `node /opt/www/virtual/RadioPlayer/server/index.mjs`.
- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog.
- Static-only deployments still work because the frontend falls back to bundled `stations.json`, but a true same-origin backend endpoint requires deploying a server that answers `/api/managed-stations.json`.

7
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "radioplayer-web",
"version": "0.1.0",
"dependencies": {
"idb": "^8.0.3",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
@@ -911,6 +912,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -8,11 +8,13 @@
"prebuild": "node scripts/bump-sw-cache-version.mjs",
"build": "vite build",
"preview": "vite preview",
"serve:backend": "node server/index.mjs",
"update:stations": "node scripts/update-stations.mjs",
"radio:import": "tsx scripts/import-radio-stations.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"idb": "^8.0.3",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},

View File

@@ -258,10 +258,10 @@
<p class="eyebrow">Pravilnik</p>
<h1>Pravilnik o zasebnosti</h1>
<p class="lead">
Ta stran pojasnjuje, katere podatke uporablja spletna aplikacija RadioPlayer, zakaj jih uporablja in
kje se ti podatki hranijo. Besedilo velja za uporabo spletne aplikacije in njene PWA namestitve.
Ta stran pojasnjuje, katere podatke RadioPlayer shrani samo v vašem brskalniku, zakaj jih uporablja in kako
jih lahko izbrišete. Besedilo velja za spletno uporabo aplikacije in za njeno PWA namestitev.
</p>
<div class="meta">Velja od: 28. 4. 2026</div>
<div class="meta">Velja od: 29. 4. 2026</div>
<div class="sections">
<section>
@@ -283,8 +283,10 @@
<h2>Kje se podatki hranijo</h2>
<p>
Zgoraj navedeni podatki se hranijo lokalno v vašem brskalniku oziroma v podatkih spletnega mesta na vaši
napravi. RadioPlayer teh nastavitev ne pošilja na lasten strežnik in jih brez vašega dejanja ne deli z
drugimi uporabniki.
napravi. Za trajnejše shranjevanje nastavitev in postaj aplikacija uporablja brskalnikovo lokalno bazo
IndexedDB. Ob prvi uporabi nove različice lahko prenese tudi stare lokalne nastavitve iz prejšnje lokalne
hrambe, da se vaši podatki ohranijo. RadioPlayer teh nastavitev ne pošilja na lasten strežnik in jih brez
vašega dejanja ne deli z drugimi uporabniki.
</p>
<p>
Aplikacija uporablja tudi predpomnjenje datotek aplikacije za hitrejši zagon in delovanje brez ponovnega
@@ -304,14 +306,19 @@
Sender SDK. Pri tem lahko Google oziroma naprava za predvajanje obdelujeta podatke, potrebne za sejo
oddajanja in usmerjanje medijev.
</p>
<p>
Na napravah Apple lahko aplikacija ponudi tudi AirPlay izbiro izhoda. V tem primeru se medijski tok
preusmeri na izbrano AirPlay napravo prek zmožnosti vašega brskalnika in operacijskega sistema, zato lahko
zunanja naprava ali Apple obdelujeta podatke, potrebne za vzpostavitev in upravljanje predvajanja.
</p>
</section>
<section>
<h2>Piškotki in analitika</h2>
<p>
Trenutna različica aplikacije ne uporablja analitičnih skript za profiliranje uporabnikov in ne nastavlja
lastnih piškotkov za oglaševanje. Aplikacija za shranjevanje nastavitev uporablja lokalno hrambo brskalnika,
ne klasičnih piškotkov.
lastnih piškotkov za oglaševanje. Aplikacija za shranjevanje nastavitev uporablja lokalno hrambo brskalnika
in IndexedDB, ne klasičnih piškotkov.
</p>
</section>
@@ -342,10 +349,6 @@
</section>
</div>
<p class="note">
Če aplikacija v prihodnje doda prijavo, obrazce ali dodatne zunanje storitve, je treba ta pravilnik ustrezno
dopolniti.
</p>
</main>
</div>
</body>

6
public/.htaccess Normal file
View File

@@ -0,0 +1,6 @@
Options -Indexes
RewriteEngine On
# Route the JSON-looking URL to the PHP endpoint
RewriteRule ^api/managed-stations\.json$ api/managed-stations.php [L]

View File

@@ -0,0 +1,42 @@
<?php
/**
* Managed catalog API endpoint.
* Served at /api/managed-stations.json via .htaccess rewrite.
* Returns the same JSON envelope as the Node backend: { schemaVersion, updatedAt, stations }.
*/
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache');
$catalogPath = __DIR__ . '/../stations.json';
if (!is_file($catalogPath)) {
http_response_code(404);
echo json_encode(['error' => 'Catalog not found']);
exit;
}
$raw = file_get_contents($catalogPath);
if ($raw === false) {
http_response_code(500);
echo json_encode(['error' => 'Failed to read catalog']);
exit;
}
$data = json_decode($raw, true);
if ($data === null) {
http_response_code(500);
echo json_encode(['error' => 'Invalid catalog JSON']);
exit;
}
// Accept both plain array and already-enveloped format
$stations = isset($data['stations']) ? $data['stations'] : $data;
$envelope = [
'schemaVersion' => 1,
'updatedAt' => date('c', filemtime($catalogPath)),
'stations' => $stations,
];
echo json_encode($envelope, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

File diff suppressed because one or more lines are too long

View File

@@ -315,6 +315,29 @@
"lastSongs": "https://data.radio.si/api/lastsongsxml/toti/json"
}
},
{
"id": "MurskiVal",
"name": "Radio Murski Val",
"slogan": "",
"category": "Regional",
"country": "SI",
"language": "sl",
"region": "Pomurje",
"tags": [
"regional",
"pomurje",
"murski-val"
],
"website": "https://murskival.si/",
"enabled": true,
"assets": {
"logo": ""
},
"streams": {
"audio": "https://stream.murskival.si/"
},
"metadata": {}
},
{
"id": "Salomon",
"name": "Radio Salomon",
@@ -1692,5 +1715,215 @@
"audio": "https://stream.nextmedia.si/proxy/ekspres1?mp=/stream"
},
"metadata": {}
},
{
"id": "radio-student",
"name": "Radio Študent",
"slogan": "",
"category": "Alternative",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["student", "alternative", "slovenija"],
"website": "https://radiostudent.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://kruljo.radiostudent.si:8001/test.ogg?ck=1777460320120" },
"metadata": {}
},
{
"id": "stajerskival",
"name": "Štajerski val",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["stajerskival", "stajerska", "slovenija"],
"website": "https://www.stajerskival.si",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.stajerskival.si:8443//;stream.mp3" },
"metadata": {}
},
{
"id": "radio-rogla",
"name": "Radio Rogla",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["rogla", "slovenija"],
"website": "https://www.radiorogla.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "http://193.105.67.24:8010/;" },
"metadata": {}
},
{
"id": "radio-94",
"name": "Radio 94",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["radio94", "slovenija"],
"website": "https://www.radio94.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "http://77.38.12.198:8000/radio94" },
"metadata": {}
},
{
"id": "radio-sora",
"name": "Radio Sora",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["sora", "slovenija"],
"website": "https://www.radio-sora.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.radio-sora.si/stream_glasba.php?bla=177746" },
"metadata": {}
},
{
"id": "primorskival",
"name": "Primorski Val",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["primorska", "val", "slovenija"],
"website": "http://www.primorskival.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://altair.streamerr.co/stream/primorskival" },
"metadata": {}
},
{
"id": "rockradio-classics",
"name": "Rock Radio Classics",
"slogan": "",
"category": "Rock",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["rock", "classics", "slovenija"],
"website": "https://www.rockradio.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.nextmedia.si/proxy/rocks_3?mp=/stream" },
"metadata": {}
},
{
"id": "rockradio-hardandheavy",
"name": "Rock Radio Hard & Heavy",
"slogan": "",
"category": "Rock",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["rock", "hard", "heavy", "metal", "slovenija"],
"website": "https://www.rockradio.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.nextmedia.si/proxy/rocks_2?mp=/stream" },
"metadata": {}
},
{
"id": "rockradio-bestballads",
"name": "Rock Radio Best Ballads",
"slogan": "",
"category": "Rock",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["rock", "ballads", "slovenija"],
"website": "https://www.rockradio.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.nextmedia.si/proxy/rocks_1?mp=/stream" },
"metadata": {}
},
{
"id": "rockradio",
"name": "Rock Radio",
"slogan": "",
"category": "Rock",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["rock", "slovenija"],
"website": "https://www.rockradio.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.nextmedia.si/proxy/rockr2_2?mp=/rock?uid=rrweb;" },
"metadata": {}
},
{
"id": "radio-gorenc",
"name": "Radio Gorenc",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "Regional",
"tags": ["gorenc", "gorenjska", "slovenija"],
"website": "https://www.radiogorenc.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream.radiogorenc.si:8001/radiogorenc.mp3" },
"metadata": {}
},
{
"id": "radio-enter",
"name": "Radio Enter",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["enter", "slovenija"],
"website": "https://enter.radio/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream2.nextmedia.si/hls/ent/live.m3u8?sid=1777453580534156964" },
"metadata": {}
},
{
"id": "juboks-radio",
"name": "JUBoks Radio",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["juboks", "slovenija"],
"website": "https://www.juboks.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream2.nextmedia.si/hls/jub/live.m3u8?sid=1777461366421238395" },
"metadata": {}
},
{
"id": "radio-frajer",
"name": "Radio Frajer",
"slogan": "",
"category": "Pop",
"country": "SI",
"language": "sl",
"region": "National",
"tags": ["frajer", "slovenija"],
"website": "https://radiofrajer.si/",
"enabled": true,
"assets": { "logo": "" },
"streams": { "audio": "https://stream2.nextmedia.si/hls/frj/live.m3u8?sid=1777461467294078340" },
"metadata": {}
}
]

View File

@@ -3,13 +3,81 @@
//
// 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-1777404493334';
const CACHE_NAME = 'radioplayer-pwa-v5-1777463324180';
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';
const STATION_SYNC_TAG = 'radio-stations-refresh';
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
const STATION_SYNC_HEADERS = {
'content-type': 'application/json; charset=utf-8',
};
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
const RADIO_COUNTRIES = [
{ name: 'Austria', code: 'AT' },
{ name: 'Belgium', code: 'BE' },
{ name: 'Bulgaria', code: 'BG' },
{ name: 'Cyprus', code: 'CY' },
{ name: 'Czechia', code: 'CZ' },
{ name: 'Denmark', code: 'DK' },
{ name: 'Estonia', code: 'EE' },
{ name: 'Finland', code: 'FI' },
{ name: 'France', code: 'FR' },
{ name: 'Germany', code: 'DE' },
{ name: 'Greece', code: 'GR' },
{ name: 'Russia', code: 'RU' },
{ name: 'Hungary', code: 'HU' },
{ name: 'Ireland', code: 'IE' },
{ name: 'Italy', code: 'IT' },
{ name: 'Japan', code: 'JP' },
{ name: 'Latvia', code: 'LV' },
{ name: 'Lithuania', code: 'LT' },
{ name: 'Luxembourg', code: 'LU' },
{ name: 'Malta', code: 'MT' },
{ name: 'Mexico', code: 'MX' },
{ name: 'Netherlands', code: 'NL' },
{ name: 'Poland', code: 'PL' },
{ name: 'Brazil', code: 'BR' },
{ name: 'Portugal', code: 'PT' },
{ name: 'Romania', code: 'RO' },
{ name: 'Croatia', code: 'HR' },
{ name: 'Serbia', code: 'RS' },
{ name: 'Montenegro', code: 'ME' },
{ name: 'Bosnia & Herzegovina', code: 'BA' },
{ name: 'Argentina', code: 'AR' },
{ name: 'United Kingdom', code: 'GB' },
{ name: 'Slovenia', code: 'SI' },
{ name: 'Slovakia', code: 'SK' },
{ name: 'Spain', code: 'ES' },
{ name: 'USA', code: 'US' },
{ name: 'Canada', code: 'CA' },
{ name: 'Australia', code: 'AU' },
{ name: 'China', code: 'CN' },
{ name: 'Sweden', code: 'SE' },
{ name: 'Switzerland', code: 'CH' },
{ name: 'Turkey', code: 'TR' },
{ name: 'Ukraine', code: 'UA' },
];
const MAX_TAGS = 12;
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
'wma',
'wmav2',
'asf',
'ra',
'rm',
'ape',
'alac',
'amr',
]);
const CORE_ASSETS = [
'./',
'index.html',
'privacy.html',
'data/radio-stations.json',
'data/radio-stations-sync.json',
'stations.json',
'manifest.json',
'images/radio-placeholder.svg',
@@ -20,10 +88,281 @@ const CORE_ASSETS = [
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname));
const DATA_PATHS = new Set([
new URL('data/radio-stations.json', self.registration.scope).pathname,
new URL('data/radio-stations-sync.json', self.registration.scope).pathname,
new URL('stations.json', self.registration.scope).pathname,
new URL('manifest.json', self.registration.scope).pathname,
]);
const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registration.scope).pathname;
const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href;
const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname;
const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href;
const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href;
const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname;
function trimToNull(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumber(value) {
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function toNullableNumber(value) {
const parsed = typeof value === 'number' ? value : Number(value ?? NaN);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function parseTags(tags) {
if (typeof tags !== 'string' || tags.trim().length === 0) return [];
const seen = new Set();
const parsed = [];
for (const rawTag of tags.split(',')) {
const tag = rawTag.trim();
if (!tag) continue;
const normalizedTag = tag.toLowerCase();
if (seen.has(normalizedTag)) continue;
seen.add(normalizedTag);
parsed.push(tag);
if (parsed.length >= MAX_TAGS) break;
}
return parsed;
}
function isHttpsUrl(value) {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === 'https:';
} catch {
return false;
}
}
function normalizeRadioBrowserStation(station, countryName) {
const stationUuid = trimToNull(station?.stationuuid);
if (!stationUuid) return null;
const name = trimToNull(station?.name);
if (!name) return null;
const streamUrl = trimToNull(station?.url_resolved) ?? trimToNull(station?.url);
if (!isHttpsUrl(streamUrl)) return null;
const codec = trimToNull(station?.codec);
if (codec && OBVIOUSLY_UNSUPPORTED_CODECS.has(codec.toLowerCase())) {
return null;
}
return {
id: stationUuid,
name,
country: trimToNull(station?.country) ?? countryName,
countryCode: trimToNull(station?.countrycode) ?? '',
language: trimToNull(station?.language),
tags: parseTags(station?.tags),
codec,
bitrate: toNullableNumber(station?.bitrate),
streamUrl,
homepage: trimToNull(station?.homepage),
logoUrl: trimToNull(station?.favicon),
votes: toNumber(station?.votes),
clickcount: toNumber(station?.clickcount),
source: 'radio-browser',
sourceStationUuid: stationUuid,
};
}
function getCountryCatalogUrl(countryCode) {
return new URL(`data/countries/${String(countryCode || '').toLowerCase()}.json`, self.registration.scope).href;
}
async function writeJsonToCache(cache, requestUrl, payload) {
await cache.put(requestUrl, new Response(JSON.stringify(payload), {
headers: STATION_SYNC_HEADERS,
}));
}
async function fetchCountryStations(country) {
const url = new URL(RADIO_BROWSER_API_ENDPOINT);
url.search = new URLSearchParams({
countrycode: country.code,
hidebroken: 'true',
is_https: 'true',
order: 'clickcount',
reverse: 'true',
limit: '100',
}).toString();
const response = await fetch(url, {
cache: 'no-store',
headers: {
accept: 'application/json',
},
mode: 'cors',
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('Expected an array response from Radio Browser.');
}
return payload
.map((station) => normalizeRadioBrowserStation(station, country.name))
.filter(Boolean);
}
async function syncRadioStations(reason = 'sync') {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const aggregatedStations = [];
const seenStationIds = new Set();
const seenStreamUrls = new Set();
const failedCountries = [];
let syncedCountries = 0;
for (const country of RADIO_COUNTRIES) {
try {
const stations = await fetchCountryStations(country);
await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations);
for (const station of stations) {
if (seenStationIds.has(station.id)) continue;
if (seenStreamUrls.has(station.streamUrl)) continue;
seenStationIds.add(station.id);
seenStreamUrls.add(station.streamUrl);
aggregatedStations.push(station);
}
syncedCountries += 1;
} catch (error) {
failedCountries.push(country.code);
console.debug(`[sw] Station sync failed for ${country.name} (${country.code})`, error);
}
}
aggregatedStations.sort((left, right) => {
const countryOrder = left.country.localeCompare(right.country, undefined, { sensitivity: 'base' });
if (countryOrder !== 0) return countryOrder;
if (right.clickcount !== left.clickcount) return right.clickcount - left.clickcount;
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const meta = {
reason,
syncedAt: new Date().toISOString(),
countryCount: syncedCountries,
failedCountries,
stationCount: aggregatedStations.length,
};
if (aggregatedStations.length === 0) {
const existingCatalog = await syncCache.match(SYNC_CATALOG_URL);
if (existingCatalog) {
await writeJsonToCache(syncCache, SYNC_META_URL, {
...meta,
reusedCachedCatalog: true,
});
return {
...meta,
reusedCachedCatalog: true,
};
}
throw new Error('Station sync fetched no stations.');
}
await Promise.all([
writeJsonToCache(syncCache, SYNC_CATALOG_URL, aggregatedStations),
writeJsonToCache(syncCache, SYNC_META_URL, meta),
]);
return meta;
}
async function respondWithSyncedCatalog(request) {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const cached = await syncCache.match(request);
if (cached) return cached;
const bundled = await caches.match(new URL('data/radio-stations.json', self.registration.scope).href)
|| await caches.match('data/radio-stations.json');
if (bundled) return bundled;
return fetch(new Request('data/radio-stations.json', { cache: 'reload' }));
}
async function respondWithSyncedCountryCatalog(request) {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const cached = await syncCache.match(request);
if (cached) return cached;
return new Response('[]', {
headers: STATION_SYNC_HEADERS,
status: 200,
});
}
async function refreshManagedCatalogCache() {
const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME);
const url = new URL('api/managed-stations.json', self.registration.scope).href;
try {
const response = await fetch(url, { cache: 'no-store' });
if (isCacheableResponse(response)) {
await managedCatalogCache.put(url, response);
}
} catch {
// Network unavailable — keep existing cache as-is.
}
}
async function respondWithManagedCatalog(request) {
const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME);
try {
const networkResponse = await fetch(request);
if (isCacheableResponse(networkResponse)) {
await managedCatalogCache.put(request, networkResponse.clone());
return withManagedCatalogSource(networkResponse, 'remote');
}
} catch {
// Fall back to the last good remote response or bundled catalog below.
}
const cachedRemoteResponse = await managedCatalogCache.match(request);
if (cachedRemoteResponse) {
return withManagedCatalogSource(cachedRemoteResponse, 'cached-remote');
}
const bundledFallback = await caches.match(BUNDLED_MANAGED_CATALOG_URL);
if (bundledFallback) {
return withManagedCatalogSource(bundledFallback, 'bundled');
}
return withManagedCatalogSource(await fetch(BUNDLED_MANAGED_CATALOG_URL), 'bundled');
}
function withManagedCatalogSource(response, source) {
const headers = new Headers(response.headers);
headers.set(MANAGED_CATALOG_SOURCE_HEADER, source);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function isCacheableResponse(response) {
return Boolean(response && response.ok && response.type === 'basic');
@@ -124,12 +463,30 @@ self.addEventListener('activate', (event) => {
Promise.all([
self.clients.claim(),
caches.keys().then((keys) => Promise.all(
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
keys.map((k) => {
if (k !== CACHE_NAME && k !== STATION_SYNC_CACHE_NAME && k !== MANAGED_CATALOG_CACHE_NAME) return caches.delete(k);
return null;
})
)),
])
);
});
self.addEventListener('sync', (event) => {
if (event.tag !== STATION_SYNC_TAG) return;
event.waitUntil(syncRadioStations('background-sync'));
});
self.addEventListener('periodicsync', (event) => {
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
event.waitUntil(syncRadioStations('periodic-sync'));
return;
}
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
event.waitUntil(refreshManagedCatalogCache());
}
});
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
@@ -153,6 +510,21 @@ self.addEventListener('fetch', (event) => {
return;
}
if (url.pathname === SYNC_CATALOG_PATH) {
event.respondWith(respondWithSyncedCatalog(event.request));
return;
}
if (url.pathname === MANAGED_CATALOG_PATH) {
event.respondWith(respondWithManagedCatalog(event.request));
return;
}
if (url.pathname.startsWith(SYNC_COUNTRY_PREFIX_PATH) && url.pathname.endsWith('.json')) {
event.respondWith(respondWithSyncedCountryCatalog(event.request));
return;
}
// Network-first for navigations and core assets to prevent "old UI" issues.
if (isHtmlNavigation) {
event.respondWith(

View File

@@ -13,6 +13,7 @@ const API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const outputPath = path.join(repoRoot, 'public', 'data', 'radio-stations.json');
const syncOutputPath = path.join(repoRoot, 'public', 'data', 'radio-stations-sync.json');
const collectedStations: RadioStation[] = [];
const seenStationIds = new Set<string>();
@@ -52,11 +53,16 @@ collectedStations.sort((left, right) => {
});
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(collectedStations, null, 2)}\n`, 'utf8');
const serializedStations = `${JSON.stringify(collectedStations, null, 2)}\n`;
await Promise.all([
writeFile(outputPath, serializedStations, 'utf8'),
writeFile(syncOutputPath, serializedStations, 'utf8'),
]);
console.log(`[radio-import] Imported ${collectedStations.length} stations from ${successfulCountries}/${radioCountries.length} countries.`);
console.log(`[radio-import] Failed countries: ${failedCountries.length > 0 ? failedCountries.join(', ') : 'None'}`);
console.log('[radio-import] Output: public/data/radio-stations.json');
console.log('[radio-import] Sync output: public/data/radio-stations-sync.json');
async function fetchCountryStations(countryCode: string): Promise<RadioBrowserStation[]> {
const url = new URL(API_ENDPOINT);

View File

@@ -61,6 +61,18 @@ function CastIcon({ size = 22 }) {
);
}
function AirPlayIcon({ size = 22, className = '' }) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="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>
);
}
function VolumeIcon() {
return (
<svg id="icon-volume" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -111,10 +123,10 @@ function HeaderControls() {
</button>
<button id="install-app-btn" className="icon-btn install-btn hidden" title="Install app" aria-label="Install app" type="button">
<InstallIcon />
<span>Install</span>
</button>
<button id="cast-btn" className="icon-btn cast-btn" title="Cast to device" aria-label="Cast to device" type="button">
<button id="cast-btn" className="icon-btn cast-btn" title="Cast or AirPlay to device" aria-label="Cast or AirPlay to device" type="button">
<CastIcon />
<AirPlayIcon className="hidden" />
</button>
</div>
<div className="header-close" />
@@ -169,6 +181,8 @@ function TrackInfo() {
<div id="now-title" className="now-title" aria-hidden="false" />
</div>
<p id="station-subtitle" />
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
<div id="station-health-detail" className="station-health-detail hidden" aria-live="polite" />
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
<span className="status-dot" />
<span id="status-text" />
@@ -184,6 +198,7 @@ function TrackInfo() {
<span id="engine-label">HTML5</span>
</span>
</div>
<div id="managed-catalog-status" className="managed-catalog-status 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"
@@ -329,7 +344,23 @@ function EditorOverlay() {
<div id="editor-overlay" className="overlay hidden" aria-hidden="true">
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="editorTitle">
<h2 id="editorTitle">Edit Stations</h2>
<p id="editor-persistence-note" className="editor-note" aria-live="polite">
Local storage: <strong id="editor-persistence-backend">IndexedDB</strong>
</p>
<p id="editor-backup-activity-note" className="editor-note editor-note-subtle" aria-live="polite">
No backup activity yet.
</p>
<ul id="editor-list" className="device-list" />
<div className="editor-tools">
<button id="export-user-data-btn" className="btn secondary" type="button">Export Data</button>
<button id="import-user-data-btn" className="btn secondary" type="button">Import Data</button>
<button id="reset-user-data-btn" className="btn delete-btn" type="button">Reset Local Data</button>
<input id="import-user-data-input" className="hidden-file-input" type="file" accept="application/json" />
</div>
<label className="editor-checkbox" htmlFor="reset-user-data-backup-checkbox">
<input id="reset-user-data-backup-checkbox" type="checkbox" defaultChecked />
<span>Download a backup before reset</span>
</label>
<form id="add-station-form">
<div className="field-row">
<input id="us_title" placeholder="Title" required />
@@ -344,7 +375,6 @@ function EditorOverlay() {
<input id="us_www" placeholder="Website (optional)" />
</div>
<input type="hidden" id="us_id" />
<input type="hidden" id="us_index" />
<div className="editor-actions">
<button id="us_save_btn" className="btn cancel" type="submit">Save</button>
<button id="editor-close-btn" className="btn secondary" type="button">Close</button>
@@ -400,6 +430,27 @@ function StationLibrary() {
</button>
<div id="station-country-filter-menu" className="library-select-menu" role="listbox" aria-label="Country filter options" />
</div>
<div className="library-select" data-sort-filter>
<button
id="station-sort-btn"
className="library-select-trigger"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="Sort stations"
>
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18M7 12h10M11 18h2" />
</svg>
<span className="library-select-prefix">Sort</span>
<span id="station-sort-text" className="library-select-value">Recommended</span>
<svg className="library-select-caret" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div id="station-sort-menu" className="library-select-menu" role="listbox" aria-label="Sort options" />
</div>
</div>
<div className="library-tabs" role="tablist" aria-label="Station filters">

View File

@@ -8,6 +8,46 @@ const hadServiceWorkerControllerAtLoad = 'serviceWorker' in navigator
? Boolean(navigator.serviceWorker.controller)
: false;
let hasReloadedForServiceWorkerUpdate = false;
const STATION_SYNC_TAG = 'radio-stations-refresh';
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
const STATION_PERIODIC_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
const MANAGED_CATALOG_PERIODIC_SYNC_INTERVAL = 24 * 60 * 60 * 1000;
async function registerStationCatalogSync(registration) {
if (!registration) {
return;
}
if ('sync' in registration) {
try {
await registration.sync.register(STATION_SYNC_TAG);
console.debug('ServiceWorker background sync registered:', STATION_SYNC_TAG);
} catch (error) {
console.debug('ServiceWorker background sync registration failed:', error);
}
}
if ('periodicSync' in registration) {
try {
await registration.periodicSync.register(STATION_PERIODIC_SYNC_TAG, {
minInterval: STATION_PERIODIC_SYNC_INTERVAL,
});
console.debug('ServiceWorker periodic sync registered:', STATION_PERIODIC_SYNC_TAG);
} catch (error) {
console.debug('ServiceWorker periodic sync registration failed:', error);
}
try {
await registration.periodicSync.register(MANAGED_CATALOG_PERIODIC_SYNC_TAG, {
minInterval: MANAGED_CATALOG_PERIODIC_SYNC_INTERVAL,
});
console.debug('ServiceWorker periodic sync registered:', MANAGED_CATALOG_PERIODIC_SYNC_TAG);
} catch (error) {
console.debug('ServiceWorker managed catalog periodic sync registration failed:', error);
}
}
}
function setupServiceWorker() {
if (!('serviceWorker' in navigator)) {
@@ -36,10 +76,17 @@ function setupServiceWorker() {
} catch (e) {
console.debug('ServiceWorker update check failed:', e);
}
await registerStationCatalogSync(reg);
console.log('ServiceWorker registered:', reg.scope);
})
.catch((err) => console.debug('ServiceWorker registration failed:', err));
window.addEventListener('online', () => {
navigator.serviceWorker.ready
.then((reg) => registerStationCatalogSync(reg))
.catch((error) => console.debug('ServiceWorker sync scheduling failed:', error));
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadServiceWorkerControllerAtLoad || hasReloadedForServiceWorkerUpdate) {
return;

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,97 @@
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
type ManagedCatalogEnvelope = {
stations?: unknown;
};
async function loadManagedCatalogFromCache(catalogUrl: string): Promise<Response | null> {
if (typeof window === 'undefined' || !('caches' in window)) {
return null;
}
try {
const cacheKeys = await caches.keys();
const managedCacheName = cacheKeys.find((cacheName) => cacheName.startsWith(MANAGED_CATALOG_CACHE_PREFIX));
if (!managedCacheName) {
return null;
}
const managedCache = await caches.open(managedCacheName);
return await managedCache.match(catalogUrl) ?? null;
} catch {
return null;
}
}
function normalizeManagedStationsPayload(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (payload && typeof payload === 'object' && Array.isArray((payload as ManagedCatalogEnvelope).stations)) {
return (payload as ManagedCatalogEnvelope).stations as unknown[];
}
return [];
}
function setLastManagedCatalogSource(source: ManagedCatalogSource) {
lastManagedCatalogSource = source;
}
export function getLastManagedCatalogSource(): ManagedCatalogSource {
return lastManagedCatalogSource;
}
export async function loadManagedStations(): Promise<unknown[]> {
const response = await fetch(`${import.meta.env.BASE_URL}stations.json`);
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
const hasServiceWorkerController = typeof navigator !== 'undefined'
&& 'serviceWorker' in navigator
&& Boolean(navigator.serviceWorker.controller);
let response = null;
if (hasServiceWorkerController) {
response = await loadManagedCatalogFromCache(remoteCatalogUrl);
if (response?.ok) {
setLastManagedCatalogSource('cached-remote');
}
}
if (!response?.ok) {
try {
response = await fetch(remoteCatalogUrl);
if (response?.ok) {
const responseSource = response.headers.get(MANAGED_CATALOG_SOURCE_HEADER);
setLastManagedCatalogSource(
responseSource === 'remote' || responseSource === 'cached-remote' || responseSource === 'bundled'
? responseSource
: 'remote',
);
}
} catch {
response = null;
}
}
if (!response?.ok) {
response = await fetch(bundledCatalogUrl);
if (response?.ok) {
setLastManagedCatalogSource('bundled');
}
}
if (!response.ok) {
setLastManagedCatalogSource('unknown');
throw new Error(`Failed to load managed stations: ${response.status}`);
}
const stations = await response.json();
return Array.isArray(stations) ? stations : [];
return normalizeManagedStationsPayload(stations);
}

View File

@@ -1,7 +1,42 @@
import type { RadioStation } from './radioTypes.js';
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response | null> {
if (typeof window === 'undefined' || !('caches' in window)) {
return null;
}
try {
const cacheKeys = await caches.keys();
const syncCacheName = cacheKeys.find((cacheName) => cacheName.startsWith(STATION_SYNC_CACHE_PREFIX));
if (!syncCacheName) {
return null;
}
const syncCache = await caches.open(syncCacheName);
return await syncCache.match(catalogUrl) ?? null;
} catch {
return null;
}
}
export async function loadRadioStations(): Promise<RadioStation[]> {
const response = await fetch(`${import.meta.env.BASE_URL}data/radio-stations.json`);
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`;
const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`;
const hasServiceWorkerController = typeof navigator !== 'undefined'
&& 'serviceWorker' in navigator
&& Boolean(navigator.serviceWorker.controller);
let response = null;
if (hasServiceWorkerController) {
response = await loadSyncedCatalogFromCache(syncedCatalogUrl);
}
if (!response?.ok) {
response = await fetch(bundledCatalogUrl);
}
if (!response.ok) {
throw new Error(`Failed to load radio stations: ${response.status}`);

View File

@@ -0,0 +1,778 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
export type PersistedStationHealth = {
attempts: number;
successes: number;
failures: number;
lastAttemptedAt: string | null;
lastSucceededAt: string | null;
lastFailedAt: string | null;
lastFailureReason: string | null;
timeoutCount: number;
lastProbeMillis: number | null;
};
export type PersistedRecentStationEntry = {
stationId: string;
playedAt: string;
country: string | null;
};
type PersistedSettings = {
volume: number | null;
lastStationId: string | null;
lastStationCountry: string | null;
castBothMode: boolean;
favoriteStationIds: string[];
stationUsageCounts: Record<string, number>;
recentStationHistory: PersistedRecentStationEntry[];
stationHealth: Record<string, PersistedStationHealth>;
lastExportedAt: string | null;
lastImportedAt: string | null;
};
type PersistedSnapshot = PersistedSettings & {
userStations: unknown[];
};
type SettingKey = keyof PersistedSettings;
type SettingRecordMap = {
volume: number | null;
lastStationId: string | null;
lastStationCountry: string | null;
castBothMode: boolean;
favoriteStationIds: string[];
stationUsageCounts: Record<string, number>;
recentStationHistory: PersistedRecentStationEntry[];
stationHealth: Record<string, PersistedStationHealth>;
lastExportedAt: string | null;
lastImportedAt: string | null;
};
type UserStationRecord = {
id: string;
sortOrder: number;
data: unknown;
};
interface RadioPlayerDb extends DBSchema {
settings: {
key: SettingKey;
value: SettingRecordMap[SettingKey];
};
userStations: {
key: string;
value: UserStationRecord;
indexes: {
'by-sort-order': number;
};
};
}
export type PlayerPersistenceSnapshot = PersistedSnapshot;
export type PlayerPersistenceBackend = 'indexeddb' | 'localstorage';
const DB_NAME = 'radioplayer';
const DB_VERSION = 1;
const DEFAULT_SNAPSHOT: PersistedSnapshot = {
volume: null,
lastStationId: null,
lastStationCountry: null,
castBothMode: false,
favoriteStationIds: [],
stationUsageCounts: {},
recentStationHistory: [],
stationHealth: {},
lastExportedAt: null,
lastImportedAt: null,
userStations: [],
};
const LOCAL_STORAGE_KEYS = {
volume: 'volume',
lastStationId: 'lastStationId',
lastStationCountry: 'lastStationCountry',
castBothMode: 'castBothMode',
favoriteStationIds: 'favoriteStationIds',
stationUsageCounts: 'stationUsageCounts',
recentStationHistory: 'recentStationHistory',
stationHealth: 'stationHealth',
lastExportedAt: 'lastExportedAt',
lastImportedAt: 'lastImportedAt',
userStations: 'userStations',
};
let dbPromise: Promise<IDBPDatabase<RadioPlayerDb> | null> | null = null;
let snapshot: PersistedSnapshot = { ...DEFAULT_SNAPSHOT };
let hydrated = false;
let persistenceBackend: PlayerPersistenceBackend = 'indexeddb';
function getDb() {
if (!dbPromise) {
dbPromise = openDB<RadioPlayerDb>(DB_NAME, DB_VERSION, {
upgrade(db: IDBPDatabase<RadioPlayerDb>) {
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings');
}
if (!db.objectStoreNames.contains('userStations')) {
const store = db.createObjectStore('userStations', { keyPath: 'id' });
store.createIndex('by-sort-order', 'sortOrder');
}
},
}).catch((error) => {
persistenceBackend = 'localstorage';
console.warn('IndexedDB unavailable, falling back to localStorage persistence.', error);
return null;
});
}
return dbPromise;
}
function safeJsonParse<T>(value: string | null, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function sanitizeIsoDate(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Date.parse(trimmed);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
}
function sanitizeStationHealthRecord(value: unknown): PersistedStationHealth | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const input = value as Record<string, unknown>;
const attempts = Math.max(0, Number(input.attempts) || 0);
const successes = Math.max(0, Number(input.successes) || 0);
const failures = Math.max(0, Number(input.failures) || 0);
const timeoutCount = Math.max(0, Number(input.timeoutCount) || 0);
const rawProbeMillis = Number(input.lastProbeMillis);
return {
attempts,
successes,
failures,
lastAttemptedAt: sanitizeIsoDate(input.lastAttemptedAt),
lastSucceededAt: sanitizeIsoDate(input.lastSucceededAt),
lastFailedAt: sanitizeIsoDate(input.lastFailedAt),
lastFailureReason: typeof input.lastFailureReason === 'string' && input.lastFailureReason.trim().length > 0
? input.lastFailureReason.trim()
: null,
timeoutCount,
lastProbeMillis: Number.isFinite(rawProbeMillis) && rawProbeMillis >= 0 ? rawProbeMillis : null,
};
}
function sanitizeStationHealthMap(value: unknown): Record<string, PersistedStationHealth> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.map(([stationId, record]) => [stationId, sanitizeStationHealthRecord(record)] as const)
.filter((entry): entry is [string, PersistedStationHealth] => Boolean(entry[0]) && Boolean(entry[1])),
);
}
function sanitizeRecentStationHistory(value: unknown): PersistedRecentStationEntry[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return null;
}
const input = entry as Record<string, unknown>;
const stationId = typeof input.stationId === 'string' && input.stationId.trim().length > 0
? input.stationId.trim()
: null;
const playedAt = sanitizeIsoDate(input.playedAt);
const country = typeof input.country === 'string' && input.country.trim().length > 0
? input.country.trim()
: null;
if (!stationId || !playedAt) {
return null;
}
return { stationId, playedAt, country };
})
.filter((entry): entry is PersistedRecentStationEntry => Boolean(entry));
}
function cloneStationHealthMap(map: Record<string, PersistedStationHealth>) {
return Object.fromEntries(
Object.entries(map).map(([stationId, record]) => [stationId, { ...record }]),
);
}
function cloneRecentStationHistory(history: PersistedRecentStationEntry[]) {
return history.map((entry) => ({ ...entry }));
}
function getLocalStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function writeLocalStorageValue(key: string, value: string | null) {
const storage = getLocalStorage();
if (!storage) return;
if (value === null) {
storage.removeItem(key);
} else {
storage.setItem(key, value);
}
}
function mirrorSettingToLocalStorage<K extends SettingKey>(key: K, value: SettingRecordMap[K]) {
switch (key) {
case 'volume':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.volume, typeof value === 'number' ? String(value) : null);
break;
case 'lastStationId':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastStationId, typeof value === 'string' ? value : null);
break;
case 'lastStationCountry':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastStationCountry, typeof value === 'string' ? value : null);
break;
case 'castBothMode':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.castBothMode, value ? '1' : '0');
break;
case 'favoriteStationIds':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.favoriteStationIds, JSON.stringify(Array.isArray(value) ? value : []));
break;
case 'stationUsageCounts':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationUsageCounts, JSON.stringify(value && typeof value === 'object' ? value : {}));
break;
case 'recentStationHistory':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.recentStationHistory, JSON.stringify(Array.isArray(value) ? value : []));
break;
case 'stationHealth':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationHealth, JSON.stringify(value && typeof value === 'object' ? value : {}));
break;
case 'lastExportedAt':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastExportedAt, typeof value === 'string' ? value : null);
break;
case 'lastImportedAt':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastImportedAt, typeof value === 'string' ? value : null);
break;
default:
break;
}
}
function mirrorUserStationsToLocalStorage(userStations: unknown[]) {
writeLocalStorageValue(LOCAL_STORAGE_KEYS.userStations, JSON.stringify(Array.isArray(userStations) ? userStations : []));
}
function clearMirroredLocalStorage() {
Object.values(LOCAL_STORAGE_KEYS).forEach((key) => writeLocalStorageValue(key, null));
}
function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
const input = source && typeof source === 'object' ? source as Record<string, unknown> : {};
const volume = typeof input.volume === 'number' && Number.isFinite(input.volume) && input.volume >= 0 && input.volume <= 100
? input.volume
: null;
const lastStationId = typeof input.lastStationId === 'string' && input.lastStationId.trim().length > 0
? input.lastStationId
: null;
const lastStationCountry = typeof input.lastStationCountry === 'string' && input.lastStationCountry.trim().length > 0
? input.lastStationCountry
: null;
const castBothMode = input.castBothMode === true;
const lastExportedAt = typeof input.lastExportedAt === 'string' && input.lastExportedAt.trim().length > 0
? input.lastExportedAt
: null;
const lastImportedAt = typeof input.lastImportedAt === 'string' && input.lastImportedAt.trim().length > 0
? input.lastImportedAt
: null;
const favoriteStationIds = Array.isArray(input.favoriteStationIds)
? input.favoriteStationIds.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: [];
const stationUsageCounts = input.stationUsageCounts && typeof input.stationUsageCounts === 'object' && !Array.isArray(input.stationUsageCounts)
? Object.fromEntries(
Object.entries(input.stationUsageCounts as Record<string, unknown>)
.map(([key, value]) => {
const numericValue = Number(value);
return [key, numericValue] as const;
})
.filter(([, value]) => Number.isFinite(value) && value >= 0),
)
: {};
const userStations = Array.isArray(input.userStations)
? input.userStations
.filter((entry) => entry && typeof entry === 'object')
.map((entry, index) => {
const station = entry as Record<string, unknown>;
const id = typeof station.id === 'string' && station.id.trim().length > 0 ? station.id : `user-import-${Date.now()}-${index}`;
return { ...station, id };
})
: [];
const recentStationHistory = sanitizeRecentStationHistory(input.recentStationHistory);
const stationHealth = sanitizeStationHealthMap(input.stationHealth);
return {
volume,
lastStationId,
lastStationCountry,
castBothMode,
favoriteStationIds,
stationUsageCounts,
recentStationHistory,
stationHealth,
lastExportedAt,
lastImportedAt,
userStations,
};
}
function readLegacyLocalStorage(): PersistedSnapshot {
const storage = getLocalStorage();
let volume: number | null = null;
const rawVolume = storage?.getItem('volume') ?? null;
if (rawVolume !== null) {
const parsed = Number(rawVolume);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) {
volume = parsed;
}
}
return {
volume,
lastStationId: storage?.getItem('lastStationId') ?? null,
lastStationCountry: storage?.getItem('lastStationCountry') ?? null,
castBothMode: storage?.getItem('castBothMode') === '1',
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}),
recentStationHistory: sanitizeRecentStationHistory(safeJsonParse<unknown[]>(storage?.getItem('recentStationHistory') ?? null, [])),
stationHealth: sanitizeStationHealthMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationHealth') ?? null, {})),
lastExportedAt: storage?.getItem('lastExportedAt') ?? null,
lastImportedAt: storage?.getItem('lastImportedAt') ?? null,
userStations: safeJsonParse<unknown[]>(storage?.getItem('userStations') ?? null, []),
};
}
async function migrateFromLocalStorageIfNeeded(db: IDBPDatabase<RadioPlayerDb>) {
const settingCount = await db.count('settings');
const userStationCount = await db.count('userStations');
if (settingCount > 0 || userStationCount > 0) {
return;
}
const legacy = readLegacyLocalStorage();
const tx = db.transaction(['settings', 'userStations'], 'readwrite');
await Promise.all([
tx.objectStore('settings').put(legacy.volume, 'volume'),
tx.objectStore('settings').put(legacy.lastStationId, 'lastStationId'),
tx.objectStore('settings').put(legacy.lastStationCountry, 'lastStationCountry'),
tx.objectStore('settings').put(legacy.castBothMode, 'castBothMode'),
tx.objectStore('settings').put(legacy.favoriteStationIds, 'favoriteStationIds'),
tx.objectStore('settings').put(legacy.stationUsageCounts, 'stationUsageCounts'),
tx.objectStore('settings').put(legacy.recentStationHistory, 'recentStationHistory'),
tx.objectStore('settings').put(legacy.stationHealth, 'stationHealth'),
tx.objectStore('settings').put(legacy.lastExportedAt, 'lastExportedAt'),
tx.objectStore('settings').put(legacy.lastImportedAt, 'lastImportedAt'),
...legacy.userStations.map((station, index) => {
const record = station as { id?: string };
const id = record?.id || `user-${Date.now()}-${index}`;
return tx.objectStore('userStations').put({
id,
sortOrder: index,
data: typeof station === 'object' && station ? { ...station, id } : { id },
});
}),
]);
await tx.done;
}
export async function hydratePlayerPersistence() {
const db = await getDb();
if (!db) {
snapshot = readLegacyLocalStorage();
hydrated = true;
return getPlayerPersistenceSnapshot();
}
await migrateFromLocalStorageIfNeeded(db);
const tx = db.transaction(['settings', 'userStations'], 'readonly');
const settingsStore = tx.objectStore('settings');
const userStationsStore = tx.objectStore('userStations').index('by-sort-order');
const volume = await settingsStore.get('volume') as SettingRecordMap['volume'] | undefined;
const lastStationId = await settingsStore.get('lastStationId') as SettingRecordMap['lastStationId'] | undefined;
const lastStationCountry = await settingsStore.get('lastStationCountry') as SettingRecordMap['lastStationCountry'] | undefined;
const castBothMode = await settingsStore.get('castBothMode') as SettingRecordMap['castBothMode'] | undefined;
const favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | undefined;
const stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | undefined;
const recentStationHistory = await settingsStore.get('recentStationHistory') as SettingRecordMap['recentStationHistory'] | undefined;
const stationHealth = await settingsStore.get('stationHealth') as SettingRecordMap['stationHealth'] | undefined;
const lastExportedAt = await settingsStore.get('lastExportedAt') as SettingRecordMap['lastExportedAt'] | undefined;
const lastImportedAt = await settingsStore.get('lastImportedAt') as SettingRecordMap['lastImportedAt'] | undefined;
const userStationRecords = await userStationsStore.getAll();
await tx.done;
snapshot = {
volume: typeof volume === 'number' ? volume : null,
lastStationId: typeof lastStationId === 'string' ? lastStationId : null,
lastStationCountry: typeof lastStationCountry === 'string' ? lastStationCountry : null,
castBothMode: castBothMode === true,
favoriteStationIds: Array.isArray(favoriteStationIds) ? favoriteStationIds.filter(Boolean) : [],
stationUsageCounts: stationUsageCounts && typeof stationUsageCounts === 'object' && !Array.isArray(stationUsageCounts)
? stationUsageCounts
: {},
recentStationHistory: sanitizeRecentStationHistory(recentStationHistory),
stationHealth: sanitizeStationHealthMap(stationHealth),
lastExportedAt: typeof lastExportedAt === 'string' ? lastExportedAt : null,
lastImportedAt: typeof lastImportedAt === 'string' ? lastImportedAt : null,
userStations: userStationRecords.map((record: UserStationRecord) => record.data),
};
hydrated = true;
return getPlayerPersistenceSnapshot();
}
export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
if (!hydrated) {
return {
volume: DEFAULT_SNAPSHOT.volume,
lastStationId: DEFAULT_SNAPSHOT.lastStationId,
lastStationCountry: DEFAULT_SNAPSHOT.lastStationCountry,
castBothMode: DEFAULT_SNAPSHOT.castBothMode,
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
recentStationHistory: cloneRecentStationHistory(DEFAULT_SNAPSHOT.recentStationHistory),
stationHealth: cloneStationHealthMap(DEFAULT_SNAPSHOT.stationHealth),
lastExportedAt: DEFAULT_SNAPSHOT.lastExportedAt,
lastImportedAt: DEFAULT_SNAPSHOT.lastImportedAt,
userStations: [...DEFAULT_SNAPSHOT.userStations],
};
}
return {
volume: snapshot.volume,
lastStationId: snapshot.lastStationId,
lastStationCountry: snapshot.lastStationCountry,
castBothMode: snapshot.castBothMode,
favoriteStationIds: [...snapshot.favoriteStationIds],
stationUsageCounts: { ...snapshot.stationUsageCounts },
recentStationHistory: cloneRecentStationHistory(snapshot.recentStationHistory),
stationHealth: cloneStationHealthMap(snapshot.stationHealth),
lastExportedAt: snapshot.lastExportedAt,
lastImportedAt: snapshot.lastImportedAt,
userStations: [...snapshot.userStations],
};
}
export function getPersistenceBackend(): PlayerPersistenceBackend {
return persistenceBackend;
}
export function getPersistedVolume() {
return snapshot.volume;
}
export function getPersistedLastStationId() {
return snapshot.lastStationId;
}
export function getPersistedLastStationCountry() {
return snapshot.lastStationCountry;
}
export function getPersistedCastBothMode() {
return snapshot.castBothMode;
}
export function getPersistedFavoriteStationIds() {
return new Set(snapshot.favoriteStationIds);
}
export function getPersistedStationUsageCounts() {
return { ...snapshot.stationUsageCounts };
}
export function getPersistedRecentStationHistory() {
return cloneRecentStationHistory(snapshot.recentStationHistory);
}
export function getPersistedStationHealth() {
return cloneStationHealthMap(snapshot.stationHealth);
}
export function getPersistedUserStations() {
return [...snapshot.userStations];
}
async function writeSetting<K extends SettingKey>(key: K, value: SettingRecordMap[K]) {
mirrorSettingToLocalStorage(key, value);
const db = await getDb();
if (!db) return;
try {
await db.put('settings', value, key);
} catch (error) {
persistenceBackend = 'localstorage';
console.warn('Failed to persist setting to IndexedDB, continuing with localStorage mirror.', error);
}
}
export function persistVolume(value: number) {
snapshot.volume = value;
void writeSetting('volume', value);
}
export function persistLastStationId(value: string | null) {
snapshot.lastStationId = value;
void writeSetting('lastStationId', value);
}
export function persistLastStationCountry(value: string | null) {
snapshot.lastStationCountry = value;
void writeSetting('lastStationCountry', value);
}
export function persistCastBothMode(value: boolean) {
snapshot.castBothMode = value;
void writeSetting('castBothMode', value);
}
export function persistFavoriteStationIds(ids: Iterable<string>) {
snapshot.favoriteStationIds = Array.from(new Set(Array.from(ids).filter(Boolean)));
void writeSetting('favoriteStationIds', snapshot.favoriteStationIds);
}
export function persistStationUsageCounts(counts: Record<string, number>) {
snapshot.stationUsageCounts = { ...counts };
void writeSetting('stationUsageCounts', snapshot.stationUsageCounts);
}
export function persistRecentStationHistory(history: PersistedRecentStationEntry[]) {
snapshot.recentStationHistory = cloneRecentStationHistory(sanitizeRecentStationHistory(history));
void writeSetting('recentStationHistory', snapshot.recentStationHistory);
}
export function persistStationHealth(health: Record<string, PersistedStationHealth>) {
snapshot.stationHealth = cloneStationHealthMap(sanitizeStationHealthMap(health));
void writeSetting('stationHealth', snapshot.stationHealth);
}
export function persistLastExportedAt(value: string | null) {
snapshot.lastExportedAt = value;
void writeSetting('lastExportedAt', value);
}
export function persistLastImportedAt(value: string | null) {
snapshot.lastImportedAt = value;
void writeSetting('lastImportedAt', value);
}
export async function upsertPersistedUserStation(station: unknown) {
const current = [...snapshot.userStations];
const incoming = (station ?? {}) as { id?: string };
const id = incoming.id || `user-${Date.now()}`;
const nextStation = typeof station === 'object' && station ? { ...station, id } : { id };
const existingIndex = current.findIndex((entry) => {
const record = entry as { id?: string };
return record.id === id;
});
const nextList = [...current];
if (existingIndex >= 0) {
nextList[existingIndex] = nextStation;
} else {
nextList.push(nextStation);
}
snapshot.userStations = nextList;
mirrorUserStationsToLocalStorage(snapshot.userStations);
const db = await getDb();
if (!db) {
return nextStation;
}
const tx = db.transaction('userStations', 'readwrite');
try {
await tx.store.clear();
await Promise.all(
nextList.map((entry, index) => {
const record = entry as { id?: string };
return tx.store.put({
id: record.id || `user-${Date.now()}-${index}`,
sortOrder: index,
data: entry,
});
}),
);
await tx.done;
} catch (error) {
persistenceBackend = 'localstorage';
console.warn('Failed to persist user stations to IndexedDB, continuing with localStorage mirror.', error);
}
return nextStation;
}
export async function deletePersistedUserStationAt(index: number) {
if (index < 0 || index >= snapshot.userStations.length) {
return;
}
snapshot.userStations = snapshot.userStations.filter((_, currentIndex) => currentIndex !== index);
mirrorUserStationsToLocalStorage(snapshot.userStations);
const db = await getDb();
if (!db) {
return;
}
const tx = db.transaction('userStations', 'readwrite');
try {
await tx.store.clear();
await Promise.all(
snapshot.userStations.map((entry, currentIndex) => {
const record = entry as { id?: string };
return tx.store.put({
id: record.id || `user-${Date.now()}-${currentIndex}`,
sortOrder: currentIndex,
data: entry,
});
}),
);
await tx.done;
} catch (error) {
persistenceBackend = 'localstorage';
console.warn('Failed to delete user station from IndexedDB, continuing with localStorage mirror.', error);
}
}
export async function deletePersistedUserStationById(id: string | null | undefined) {
if (!id) {
return;
}
const index = snapshot.userStations.findIndex((entry) => {
const record = entry as { id?: string };
return record.id === id;
});
if (index < 0) {
return;
}
await deletePersistedUserStationAt(index);
}
export async function importPlayerPersistence(source: unknown) {
snapshot = sanitizeImportedSnapshot(source);
hydrated = true;
mirrorSettingToLocalStorage('volume', snapshot.volume);
mirrorSettingToLocalStorage('lastStationId', snapshot.lastStationId);
mirrorSettingToLocalStorage('lastStationCountry', snapshot.lastStationCountry);
mirrorSettingToLocalStorage('castBothMode', snapshot.castBothMode);
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts);
mirrorSettingToLocalStorage('recentStationHistory', snapshot.recentStationHistory);
mirrorSettingToLocalStorage('stationHealth', snapshot.stationHealth);
mirrorSettingToLocalStorage('lastExportedAt', snapshot.lastExportedAt);
mirrorSettingToLocalStorage('lastImportedAt', snapshot.lastImportedAt);
mirrorUserStationsToLocalStorage(snapshot.userStations);
const db = await getDb();
if (!db) {
return getPlayerPersistenceSnapshot();
}
const tx = db.transaction(['settings', 'userStations'], 'readwrite');
try {
await tx.objectStore('settings').clear();
await tx.objectStore('userStations').clear();
await Promise.all([
tx.objectStore('settings').put(snapshot.volume, 'volume'),
tx.objectStore('settings').put(snapshot.lastStationId, 'lastStationId'),
tx.objectStore('settings').put(snapshot.lastStationCountry, 'lastStationCountry'),
tx.objectStore('settings').put(snapshot.castBothMode, 'castBothMode'),
tx.objectStore('settings').put(snapshot.favoriteStationIds, 'favoriteStationIds'),
tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'),
tx.objectStore('settings').put(snapshot.recentStationHistory, 'recentStationHistory'),
tx.objectStore('settings').put(snapshot.stationHealth, 'stationHealth'),
tx.objectStore('settings').put(snapshot.lastExportedAt, 'lastExportedAt'),
tx.objectStore('settings').put(snapshot.lastImportedAt, 'lastImportedAt'),
...snapshot.userStations.map((entry, index) => {
const record = entry as { id?: string };
return tx.objectStore('userStations').put({
id: record.id || `user-import-${Date.now()}-${index}`,
sortOrder: index,
data: entry,
});
}),
]);
await tx.done;
} catch (error) {
persistenceBackend = 'localstorage';
console.warn('Failed to import data into IndexedDB, continuing with localStorage mirror.', error);
}
return getPlayerPersistenceSnapshot();
}
export async function clearPlayerPersistence() {
snapshot = {
...DEFAULT_SNAPSHOT,
favoriteStationIds: [],
stationUsageCounts: {},
recentStationHistory: [],
stationHealth: {},
userStations: [],
};
hydrated = true;
clearMirroredLocalStorage();
const db = await getDb();
if (!db) {
return getPlayerPersistenceSnapshot();
}
const tx = db.transaction(['settings', 'userStations'], 'readwrite');
try {
await tx.objectStore('settings').clear();
await tx.objectStore('userStations').clear();
await tx.done;
} catch (error) {
persistenceBackend = 'localstorage';
console.warn('Failed to clear IndexedDB persistence, continuing with localStorage mirror.', error);
}
return getPlayerPersistenceSnapshot();
}

View File

@@ -177,10 +177,6 @@ input:focus-visible,
margin-right: 0;
}
.player-layout.library-collapsed {
/* desktop collapse handled by .sidebar-wrap */
}
.station-library {
position: relative;
min-width: 0;
@@ -261,6 +257,11 @@ input:focus-visible,
gap: 10px;
}
.library-select-sort-icon {
flex: 0 0 auto;
opacity: 0.7;
}
.library-select {
min-width: 0;
position: relative;
@@ -709,6 +710,32 @@ input:focus-visible,
gap: 6px;
}
.station-info-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(255, 132, 132, 0.32);
border-radius: 999px;
background: rgba(255, 132, 132, 0.14);
color: #ffc2c2;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
cursor: help;
user-select: none;
}
.station-info-indicator:focus-visible {
outline: 2px solid rgba(255, 184, 184, 0.62);
outline-offset: 2px;
}
.station-info-indicator-inline {
flex: 0 0 auto;
}
.library-tag {
max-width: 100%;
padding: 4px 8px;
@@ -721,6 +748,24 @@ input:focus-visible,
line-height: 1.2;
}
.library-tag-healthy {
border-color: rgba(132,242,168,0.34);
background: rgba(132,242,168,0.14);
color: #b6ffd0;
}
.library-tag-warning {
border-color: rgba(255, 184, 77, 0.34);
background: rgba(255, 184, 77, 0.14);
color: #ffd99a;
}
.library-tag-proxy {
border-color: rgba(255, 132, 132, 0.32);
background: rgba(255, 132, 132, 0.14);
color: #ffc2c2;
}
.library-tag.muted {
color: var(--text-soft);
}
@@ -1267,6 +1312,82 @@ header {
border-color: rgba(255,255,255,0.28);
}
/* ── Coverflow sparkle effects (box-shadow / border — no ::after needed) ── */
.coverflow-item.icon-flash,
.coverflow-item.icon-gradient-sweep,
.coverflow-item.icon-glow-pulse {
/* Disable transition so CSS animation isn't fought by transition interpolation */
transition: none !important;
}
.coverflow-item.icon-flash {
animation: icon-flash 0.8s ease-out forwards;
}
.coverflow-item.icon-gradient-sweep {
animation: icon-gradient-sweep 1.1s ease-out forwards;
}
.coverflow-item.icon-glow-pulse {
animation: icon-glow-pulse 1.4s ease-out forwards;
}
/* Selected variants — start/end from the accent-tinted baseline */
.coverflow-item.selected.icon-flash {
animation: icon-flash-selected 0.8s ease-out forwards;
}
.coverflow-item.selected.icon-gradient-sweep {
animation: icon-gradient-sweep-selected 1.1s ease-out forwards;
}
.coverflow-item.selected.icon-glow-pulse {
animation: icon-glow-pulse-selected 1.4s ease-out forwards;
}
/* Non-selected */
@keyframes icon-flash {
0% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); }
20% { box-shadow: 0 0 0 3px rgba(255,255,255,0.9), 0 0 22px 4px rgba(255,255,255,0.55); border-color: rgba(255,255,255,0.95); }
55% { box-shadow: 0 0 0 2px rgba(255,255,255,0.4), 0 0 12px 2px rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.5); }
100% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); }
}
@keyframes icon-gradient-sweep {
0% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); background: rgba(255,255,255,0.08); }
15% { border-color: rgba(var(--accent-rgb), 0.9); }
40% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.8), 0 0 24px 6px rgba(var(--accent-rgb), 0.45); background: rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 1); }
80% { box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 12px 28px rgba(0,0,0,0.28); background: rgba(255,255,255,0.08); }
100% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); background: rgba(255,255,255,0.08); }
}
@keyframes icon-glow-pulse {
0% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); }
30% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.85), 0 0 28px 8px rgba(var(--accent-rgb), 0.5); border-color: rgba(var(--accent-rgb), 1); }
65% { box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.4), 0 8px 28px rgba(var(--accent-rgb), 0.25); border-color: rgba(var(--accent-rgb), 0.55); }
100% { box-shadow: 0 12px 28px rgba(0,0,0,0.28); border-color: var(--border); }
}
/* Selected variants */
@keyframes icon-flash-selected {
0% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
20% { box-shadow: 0 0 0 3px rgba(255,255,255,0.95), 0 0 26px 6px rgba(255,255,255,0.5); border-color: rgba(255,255,255,0.95); }
55% { box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.6), 0 0 16px 3px rgba(var(--accent-rgb), 0.35); border-color: rgba(var(--accent-rgb), 0.75); }
100% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
}
@keyframes icon-gradient-sweep-selected {
0% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
40% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 1), 0 0 32px 8px rgba(var(--accent-rgb), 0.6); border-color: rgba(var(--accent-rgb), 1); }
100% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
}
@keyframes icon-glow-pulse-selected {
0% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
30% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 1), 0 0 32px 10px rgba(var(--accent-rgb), 0.65); border-color: rgba(var(--accent-rgb), 1); }
65% { box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.55), 0 8px 28px rgba(var(--accent-rgb), 0.38); border-color: rgba(var(--accent-rgb), 0.7); }
100% { box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.48); }
}
.coverflow-item img {
width: 100%;
height: 100%;
@@ -1361,6 +1482,67 @@ header {
font-size: 0.98rem;
}
.station-health-summary {
display: inline-flex;
align-items: center;
width: fit-content;
margin-top: 12px;
padding: 5px 10px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 999px;
background: rgba(255,255,255,0.06);
color: var(--text-main);
font-size: 0.76rem;
font-weight: 800;
line-height: 1.2;
}
.station-health-summary-info {
padding: 0;
border: 0;
background: transparent;
}
.station-info-indicator-player {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
.station-health-summary-healthy {
border-color: rgba(132,242,168,0.34);
background: rgba(132,242,168,0.14);
color: #b6ffd0;
}
.station-health-summary-warning {
border-color: rgba(255, 184, 77, 0.34);
background: rgba(255, 184, 77, 0.14);
color: #ffd99a;
}
.station-health-summary-proxy {
border-color: rgba(255, 132, 132, 0.32);
background: rgba(255, 132, 132, 0.14);
color: #ffc2c2;
}
.station-health-detail {
margin-top: 7px;
color: var(--text-soft);
font-size: 0.82rem;
line-height: 1.35;
max-width: 54ch;
}
.station-health-detail-warning {
color: #ffd99a;
}
.station-health-detail-proxy {
color: #ffc2c2;
}
.status-indicator-wrap {
display: flex;
flex-wrap: wrap;
@@ -1371,6 +1553,14 @@ header {
font-size: 0.88rem;
}
.managed-catalog-status {
margin-top: 8px;
color: var(--text-soft);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.status-dot {
width: 8px;
height: 8px;
@@ -1404,6 +1594,11 @@ header {
box-shadow: 0 0 14px rgba(143,179,255,0.12);
}
.engine-airplay {
border-color: rgba(77,215,200,0.52);
box-shadow: 0 0 14px rgba(77,215,200,0.14);
}
.engine-html {
border-color: rgba(255,255,255,0.18);
}
@@ -1736,6 +1931,39 @@ input[type=range]::-webkit-slider-thumb {
white-space: nowrap;
}
.station-card-health {
margin-top: 7px;
display: inline-flex;
align-items: center;
width: fit-content;
padding: 3px 8px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 999px;
background: rgba(255,255,255,0.06);
color: var(--text-main);
font-size: 0.7rem;
font-weight: 800;
line-height: 1.2;
}
.station-card-health-healthy {
border-color: rgba(132,242,168,0.34);
background: rgba(132,242,168,0.14);
color: #b6ffd0;
}
.station-card-health-warning {
border-color: rgba(255, 184, 77, 0.34);
background: rgba(255, 184, 77, 0.14);
color: #ffd99a;
}
.station-card-health-proxy {
border-color: rgba(255, 132, 132, 0.32);
background: rgba(255, 132, 132, 0.14);
color: #ffc2c2;
}
.device {
margin-bottom: 10px;
padding: 13px 14px;
@@ -1773,6 +2001,52 @@ input[type=range]::-webkit-slider-thumb {
gap: 8px;
}
.editor-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.editor-tools .btn {
flex: 1 1 160px;
}
.hidden-file-input {
display: none;
}
.editor-note {
margin: 0 0 12px;
color: var(--text-muted);
font-size: 0.82rem;
}
.editor-note strong {
color: var(--text-main);
}
.editor-note-subtle {
margin-top: -4px;
font-size: 0.78rem;
}
.editor-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
margin: -4px 0 16px;
color: var(--text-muted);
font-size: 0.82rem;
}
.editor-checkbox input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
}
.field-row {
margin-bottom: 10px;
}
@@ -1861,6 +2135,12 @@ input[type=range]::-webkit-slider-thumb {
background: rgba(143,179,255,0.14);
}
.cast-btn.cast-airplay-active,
.cast-btn.cast-airplay-active:hover {
border-color: rgba(77,215,200,0.56);
background: rgba(77,215,200,0.15);
}
@media (max-width: 1100px) {
body.library-open {
overflow: hidden;
@@ -1937,7 +2217,7 @@ input[type=range]::-webkit-slider-thumb {
min-height: 100dvh;
width: 100%;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto auto;
grid-template-rows: auto auto auto auto auto auto auto auto;
grid-template-areas:
"header"
"artwork"
@@ -2131,13 +2411,18 @@ input[type=range]::-webkit-slider-thumb {
align-self: center;
}
#station-health-detail {
display: none !important;
}
.artwork-stack {
width: 100%;
gap: 6px;
}
.artwork-container {
width: min(34vw, 132px);
width: min(34vw, 132px, 22dvh);
max-height: min(132px, 22dvh);
padding: 4px;
border-radius: 18px;
}
@@ -2183,7 +2468,7 @@ input[type=range]::-webkit-slider-thumb {
}
.track-info {
min-height: 108px;
min-height: 72px;
align-items: center;
justify-content: center;
text-align: center;
@@ -2363,10 +2648,12 @@ input[type=range]::-webkit-slider-thumb {
.editor-station-actions,
.editor-actions {
width: 100%;
flex-wrap: wrap;
}
.editor-station-actions .btn,
.editor-actions .btn {
.editor-actions .btn,
.editor-tools .btn {
flex: 1;
}
}
@@ -2395,6 +2682,54 @@ input[type=range]::-webkit-slider-thumb {
}
}
@media (min-width: 761px) and (max-height: 860px) {
#station-subtitle,
#managed-catalog-status,
#cast-output-row,
#station-health-detail {
display: none !important;
}
.artwork-container {
width: min(100%, 360px);
}
.track-info h2 {
font-size: clamp(1.9rem, 4vw, 2.8rem);
}
.status-indicator-wrap {
margin-top: 10px;
}
.station-health-summary {
margin-top: 8px;
}
}
@media (max-width: 760px) and (max-height: 650px) {
.artwork-container {
width: min(26vw, 100px, 16dvh);
}
.artwork-coverflow {
height: 56px;
}
.coverflow-item {
width: 50px;
height: 44px;
}
.track-info {
min-height: 72px;
}
.track-info h2 {
font-size: clamp(1.25rem, 7vw, 1.8rem);
}
}
@media (max-width: 380px) {
.glass-card {
padding: 13px;

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

36
sync.sh
View File

@@ -1,28 +1,42 @@
#!/bin/bash
localFolder='/mnt/d/Sites/RadioPlayer/web/dist/.'
set -euo pipefail
repoRoot='/mnt/d/Sites/RadioPlayer/web'
localDistFolder="$repoRoot/dist/."
remoteFolder='/opt/www/virtual/RadioPlayer/'
remoteServer='klevze@server.klevze.si'
run_build() {
if grep -qi microsoft /proc/version 2>/dev/null && command -v powershell.exe >/dev/null 2>&1; then
local windowsRepoRoot
windowsRepoRoot="$(wslpath -w "$repoRoot")"
powershell.exe -NoProfile -Command "Set-Location -LiteralPath '$windowsRepoRoot'; npm run build"
return
fi
npm run build
}
run_build
if [[ "${SKIP_DEPLOY:-0}" == "1" ]]; then
echo "Build completed; skipping deploy because SKIP_DEPLOY=1"
exit 0
fi
rsync -avz \
--chmod=D755,F644 \
--exclude ".phpintel/" \
--exclude "bootstrap/cache/" \
--exclude ".env" \
--exclude "public/hot" \
--exclude "public/files" \
--exclude "node_modules" \
--exclude "aritmija_devTemplate" \
--exclude "web82/" \
--exclude "storage/" \
--exclude "oldSite/" \
--exclude "vendor" \
--exclude "resources/lang" \
--exclude ".git/" \
--exclude ".gemini" \
--exclude ".github" \
--exclude ".vscode" \
-e ssh \
$localFolder \
$localDistFolder \
$remoteServer:$remoteFolder/
echo "Deployed to $remoteServer:$remoteFolder"

View File

@@ -12,5 +12,5 @@
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
"include": ["src/**/*", "scripts/**/*.ts"]
}

View File

@@ -1,11 +1,77 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
import { readFile, stat } from 'node:fs/promises';
const MANAGED_CATALOG_API_PATH = '/api/managed-stations.json';
async function loadManagedCatalogEnvelope(repoRoot) {
const candidates = [
resolve(repoRoot, 'public', 'stations.json'),
resolve(repoRoot, 'dist', 'stations.json'),
resolve(repoRoot, 'stations.json'),
];
let found = null;
for (const p of candidates) {
try {
const s = await stat(p);
if (s.isFile()) { found = { filePath: p, mtime: s.mtime }; break; }
} catch { /* try next */ }
}
if (!found) throw new Error('Managed catalog source file was not found.');
const raw = JSON.parse(await readFile(found.filePath, 'utf8'));
const stations = Array.isArray(raw) ? raw : raw.stations;
return { schemaVersion: 1, updatedAt: found.mtime.toISOString(), stations };
}
const buildStamp = `${Date.now()}`;
function managedCatalogApiPlugin() {
async function handleManagedCatalogRequest(req, res, next) {
if (!req.url) {
next();
return;
}
const requestUrl = new URL(req.url, 'http://127.0.0.1');
if (requestUrl.pathname !== MANAGED_CATALOG_API_PATH) {
next();
return;
}
try {
const payload = await loadManagedCatalogEnvelope(process.cwd());
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('cache-control', 'no-store');
res.end(`${JSON.stringify(payload)}\n`);
} catch (error) {
res.statusCode = 500;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: 'Failed to load managed catalog',
message: error instanceof Error ? error.message : 'Unknown error',
}));
}
}
return {
name: 'managed-catalog-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
void handleManagedCatalogRequest(req, res, next);
});
},
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => {
void handleManagedCatalogRequest(req, res, next);
});
},
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), managedCatalogApiPlugin()],
base: './',
build: {
rollupOptions: {