Compare commits
2 Commits
8bd9106ff3
...
c7fceee11f
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fceee11f | |||
| 10a239a155 |
@@ -158,7 +158,6 @@ public/
|
|||||||
stations.json
|
stations.json
|
||||||
sw.js
|
sw.js
|
||||||
scripts/
|
scripts/
|
||||||
bump-sw-cache-version.mjs
|
|
||||||
cron-refresh-stations.sh
|
cron-refresh-stations.sh
|
||||||
cron-update-stations.sh
|
cron-update-stations.sh
|
||||||
import-radio-stations.ts
|
import-radio-stations.ts
|
||||||
|
|||||||
@@ -51139,6 +51139,57 @@
|
|||||||
"source": "radio-browser",
|
"source": "radio-browser",
|
||||||
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01",
|
||||||
|
"name": "Aktual Dalmacija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualDalmacija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc",
|
||||||
|
"name": "Aktual Galama - Fešta",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualGalama",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9",
|
||||||
|
"name": "Aktual Nostalgija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualNostalgija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
||||||
"name": "Radio1",
|
"name": "Radio1",
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
// NOTE: This service worker is for the web/PWA build.
|
// NOTE: This service worker is for the web/PWA build.
|
||||||
// For development we aggressively unregister SWs in `src/player.js`.
|
// For development we aggressively unregister SWs in `src/player.js`.
|
||||||
//
|
//
|
||||||
// This value is rewritten automatically before each build so deployed clients
|
// This checked-in file stays stable. The Vite web build stamps the matching
|
||||||
// refresh to the newest shell and cached assets.
|
// token in `dist/sw.js` without mutating source files in the repo.
|
||||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777223362895';
|
const CACHE_NAME = 'radioplayer-pwa-v5-__APP_BUILD_ID__';
|
||||||
|
|
||||||
const CORE_ASSETS = [
|
const CORE_ASSETS = [
|
||||||
'./',
|
'./',
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>About RadioPlayer | Online Radio Player by 76bit.net</title>
|
||||||
|
<meta name="description" content="Learn about RadioPlayer, a simple online radio player by 76bit.net for browsing stations, switching quickly, and listening on desktop and mobile.">
|
||||||
|
<meta name="keywords" content="RadioPlayer, online radio player, internet radio, radio stations, radio streaming, 76bit.net, mobile radio player, desktop radio player">
|
||||||
|
<meta property="og:title" content="About RadioPlayer | Online Radio Player by 76bit.net">
|
||||||
|
<meta property="og:description" content="Basic information about RadioPlayer, including what the player does, who made it, and its release date.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="RadioPlayer">
|
||||||
|
<meta property="og:image" content="assets/radioplayer-logo-512.png">
|
||||||
|
<meta property="og:image:alt" content="RadioPlayer logo">
|
||||||
|
<meta name="theme-color" content="#111318">
|
||||||
|
<link rel="manifest" href="manifest.json?v=__BUILD_STAMP__">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page-bg: #111318;
|
||||||
|
--panel: rgba(16, 20, 27, 0.82);
|
||||||
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
|
--text-main: #f7f8fb;
|
||||||
|
--text-muted: rgba(247, 248, 251, 0.72);
|
||||||
|
--accent: #4dd7c8;
|
||||||
|
--accent-2: #ffb45c;
|
||||||
|
--shadow: 0 28px 72px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-height: 100%; background: var(--page-bg); font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text-main);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(77, 215, 200, 0.24), transparent 34%),
|
||||||
|
radial-gradient(circle at 86% 82%, rgba(255, 180, 92, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #111318 0%, #101218 58%, #111821 100%);
|
||||||
|
}
|
||||||
|
.page-shell { width: min(900px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||||
|
.topbar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:20px; }
|
||||||
|
.brand { display:inline-flex; align-items:center; gap:12px; text-decoration:none; color:inherit; }
|
||||||
|
.brand img { width:44px; height:44px; border-radius:14px; box-shadow:0 12px 28px rgba(77, 215, 200, 0.18); }
|
||||||
|
.brand-title { display:block; font-size:1.02rem; font-weight:800; }
|
||||||
|
.brand-subtitle { display:block; color:var(--text-muted); font-size:0.82rem; font-weight:700; }
|
||||||
|
.back-link { padding:0.68rem 0.92rem; border:1px solid var(--border); border-radius:999px; background:rgba(255,255,255,0.06); color:var(--text-main); text-decoration:none; font-size:0.9rem; font-weight:700; }
|
||||||
|
.card { padding: clamp(24px, 4vw, 42px); border:1px solid var(--border); border-radius:28px; background: linear-gradient(180deg, rgba(16,20,27,0.82), rgba(255,255,255,0.035)); box-shadow:var(--shadow); backdrop-filter: blur(22px) saturate(130%); }
|
||||||
|
.eyebrow { margin:0 0 10px; color:var(--accent); font-size:0.78rem; font-weight:800; letter-spacing:0.08em; text-transform:uppercase; }
|
||||||
|
h1 { margin:0; font-size: clamp(2rem, 5vw, 3.25rem); line-height:0.98; }
|
||||||
|
.lead { margin: 16px 0 0; max-width: 60ch; }
|
||||||
|
p, li { color:var(--text-muted); line-height:1.7; }
|
||||||
|
.hero-grid { display:grid; grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr); gap:18px; align-items:start; }
|
||||||
|
.maker-card {
|
||||||
|
display:grid;
|
||||||
|
gap:14px;
|
||||||
|
padding:20px;
|
||||||
|
border:1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius:22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.065), rgba(255,255,255,0.025)),
|
||||||
|
radial-gradient(circle at top left, rgba(77, 215, 200, 0.16), transparent 48%);
|
||||||
|
}
|
||||||
|
.maker-logo-wrap {
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
min-height:132px;
|
||||||
|
padding:20px;
|
||||||
|
border:1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius:18px;
|
||||||
|
background: rgba(8, 12, 18, 0.68);
|
||||||
|
}
|
||||||
|
.maker-logo { width:min(100%, 220px); height:auto; display:block; }
|
||||||
|
.maker-title { margin:0; color:var(--text-main); font-size:1.08rem; }
|
||||||
|
.maker-copy { margin:0; }
|
||||||
|
.maker-meta { display:grid; gap:6px; }
|
||||||
|
.maker-meta p { margin:0; }
|
||||||
|
.sections { display:grid; gap:16px; margin-top:28px; }
|
||||||
|
section { padding:18px 20px; border:1px solid rgba(255,255,255,0.08); border-radius:20px; background: rgba(255,255,255,0.045); }
|
||||||
|
h2 { margin:0 0 10px; font-size:1.05rem; }
|
||||||
|
ul { margin:0; padding-left:1.2rem; }
|
||||||
|
a { color:inherit; }
|
||||||
|
@media (max-width: 820px) { .hero-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 720px) { .page-shell { width:min(100% - 20px, 960px); padding-top:16px; } .topbar { flex-direction:column; align-items:flex-start; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="brand" href="./">
|
||||||
|
<img src="assets/radioplayer-logo-192.png" alt="" aria-hidden="true">
|
||||||
|
<span>
|
||||||
|
<span class="brand-title">RadioPlayer</span>
|
||||||
|
<span class="brand-subtitle">About</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a class="back-link" href="./">Back to player</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="card">
|
||||||
|
<p class="eyebrow">About</p>
|
||||||
|
<div class="hero-grid">
|
||||||
|
<div>
|
||||||
|
<h1>About RadioPlayer</h1>
|
||||||
|
<p class="lead">RadioPlayer is a simple online radio player that helps you find stations, start listening quickly, and move between stations without extra clutter.</p>
|
||||||
|
<p class="lead">It works well on desktop and mobile, keeps the experience clear, and makes it easy to come back and continue listening.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="maker-card" aria-label="76bit">
|
||||||
|
<div class="maker-logo-wrap">
|
||||||
|
<img class="maker-logo" src="images/76bit_logo.webp" alt="76bit logo">
|
||||||
|
</div>
|
||||||
|
<p class="maker-title">Made by 76bit.net</p>
|
||||||
|
<div class="maker-meta">
|
||||||
|
<p class="maker-copy">Release date: April 29, 2026</p>
|
||||||
|
<p class="maker-copy">Simple radio browsing and playback in one place.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sections">
|
||||||
|
<section>
|
||||||
|
<h2>What it does</h2>
|
||||||
|
<p>Browse radio stations, search the list, switch between stations, and start playback with a small and simple interface.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Basic features</h2>
|
||||||
|
<p>The player helps you discover stations, keep favorites close, and return to listening quickly on both phone and desktop screens.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Why people use it</h2>
|
||||||
|
<p>It is made for listeners who want radio to feel fast, clear, and easy to use without unnecessary steps.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Docs | RadioPlayer</title>
|
||||||
|
<meta name="description" content="Simple documentation for using RadioPlayer on desktop and mobile.">
|
||||||
|
<meta name="theme-color" content="#111318">
|
||||||
|
<link rel="manifest" href="manifest.json?v=__BUILD_STAMP__">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page-bg: #111318;
|
||||||
|
--panel: rgba(16, 20, 27, 0.82);
|
||||||
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
|
--text-main: #f7f8fb;
|
||||||
|
--text-muted: rgba(247, 248, 251, 0.72);
|
||||||
|
--accent: #4dd7c8;
|
||||||
|
--accent-2: #ffb45c;
|
||||||
|
--shadow: 0 28px 72px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-height: 100%; background: var(--page-bg); font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text-main);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(77, 215, 200, 0.24), transparent 34%),
|
||||||
|
radial-gradient(circle at 86% 82%, rgba(255, 180, 92, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #111318 0%, #101218 58%, #111821 100%);
|
||||||
|
}
|
||||||
|
.page-shell { width: min(900px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||||
|
.topbar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:20px; }
|
||||||
|
.brand { display:inline-flex; align-items:center; gap:12px; text-decoration:none; color:inherit; }
|
||||||
|
.brand img { width:44px; height:44px; border-radius:14px; box-shadow:0 12px 28px rgba(77, 215, 200, 0.18); }
|
||||||
|
.brand-title { display:block; font-size:1.02rem; font-weight:800; }
|
||||||
|
.brand-subtitle { display:block; color:var(--text-muted); font-size:0.82rem; font-weight:700; }
|
||||||
|
.back-link { padding:0.68rem 0.92rem; border:1px solid var(--border); border-radius:999px; background:rgba(255,255,255,0.06); color:var(--text-main); text-decoration:none; font-size:0.9rem; font-weight:700; }
|
||||||
|
.card { padding: clamp(24px, 4vw, 42px); border:1px solid var(--border); border-radius:28px; background: linear-gradient(180deg, rgba(16,20,27,0.82), rgba(255,255,255,0.035)); box-shadow:var(--shadow); backdrop-filter: blur(22px) saturate(130%); }
|
||||||
|
.eyebrow { margin:0 0 10px; color:var(--accent-2); font-size:0.78rem; font-weight:800; letter-spacing:0.08em; text-transform:uppercase; }
|
||||||
|
h1 { margin:0; font-size: clamp(2rem, 5vw, 3.25rem); line-height:0.98; }
|
||||||
|
.lead { margin: 16px 0 0; max-width: 60ch; }
|
||||||
|
p, li { color:var(--text-muted); line-height:1.7; }
|
||||||
|
.sections { display:grid; gap:16px; margin-top:28px; }
|
||||||
|
section { padding:18px 20px; border:1px solid rgba(255,255,255,0.08); border-radius:20px; background: rgba(255,255,255,0.045); }
|
||||||
|
h2 { margin:0 0 10px; font-size:1.05rem; }
|
||||||
|
h3 { margin:18px 0 8px; font-size:0.96rem; color:var(--text-main); }
|
||||||
|
ul { margin:0; padding-left:1.2rem; }
|
||||||
|
ol { margin:0; padding-left:1.2rem; }
|
||||||
|
.toc {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
.toc a,
|
||||||
|
.inline-link {
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toc a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.grid-two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.45fr) minmax(240px, 0.85fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.065), rgba(255,255,255,0.025)),
|
||||||
|
radial-gradient(circle at top left, rgba(77, 215, 200, 0.16), transparent 48%);
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.summary-list li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 3px solid rgba(77, 215, 200, 0.68);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(77, 215, 200, 0.08);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
a { color:inherit; }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.grid-two { grid-template-columns: 1fr; }
|
||||||
|
.hero-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page-shell { width:min(100% - 20px, 960px); padding-top:16px; }
|
||||||
|
.topbar { flex-direction:column; align-items:flex-start; }
|
||||||
|
.toc { gap: 8px; }
|
||||||
|
.toc a { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="brand" href="./">
|
||||||
|
<img src="assets/radioplayer-logo-192.png" alt="" aria-hidden="true">
|
||||||
|
<span>
|
||||||
|
<span class="brand-title">RadioPlayer</span>
|
||||||
|
<span class="brand-subtitle">Docs</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a class="back-link" href="./">Back to player</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="card">
|
||||||
|
<p class="eyebrow">Docs</p>
|
||||||
|
<div class="hero-grid">
|
||||||
|
<div>
|
||||||
|
<h1>Simple guide to using RadioPlayer.</h1>
|
||||||
|
<p class="lead">RadioPlayer helps you browse stations, start listening quickly, and move between stations with a clean player that works on desktop and mobile.</p>
|
||||||
|
<p class="lead">This page covers the basics only, so it stays easy to read and useful for everyday listening.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="summary-card" aria-label="Quick summary">
|
||||||
|
<span class="summary-label">Quick summary</span>
|
||||||
|
<ul class="summary-list">
|
||||||
|
<li>Browse stations and start playback fast.</li>
|
||||||
|
<li>Use search, favorites, and recents to find stations again.</li>
|
||||||
|
<li>Install the app for easier access on supported devices.</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="toc" aria-label="Documentation sections">
|
||||||
|
<a href="#overview">Overview</a>
|
||||||
|
<a href="#using-the-player">Using the player</a>
|
||||||
|
<a href="#stations-and-countries">Stations and countries</a>
|
||||||
|
<a href="#install-and-offline">Install and offline</a>
|
||||||
|
<a href="#tips">Tips</a>
|
||||||
|
<a href="#help">Help</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sections">
|
||||||
|
<section id="overview">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>RadioPlayer is an online radio player made to keep listening simple. You can open the player, browse available stations, search for something specific, and switch stations quickly without getting lost in a busy interface.</p>
|
||||||
|
<div class="grid-two">
|
||||||
|
<div>
|
||||||
|
<h3>What you can do</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Browse a station library with a simple player view.</li>
|
||||||
|
<li>Search stations and move between them quickly.</li>
|
||||||
|
<li>Keep favorites and recent stations easy to reach.</li>
|
||||||
|
<li>Use the player on desktop and mobile screens.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>What the player focuses on</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Fast access to stations without extra clutter.</li>
|
||||||
|
<li>Clear playback controls that are easy to understand.</li>
|
||||||
|
<li>A lightweight experience that is easy to return to.</li>
|
||||||
|
<li>Simple listening whether you are on phone or desktop.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="using-the-player">
|
||||||
|
<h2>Using the player</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div>
|
||||||
|
<h3>Quick start</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open the player and press play to start the current station.</li>
|
||||||
|
<li>Use next and previous to move through the available station list.</li>
|
||||||
|
<li>Open the station library to search and choose something else.</li>
|
||||||
|
<li>Save stations as favorites for faster access later.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>While you listen</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The player is designed to stay simple while you switch stations.</li>
|
||||||
|
<li>Search helps narrow the station list quickly.</li>
|
||||||
|
<li>Recent stations help you get back to what you heard before.</li>
|
||||||
|
<li>Favorites keep your usual choices close at hand.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Playback and status</h3>
|
||||||
|
<ul>
|
||||||
|
<li>You can control playback, volume, and station changes from the main player.</li>
|
||||||
|
<li>Status messages help explain what the player is doing when needed.</li>
|
||||||
|
<li>The layout stays compact so listening feels quick and direct.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="stations-and-countries">
|
||||||
|
<h2>Stations and countries</h2>
|
||||||
|
<p>RadioPlayer can help you narrow the station list by country, so it is easier to explore the stations you care about instead of scrolling through everything at once.</p>
|
||||||
|
<div class="grid-two">
|
||||||
|
<div>
|
||||||
|
<h3>Finding stations</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use the library to browse the available station list.</li>
|
||||||
|
<li>Search helps you get to a station faster.</li>
|
||||||
|
<li>Country filters can make the list easier to manage.</li>
|
||||||
|
<li>Your listening flow stays simple even with a larger catalog.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Keeping things current</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The station list can refresh so the player stays useful over time.</li>
|
||||||
|
<li>Saved data helps the player open quickly when you come back.</li>
|
||||||
|
<li>Recent and favorite stations help reduce repeated searching.</li>
|
||||||
|
<li>The goal is faster listening with less friction.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="note">If you return often, favorites and recents help make the player feel faster and more personal.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="install-and-offline">
|
||||||
|
<h2>Install and offline</h2>
|
||||||
|
<div class="grid-two">
|
||||||
|
<div>
|
||||||
|
<h3>Install and offline</h3>
|
||||||
|
<ul>
|
||||||
|
<li>You can install the app on supported devices for easier access.</li>
|
||||||
|
<li>Installed use can feel faster and closer to a native app.</li>
|
||||||
|
<li>Some player data can stay available so reopening feels quicker.</li>
|
||||||
|
<li>The app is designed to come back up smoothly after the first visit.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Listening on different devices</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The layout is made to work well on both desktop and mobile.</li>
|
||||||
|
<li>Supported devices and browsers can offer extra playback options.</li>
|
||||||
|
<li>The player keeps controls visible so switching and listening stay easy.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tips">
|
||||||
|
<h2>Tips</h2>
|
||||||
|
<div class="grid-two">
|
||||||
|
<div>
|
||||||
|
<h3>For faster listening</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use favorites for the stations you play most often.</li>
|
||||||
|
<li>Check recents when you want to go back quickly.</li>
|
||||||
|
<li>Use search instead of scrolling long lists.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>For a cleaner experience</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use country filters when the station list feels too wide.</li>
|
||||||
|
<li>Open the player on the device you listen on most.</li>
|
||||||
|
<li>Keep the installed version handy if your browser supports it.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="help">
|
||||||
|
<h2>Help</h2>
|
||||||
|
<p>If you just want the basics, this page is enough: browse, search, play, and save favorites. For general app information, open <a class="inline-link" href="about.html">About</a>. For privacy details, open <a class="inline-link" href="privacy.html">Privacy</a>.</p>
|
||||||
|
<p class="muted">This public docs page is intentionally short so it stays clean and easy to scan.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'RadioWebApp/**',
|
||||||
|
'coverage/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,cjs,jsx,ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
...globals.serviceworker,
|
||||||
|
__APP_BUILD_ID__: 'readonly',
|
||||||
|
__RADIO_COUNTRIES_JSON__: 'readonly',
|
||||||
|
__MANAGED_COUNTRY_CODE__: 'readonly',
|
||||||
|
cast: 'readonly',
|
||||||
|
chrome: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrors: 'none',
|
||||||
|
}],
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/player.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+13
-1
@@ -5,8 +5,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RadioPlayer</title>
|
<title>RadioPlayer</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json?v=__BUILD_STAMP__">
|
||||||
<meta name="description" content="RadioPlayer - stream and cast your favorite radio stations.">
|
<meta name="description" content="RadioPlayer - stream and cast your favorite radio stations.">
|
||||||
|
<link rel="canonical" href="https://radio.76bit.net/">
|
||||||
|
<meta property="og:title" content="RadioPlayer">
|
||||||
|
<meta property="og:description" content="Stream and cast your favorite radio stations with a fast, simple player for desktop and mobile.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://radio.76bit.net/">
|
||||||
|
<meta property="og:site_name" content="RadioPlayer">
|
||||||
|
<meta property="og:image" content="https://radio.76bit.net/assets/radioplayer-logo-512.png">
|
||||||
|
<meta property="og:image:alt" content="RadioPlayer logo">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="RadioPlayer">
|
||||||
|
<meta name="twitter:description" content="Stream and cast your favorite radio stations with a fast, simple player for desktop and mobile.">
|
||||||
|
<meta name="twitter:image" content="https://radio.76bit.net/assets/radioplayer-logo-512.png">
|
||||||
<meta name="theme-color" content="#111318">
|
<meta name="theme-color" content="#111318">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-title" content="RadioPlayer">
|
<meta name="apple-mobile-web-app-title" content="RadioPlayer">
|
||||||
|
|||||||
Generated
+3036
-259
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -5,10 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/bump-sw-cache-version.mjs",
|
"build": "vite build && node scripts/stamp-dist-sw.mjs",
|
||||||
"build": "vite build",
|
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \"scripts/**/*.{js,mjs,ts}\" \"tests/**/*.js\" \"*.js\" \"public/sw.js\"",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"serve:backend": "node server/index.mjs",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"check": "npm run lint && npm run typecheck && npm run test",
|
||||||
"update:stations": "node scripts/update-stations.mjs",
|
"update:stations": "node scripts/update-stations.mjs",
|
||||||
"update:stations:build": "npm run update:stations && npm run build",
|
"update:stations:build": "npm run update:stations && npm run build",
|
||||||
"radio:import": "tsx scripts/import-radio-stations.ts",
|
"radio:import": "tsx scripts/import-radio-stations.ts",
|
||||||
@@ -16,14 +19,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"tsx": "^4.20.6",
|
"@types/node": "^24.13.2",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"eslint": "^10.6.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
|
"globals": "^17.7.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"terser": "^5.48.0",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.10"
|
"typescript-eslint": "^8.62.1",
|
||||||
|
"vite": "^8.1.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const HOST = '127.0.0.1';
|
||||||
|
const PORT = 4174;
|
||||||
|
const BASE_URL = `http://${HOST}:${PORT}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `npm run build && npm run preview -- --host ${HOST} --port ${PORT} --strictPort`,
|
||||||
|
url: BASE_URL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
+49
-52
@@ -1,13 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="sl">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Pravilnik o zasebnosti | RadioPlayer</title>
|
<title>Privacy Policy | RadioPlayer</title>
|
||||||
<meta name="description" content="Pravilnik o zasebnosti za spletno aplikacijo RadioPlayer.">
|
<meta name="description" content="Privacy policy for the RadioPlayer web app.">
|
||||||
<meta name="theme-color" content="#111318">
|
<meta name="theme-color" content="#111318">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json?v=__BUILD_STAMP__">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
@@ -248,103 +248,100 @@
|
|||||||
<img src="assets/radioplayer-logo-192.png" alt="RadioPlayer">
|
<img src="assets/radioplayer-logo-192.png" alt="RadioPlayer">
|
||||||
<span>
|
<span>
|
||||||
<span class="brand-title">RadioPlayer</span>
|
<span class="brand-title">RadioPlayer</span>
|
||||||
<span class="brand-subtitle">Spletni predvajalnik radijskih postaj</span>
|
<span class="brand-subtitle">Web radio player</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="back-link" href="./">Nazaj v aplikacijo</a>
|
<a class="back-link" href="./">Back to the app</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="privacy-card">
|
<main class="privacy-card">
|
||||||
<p class="eyebrow">Pravilnik</p>
|
<p class="eyebrow">Policy</p>
|
||||||
<h1>Pravilnik o zasebnosti</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Ta stran pojasnjuje, katere podatke RadioPlayer shrani samo v vašem brskalniku, zakaj jih uporablja in kako
|
This page explains which data RadioPlayer stores only in your browser, why it uses that data, and how you
|
||||||
jih lahko izbrišete. Besedilo velja za spletno uporabo aplikacije in za njeno PWA namestitev.
|
can remove it. This text applies to the web version of the app and to its PWA installation.
|
||||||
</p>
|
</p>
|
||||||
<div class="meta">Velja od: 29. 4. 2026</div>
|
<div class="meta">Effective date: April 29, 2026</div>
|
||||||
|
|
||||||
<div class="sections">
|
<div class="sections">
|
||||||
<section>
|
<section>
|
||||||
<h2>Kateri podatki nastanejo pri uporabi</h2>
|
<h2>What data is created during use</h2>
|
||||||
<p>
|
<p>
|
||||||
RadioPlayer ne zahteva registracije ali ustvarjanja uporabniškega računa. Aplikacija pa lahko v vašem
|
RadioPlayer does not require registration or a user account. The app may, however, store settings and
|
||||||
brskalniku lokalno shrani nastavitve in sezname, ki jih ustvarite sami, da si jih ob naslednjem obisku
|
lists that you create locally in your browser so they are available again the next time you visit.
|
||||||
zapomni.
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>glasnost predvajanja, zadnjo izbrano postajo in zadnji izbran filter države,</li>
|
<li>playback volume, the last selected station, and the last selected country filter,</li>
|
||||||
<li>seznam priljubljenih postaj in lokalno statistiko predvajanja za prikaz nedavno poslušanih postaj,</li>
|
<li>your list of favorite stations and local playback statistics used to show recently played stations,</li>
|
||||||
<li>postaje, ki jih dodate ročno v urejevalniku,</li>
|
<li>stations that you add manually in the editor,</li>
|
||||||
<li>nastavitev načina oddajanja zvoka pri Google Cast povezavi.</li>
|
<li>the selected audio casting mode for a Google Cast connection.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Kje se podatki hranijo</h2>
|
<h2>Where the data is stored</h2>
|
||||||
<p>
|
<p>
|
||||||
Zgoraj navedeni podatki se hranijo lokalno v vašem brskalniku oziroma v podatkih spletnega mesta na vaši
|
The data listed above is stored locally in your browser or in this website's data on your device. For
|
||||||
napravi. Za trajnejše shranjevanje nastavitev in postaj aplikacija uporablja brskalnikovo lokalno bazo
|
more persistent storage of settings and stations, the app uses the browser's local IndexedDB database.
|
||||||
IndexedDB. Ob prvi uporabi nove različice lahko prenese tudi stare lokalne nastavitve iz prejšnje lokalne
|
When you use a new version for the first time, it may also migrate older local settings from previous
|
||||||
hrambe, da se vaši podatki ohranijo. RadioPlayer teh nastavitev ne pošilja na lasten strežnik in jih brez
|
local storage so your data is preserved. RadioPlayer does not send these settings to its own server and
|
||||||
vašega dejanja ne deli z drugimi uporabniki.
|
does not share them with other users without your action.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Aplikacija uporablja tudi predpomnjenje datotek aplikacije za hitrejši zagon in delovanje brez ponovnega
|
The app also uses cached application files for faster startup and operation without downloading all static
|
||||||
prenosa vseh statičnih datotek. To predpomnjenje lahko kadarkoli izbrišete v nastavitvah brskalnika.
|
files again. You can clear this cache at any time in your browser settings.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Zunanji ponudniki in prenosi</h2>
|
<h2>External providers and streaming</h2>
|
||||||
<p>
|
<p>
|
||||||
Ko zaženete radijski tok, se vaš brskalnik poveže neposredno s strežnikom radijske postaje ali drugega
|
When you start a radio stream, your browser connects directly to the radio station's server or another
|
||||||
ponudnika toka. Ti zunanji ponudniki praviloma prejmejo tehnične podatke, ki so potrebni za prenos, na
|
stream provider. These external providers typically receive the technical data required to deliver the
|
||||||
primer IP naslov, podatke o brskalniku in čas zahteve.
|
stream, such as your IP address, browser details, and the time of the request.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Če uporabite funkcijo Google Cast, se za vzpostavitev in upravljanje oddajanja uporabi Google Cast Web
|
If you use Google Cast, the Google Cast Web Sender SDK is used to establish and manage casting. In that
|
||||||
Sender SDK. Pri tem lahko Google oziroma naprava za predvajanje obdelujeta podatke, potrebne za sejo
|
case, Google or the playback device may process data required for the casting session and media routing.
|
||||||
oddajanja in usmerjanje medijev.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Na napravah Apple lahko aplikacija ponudi tudi AirPlay izbiro izhoda. V tem primeru se medijski tok
|
On Apple devices, the app may also offer AirPlay output selection. In that case, the media stream is
|
||||||
preusmeri na izbrano AirPlay napravo prek zmožnosti vašega brskalnika in operacijskega sistema, zato lahko
|
routed to the selected AirPlay device through your browser and operating system capabilities, so the
|
||||||
zunanja naprava ali Apple obdelujeta podatke, potrebne za vzpostavitev in upravljanje predvajanja.
|
external device or Apple may process data required to establish and manage playback.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Piškotki in analitika</h2>
|
<h2>Cookies and analytics</h2>
|
||||||
<p>
|
<p>
|
||||||
Trenutna različica aplikacije ne uporablja analitičnih skript za profiliranje uporabnikov in ne nastavlja
|
The current version of the app does not use analytics scripts for user profiling and does not set its own
|
||||||
lastnih piškotkov za oglaševanje. Aplikacija za shranjevanje nastavitev uporablja lokalno hrambo brskalnika
|
advertising cookies. To store settings, the app uses the browser's local storage and IndexedDB rather
|
||||||
in IndexedDB, ne klasičnih piškotkov.
|
than traditional cookies.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Dnevniki gostovanja</h2>
|
<h2>Hosting logs</h2>
|
||||||
<p>
|
<p>
|
||||||
Gostovanje spletne strani lahko zaradi varnosti, stabilnosti ali odpravljanja napak samodejno vodi osnovne
|
The website host may automatically keep basic technical access logs for security, stability, or debugging
|
||||||
tehnične dnevnike dostopa. Obseg teh dnevnikov je odvisen od ponudnika gostovanja in ni del nastavitev,
|
purposes. The scope of these logs depends on the hosting provider and is not part of the settings managed
|
||||||
ki jih upravlja uporabniški vmesnik RadioPlayer.
|
by the RadioPlayer user interface.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Kako izbrišete lokalne podatke</h2>
|
<h2>How to delete local data</h2>
|
||||||
<p>
|
<p>
|
||||||
Če želite odstraniti shranjene priljubljene postaje, uporabniške postaje ali druge nastavitve, izbrišite
|
If you want to remove saved favorite stations, custom stations, or other settings, delete this website's
|
||||||
podatke tega spletnega mesta v svojem brskalniku. S tem se izbrišejo lokalne nastavitve in predpomnjene
|
data in your browser. This removes local settings and cached application files from this device.
|
||||||
datoteke aplikacije na tej napravi.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Posodobitve pravilnika</h2>
|
<h2>Policy updates</h2>
|
||||||
<p>
|
<p>
|
||||||
Pravilnik se lahko posodobi, če se spremeni delovanje aplikacije, način gostovanja ali vključeni zunanji
|
This policy may be updated if the app's behavior, hosting setup, or external providers change. The
|
||||||
ponudniki. Na tej strani bo vedno objavljena veljavna različica besedila.
|
current version of the text will always be published on this page.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* Managed catalog API endpoint.
|
* Managed catalog API endpoint.
|
||||||
* Served at /api/managed-stations.json via .htaccess rewrite.
|
* Served at /api/managed-stations.json via .htaccess rewrite.
|
||||||
* Returns the same JSON envelope as the Node backend: { schemaVersion, updatedAt, stations }.
|
* Returns the same JSON envelope used by the Vite dev/preview middleware:
|
||||||
|
* { schemaVersion, updatedAt, stations }.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|||||||
@@ -51139,6 +51139,57 @@
|
|||||||
"source": "radio-browser",
|
"source": "radio-browser",
|
||||||
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01",
|
||||||
|
"name": "Aktual Dalmacija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualDalmacija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc",
|
||||||
|
"name": "Aktual Galama - Fešta",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualGalama",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9",
|
||||||
|
"name": "Aktual Nostalgija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualNostalgija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
||||||
"name": "Radio1",
|
"name": "Radio1",
|
||||||
|
|||||||
@@ -51139,6 +51139,57 @@
|
|||||||
"source": "radio-browser",
|
"source": "radio-browser",
|
||||||
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
"sourceStationUuid": "b3efdf8f-dba0-11e9-a8ba-52543be04c81"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01",
|
||||||
|
"name": "Aktual Dalmacija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualDalmacija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "a3e4b1b3-0e4c-4fd6-9f69-6a4c0b4f7e01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc",
|
||||||
|
"name": "Aktual Galama - Fešta",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualGalama",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "b1d6a9d8-0d3d-49be-9c7b-9a7f2d52b5bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9",
|
||||||
|
"name": "Aktual Nostalgija",
|
||||||
|
"country": "Slovenia",
|
||||||
|
"countryCode": "SI",
|
||||||
|
"language": "slovenian",
|
||||||
|
"tags": [],
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 0,
|
||||||
|
"streamUrl": "https://live.radio.si/AktualNostalgija",
|
||||||
|
"homepage": "https://radio.svet24.si/",
|
||||||
|
"logoUrl": "https://datacache.radio.si/api/radiostations/logo/aktual.svg",
|
||||||
|
"votes": 0,
|
||||||
|
"clickcount": 0,
|
||||||
|
"source": "radio-browser",
|
||||||
|
"sourceStationUuid": "c8e0a7d4-2ce1-4a1e-a1bc-2fbb1c40d8f9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
"id": "3cd65080-c8b5-4d06-86bb-62481e5d6e06",
|
||||||
"name": "Radio1",
|
"name": "Radio1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Facebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -595,30 +595,6 @@
|
|||||||
"lastSongs": "http://data.radio.si/api/lastsongsxml/velenje/json"
|
"lastSongs": "http://data.radio.si/api/lastsongsxml/velenje/json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "AktualK",
|
|
||||||
"name": "Radio Aktual Kum",
|
|
||||||
"slogan": "Narejen za vaša ušesa",
|
|
||||||
"category": "Regional",
|
|
||||||
"country": "SI",
|
|
||||||
"language": "sl",
|
|
||||||
"region": "Zasavje",
|
|
||||||
"tags": [
|
|
||||||
"regional",
|
|
||||||
"aktual"
|
|
||||||
],
|
|
||||||
"website": null,
|
|
||||||
"enabled": true,
|
|
||||||
"assets": {
|
|
||||||
"logo": "http://datacache.radio.si/api/radiostations/logo/aktualk.svg"
|
|
||||||
},
|
|
||||||
"streams": {
|
|
||||||
"audio": "http://live.radio.si/AktualK"
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"lastSongs": "http://data.radio.si/api/lastsongsxml/aktualk/json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "AktualRomantika",
|
"id": "AktualRomantika",
|
||||||
"name": "Radio Aktual - Romantika",
|
"name": "Radio Aktual - Romantika",
|
||||||
|
|||||||
+82
-57
@@ -1,67 +1,23 @@
|
|||||||
// NOTE: This service worker is for the web/PWA build.
|
// NOTE: This service worker is for the web/PWA build.
|
||||||
// For development we aggressively unregister SWs in `src/player.js`.
|
// For development we aggressively unregister SWs in `src/player.js`.
|
||||||
//
|
//
|
||||||
// This value is rewritten automatically before each build so deployed clients
|
// This checked-in file stays stable. The build stamps the token below into
|
||||||
// refresh to the newest shell and cached assets.
|
// `dist/sw.js` so deployed clients still refresh to the newest shell.
|
||||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316';
|
const CACHE_NAME = 'radioplayer-pwa-v5-__APP_BUILD_ID__';
|
||||||
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
|
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1-__APP_BUILD_ID__';
|
||||||
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-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 RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
|
||||||
const STATION_SYNC_TAG = 'radio-stations-refresh';
|
const STATION_SYNC_TAG = '__SW_STATION_SYNC_TAG__';
|
||||||
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
|
const STATION_PERIODIC_SYNC_TAG = '__SW_STATION_PERIODIC_SYNC_TAG__';
|
||||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
|
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = '__SW_MANAGED_CATALOG_PERIODIC_SYNC_TAG__';
|
||||||
const STATION_SYNC_HEADERS = {
|
const STATION_SYNC_HEADERS = {
|
||||||
'content-type': 'application/json; charset=utf-8',
|
'content-type': 'application/json; charset=utf-8',
|
||||||
};
|
};
|
||||||
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||||
|
|
||||||
const RADIO_COUNTRIES = [
|
const RADIO_COUNTRIES = __RADIO_COUNTRIES_JSON__;
|
||||||
{ 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 DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code);
|
const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code);
|
||||||
const MANAGED_COUNTRY_CODE = 'SI';
|
const MANAGED_COUNTRY_CODE = '__MANAGED_COUNTRY_CODE__';
|
||||||
const MAX_TAGS = 12;
|
const MAX_TAGS = 12;
|
||||||
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
|
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
|
||||||
'wma',
|
'wma',
|
||||||
@@ -97,7 +53,7 @@ const DATA_PATHS = new Set([
|
|||||||
const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', 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_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_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_META_URL = new URL('__SW_STATION_SYNC_META_PATH__', self.registration.scope).href;
|
||||||
const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href;
|
const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href;
|
||||||
const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
|
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 BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href;
|
||||||
@@ -377,10 +333,23 @@ async function refreshManagedCatalogCache() {
|
|||||||
const response = await fetch(url, { cache: 'no-store' });
|
const response = await fetch(url, { cache: 'no-store' });
|
||||||
if (isCacheableResponse(response)) {
|
if (isCacheableResponse(response)) {
|
||||||
await managedCatalogCache.put(url, response);
|
await managedCatalogCache.put(url, response);
|
||||||
|
return { updated: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated: false,
|
||||||
|
error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Network unavailable — keep existing cache as-is.
|
// Network unavailable — keep existing cache as-is.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { updated: false, error: 'Managed catalog request failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyClients(message) {
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
await Promise.allSettled(clients.map((client) => client.postMessage(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function respondWithManagedCatalog(request) {
|
async function respondWithManagedCatalog(request) {
|
||||||
@@ -530,7 +499,23 @@ self.addEventListener('activate', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('sync', (event) => {
|
self.addEventListener('sync', (event) => {
|
||||||
if (event.tag !== STATION_SYNC_TAG) return;
|
if (event.tag !== STATION_SYNC_TAG) return;
|
||||||
event.waitUntil(syncRadioStations('background-sync'));
|
event.waitUntil(
|
||||||
|
syncRadioStations('background-sync')
|
||||||
|
.then((meta) => notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'background-sync',
|
||||||
|
meta,
|
||||||
|
}))
|
||||||
|
.catch(async (error) => {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-update-failed',
|
||||||
|
reason: 'background-sync',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
@@ -544,6 +529,14 @@ self.addEventListener('message', (event) => {
|
|||||||
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (syncResult) {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'country-selection-update',
|
||||||
|
meta: syncResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
event.ports?.[0]?.postMessage({
|
event.ports?.[0]?.postMessage({
|
||||||
ok: true,
|
ok: true,
|
||||||
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
||||||
@@ -560,11 +553,43 @@ self.addEventListener('message', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('periodicsync', (event) => {
|
self.addEventListener('periodicsync', (event) => {
|
||||||
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
||||||
event.waitUntil(syncRadioStations('periodic-sync'));
|
event.waitUntil(
|
||||||
|
syncRadioStations('periodic-sync')
|
||||||
|
.then((meta) => notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
meta,
|
||||||
|
}))
|
||||||
|
.catch(async (error) => {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-update-failed',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
||||||
event.waitUntil(refreshManagedCatalogCache());
|
event.waitUntil(
|
||||||
|
refreshManagedCatalogCache().then((result) => {
|
||||||
|
if (!result.updated) {
|
||||||
|
return notifyClients({
|
||||||
|
type: 'managed-catalog-update-failed',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
error: result.error || 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifyClients({
|
||||||
|
type: 'managed-catalog-updated',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
const swPath = resolve(process.cwd(), 'public', 'sw.js');
|
|
||||||
const buildStamp = `${Date.now()}`;
|
|
||||||
|
|
||||||
const source = await readFile(swPath, 'utf8');
|
|
||||||
const next = source.replace(
|
|
||||||
/const CACHE_NAME = 'radioplayer-pwa-v5(?:-[^']+)?';/,
|
|
||||||
`const CACHE_NAME = 'radioplayer-pwa-v5-${buildStamp}';`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (next === source) {
|
|
||||||
throw new Error('Failed to update service worker cache version.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(swPath, next, 'utf8');
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const publicSwPath = resolve(repoRoot, 'public', 'sw.js');
|
||||||
|
const distSwPath = resolve(repoRoot, 'dist', 'sw.js');
|
||||||
|
const distIndexHtmlPath = resolve(repoRoot, 'dist', 'index.html');
|
||||||
|
const radioConfigPath = resolve(repoRoot, 'src', 'radio', 'radioConfig.json');
|
||||||
|
const serviceWorkerConfigPath = resolve(repoRoot, 'src', 'shared', 'serviceWorkerConfig.json');
|
||||||
|
|
||||||
|
const distIndexHtml = await readFile(distIndexHtmlPath, 'utf8');
|
||||||
|
const buildStampMatch = distIndexHtml.match(/assets\/(?:main|player)-(\d+)-/);
|
||||||
|
if (!buildStampMatch) {
|
||||||
|
throw new Error('Unable to infer build stamp from dist/index.html.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStamp = buildStampMatch[1];
|
||||||
|
const radioConfig = JSON.parse(await readFile(radioConfigPath, 'utf8'));
|
||||||
|
const serviceWorkerConfig = JSON.parse(await readFile(serviceWorkerConfigPath, 'utf8'));
|
||||||
|
const swTemplate = await readFile(publicSwPath, 'utf8');
|
||||||
|
|
||||||
|
const stampedSource = swTemplate
|
||||||
|
.replaceAll('__APP_BUILD_ID__', buildStamp)
|
||||||
|
.replace('__RADIO_COUNTRIES_JSON__', JSON.stringify(radioConfig.radioCountries))
|
||||||
|
.replaceAll('__MANAGED_COUNTRY_CODE__', String(radioConfig.managedCountryCode ?? 'SI'))
|
||||||
|
.replaceAll('__SW_STATION_SYNC_TAG__', serviceWorkerConfig.stationSyncTag)
|
||||||
|
.replaceAll('__SW_STATION_PERIODIC_SYNC_TAG__', serviceWorkerConfig.stationPeriodicSyncTag)
|
||||||
|
.replaceAll('__SW_MANAGED_CATALOG_PERIODIC_SYNC_TAG__', serviceWorkerConfig.managedCatalogPeriodicSyncTag)
|
||||||
|
.replaceAll('__SW_STATION_SYNC_META_PATH__', serviceWorkerConfig.stationSyncMetaPath);
|
||||||
|
|
||||||
|
await writeFile(distSwPath, stampedSource, 'utf8');
|
||||||
|
console.log(`[stamp-dist-sw] Updated dist/sw.js with build stamp ${buildStamp}`);
|
||||||
+426
-60
@@ -73,6 +73,74 @@ function AirPlayIcon({ size = 22, className = '' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoIcon({ size = 14 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimerIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="13" r="8" />
|
||||||
|
<path d="M12 9v4l2 2" />
|
||||||
|
<path d="M9 2h6" />
|
||||||
|
<path d="M12 2v3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.25 9a2.75 2.75 0 1 1 4.6 2.02c-.86.76-1.35 1.23-1.35 2.48" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlarmIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="13" r="8" />
|
||||||
|
<path d="M12 10v4l2 1.5" />
|
||||||
|
<path d="m5 4 2.5 2.5" />
|
||||||
|
<path d="M19 4 16.5 6.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsIcon({ size = 14 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M7 3h7l3 3v15H7z" />
|
||||||
|
<path d="M14 3v4h4" />
|
||||||
|
<path d="M9 11h6" />
|
||||||
|
<path d="M9 15h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivacyIcon({ size = 14 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 22s8-4 8-10V6l-8-3-8 3v6c0 6 8 10 8 10Z" />
|
||||||
|
<path d="M9.5 12.5 11.5 14.5 14.5 10.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VolumeIcon() {
|
function VolumeIcon() {
|
||||||
return (
|
return (
|
||||||
<svg id="icon-volume" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg id="icon-volume" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -121,6 +189,13 @@ function HeaderControls() {
|
|||||||
<button id="stations-list-btn" className="icon-btn" aria-label="All Stations" title="All Stations" type="button">
|
<button id="stations-list-btn" className="icon-btn" aria-label="All Stations" title="All Stations" type="button">
|
||||||
<ListIcon />
|
<ListIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button id="session-scheduler-btn" className="icon-btn session-scheduler-btn" aria-label="Sleep timer and wake alarm" title="Sleep timer and wake alarm" type="button">
|
||||||
|
<TimerIcon />
|
||||||
|
<span id="session-scheduler-badge" className="session-scheduler-badge hidden" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button id="shortcut-help-btn" className="icon-btn" aria-label="Keyboard shortcuts" title="Keyboard shortcuts" type="button">
|
||||||
|
<HelpIcon />
|
||||||
|
</button>
|
||||||
<button id="install-app-btn" className="icon-btn install-btn hidden" title="Install app" aria-label="Install app" type="button">
|
<button id="install-app-btn" className="icon-btn install-btn hidden" title="Install app" aria-label="Install app" type="button">
|
||||||
<InstallIcon />
|
<InstallIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -162,11 +237,48 @@ function QuickPickCarousel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SyncToast() {
|
||||||
|
return (
|
||||||
|
<div className="quickpick-sync-info" aria-live="polite">
|
||||||
|
<div id="managed-catalog-status" className="managed-catalog-status hidden" />
|
||||||
|
<div id="catalog-sync-status" className="catalog-sync-status hidden">
|
||||||
|
<span id="catalog-sync-status-text" />
|
||||||
|
<time id="catalog-sync-status-time" className="catalog-sync-status-time" />
|
||||||
|
</div>
|
||||||
|
<div id="catalog-sync-history" className="catalog-sync-history hidden" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LegalLinks() {
|
function LegalLinks() {
|
||||||
return (
|
return (
|
||||||
<nav className="legal-links" aria-label="Legal">
|
<nav className="legal-links" aria-label="Legal">
|
||||||
<a className="legal-link" href={`${import.meta.env.BASE_URL}privacy.html`}>
|
<a
|
||||||
Pravilnik o zasebnosti
|
className="legal-link"
|
||||||
|
data-variant="about"
|
||||||
|
href={`${import.meta.env.BASE_URL}about.html`}
|
||||||
|
aria-label="About RadioPlayer"
|
||||||
|
title="About"
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="legal-link"
|
||||||
|
data-variant="docs"
|
||||||
|
href={`${import.meta.env.BASE_URL}docs.html`}
|
||||||
|
aria-label="Docs"
|
||||||
|
title="Docs"
|
||||||
|
>
|
||||||
|
<DocsIcon />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="legal-link"
|
||||||
|
data-variant="privacy"
|
||||||
|
href={`${import.meta.env.BASE_URL}privacy.html`}
|
||||||
|
aria-label="Pravilnik o zasebnosti"
|
||||||
|
title="Pravilnik o zasebnosti"
|
||||||
|
>
|
||||||
|
<PrivacyIcon />
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@@ -181,24 +293,32 @@ function TrackInfo() {
|
|||||||
<div id="now-title" className="now-title" aria-hidden="false" />
|
<div id="now-title" className="now-title" aria-hidden="false" />
|
||||||
</div>
|
</div>
|
||||||
<p id="station-subtitle" />
|
<p id="station-subtitle" />
|
||||||
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
|
<div className="station-meta-row">
|
||||||
<div id="station-health-detail" className="station-health-detail hidden" aria-live="polite" />
|
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
|
||||||
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
|
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
|
||||||
<span className="status-dot" />
|
<span className="status-dot" />
|
||||||
<span id="status-text" />
|
<span id="status-text" />
|
||||||
<span id="engine-badge" className="engine-badge engine-html" title="HTML5 Audio playback">
|
<span id="engine-badge" className="engine-badge engine-html" title="HTML5 Audio playback">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||||
strokeLinejoin="round" aria-hidden="true">
|
strokeLinejoin="round" aria-hidden="true">
|
||||||
<path d="M4 15V9" />
|
<path d="M4 15V9" />
|
||||||
<path d="M8 19V5" />
|
<path d="M8 19V5" />
|
||||||
<path d="M12 16V8" />
|
<path d="M12 16V8" />
|
||||||
<path d="M16 18V6" />
|
<path d="M16 18V6" />
|
||||||
<path d="M20 15V9" />
|
<path d="M20 15V9" />
|
||||||
</svg>
|
</svg>
|
||||||
<span id="engine-label">HTML5</span>
|
<span id="engine-label">HTML5</span>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
|
<div id="station-health-detail" className="station-health-detail hidden" aria-live="polite" />
|
||||||
|
<section id="song-history-panel" className="song-history-panel hidden" aria-labelledby="song-history-title">
|
||||||
|
<div className="song-history-header">
|
||||||
|
<h3 id="song-history-title">Recent tracks</h3>
|
||||||
|
<button id="song-history-clear-btn" className="session-link-btn" type="button">Clear</button>
|
||||||
|
</div>
|
||||||
|
<ul id="song-history-list" className="song-history-list" />
|
||||||
|
</section>
|
||||||
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
|
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
|
||||||
<span className="cast-output-label">Output:</span>
|
<span className="cast-output-label">Output:</span>
|
||||||
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
|
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
|
||||||
@@ -213,17 +333,6 @@ function TrackInfo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar() {
|
|
||||||
return (
|
|
||||||
<div className="progress-container">
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div className="progress-fill" />
|
|
||||||
<div className="progress-handle" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlayerControls() {
|
function PlayerControls() {
|
||||||
return (
|
return (
|
||||||
<section className="controls-section">
|
<section className="controls-section">
|
||||||
@@ -269,6 +378,22 @@ function VolumeControl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionSummary() {
|
||||||
|
return (
|
||||||
|
<section className="session-summary-section">
|
||||||
|
<button id="session-summary-btn" className="session-summary-btn" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||||
|
<span className="session-summary-icon" aria-hidden="true">
|
||||||
|
<TimerIcon size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="session-summary-copy">
|
||||||
|
<span className="session-summary-label">Session tools</span>
|
||||||
|
<span id="session-summary-text" className="session-summary-text">Sleep timer off · Wake alarm off</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InstallPromptBanner() {
|
function InstallPromptBanner() {
|
||||||
return (
|
return (
|
||||||
<section id="install-prompt-banner" className="install-prompt-banner hidden" aria-live="polite" aria-label="Install RadioPlayer">
|
<section id="install-prompt-banner" className="install-prompt-banner hidden" aria-live="polite" aria-label="Install RadioPlayer">
|
||||||
@@ -421,6 +546,90 @@ function CountrySelectionOverlay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShortcutHelpOverlay() {
|
||||||
|
return (
|
||||||
|
<div id="shortcut-help-overlay" className="overlay hidden" aria-hidden="true">
|
||||||
|
<div className="modal shortcut-help-modal" role="dialog" aria-modal="true" aria-labelledby="shortcutHelpTitle">
|
||||||
|
<h2 id="shortcutHelpTitle">Keyboard shortcuts</h2>
|
||||||
|
<ul className="shortcut-help-list">
|
||||||
|
<li><kbd>Space</kbd><span>Play or stop</span></li>
|
||||||
|
<li><kbd>Left</kbd><span>Previous station</span></li>
|
||||||
|
<li><kbd>Right</kbd><span>Next station</span></li>
|
||||||
|
<li><kbd>M</kbd><span>Mute audio</span></li>
|
||||||
|
<li><kbd>?</kbd><span>Open this help</span></li>
|
||||||
|
<li><kbd>Esc</kbd><span>Close overlays and library</span></li>
|
||||||
|
</ul>
|
||||||
|
<div className="editor-actions">
|
||||||
|
<button id="shortcut-help-close-btn" className="btn cancel" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionSchedulerOverlay() {
|
||||||
|
return (
|
||||||
|
<div id="session-scheduler-overlay" className="overlay hidden" aria-hidden="true">
|
||||||
|
<div className="modal session-scheduler-modal" role="dialog" aria-modal="true" aria-labelledby="sessionSchedulerTitle">
|
||||||
|
<div className="session-scheduler-top">
|
||||||
|
<div>
|
||||||
|
<p className="session-scheduler-eyebrow">Session tools</p>
|
||||||
|
<h2 id="sessionSchedulerTitle">Sleep and wake</h2>
|
||||||
|
<p className="session-scheduler-intro">Set when playback should gently stop, then choose when you want RadioPlayer ready again.</p>
|
||||||
|
</div>
|
||||||
|
<button id="session-scheduler-close-btn" className="icon-btn" aria-label="Close session tools" type="button">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="session-scheduler-grid">
|
||||||
|
<section className="session-panel session-panel-sleep" aria-labelledby="sleepTimerPanelTitle">
|
||||||
|
<div className="session-panel-heading">
|
||||||
|
<span className="session-panel-icon" aria-hidden="true"><TimerIcon size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<h3 id="sleepTimerPanelTitle">Sleep timer</h3>
|
||||||
|
<p>Ideal when you want the stream to stop automatically after you drift off.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="session-tool-actions">
|
||||||
|
<button className="session-chip" data-sleep-minutes="15" type="button">15 min</button>
|
||||||
|
<button className="session-chip" data-sleep-minutes="30" type="button">30 min</button>
|
||||||
|
<button className="session-chip" data-sleep-minutes="60" type="button">60 min</button>
|
||||||
|
<button className="session-chip secondary" data-sleep-minutes="0" type="button">Turn off</button>
|
||||||
|
</div>
|
||||||
|
<div id="sleep-timer-status" className="session-tool-status session-tool-status-strong" aria-live="polite">Off</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="session-panel session-panel-wake" aria-labelledby="wakeAlarmPanelTitle">
|
||||||
|
<div className="session-panel-heading">
|
||||||
|
<span className="session-panel-icon" aria-hidden="true"><AlarmIcon size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<h3 id="wakeAlarmPanelTitle">Wake alarm</h3>
|
||||||
|
<p>Queue the current station to resume later. Best after you have interacted with playback.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="session-tool-actions">
|
||||||
|
<button className="session-chip" data-wake-minutes="15" type="button">15 min</button>
|
||||||
|
<button className="session-chip" data-wake-minutes="30" type="button">30 min</button>
|
||||||
|
<button className="session-chip" data-wake-minutes="60" type="button">60 min</button>
|
||||||
|
<button className="session-chip secondary" data-wake-minutes="0" type="button">Turn off</button>
|
||||||
|
</div>
|
||||||
|
<div id="wake-alarm-status" className="session-tool-status session-tool-status-strong" aria-live="polite">Off</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="session-scheduler-footer">
|
||||||
|
<div id="session-scheduler-footnote" className="session-scheduler-footnote">Wake alarms may still be limited by browser autoplay rules.</div>
|
||||||
|
<button id="session-scheduler-done-btn" className="btn cancel" type="button">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StationLibrary() {
|
function StationLibrary() {
|
||||||
return (
|
return (
|
||||||
<aside id="station-library" className="station-library" aria-label="Station library">
|
<aside id="station-library" className="station-library" aria-label="Station library">
|
||||||
@@ -429,16 +638,49 @@ function StationLibrary() {
|
|||||||
<p className="library-eyebrow">Browse</p>
|
<p className="library-eyebrow">Browse</p>
|
||||||
<h2>Stations</h2>
|
<h2>Stations</h2>
|
||||||
</div>
|
</div>
|
||||||
<button id="station-library-close" className="icon-btn library-close" aria-label="Close station library" type="button">
|
<div className="library-top-actions">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
<button
|
||||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
id="station-library-filters-toggle"
|
||||||
<path d="M18 6 6 18" />
|
className="icon-btn library-filters-toggle"
|
||||||
<path d="m6 6 12 12" />
|
aria-label="Collapse search and filters"
|
||||||
</svg>
|
aria-controls="station-library-controls"
|
||||||
</button>
|
aria-expanded="true"
|
||||||
|
type="button"
|
||||||
|
title="Collapse search and filters"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M7 12h10" />
|
||||||
|
<path d="M10 17h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="station-library-close" className="icon-btn library-close" aria-label="Close station library" type="button">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<button
|
||||||
|
id="clear-managed-catalog-btn"
|
||||||
|
className="icon-btn library-clear-managed"
|
||||||
|
aria-label="Clear persisted managed catalog"
|
||||||
|
title="Clear persisted managed catalog"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 0-9 9" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="library-filter-grid">
|
<div id="station-library-controls" className="library-filter-grid">
|
||||||
<label className="library-search" htmlFor="station-search-input">
|
<label className="library-search" htmlFor="station-search-input">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
@@ -457,7 +699,7 @@ function StationLibrary() {
|
|||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-label="Country filter"
|
aria-label="Country filter"
|
||||||
>
|
>
|
||||||
<img id="station-country-filter-flag" className="library-select-flag" alt="" aria-hidden="true" src="https://flagcdn.com/w20/eu.png" />
|
<span id="station-country-filter-flag" className="library-select-flag" aria-hidden="true">🌐</span>
|
||||||
<span className="library-select-prefix">Filter</span>
|
<span className="library-select-prefix">Filter</span>
|
||||||
<span id="station-country-filter-text" className="library-select-value">All countries</span>
|
<span id="station-country-filter-text" className="library-select-value">All countries</span>
|
||||||
<svg className="library-select-caret" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg className="library-select-caret" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
@@ -467,26 +709,16 @@ function StationLibrary() {
|
|||||||
<div id="station-country-filter-menu" className="library-select-menu" role="listbox" aria-label="Country filter options" />
|
<div id="station-country-filter-menu" className="library-select-menu" role="listbox" aria-label="Country filter options" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="library-select" data-sort-filter>
|
<button id="station-advanced-filters-btn" className="library-filter-settings-btn" aria-label="Open filter settings" title="Filter settings" type="button">
|
||||||
<button
|
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
id="station-sort-btn"
|
<line x1="4" y1="6" x2="20" y2="6" />
|
||||||
className="library-select-trigger"
|
<circle cx="9" cy="6" r="2" />
|
||||||
type="button"
|
<line x1="4" y1="12" x2="20" y2="12" />
|
||||||
aria-haspopup="listbox"
|
<circle cx="15" cy="12" r="2" />
|
||||||
aria-expanded="false"
|
<line x1="4" y1="18" x2="20" y2="18" />
|
||||||
aria-label="Sort stations"
|
<circle cx="11" cy="18" r="2" />
|
||||||
>
|
</svg>
|
||||||
<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">
|
</button>
|
||||||
<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>
|
||||||
|
|
||||||
<div className="library-tabs" role="tablist" aria-label="Station filters">
|
<div className="library-tabs" role="tablist" aria-label="Station filters">
|
||||||
@@ -551,6 +783,136 @@ function StationLibrary() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdvancedFiltersOverlay() {
|
||||||
|
return (
|
||||||
|
<div id="station-advanced-filters-overlay" className="overlay hidden" aria-hidden="true">
|
||||||
|
<div className="modal advanced-filters-modal" role="dialog" aria-modal="true" aria-labelledby="advancedFiltersTitle">
|
||||||
|
<div className="advanced-filters-top">
|
||||||
|
<div>
|
||||||
|
<p className="session-scheduler-eyebrow">Station filters</p>
|
||||||
|
<h2 id="advancedFiltersTitle">More ways to tune the library</h2>
|
||||||
|
</div>
|
||||||
|
<button id="station-advanced-filters-close-btn" className="icon-btn" aria-label="Close filter settings" type="button">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="advanced-filters-grid">
|
||||||
|
<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 className="library-select" data-language-filter>
|
||||||
|
<button
|
||||||
|
id="station-language-filter-btn"
|
||||||
|
className="library-select-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Language filter"
|
||||||
|
>
|
||||||
|
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 5h10" />
|
||||||
|
<path d="M4 9h6" />
|
||||||
|
<path d="M14 5s0 6-5 10" />
|
||||||
|
<path d="M12 15c1.2 1.6 2.9 3 5 4" />
|
||||||
|
<path d="M16 19l4-10 4 10" transform="translate(-2 -2)" />
|
||||||
|
<path d="M15.5 13h5" />
|
||||||
|
</svg>
|
||||||
|
<span className="library-select-prefix">Language</span>
|
||||||
|
<span id="station-language-filter-text" className="library-select-value">All languages</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-language-filter-menu" className="library-select-menu" role="listbox" aria-label="Language filter options" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library-select" data-codec-filter>
|
||||||
|
<button
|
||||||
|
id="station-codec-filter-btn"
|
||||||
|
className="library-select-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Codec filter"
|
||||||
|
>
|
||||||
|
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="2" />
|
||||||
|
<path d="M8 10h8" />
|
||||||
|
<path d="M8 14h5" />
|
||||||
|
</svg>
|
||||||
|
<span className="library-select-prefix">Codec</span>
|
||||||
|
<span id="station-codec-filter-text" className="library-select-value">All codecs</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-codec-filter-menu" className="library-select-menu" role="listbox" aria-label="Codec filter options" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library-select" data-bitrate-filter>
|
||||||
|
<button
|
||||||
|
id="station-bitrate-filter-btn"
|
||||||
|
className="library-select-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Bitrate filter"
|
||||||
|
>
|
||||||
|
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M5 18V9" />
|
||||||
|
<path d="M10 18V5" />
|
||||||
|
<path d="M15 18v-7" />
|
||||||
|
<path d="M20 18V7" />
|
||||||
|
</svg>
|
||||||
|
<span className="library-select-prefix">Bitrate</span>
|
||||||
|
<span id="station-bitrate-filter-text" className="library-select-value">Any bitrate</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-bitrate-filter-menu" className="library-select-menu" role="listbox" aria-label="Bitrate filter options" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="station-health-filter-btn" className="library-select-trigger library-select-toggle" aria-pressed="false" type="button">
|
||||||
|
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 21s-7-4.35-9.5-8.55C.53 8.93 2.84 5 6.97 5c1.97 0 3.36.96 5.03 2.83C13.67 5.96 15.06 5 17.03 5c4.13 0 6.44 3.93 4.47 7.45C19 16.65 12 21 12 21Z" />
|
||||||
|
<path d="m9.3 12.25 1.75 1.75 3.65-4" />
|
||||||
|
</svg>
|
||||||
|
<span className="library-select-prefix">Health</span>
|
||||||
|
<span id="station-health-filter-text" className="library-select-value">All stations</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-actions advanced-filters-actions">
|
||||||
|
<button id="station-advanced-filters-done-btn" className="btn cancel" type="button">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -563,17 +925,21 @@ export default function App() {
|
|||||||
<HeaderControls />
|
<HeaderControls />
|
||||||
<ArtworkPanel />
|
<ArtworkPanel />
|
||||||
<TrackInfo />
|
<TrackInfo />
|
||||||
<ProgressBar />
|
|
||||||
<PlayerControls />
|
<PlayerControls />
|
||||||
<VolumeControl />
|
<VolumeControl />
|
||||||
|
<SessionSummary />
|
||||||
<QuickPickCarousel />
|
<QuickPickCarousel />
|
||||||
<LegalLinks />
|
<LegalLinks />
|
||||||
|
<AdvancedFiltersOverlay />
|
||||||
<StationsOverlay />
|
<StationsOverlay />
|
||||||
<EditorOverlay />
|
<EditorOverlay />
|
||||||
|
<SessionSchedulerOverlay />
|
||||||
|
<ShortcutHelpOverlay />
|
||||||
<CountrySelectionOverlay />
|
<CountrySelectionOverlay />
|
||||||
</main>
|
</main>
|
||||||
<InstallPromptBanner />
|
<InstallPromptBanner />
|
||||||
</div>
|
</div>
|
||||||
|
<SyncToast />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-16
@@ -1,18 +1,56 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
import serviceWorkerConfig from './shared/serviceWorkerConfig.json';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
const APP_BUILD_ID = __APP_BUILD_ID__;
|
||||||
|
|
||||||
const hadServiceWorkerControllerAtLoad = 'serviceWorker' in navigator
|
const hadServiceWorkerControllerAtLoad = 'serviceWorker' in navigator
|
||||||
? Boolean(navigator.serviceWorker.controller)
|
? Boolean(navigator.serviceWorker.controller)
|
||||||
: false;
|
: false;
|
||||||
let hasReloadedForServiceWorkerUpdate = false;
|
let hasReloadedForServiceWorkerUpdate = false;
|
||||||
const STATION_SYNC_TAG = 'radio-stations-refresh';
|
const STATION_SYNC_TAG = serviceWorkerConfig.stationSyncTag;
|
||||||
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
|
const STATION_PERIODIC_SYNC_TAG = serviceWorkerConfig.stationPeriodicSyncTag;
|
||||||
const STATION_PERIODIC_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
|
const STATION_PERIODIC_SYNC_INTERVAL = serviceWorkerConfig.stationPeriodicSyncIntervalMs;
|
||||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
|
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = serviceWorkerConfig.managedCatalogPeriodicSyncTag;
|
||||||
const MANAGED_CATALOG_PERIODIC_SYNC_INTERVAL = 24 * 60 * 60 * 1000;
|
const MANAGED_CATALOG_PERIODIC_SYNC_INTERVAL = serviceWorkerConfig.managedCatalogPeriodicSyncIntervalMs;
|
||||||
|
const STATION_SYNC_CACHE_PREFIX = serviceWorkerConfig.stationSyncCachePrefix;
|
||||||
|
const STATION_SYNC_META_URL = `${import.meta.env.BASE_URL}${serviceWorkerConfig.stationSyncMetaPath}`;
|
||||||
|
|
||||||
|
async function shouldScheduleImmediateStationSync() {
|
||||||
|
if (!('caches' in window)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKeys = await caches.keys();
|
||||||
|
const syncCacheName = cacheKeys.find((cacheName) => cacheName.startsWith(STATION_SYNC_CACHE_PREFIX));
|
||||||
|
if (!syncCacheName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncCache = await caches.open(syncCacheName);
|
||||||
|
const cachedMeta = await syncCache.match(STATION_SYNC_META_URL);
|
||||||
|
if (!cachedMeta?.ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await cachedMeta.json();
|
||||||
|
const syncedAt = typeof payload?.syncedAt === 'string'
|
||||||
|
? Date.parse(payload.syncedAt)
|
||||||
|
: Number.NaN;
|
||||||
|
|
||||||
|
if (!Number.isFinite(syncedAt)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Date.now() - syncedAt) >= STATION_PERIODIC_SYNC_INTERVAL;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Station sync freshness check failed:', error);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function registerStationCatalogSync(registration) {
|
async function registerStationCatalogSync(registration) {
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
@@ -21,8 +59,12 @@ async function registerStationCatalogSync(registration) {
|
|||||||
|
|
||||||
if ('sync' in registration) {
|
if ('sync' in registration) {
|
||||||
try {
|
try {
|
||||||
await registration.sync.register(STATION_SYNC_TAG);
|
if (await shouldScheduleImmediateStationSync()) {
|
||||||
console.debug('ServiceWorker background sync registered:', STATION_SYNC_TAG);
|
await registration.sync.register(STATION_SYNC_TAG);
|
||||||
|
console.debug('ServiceWorker background sync registered:', STATION_SYNC_TAG);
|
||||||
|
} else {
|
||||||
|
console.debug('ServiceWorker background sync skipped: cached station catalog is fresh.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('ServiceWorker background sync registration failed:', error);
|
console.debug('ServiceWorker background sync registration failed:', error);
|
||||||
}
|
}
|
||||||
@@ -68,7 +110,7 @@ function setupServiceWorker() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const swUrl = `${import.meta.env.BASE_URL}sw.js`;
|
const swUrl = `${import.meta.env.BASE_URL}sw.js?v=${encodeURIComponent(APP_BUILD_ID)}`;
|
||||||
navigator.serviceWorker.register(swUrl, { updateViaCache: 'none' })
|
navigator.serviceWorker.register(swUrl, { updateViaCache: 'none' })
|
||||||
.then(async (reg) => {
|
.then(async (reg) => {
|
||||||
try {
|
try {
|
||||||
@@ -102,13 +144,11 @@ setupServiceWorker();
|
|||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
const root = createRoot(rootEl);
|
const root = createRoot(rootEl);
|
||||||
|
|
||||||
flushSync(() => {
|
root.render(
|
||||||
root.render(
|
<React.StrictMode>
|
||||||
<React.StrictMode>
|
<App />
|
||||||
<App />
|
</React.StrictMode>,
|
||||||
</React.StrictMode>,
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('app-loading')?.remove();
|
document.getElementById('app-loading')?.remove();
|
||||||
|
|
||||||
|
|||||||
+1159
-530
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
import serviceWorkerConfig from '../shared/serviceWorkerConfig.json';
|
||||||
|
|
||||||
|
export const STATION_LIBRARY_PAGE_SIZE = 24;
|
||||||
|
export const RECENT_STATION_HISTORY_LIMIT = 40;
|
||||||
|
export const ARTWORK_SHINE_EFFECTS = ['shine-sweep', 'shine-glint', 'shine-flare'];
|
||||||
|
export const ICON_SPARKLE_EFFECTS = ['icon-flash', 'icon-gradient-sweep', 'icon-glow-pulse'];
|
||||||
|
export const RADIO_PLACEHOLDER_LOGO = `${import.meta.env.BASE_URL}images/radio-placeholder.svg`;
|
||||||
|
|
||||||
|
export const IMPORT_EXPORT_SCHEMA = 'radioplayer-local-data';
|
||||||
|
export const IMPORT_EXPORT_VERSION = 1;
|
||||||
|
export const PLAYBACK_START_TIMEOUT_MS = 12000;
|
||||||
|
export const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
|
||||||
|
export const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = serviceWorkerConfig.messages.syncCountries;
|
||||||
|
export const SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE = serviceWorkerConfig.messages.stationCatalogUpdated;
|
||||||
|
export const SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE = serviceWorkerConfig.messages.stationCatalogUpdateFailed;
|
||||||
|
export const SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE = serviceWorkerConfig.messages.managedCatalogUpdated;
|
||||||
|
export const SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE = serviceWorkerConfig.messages.managedCatalogUpdateFailed;
|
||||||
|
|
||||||
|
export const STATION_THEMES = [
|
||||||
|
{ accent: '#4dd7c8', accent2: '#ffb45c', accent3: '#8fb3ff', page: '#171b22', panel: '#111821' },
|
||||||
|
{ accent: '#ff7aa8', accent2: '#ffd166', accent3: '#8fb3ff', page: '#22151d', panel: '#1b131a' },
|
||||||
|
{ accent: '#7ce38b', accent2: '#53c7ff', accent3: '#f0c86a', page: '#132018', panel: '#101a16' },
|
||||||
|
{ accent: '#b28cff', accent2: '#6ee7d8', accent3: '#ff9f6e', page: '#19162a', panel: '#141221' },
|
||||||
|
{ accent: '#ff9f43', accent2: '#4dd7c8', accent3: '#feca57', page: '#241912', panel: '#1c1510' },
|
||||||
|
{ accent: '#6ecbff', accent2: '#b28cff', accent3: '#ff7aa8', page: '#111a25', panel: '#101720' },
|
||||||
|
{ accent: '#f7d36b', accent2: '#ff7a7a', accent3: '#6ee7d8', page: '#211b10', panel: '#1a160e' },
|
||||||
|
{ accent: '#9cff6e', accent2: '#ff8bd1', accent3: '#78a7ff', page: '#14200f', panel: '#111a0e' },
|
||||||
|
{ accent: '#ff6f61', accent2: '#ffd166', accent3: '#80ed99', page: '#241513', panel: '#1c1110' },
|
||||||
|
{ accent: '#64dfdf', accent2: '#c77dff', accent3: '#f6bd60', page: '#101f22', panel: '#0e191c' },
|
||||||
|
{ accent: '#a3cef1', accent2: '#f28482', accent3: '#f6bd60', page: '#121b24', panel: '#101720' },
|
||||||
|
{ accent: '#d0f4de', accent2: '#ff99c8', accent3: '#a9def9', page: '#10201a', panel: '#0e1815' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
export const playerDom = {
|
||||||
|
stationNameEl: document.getElementById('station-name'),
|
||||||
|
stationSubtitleEl: document.getElementById('station-subtitle'),
|
||||||
|
stationHealthSummaryEl: document.getElementById('station-health-summary'),
|
||||||
|
stationHealthDetailEl: document.getElementById('station-health-detail'),
|
||||||
|
sessionSchedulerBtn: document.getElementById('session-scheduler-btn'),
|
||||||
|
sessionSchedulerBadge: document.getElementById('session-scheduler-badge'),
|
||||||
|
sessionSummaryBtn: document.getElementById('session-summary-btn'),
|
||||||
|
sessionSummaryText: document.getElementById('session-summary-text'),
|
||||||
|
sleepTimerStatusEl: document.getElementById('sleep-timer-status'),
|
||||||
|
wakeAlarmStatusEl: document.getElementById('wake-alarm-status'),
|
||||||
|
sleepTimerButtons: document.querySelectorAll('[data-sleep-minutes]'),
|
||||||
|
wakeAlarmButtons: document.querySelectorAll('[data-wake-minutes]'),
|
||||||
|
songHistoryPanelEl: document.getElementById('song-history-panel'),
|
||||||
|
songHistoryListEl: document.getElementById('song-history-list'),
|
||||||
|
songHistoryClearBtn: document.getElementById('song-history-clear-btn'),
|
||||||
|
managedCatalogStatusEl: document.getElementById('managed-catalog-status'),
|
||||||
|
catalogSyncStatusEl: document.getElementById('catalog-sync-status'),
|
||||||
|
catalogSyncStatusTextEl: document.getElementById('catalog-sync-status-text'),
|
||||||
|
catalogSyncStatusTimeEl: document.getElementById('catalog-sync-status-time'),
|
||||||
|
catalogSyncHistoryEl: document.getElementById('catalog-sync-history'),
|
||||||
|
nowPlayingEl: document.getElementById('now-playing'),
|
||||||
|
nowArtistEl: document.getElementById('now-artist'),
|
||||||
|
nowTitleEl: document.getElementById('now-title'),
|
||||||
|
statusTextEl: document.getElementById('status-text'),
|
||||||
|
statusDotEl: document.querySelector('.status-dot'),
|
||||||
|
engineBadgeEl: document.getElementById('engine-badge'),
|
||||||
|
engineLabelEl: document.getElementById('engine-label'),
|
||||||
|
playBtn: document.getElementById('play-btn'),
|
||||||
|
iconPlay: document.getElementById('icon-play'),
|
||||||
|
iconStop: document.getElementById('icon-stop'),
|
||||||
|
prevBtn: document.getElementById('prev-btn'),
|
||||||
|
nextBtn: document.getElementById('next-btn'),
|
||||||
|
volumeSlider: document.getElementById('volume-slider'),
|
||||||
|
volumeValue: document.getElementById('volume-value'),
|
||||||
|
muteBtn: document.getElementById('mute-btn'),
|
||||||
|
iconVolume: document.getElementById('icon-volume'),
|
||||||
|
iconMuted: document.getElementById('icon-muted'),
|
||||||
|
castOverlay: document.getElementById('cast-overlay'),
|
||||||
|
closeOverlayBtn: document.getElementById('close-overlay'),
|
||||||
|
deviceListEl: document.getElementById('device-list'),
|
||||||
|
coverflowStageEl: document.getElementById('artwork-coverflow-stage'),
|
||||||
|
coverflowPrevBtn: document.getElementById('artwork-prev'),
|
||||||
|
coverflowNextBtn: document.getElementById('artwork-next'),
|
||||||
|
artworkPlaceholder: document.querySelector('.artwork-placeholder'),
|
||||||
|
logoTextEl: document.querySelector('.station-logo-text'),
|
||||||
|
logoImgEl: document.getElementById('station-logo-img'),
|
||||||
|
stationLibraryEl: document.getElementById('station-library'),
|
||||||
|
playerLayoutEl: document.querySelector('.player-layout'),
|
||||||
|
stationLibraryListEl: document.getElementById('station-library-list'),
|
||||||
|
stationLibrarySummaryEl: document.getElementById('station-library-summary'),
|
||||||
|
stationSearchInput: document.getElementById('station-search-input'),
|
||||||
|
stationLibraryCloseBtn: document.getElementById('station-library-close'),
|
||||||
|
stationLibraryFiltersToggleBtn: document.getElementById('station-library-filters-toggle'),
|
||||||
|
clearManagedCatalogBtn: document.getElementById('clear-managed-catalog-btn'),
|
||||||
|
stationCategoryListEl: document.getElementById('station-category-list'),
|
||||||
|
stationCountryFilterWrapEl: document.querySelector('[data-country-filter]'),
|
||||||
|
stationCountryFilterBtn: document.getElementById('station-country-filter-btn'),
|
||||||
|
stationCountryFilterMenu: document.getElementById('station-country-filter-menu'),
|
||||||
|
stationCountryFilterText: document.getElementById('station-country-filter-text'),
|
||||||
|
stationCountryFilterFlag: document.getElementById('station-country-filter-flag'),
|
||||||
|
stationAdvancedFiltersBtn: document.getElementById('station-advanced-filters-btn'),
|
||||||
|
stationAdvancedFiltersOverlay: document.getElementById('station-advanced-filters-overlay'),
|
||||||
|
stationAdvancedFiltersCloseBtn: document.getElementById('station-advanced-filters-close-btn'),
|
||||||
|
stationAdvancedFiltersDoneBtn: document.getElementById('station-advanced-filters-done-btn'),
|
||||||
|
countrySelectionOverlay: document.getElementById('country-selection-overlay'),
|
||||||
|
countrySelectionSearchInput: document.getElementById('country-selection-search-input'),
|
||||||
|
countrySelectionSummaryEl: document.getElementById('country-selection-summary'),
|
||||||
|
countrySelectionEmptyEl: document.getElementById('country-selection-empty'),
|
||||||
|
countrySelectionListEl: document.getElementById('country-selection-list'),
|
||||||
|
countrySelectionDefaultsBtn: document.getElementById('country-selection-defaults-btn'),
|
||||||
|
countrySelectionAllBtn: document.getElementById('country-selection-all-btn'),
|
||||||
|
countrySelectionSaveBtn: document.getElementById('country-selection-save-btn'),
|
||||||
|
countrySelectionCancelBtn: document.getElementById('country-selection-cancel-btn'),
|
||||||
|
stationSortBtn: document.getElementById('station-sort-btn'),
|
||||||
|
stationSortText: document.getElementById('station-sort-text'),
|
||||||
|
stationSortMenu: document.getElementById('station-sort-menu'),
|
||||||
|
stationSortWrapEl: document.querySelector('[data-sort-filter]'),
|
||||||
|
stationLibraryPaginationEl: document.getElementById('station-library-pagination'),
|
||||||
|
stationLibraryPagePrevBtn: document.getElementById('station-library-page-prev'),
|
||||||
|
stationLibraryPageNextBtn: document.getElementById('station-library-page-next'),
|
||||||
|
stationLibraryPageInfo: document.getElementById('station-library-page-info'),
|
||||||
|
stationLanguageFilterWrapEl: document.querySelector('[data-language-filter]'),
|
||||||
|
stationLanguageFilterBtn: document.getElementById('station-language-filter-btn'),
|
||||||
|
stationLanguageFilterText: document.getElementById('station-language-filter-text'),
|
||||||
|
stationLanguageFilterMenu: document.getElementById('station-language-filter-menu'),
|
||||||
|
stationCodecFilterWrapEl: document.querySelector('[data-codec-filter]'),
|
||||||
|
stationCodecFilterBtn: document.getElementById('station-codec-filter-btn'),
|
||||||
|
stationCodecFilterText: document.getElementById('station-codec-filter-text'),
|
||||||
|
stationCodecFilterMenu: document.getElementById('station-codec-filter-menu'),
|
||||||
|
stationBitrateFilterWrapEl: document.querySelector('[data-bitrate-filter]'),
|
||||||
|
stationBitrateFilterBtn: document.getElementById('station-bitrate-filter-btn'),
|
||||||
|
stationBitrateFilterText: document.getElementById('station-bitrate-filter-text'),
|
||||||
|
stationBitrateFilterMenu: document.getElementById('station-bitrate-filter-menu'),
|
||||||
|
stationHealthFilterBtn: document.getElementById('station-health-filter-btn'),
|
||||||
|
stationHealthFilterText: document.getElementById('station-health-filter-text'),
|
||||||
|
stationTabBtns: document.querySelectorAll('[data-station-tab]'),
|
||||||
|
installPromptBannerEl: document.getElementById('install-prompt-banner'),
|
||||||
|
installPromptActionBtn: document.getElementById('install-prompt-action'),
|
||||||
|
installPromptDismissBtn: document.getElementById('install-prompt-dismiss'),
|
||||||
|
editBtn: document.getElementById('edit-stations-btn'),
|
||||||
|
stationsListBtn: document.getElementById('stations-list-btn'),
|
||||||
|
shortcutHelpBtn: document.getElementById('shortcut-help-btn'),
|
||||||
|
installAppBtn: document.getElementById('install-app-btn'),
|
||||||
|
castBtn: document.getElementById('cast-btn'),
|
||||||
|
editorOverlay: document.getElementById('editor-overlay'),
|
||||||
|
sessionSchedulerOverlay: document.getElementById('session-scheduler-overlay'),
|
||||||
|
sessionSchedulerCloseBtn: document.getElementById('session-scheduler-close-btn'),
|
||||||
|
sessionSchedulerDoneBtn: document.getElementById('session-scheduler-done-btn'),
|
||||||
|
shortcutHelpOverlay: document.getElementById('shortcut-help-overlay'),
|
||||||
|
shortcutHelpCloseBtn: document.getElementById('shortcut-help-close-btn'),
|
||||||
|
editorCloseBtn: document.getElementById('editor-close-btn'),
|
||||||
|
editorListEl: document.getElementById('editor-list'),
|
||||||
|
editorPersistenceNoteEl: document.getElementById('editor-persistence-note'),
|
||||||
|
editorPersistenceBackendEl: document.getElementById('editor-persistence-backend'),
|
||||||
|
editorBackupActivityNoteEl: document.getElementById('editor-backup-activity-note'),
|
||||||
|
exportUserDataBtn: document.getElementById('export-user-data-btn'),
|
||||||
|
importUserDataBtn: document.getElementById('import-user-data-btn'),
|
||||||
|
importUserDataInput: document.getElementById('import-user-data-input'),
|
||||||
|
resetUserDataBtn: document.getElementById('reset-user-data-btn'),
|
||||||
|
resetUserDataBackupCheckbox: document.getElementById('reset-user-data-backup-checkbox'),
|
||||||
|
addStationForm: document.getElementById('add-station-form'),
|
||||||
|
usTitle: document.getElementById('us_title'),
|
||||||
|
usUrl: document.getElementById('us_url'),
|
||||||
|
usLogo: document.getElementById('us_logo'),
|
||||||
|
usWww: document.getElementById('us_www'),
|
||||||
|
usId: document.getElementById('us_id'),
|
||||||
|
castOutputRow: document.getElementById('cast-output-row'),
|
||||||
|
castOutputBtn: document.getElementById('cast-output-btn'),
|
||||||
|
castOutputText: document.getElementById('cast-output-text'),
|
||||||
|
};
|
||||||
|
|
||||||
|
playerDom.castBtnCastIcon = playerDom.castBtn?.querySelector('svg:not(.hidden)') ?? null;
|
||||||
|
playerDom.castBtnAirPlayIcon = playerDom.castBtn?.querySelectorAll('svg')?.[1] ?? null;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { STATION_THEMES } from './constants.js';
|
||||||
|
|
||||||
|
export function prefersReducedMotion() {
|
||||||
|
try {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobileViewport() {
|
||||||
|
return window.matchMedia('(max-width: 760px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStandalonePwa() {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|| window.matchMedia('(display-mode: fullscreen)').matches
|
||||||
|
|| window.navigator.standalone === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobileInstallViewport() {
|
||||||
|
return window.matchMedia('(max-width: 760px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHttpsIfHttp(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '';
|
||||||
|
return url.startsWith('http://') ? (`https://${url.slice('http://'.length)}`) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashString(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const normalized = String(hex || '').replace('#', '').trim();
|
||||||
|
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
|
||||||
|
const num = parseInt(normalized, 16);
|
||||||
|
return `${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStationTheme(station) {
|
||||||
|
const rawTheme = station?.raw?.theme || station?.theme || {};
|
||||||
|
const key = `${station?.id || ''}:${station?.name || station?.title || ''}`;
|
||||||
|
const fallback = STATION_THEMES[hashString(key) % STATION_THEMES.length];
|
||||||
|
return {
|
||||||
|
accent: rawTheme.accent || rawTheme.primary || station?.raw?.color || fallback.accent,
|
||||||
|
accent2: rawTheme.accent2 || rawTheme.secondary || fallback.accent2,
|
||||||
|
accent3: rawTheme.accent3 || fallback.accent3,
|
||||||
|
page: rawTheme.page || rawTheme.background || fallback.page,
|
||||||
|
panel: rawTheme.panel || fallback.panel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyStationTheme(station) {
|
||||||
|
if (!station) return;
|
||||||
|
const theme = getStationTheme(station);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--accent', theme.accent);
|
||||||
|
root.style.setProperty('--accent-2', theme.accent2);
|
||||||
|
root.style.setProperty('--accent-3', theme.accent3);
|
||||||
|
root.style.setProperty('--page-bg', theme.page);
|
||||||
|
root.style.setProperty('--theme-panel', theme.panel);
|
||||||
|
root.style.setProperty('--accent-rgb', hexToRgb(theme.accent) || '77, 215, 200');
|
||||||
|
root.style.setProperty('--accent-2-rgb', hexToRgb(theme.accent2) || '255, 180, 92');
|
||||||
|
root.style.setProperty('--accent-3-rgb', hexToRgb(theme.accent3) || '143, 179, 255');
|
||||||
|
root.style.setProperty('--theme-page-rgb', hexToRgb(theme.page) || '17, 19, 24');
|
||||||
|
root.style.setProperty('--theme-panel-rgb', hexToRgb(theme.panel) || '16, 20, 27');
|
||||||
|
root.style.setProperty('--accent-glow', `rgba(${hexToRgb(theme.accent) || '77, 215, 200'}, 0.34)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStationStreamUrl(station) {
|
||||||
|
if (!station) return '';
|
||||||
|
return station.streamUrl
|
||||||
|
|| station.url
|
||||||
|
|| station.streams?.audio
|
||||||
|
|| station.streams?.primary
|
||||||
|
|| station.liveAudio
|
||||||
|
|| station.liveStream
|
||||||
|
|| station.liveVideo
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStationHomepage(station) {
|
||||||
|
if (!station) return '';
|
||||||
|
return station.url
|
||||||
|
|| station.homepage
|
||||||
|
|| station.website
|
||||||
|
|| station.www
|
||||||
|
|| station.raw?.homepage
|
||||||
|
|| station.raw?.website
|
||||||
|
|| station.raw?.www
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStationVideoUrl(station) {
|
||||||
|
return station?.streams?.video || station?.liveVideo || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStationMetadataUrl(station) {
|
||||||
|
return station?.metadata?.lastSongs
|
||||||
|
|| station?.currentSong
|
||||||
|
|| station?.lastSongs
|
||||||
|
|| station?.raw?.metadata?.lastSongs
|
||||||
|
|| station?.raw?.currentSong
|
||||||
|
|| station?.raw?.lastSongs
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStationRecord(station, isUserStation = false) {
|
||||||
|
const name = station?.name || station?.title || station?.id || 'Unknown';
|
||||||
|
const url = getStationStreamUrl(station);
|
||||||
|
const bitrate = Number(station?.bitrate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: station?.id || `${isUserStation ? 'user' : 'station'}-${name.replace(/\s+/g, '-')}`,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
logo: station?.logoUrl || station?.assets?.logo || station?.logo || '',
|
||||||
|
poster: station?.assets?.poster || station?.poster || '',
|
||||||
|
slogan: station?.slogan || station?.description || station?.defaultText || '',
|
||||||
|
category: station?.category || station?.genre || '',
|
||||||
|
country: station?.country || '',
|
||||||
|
countryCode: station?.countryCode || '',
|
||||||
|
language: station?.language || '',
|
||||||
|
region: station?.region || '',
|
||||||
|
tags: Array.isArray(station?.tags) ? station.tags : [],
|
||||||
|
codec: station?.codec || '',
|
||||||
|
bitrate: Number.isFinite(bitrate) && bitrate > 0 ? bitrate : null,
|
||||||
|
homepage: station?.homepage || station?.website || station?.www || '',
|
||||||
|
website: station?.website || station?.homepage || station?.www || '',
|
||||||
|
metadata: station?.metadata || (station?.lastSongs ? { lastSongs: station.lastSongs } : {}),
|
||||||
|
streams: station?.streams || {
|
||||||
|
audio: url,
|
||||||
|
...(getStationVideoUrl(station) ? { video: getStationVideoUrl(station) } : {}),
|
||||||
|
},
|
||||||
|
enabled: typeof station?.enabled === 'boolean' ? station.enabled : true,
|
||||||
|
source: station?.source || station?.raw?.source || '',
|
||||||
|
sourceStationUuid: station?.sourceStationUuid || station?.raw?.sourceStationUuid || '',
|
||||||
|
raw: station,
|
||||||
|
_user: isUserStation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetadataFetchUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '';
|
||||||
|
|
||||||
|
const safeUrl = toHttpsIfHttp(url) || url;
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(safeUrl);
|
||||||
|
if (parsed.hostname === 'data.radio.si') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return safeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(safeUrl);
|
||||||
|
if (parsed.hostname === 'data.radio.si') {
|
||||||
|
return `/radio-si-data${parsed.pathname}${parsed.search}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return safeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueNonEmpty(urls) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const url of urls) {
|
||||||
|
if (!url || typeof url !== 'string') continue;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setImgWithFallback(imgEl, urls, onFinalError) {
|
||||||
|
const candidates = uniqueNonEmpty(urls);
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
if (!imgEl || candidates.length === 0) {
|
||||||
|
if (imgEl) {
|
||||||
|
imgEl.onload = null;
|
||||||
|
imgEl.onerror = null;
|
||||||
|
imgEl.src = '';
|
||||||
|
}
|
||||||
|
if (onFinalError) onFinalError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryNext = () => {
|
||||||
|
if (index >= candidates.length) {
|
||||||
|
if (onFinalError) onFinalError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imgEl.src = candidates[index++];
|
||||||
|
};
|
||||||
|
|
||||||
|
imgEl.onerror = tryNext;
|
||||||
|
try {
|
||||||
|
imgEl.referrerPolicy = 'no-referrer';
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
tryNext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeStationRecord, toHttpsIfHttp } from './stationHelpers.js';
|
||||||
|
|
||||||
|
describe('stationHelpers', () => {
|
||||||
|
it('upgrades plain http stream URLs to https', () => {
|
||||||
|
expect(toHttpsIfHttp('http://example.com/stream')).toBe('https://example.com/stream');
|
||||||
|
expect(toHttpsIfHttp('https://example.com/stream')).toBe('https://example.com/stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes station records into the player shape', () => {
|
||||||
|
const station = normalizeStationRecord({
|
||||||
|
id: 'station-1',
|
||||||
|
name: 'Radio Test',
|
||||||
|
streamUrl: 'https://example.com/live',
|
||||||
|
tags: ['news'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(station.id).toBe('station-1');
|
||||||
|
expect(station.name).toBe('Radio Test');
|
||||||
|
expect(station.url).toBe('https://example.com/live');
|
||||||
|
expect(station.streams.audio).toBe('https://example.com/live');
|
||||||
|
expect(station.tags).toEqual(['news']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
|
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
|
||||||
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadPersistedManagedStationCatalog,
|
||||||
|
persistManagedStationCatalog,
|
||||||
|
} from '../storage/stationCatalogPersistence.js';
|
||||||
|
|
||||||
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
||||||
|
|
||||||
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
||||||
@@ -44,10 +49,26 @@ function setLastManagedCatalogSource(source: ManagedCatalogSource) {
|
|||||||
lastManagedCatalogSource = source;
|
lastManagedCatalogSource = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeManagedCatalogSource(source: unknown): ManagedCatalogSource {
|
||||||
|
return source === 'remote' || source === 'cached-remote' || source === 'bundled'
|
||||||
|
? source
|
||||||
|
: 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
||||||
return lastManagedCatalogSource;
|
return lastManagedCatalogSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedManagedStations(): Promise<unknown[] | null> {
|
||||||
|
const persisted = await loadPersistedManagedStationCatalog();
|
||||||
|
if (!persisted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastManagedCatalogSource(normalizeManagedCatalogSource(persisted.source));
|
||||||
|
return normalizeManagedStationsPayload(persisted.stations);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadManagedStations(): Promise<unknown[]> {
|
export async function loadManagedStations(): Promise<unknown[]> {
|
||||||
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
||||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
||||||
@@ -92,6 +113,7 @@ export async function loadManagedStations(): Promise<unknown[]> {
|
|||||||
throw new Error(`Failed to load managed stations: ${response.status}`);
|
throw new Error(`Failed to load managed stations: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stations = await response.json();
|
const stations = normalizeManagedStationsPayload(await response.json());
|
||||||
return normalizeManagedStationsPayload(stations);
|
await persistManagedStationCatalog(stations, getLastManagedCatalogSource());
|
||||||
|
return stations;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { RadioStation } from './radioTypes.js';
|
import type { RadioStation } from './radioTypes.js';
|
||||||
|
import {
|
||||||
|
loadPersistedRadioStationCatalog,
|
||||||
|
persistRadioStationCatalog,
|
||||||
|
} from '../storage/stationCatalogPersistence.js';
|
||||||
|
|
||||||
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
||||||
|
const APP_BUILD_ID = __APP_BUILD_ID__;
|
||||||
|
|
||||||
async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response | null> {
|
async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response | null> {
|
||||||
if (typeof window === 'undefined' || !('caches' in window)) {
|
if (typeof window === 'undefined' || !('caches' in window)) {
|
||||||
@@ -21,9 +26,14 @@ async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedRadioStations(): Promise<RadioStation[] | null> {
|
||||||
|
const persisted = await loadPersistedRadioStationCatalog();
|
||||||
|
return Array.isArray(persisted) ? persisted as RadioStation[] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadRadioStations(): Promise<RadioStation[]> {
|
export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||||
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.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 bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json?v=${encodeURIComponent(APP_BUILD_ID)}`;
|
||||||
const hasServiceWorkerController = typeof navigator !== 'undefined'
|
const hasServiceWorkerController = typeof navigator !== 'undefined'
|
||||||
&& 'serviceWorker' in navigator
|
&& 'serviceWorker' in navigator
|
||||||
&& Boolean(navigator.serviceWorker.controller);
|
&& Boolean(navigator.serviceWorker.controller);
|
||||||
@@ -43,5 +53,7 @@ export async function loadRadioStations(): Promise<RadioStation[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stations = (await response.json()) as RadioStation[];
|
const stations = (await response.json()) as RadioStation[];
|
||||||
return Array.isArray(stations) ? stations : [];
|
const normalizedStations = Array.isArray(stations) ? stations : [];
|
||||||
|
await persistRadioStationCatalog(normalizedStations);
|
||||||
|
return normalizedStations;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"managedCountryCode": "SI",
|
||||||
|
"radioCountries": [
|
||||||
|
{ "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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radioCountries.js';
|
||||||
|
|
||||||
|
describe('radioCountries', () => {
|
||||||
|
it('includes the managed country in the default synced list', () => {
|
||||||
|
expect(radioCountries.some((country) => country.code === managedCountryCode)).toBe(true);
|
||||||
|
expect(defaultSelectedRadioCountryCodes).toContain(managedCountryCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,50 +1,8 @@
|
|||||||
export const radioCountries = [
|
import radioConfig from './radioConfig.json' with { type: 'json' };
|
||||||
{ 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' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const managedCountryCode = 'SI';
|
export const radioCountries = radioConfig.radioCountries;
|
||||||
|
|
||||||
|
export const managedCountryCode = radioConfig.managedCountryCode;
|
||||||
export const defaultSelectedRadioCountryCodes = radioCountries.map((country) => country.code);
|
export const defaultSelectedRadioCountryCodes = radioCountries.map((country) => country.code);
|
||||||
|
|
||||||
export type RadioCountry = (typeof radioCountries)[number];
|
export type RadioCountry = (typeof radioCountries)[number];
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"stationSyncTag": "radio-stations-refresh",
|
||||||
|
"stationPeriodicSyncTag": "radio-stations-periodic-refresh",
|
||||||
|
"managedCatalogPeriodicSyncTag": "radioplayer-managed-catalog-refresh",
|
||||||
|
"stationPeriodicSyncIntervalMs": 43200000,
|
||||||
|
"managedCatalogPeriodicSyncIntervalMs": 86400000,
|
||||||
|
"stationSyncCachePrefix": "radioplayer-station-sync-",
|
||||||
|
"stationSyncMetaPath": "data/radio-stations-sync-meta.json",
|
||||||
|
"messages": {
|
||||||
|
"syncCountries": "set-sync-countries",
|
||||||
|
"stationCatalogUpdated": "station-catalog-updated",
|
||||||
|
"stationCatalogUpdateFailed": "station-catalog-update-failed",
|
||||||
|
"managedCatalogUpdated": "managed-catalog-updated",
|
||||||
|
"managedCatalogUpdateFailed": "managed-catalog-update-failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ export type PersistedRecentStationEntry = {
|
|||||||
country: string | null;
|
country: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PersistedSongHistoryEntry = {
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
seenAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PersistedSettings = {
|
type PersistedSettings = {
|
||||||
volume: number | null;
|
volume: number | null;
|
||||||
lastStationId: string | null;
|
lastStationId: string | null;
|
||||||
@@ -28,6 +34,7 @@ type PersistedSettings = {
|
|||||||
favoriteStationIds: string[];
|
favoriteStationIds: string[];
|
||||||
stationUsageCounts: Record<string, number>;
|
stationUsageCounts: Record<string, number>;
|
||||||
recentStationHistory: PersistedRecentStationEntry[];
|
recentStationHistory: PersistedRecentStationEntry[];
|
||||||
|
stationSongHistory: Record<string, PersistedSongHistoryEntry[]>;
|
||||||
stationHealth: Record<string, PersistedStationHealth>;
|
stationHealth: Record<string, PersistedStationHealth>;
|
||||||
lastExportedAt: string | null;
|
lastExportedAt: string | null;
|
||||||
lastImportedAt: string | null;
|
lastImportedAt: string | null;
|
||||||
@@ -48,6 +55,7 @@ type SettingRecordMap = {
|
|||||||
favoriteStationIds: string[];
|
favoriteStationIds: string[];
|
||||||
stationUsageCounts: Record<string, number>;
|
stationUsageCounts: Record<string, number>;
|
||||||
recentStationHistory: PersistedRecentStationEntry[];
|
recentStationHistory: PersistedRecentStationEntry[];
|
||||||
|
stationSongHistory: Record<string, PersistedSongHistoryEntry[]>;
|
||||||
stationHealth: Record<string, PersistedStationHealth>;
|
stationHealth: Record<string, PersistedStationHealth>;
|
||||||
lastExportedAt: string | null;
|
lastExportedAt: string | null;
|
||||||
lastImportedAt: string | null;
|
lastImportedAt: string | null;
|
||||||
@@ -88,6 +96,7 @@ const DEFAULT_SNAPSHOT: PersistedSnapshot = {
|
|||||||
favoriteStationIds: [],
|
favoriteStationIds: [],
|
||||||
stationUsageCounts: {},
|
stationUsageCounts: {},
|
||||||
recentStationHistory: [],
|
recentStationHistory: [],
|
||||||
|
stationSongHistory: {},
|
||||||
stationHealth: {},
|
stationHealth: {},
|
||||||
lastExportedAt: null,
|
lastExportedAt: null,
|
||||||
lastImportedAt: null,
|
lastImportedAt: null,
|
||||||
@@ -103,6 +112,7 @@ const LOCAL_STORAGE_KEYS = {
|
|||||||
favoriteStationIds: 'favoriteStationIds',
|
favoriteStationIds: 'favoriteStationIds',
|
||||||
stationUsageCounts: 'stationUsageCounts',
|
stationUsageCounts: 'stationUsageCounts',
|
||||||
recentStationHistory: 'recentStationHistory',
|
recentStationHistory: 'recentStationHistory',
|
||||||
|
stationSongHistory: 'stationSongHistory',
|
||||||
stationHealth: 'stationHealth',
|
stationHealth: 'stationHealth',
|
||||||
lastExportedAt: 'lastExportedAt',
|
lastExportedAt: 'lastExportedAt',
|
||||||
lastImportedAt: 'lastImportedAt',
|
lastImportedAt: 'lastImportedAt',
|
||||||
@@ -238,6 +248,43 @@ function sanitizeRecentStationHistory(value: unknown): PersistedRecentStationEnt
|
|||||||
.filter((entry): entry is PersistedRecentStationEntry => Boolean(entry));
|
.filter((entry): entry is PersistedRecentStationEntry => Boolean(entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeSongHistoryEntry(value: unknown): PersistedSongHistoryEntry | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
const artist = typeof input.artist === 'string' ? input.artist.trim() : '';
|
||||||
|
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
||||||
|
const seenAt = sanitizeIsoDate(input.seenAt);
|
||||||
|
|
||||||
|
if (!artist || !title || !seenAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { artist, title, seenAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStationSongHistoryMap(value: unknown): Record<string, PersistedSongHistoryEntry[]> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([stationId, entries]) => {
|
||||||
|
const history = Array.isArray(entries)
|
||||||
|
? entries
|
||||||
|
.map((entry) => sanitizeSongHistoryEntry(entry))
|
||||||
|
.filter((entry): entry is PersistedSongHistoryEntry => Boolean(entry))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [stationId, history] as const;
|
||||||
|
})
|
||||||
|
.filter(([stationId, history]) => Boolean(stationId) && history.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function cloneStationHealthMap(map: Record<string, PersistedStationHealth>) {
|
function cloneStationHealthMap(map: Record<string, PersistedStationHealth>) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(map).map(([stationId, record]) => [stationId, { ...record }]),
|
Object.entries(map).map(([stationId, record]) => [stationId, { ...record }]),
|
||||||
@@ -248,6 +295,12 @@ function cloneRecentStationHistory(history: PersistedRecentStationEntry[]) {
|
|||||||
return history.map((entry) => ({ ...entry }));
|
return history.map((entry) => ({ ...entry }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneSongHistoryMap(history: Record<string, PersistedSongHistoryEntry[]>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(history).map(([stationId, entries]) => [stationId, entries.map((entry) => ({ ...entry }))]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getLocalStorage(): Storage | null {
|
function getLocalStorage(): Storage | null {
|
||||||
try {
|
try {
|
||||||
return globalThis.localStorage ?? null;
|
return globalThis.localStorage ?? null;
|
||||||
@@ -293,6 +346,9 @@ function mirrorSettingToLocalStorage<K extends SettingKey>(key: K, value: Settin
|
|||||||
case 'recentStationHistory':
|
case 'recentStationHistory':
|
||||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.recentStationHistory, JSON.stringify(Array.isArray(value) ? value : []));
|
writeLocalStorageValue(LOCAL_STORAGE_KEYS.recentStationHistory, JSON.stringify(Array.isArray(value) ? value : []));
|
||||||
break;
|
break;
|
||||||
|
case 'stationSongHistory':
|
||||||
|
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationSongHistory, JSON.stringify(value && typeof value === 'object' ? value : {}));
|
||||||
|
break;
|
||||||
case 'stationHealth':
|
case 'stationHealth':
|
||||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationHealth, JSON.stringify(value && typeof value === 'object' ? value : {}));
|
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationHealth, JSON.stringify(value && typeof value === 'object' ? value : {}));
|
||||||
break;
|
break;
|
||||||
@@ -357,6 +413,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const recentStationHistory = sanitizeRecentStationHistory(input.recentStationHistory);
|
const recentStationHistory = sanitizeRecentStationHistory(input.recentStationHistory);
|
||||||
|
const stationSongHistory = sanitizeStationSongHistoryMap(input.stationSongHistory);
|
||||||
const stationHealth = sanitizeStationHealthMap(input.stationHealth);
|
const stationHealth = sanitizeStationHealthMap(input.stationHealth);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -368,6 +425,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
|||||||
favoriteStationIds,
|
favoriteStationIds,
|
||||||
stationUsageCounts,
|
stationUsageCounts,
|
||||||
recentStationHistory,
|
recentStationHistory,
|
||||||
|
stationSongHistory,
|
||||||
stationHealth,
|
stationHealth,
|
||||||
lastExportedAt,
|
lastExportedAt,
|
||||||
lastImportedAt,
|
lastImportedAt,
|
||||||
@@ -396,6 +454,7 @@ function readLegacyLocalStorage(): PersistedSnapshot {
|
|||||||
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
|
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
|
||||||
stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}),
|
stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}),
|
||||||
recentStationHistory: sanitizeRecentStationHistory(safeJsonParse<unknown[]>(storage?.getItem('recentStationHistory') ?? null, [])),
|
recentStationHistory: sanitizeRecentStationHistory(safeJsonParse<unknown[]>(storage?.getItem('recentStationHistory') ?? null, [])),
|
||||||
|
stationSongHistory: sanitizeStationSongHistoryMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationSongHistory') ?? null, {})),
|
||||||
stationHealth: sanitizeStationHealthMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationHealth') ?? null, {})),
|
stationHealth: sanitizeStationHealthMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationHealth') ?? null, {})),
|
||||||
lastExportedAt: storage?.getItem('lastExportedAt') ?? null,
|
lastExportedAt: storage?.getItem('lastExportedAt') ?? null,
|
||||||
lastImportedAt: storage?.getItem('lastImportedAt') ?? null,
|
lastImportedAt: storage?.getItem('lastImportedAt') ?? null,
|
||||||
@@ -423,6 +482,7 @@ async function migrateFromLocalStorageIfNeeded(db: IDBPDatabase<RadioPlayerDb>)
|
|||||||
tx.objectStore('settings').put(legacy.favoriteStationIds, 'favoriteStationIds'),
|
tx.objectStore('settings').put(legacy.favoriteStationIds, 'favoriteStationIds'),
|
||||||
tx.objectStore('settings').put(legacy.stationUsageCounts, 'stationUsageCounts'),
|
tx.objectStore('settings').put(legacy.stationUsageCounts, 'stationUsageCounts'),
|
||||||
tx.objectStore('settings').put(legacy.recentStationHistory, 'recentStationHistory'),
|
tx.objectStore('settings').put(legacy.recentStationHistory, 'recentStationHistory'),
|
||||||
|
tx.objectStore('settings').put(legacy.stationSongHistory, 'stationSongHistory'),
|
||||||
tx.objectStore('settings').put(legacy.stationHealth, 'stationHealth'),
|
tx.objectStore('settings').put(legacy.stationHealth, 'stationHealth'),
|
||||||
tx.objectStore('settings').put(legacy.lastExportedAt, 'lastExportedAt'),
|
tx.objectStore('settings').put(legacy.lastExportedAt, 'lastExportedAt'),
|
||||||
tx.objectStore('settings').put(legacy.lastImportedAt, 'lastImportedAt'),
|
tx.objectStore('settings').put(legacy.lastImportedAt, 'lastImportedAt'),
|
||||||
@@ -461,6 +521,7 @@ export async function hydratePlayerPersistence() {
|
|||||||
const favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | undefined;
|
const favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | undefined;
|
||||||
const stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | undefined;
|
const stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | undefined;
|
||||||
const recentStationHistory = await settingsStore.get('recentStationHistory') as SettingRecordMap['recentStationHistory'] | undefined;
|
const recentStationHistory = await settingsStore.get('recentStationHistory') as SettingRecordMap['recentStationHistory'] | undefined;
|
||||||
|
const stationSongHistory = await settingsStore.get('stationSongHistory') as SettingRecordMap['stationSongHistory'] | undefined;
|
||||||
const stationHealth = await settingsStore.get('stationHealth') as SettingRecordMap['stationHealth'] | undefined;
|
const stationHealth = await settingsStore.get('stationHealth') as SettingRecordMap['stationHealth'] | undefined;
|
||||||
const lastExportedAt = await settingsStore.get('lastExportedAt') as SettingRecordMap['lastExportedAt'] | undefined;
|
const lastExportedAt = await settingsStore.get('lastExportedAt') as SettingRecordMap['lastExportedAt'] | undefined;
|
||||||
const lastImportedAt = await settingsStore.get('lastImportedAt') as SettingRecordMap['lastImportedAt'] | undefined;
|
const lastImportedAt = await settingsStore.get('lastImportedAt') as SettingRecordMap['lastImportedAt'] | undefined;
|
||||||
@@ -479,6 +540,7 @@ export async function hydratePlayerPersistence() {
|
|||||||
? stationUsageCounts
|
? stationUsageCounts
|
||||||
: {},
|
: {},
|
||||||
recentStationHistory: sanitizeRecentStationHistory(recentStationHistory),
|
recentStationHistory: sanitizeRecentStationHistory(recentStationHistory),
|
||||||
|
stationSongHistory: sanitizeStationSongHistoryMap(stationSongHistory),
|
||||||
stationHealth: sanitizeStationHealthMap(stationHealth),
|
stationHealth: sanitizeStationHealthMap(stationHealth),
|
||||||
lastExportedAt: typeof lastExportedAt === 'string' ? lastExportedAt : null,
|
lastExportedAt: typeof lastExportedAt === 'string' ? lastExportedAt : null,
|
||||||
lastImportedAt: typeof lastImportedAt === 'string' ? lastImportedAt : null,
|
lastImportedAt: typeof lastImportedAt === 'string' ? lastImportedAt : null,
|
||||||
@@ -500,6 +562,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
|||||||
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
|
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
|
||||||
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
|
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
|
||||||
recentStationHistory: cloneRecentStationHistory(DEFAULT_SNAPSHOT.recentStationHistory),
|
recentStationHistory: cloneRecentStationHistory(DEFAULT_SNAPSHOT.recentStationHistory),
|
||||||
|
stationSongHistory: cloneSongHistoryMap(DEFAULT_SNAPSHOT.stationSongHistory),
|
||||||
stationHealth: cloneStationHealthMap(DEFAULT_SNAPSHOT.stationHealth),
|
stationHealth: cloneStationHealthMap(DEFAULT_SNAPSHOT.stationHealth),
|
||||||
lastExportedAt: DEFAULT_SNAPSHOT.lastExportedAt,
|
lastExportedAt: DEFAULT_SNAPSHOT.lastExportedAt,
|
||||||
lastImportedAt: DEFAULT_SNAPSHOT.lastImportedAt,
|
lastImportedAt: DEFAULT_SNAPSHOT.lastImportedAt,
|
||||||
@@ -516,6 +579,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
|||||||
favoriteStationIds: [...snapshot.favoriteStationIds],
|
favoriteStationIds: [...snapshot.favoriteStationIds],
|
||||||
stationUsageCounts: { ...snapshot.stationUsageCounts },
|
stationUsageCounts: { ...snapshot.stationUsageCounts },
|
||||||
recentStationHistory: cloneRecentStationHistory(snapshot.recentStationHistory),
|
recentStationHistory: cloneRecentStationHistory(snapshot.recentStationHistory),
|
||||||
|
stationSongHistory: cloneSongHistoryMap(snapshot.stationSongHistory),
|
||||||
stationHealth: cloneStationHealthMap(snapshot.stationHealth),
|
stationHealth: cloneStationHealthMap(snapshot.stationHealth),
|
||||||
lastExportedAt: snapshot.lastExportedAt,
|
lastExportedAt: snapshot.lastExportedAt,
|
||||||
lastImportedAt: snapshot.lastImportedAt,
|
lastImportedAt: snapshot.lastImportedAt,
|
||||||
@@ -559,6 +623,10 @@ export function getPersistedRecentStationHistory() {
|
|||||||
return cloneRecentStationHistory(snapshot.recentStationHistory);
|
return cloneRecentStationHistory(snapshot.recentStationHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPersistedStationSongHistory() {
|
||||||
|
return cloneSongHistoryMap(snapshot.stationSongHistory);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPersistedStationHealth() {
|
export function getPersistedStationHealth() {
|
||||||
return cloneStationHealthMap(snapshot.stationHealth);
|
return cloneStationHealthMap(snapshot.stationHealth);
|
||||||
}
|
}
|
||||||
@@ -620,6 +688,11 @@ export function persistRecentStationHistory(history: PersistedRecentStationEntry
|
|||||||
void writeSetting('recentStationHistory', snapshot.recentStationHistory);
|
void writeSetting('recentStationHistory', snapshot.recentStationHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function persistStationSongHistory(history: Record<string, PersistedSongHistoryEntry[]>) {
|
||||||
|
snapshot.stationSongHistory = cloneSongHistoryMap(sanitizeStationSongHistoryMap(history));
|
||||||
|
void writeSetting('stationSongHistory', snapshot.stationSongHistory);
|
||||||
|
}
|
||||||
|
|
||||||
export function persistStationHealth(health: Record<string, PersistedStationHealth>) {
|
export function persistStationHealth(health: Record<string, PersistedStationHealth>) {
|
||||||
snapshot.stationHealth = cloneStationHealthMap(sanitizeStationHealthMap(health));
|
snapshot.stationHealth = cloneStationHealthMap(sanitizeStationHealthMap(health));
|
||||||
void writeSetting('stationHealth', snapshot.stationHealth);
|
void writeSetting('stationHealth', snapshot.stationHealth);
|
||||||
@@ -745,6 +818,7 @@ export async function importPlayerPersistence(source: unknown) {
|
|||||||
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
|
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
|
||||||
mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts);
|
mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts);
|
||||||
mirrorSettingToLocalStorage('recentStationHistory', snapshot.recentStationHistory);
|
mirrorSettingToLocalStorage('recentStationHistory', snapshot.recentStationHistory);
|
||||||
|
mirrorSettingToLocalStorage('stationSongHistory', snapshot.stationSongHistory);
|
||||||
mirrorSettingToLocalStorage('stationHealth', snapshot.stationHealth);
|
mirrorSettingToLocalStorage('stationHealth', snapshot.stationHealth);
|
||||||
mirrorSettingToLocalStorage('lastExportedAt', snapshot.lastExportedAt);
|
mirrorSettingToLocalStorage('lastExportedAt', snapshot.lastExportedAt);
|
||||||
mirrorSettingToLocalStorage('lastImportedAt', snapshot.lastImportedAt);
|
mirrorSettingToLocalStorage('lastImportedAt', snapshot.lastImportedAt);
|
||||||
@@ -768,6 +842,7 @@ export async function importPlayerPersistence(source: unknown) {
|
|||||||
tx.objectStore('settings').put(snapshot.favoriteStationIds, 'favoriteStationIds'),
|
tx.objectStore('settings').put(snapshot.favoriteStationIds, 'favoriteStationIds'),
|
||||||
tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'),
|
tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'),
|
||||||
tx.objectStore('settings').put(snapshot.recentStationHistory, 'recentStationHistory'),
|
tx.objectStore('settings').put(snapshot.recentStationHistory, 'recentStationHistory'),
|
||||||
|
tx.objectStore('settings').put(snapshot.stationSongHistory, 'stationSongHistory'),
|
||||||
tx.objectStore('settings').put(snapshot.stationHealth, 'stationHealth'),
|
tx.objectStore('settings').put(snapshot.stationHealth, 'stationHealth'),
|
||||||
tx.objectStore('settings').put(snapshot.lastExportedAt, 'lastExportedAt'),
|
tx.objectStore('settings').put(snapshot.lastExportedAt, 'lastExportedAt'),
|
||||||
tx.objectStore('settings').put(snapshot.lastImportedAt, 'lastImportedAt'),
|
tx.objectStore('settings').put(snapshot.lastImportedAt, 'lastImportedAt'),
|
||||||
@@ -817,4 +892,4 @@ export async function clearPlayerPersistence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return getPlayerPersistenceSnapshot();
|
return getPlayerPersistenceSnapshot();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||||
|
|
||||||
|
type StationCatalogKey = 'radio-browser' | 'managed';
|
||||||
|
|
||||||
|
type StationCatalogRecord = {
|
||||||
|
key: StationCatalogKey;
|
||||||
|
stations: unknown[];
|
||||||
|
source: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StationCatalogDb extends DBSchema {
|
||||||
|
catalogs: {
|
||||||
|
key: StationCatalogKey;
|
||||||
|
value: StationCatalogRecord;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'radioplayer-station-catalogs';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const LOCAL_STORAGE_KEYS = {
|
||||||
|
radioBrowser: 'radioplayer.stationCatalog.radioBrowser',
|
||||||
|
managed: 'radioplayer.stationCatalog.managed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUILD_ID_KEY = 'radioplayer.stationCatalog.buildId';
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<StationCatalogDb> | null> | null = null;
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<StationCatalogDb>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains('catalogs')) {
|
||||||
|
db.createObjectStore('catalogs', { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('Station catalog IndexedDB unavailable, using localStorage fallback.', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStorageKey(key: StationCatalogKey) {
|
||||||
|
return key === 'managed' ? LOCAL_STORAGE_KEYS.managed : LOCAL_STORAGE_KEYS.radioBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStations(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSource(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCatalogRecord(key: StationCatalogKey, value: unknown): StationCatalogRecord | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Partial<StationCatalogRecord>;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
stations: sanitizeStations(record.stations),
|
||||||
|
source: sanitizeSource(record.source),
|
||||||
|
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
|
||||||
|
? record.updatedAt
|
||||||
|
: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalogFromLocalStorage(key: StationCatalogKey): StationCatalogRecord | null {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = storage.getItem(getLocalStorageKey(key));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return sanitizeCatalogRecord(key, JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCatalogToLocalStorage(record: StationCatalogRecord) {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(getLocalStorageKey(record.key), JSON.stringify(record));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/write failures and rely on IndexedDB when available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredBuildId(): string | null {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
try {
|
||||||
|
return storage.getItem(BUILD_ID_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredBuildId(buildId: string) {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(BUILD_ID_KEY, buildId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearManagedCatalogPersistence() {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (storage) {
|
||||||
|
try {
|
||||||
|
storage.removeItem(getLocalStorageKey('managed'));
|
||||||
|
storage.removeItem(BUILD_ID_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
if (db) {
|
||||||
|
try {
|
||||||
|
await db.delete('catalogs', 'managed');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCatalog(key: StationCatalogKey): Promise<StationCatalogRecord | null> {
|
||||||
|
// If a previous build persisted a managed catalog, invalidate it when the
|
||||||
|
// current build id differs. This forces clients to fetch the updated managed
|
||||||
|
// catalog after a deploy without requiring manual cache clearing.
|
||||||
|
if (key === 'managed') {
|
||||||
|
const stored = getStoredBuildId();
|
||||||
|
if (stored && stored !== __APP_BUILD_ID__) {
|
||||||
|
await clearManagedCatalogPersistence();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
return readCatalogFromLocalStorage(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await db.get('catalogs', key);
|
||||||
|
return sanitizeCatalogRecord(key, record);
|
||||||
|
} catch {
|
||||||
|
return readCatalogFromLocalStorage(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCatalog(key: StationCatalogKey, stations: unknown[], source: string | null = null) {
|
||||||
|
const record: StationCatalogRecord = {
|
||||||
|
key,
|
||||||
|
stations: sanitizeStations(stations),
|
||||||
|
source: sanitizeSource(source),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
writeCatalogToLocalStorage(record);
|
||||||
|
|
||||||
|
// Persist build id for managed catalog so we can invalidate on next run.
|
||||||
|
if (key === 'managed') {
|
||||||
|
setStoredBuildId(__APP_BUILD_ID__);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.put('catalogs', record);
|
||||||
|
} catch {
|
||||||
|
// Keep the localStorage mirror if IndexedDB writes fail.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedRadioStationCatalog(): Promise<unknown[] | null> {
|
||||||
|
const record = await readCatalog('radio-browser');
|
||||||
|
return record ? record.stations : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistRadioStationCatalog(stations: unknown[]) {
|
||||||
|
await writeCatalog('radio-browser', stations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedManagedStationCatalog(): Promise<{ stations: unknown[]; source: string | null } | null> {
|
||||||
|
const record = await readCatalog('managed');
|
||||||
|
return record
|
||||||
|
? {
|
||||||
|
stations: record.stations,
|
||||||
|
source: record.source,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistManagedStationCatalog(stations: unknown[], source: string | null) {
|
||||||
|
await writeCatalog('managed', stations, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPersistedManagedStationCatalog(): Promise<void> {
|
||||||
|
await clearManagedCatalogPersistence();
|
||||||
|
}
|
||||||
+925
-103
File diff suppressed because it is too large
Load Diff
Vendored
+3
-1
@@ -1 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_BUILD_ID__: string;
|
||||||
@@ -26,6 +26,7 @@ if [[ "${SKIP_DEPLOY:-0}" == "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
rsync -avz \
|
rsync -avz \
|
||||||
|
--delete \
|
||||||
--chmod=D755,F644 \
|
--chmod=D755,F644 \
|
||||||
--exclude ".phpintel/" \
|
--exclude ".phpintel/" \
|
||||||
--exclude ".env" \
|
--exclude ".env" \
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('loads the app shell, shows playback UI, and registers a service worker', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.locator('.app-title')).toHaveText('RadioPlayer');
|
||||||
|
await expect(page.locator('#play-btn')).toBeVisible();
|
||||||
|
await expect(page.locator('#mute-btn')).toBeVisible();
|
||||||
|
await expect(page.locator('#station-library')).toBeVisible();
|
||||||
|
await expect(page.locator('#station-name')).not.toHaveText('', { timeout: 20000 });
|
||||||
|
|
||||||
|
const serviceWorkerState = await page.evaluate(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
return { supported: false, controlled: false, scope: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
controlled: Boolean(navigator.serviceWorker.controller),
|
||||||
|
scope: registration.scope,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(serviceWorkerState.supported).toBe(true);
|
||||||
|
expect(serviceWorkerState.controlled).toBe(true);
|
||||||
|
expect(serviceWorkerState.scope).toContain('4174');
|
||||||
|
});
|
||||||
+16
-1
@@ -26,6 +26,15 @@ async function loadManagedCatalogEnvelope(repoRoot) {
|
|||||||
|
|
||||||
const buildStamp = `${Date.now()}`;
|
const buildStamp = `${Date.now()}`;
|
||||||
|
|
||||||
|
function htmlBuildVersionPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'html-build-version',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replaceAll('__BUILD_STAMP__', buildStamp);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function managedCatalogApiPlugin() {
|
function managedCatalogApiPlugin() {
|
||||||
async function handleManagedCatalogRequest(req, res, next) {
|
async function handleManagedCatalogRequest(req, res, next) {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
@@ -71,12 +80,18 @@ function managedCatalogApiPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), managedCatalogApiPlugin()],
|
define: {
|
||||||
|
__APP_BUILD_ID__: JSON.stringify(buildStamp),
|
||||||
|
},
|
||||||
|
plugins: [react(), managedCatalogApiPlugin(), htmlBuildVersionPlugin()],
|
||||||
base: './',
|
base: './',
|
||||||
build: {
|
build: {
|
||||||
|
minify: 'terser',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: resolve(process.cwd(), 'index.html'),
|
main: resolve(process.cwd(), 'index.html'),
|
||||||
|
about: resolve(process.cwd(), 'about.html'),
|
||||||
|
docs: resolve(process.cwd(), 'docs.html'),
|
||||||
privacy: resolve(process.cwd(), 'privacy.html'),
|
privacy: resolve(process.cwd(), 'privacy.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.{js,ts}'],
|
||||||
|
coverage: {
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user