Add managed catalog sync and player UX improvements
This commit is contained in:
42
README.md
42
README.md
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
25
privacy.html
25
privacy.html
@@ -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
6
public/.htaccess
Normal 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]
|
||||
42
public/api/managed-stations.php
Normal file
42
public/api/managed-stations.php
Normal 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);
|
||||
68103
public/data/radio-stations-sync.json
Normal file
68103
public/data/radio-stations-sync.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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": {}
|
||||
}
|
||||
]
|
||||
|
||||
376
public/sw.js
376
public/sw.js
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
57
src/App.jsx
57
src/App.jsx
@@ -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">
|
||||
|
||||
47
src/main.jsx
47
src/main.jsx
@@ -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;
|
||||
|
||||
1265
src/player.js
1265
src/player.js
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
778
src/storage/playerPersistence.ts
Normal file
778
src/storage/playerPersistence.ts
Normal 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();
|
||||
}
|
||||
351
src/styles.css
351
src/styles.css
@@ -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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
36
sync.sh
36
sync.sh
@@ -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"
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts"]
|
||||
"include": ["src/**/*", "scripts/**/*.ts"]
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user