Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fceee11f | |||
| 10a239a155 |
@@ -158,7 +158,6 @@ public/
|
||||
stations.json
|
||||
sw.js
|
||||
scripts/
|
||||
bump-sw-cache-version.mjs
|
||||
cron-refresh-stations.sh
|
||||
cron-update-stations.sh
|
||||
import-radio-stations.ts
|
||||
|
||||
@@ -51139,6 +51139,57 @@
|
||||
"source": "radio-browser",
|
||||
"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",
|
||||
"name": "Radio1",
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
// NOTE: This service worker is for the web/PWA build.
|
||||
// For development we aggressively unregister SWs in `src/player.js`.
|
||||
//
|
||||
// This value is rewritten automatically before each build so deployed clients
|
||||
// refresh to the newest shell and cached assets.
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777223362895';
|
||||
// This checked-in file stays stable. The Vite web build stamps the matching
|
||||
// token in `dist/sw.js` without mutating source files in the repo.
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-__APP_BUILD_ID__';
|
||||
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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.">
|
||||
<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="apple-mobile-web-app-capable" content="yes">
|
||||
<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",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/bump-sw-cache-version.mjs",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/stamp-dist-sw.mjs",
|
||||
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \"scripts/**/*.{js,mjs,ts}\" \"tests/**/*.js\" \"*.js\" \"public/sw.js\"",
|
||||
"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:build": "npm run update:stations && npm run build",
|
||||
"radio:import": "tsx scripts/import-radio-stations.ts",
|
||||
@@ -16,14 +19,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tsx": "^4.20.6",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@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",
|
||||
"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>
|
||||
<html lang="sl">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pravilnik o zasebnosti | RadioPlayer</title>
|
||||
<meta name="description" content="Pravilnik o zasebnosti za spletno aplikacijo RadioPlayer.">
|
||||
<title>Privacy Policy | RadioPlayer</title>
|
||||
<meta name="description" content="Privacy policy for the RadioPlayer web app.">
|
||||
<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="512x512" href="assets/radioplayer-logo-512.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">
|
||||
<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>
|
||||
</a>
|
||||
<a class="back-link" href="./">Nazaj v aplikacijo</a>
|
||||
<a class="back-link" href="./">Back to the app</a>
|
||||
</div>
|
||||
|
||||
<main class="privacy-card">
|
||||
<p class="eyebrow">Pravilnik</p>
|
||||
<h1>Pravilnik o zasebnosti</h1>
|
||||
<p class="eyebrow">Policy</p>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="lead">
|
||||
Ta stran pojasnjuje, katere podatke RadioPlayer shrani samo v vašem brskalniku, zakaj jih uporablja in kako
|
||||
jih lahko izbrišete. Besedilo velja za spletno uporabo aplikacije in za njeno PWA namestitev.
|
||||
This page explains which data RadioPlayer stores only in your browser, why it uses that data, and how you
|
||||
can remove it. This text applies to the web version of the app and to its PWA installation.
|
||||
</p>
|
||||
<div class="meta">Velja od: 29. 4. 2026</div>
|
||||
<div class="meta">Effective date: April 29, 2026</div>
|
||||
|
||||
<div class="sections">
|
||||
<section>
|
||||
<h2>Kateri podatki nastanejo pri uporabi</h2>
|
||||
<h2>What data is created during use</h2>
|
||||
<p>
|
||||
RadioPlayer ne zahteva registracije ali ustvarjanja uporabniškega računa. Aplikacija pa lahko v vašem
|
||||
brskalniku lokalno shrani nastavitve in sezname, ki jih ustvarite sami, da si jih ob naslednjem obisku
|
||||
zapomni.
|
||||
RadioPlayer does not require registration or a user account. The app may, however, store settings and
|
||||
lists that you create locally in your browser so they are available again the next time you visit.
|
||||
</p>
|
||||
<ul>
|
||||
<li>glasnost predvajanja, zadnjo izbrano postajo in zadnji izbran filter države,</li>
|
||||
<li>seznam priljubljenih postaj in lokalno statistiko predvajanja za prikaz nedavno poslušanih postaj,</li>
|
||||
<li>postaje, ki jih dodate ročno v urejevalniku,</li>
|
||||
<li>nastavitev načina oddajanja zvoka pri Google Cast povezavi.</li>
|
||||
<li>playback volume, the last selected station, and the last selected country filter,</li>
|
||||
<li>your list of favorite stations and local playback statistics used to show recently played stations,</li>
|
||||
<li>stations that you add manually in the editor,</li>
|
||||
<li>the selected audio casting mode for a Google Cast connection.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kje se podatki hranijo</h2>
|
||||
<h2>Where the data is stored</h2>
|
||||
<p>
|
||||
Zgoraj navedeni podatki se hranijo lokalno v vašem brskalniku oziroma v podatkih spletnega mesta na vaši
|
||||
napravi. Za trajnejše shranjevanje nastavitev in postaj aplikacija uporablja brskalnikovo lokalno bazo
|
||||
IndexedDB. Ob prvi uporabi nove različice lahko prenese tudi stare lokalne nastavitve iz prejšnje lokalne
|
||||
hrambe, da se vaši podatki ohranijo. RadioPlayer teh nastavitev ne pošilja na lasten strežnik in jih brez
|
||||
vašega dejanja ne deli z drugimi uporabniki.
|
||||
The data listed above is stored locally in your browser or in this website's data on your device. For
|
||||
more persistent storage of settings and stations, the app uses the browser's local IndexedDB database.
|
||||
When you use a new version for the first time, it may also migrate older local settings from previous
|
||||
local storage so your data is preserved. RadioPlayer does not send these settings to its own server and
|
||||
does not share them with other users without your action.
|
||||
</p>
|
||||
<p>
|
||||
Aplikacija uporablja tudi predpomnjenje datotek aplikacije za hitrejši zagon in delovanje brez ponovnega
|
||||
prenosa vseh statičnih datotek. To predpomnjenje lahko kadarkoli izbrišete v nastavitvah brskalnika.
|
||||
The app also uses cached application files for faster startup and operation without downloading all static
|
||||
files again. You can clear this cache at any time in your browser settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Zunanji ponudniki in prenosi</h2>
|
||||
<h2>External providers and streaming</h2>
|
||||
<p>
|
||||
Ko zaženete radijski tok, se vaš brskalnik poveže neposredno s strežnikom radijske postaje ali drugega
|
||||
ponudnika toka. Ti zunanji ponudniki praviloma prejmejo tehnične podatke, ki so potrebni za prenos, na
|
||||
primer IP naslov, podatke o brskalniku in čas zahteve.
|
||||
When you start a radio stream, your browser connects directly to the radio station's server or another
|
||||
stream provider. These external providers typically receive the technical data required to deliver the
|
||||
stream, such as your IP address, browser details, and the time of the request.
|
||||
</p>
|
||||
<p>
|
||||
Če uporabite funkcijo Google Cast, se za vzpostavitev in upravljanje oddajanja uporabi Google Cast Web
|
||||
Sender SDK. Pri tem lahko Google oziroma naprava za predvajanje obdelujeta podatke, potrebne za sejo
|
||||
oddajanja in usmerjanje medijev.
|
||||
If you use Google Cast, the Google Cast Web Sender SDK is used to establish and manage casting. In that
|
||||
case, Google or the playback device may process data required for the casting session and media routing.
|
||||
</p>
|
||||
<p>
|
||||
Na napravah Apple lahko aplikacija ponudi tudi AirPlay izbiro izhoda. V tem primeru se medijski tok
|
||||
preusmeri na izbrano AirPlay napravo prek zmožnosti vašega brskalnika in operacijskega sistema, zato lahko
|
||||
zunanja naprava ali Apple obdelujeta podatke, potrebne za vzpostavitev in upravljanje predvajanja.
|
||||
On Apple devices, the app may also offer AirPlay output selection. In that case, the media stream is
|
||||
routed to the selected AirPlay device through your browser and operating system capabilities, so the
|
||||
external device or Apple may process data required to establish and manage playback.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Piškotki in analitika</h2>
|
||||
<h2>Cookies and analytics</h2>
|
||||
<p>
|
||||
Trenutna različica aplikacije ne uporablja analitičnih skript za profiliranje uporabnikov in ne nastavlja
|
||||
lastnih piškotkov za oglaševanje. Aplikacija za shranjevanje nastavitev uporablja lokalno hrambo brskalnika
|
||||
in IndexedDB, ne klasičnih piškotkov.
|
||||
The current version of the app does not use analytics scripts for user profiling and does not set its own
|
||||
advertising cookies. To store settings, the app uses the browser's local storage and IndexedDB rather
|
||||
than traditional cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Dnevniki gostovanja</h2>
|
||||
<h2>Hosting logs</h2>
|
||||
<p>
|
||||
Gostovanje spletne strani lahko zaradi varnosti, stabilnosti ali odpravljanja napak samodejno vodi osnovne
|
||||
tehnične dnevnike dostopa. Obseg teh dnevnikov je odvisen od ponudnika gostovanja in ni del nastavitev,
|
||||
ki jih upravlja uporabniški vmesnik RadioPlayer.
|
||||
The website host may automatically keep basic technical access logs for security, stability, or debugging
|
||||
purposes. The scope of these logs depends on the hosting provider and is not part of the settings managed
|
||||
by the RadioPlayer user interface.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kako izbrišete lokalne podatke</h2>
|
||||
<h2>How to delete local data</h2>
|
||||
<p>
|
||||
Če želite odstraniti shranjene priljubljene postaje, uporabniške postaje ali druge nastavitve, izbrišite
|
||||
podatke tega spletnega mesta v svojem brskalniku. S tem se izbrišejo lokalne nastavitve in predpomnjene
|
||||
datoteke aplikacije na tej napravi.
|
||||
If you want to remove saved favorite stations, custom stations, or other settings, delete this website's
|
||||
data in your browser. This removes local settings and cached application files from this device.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Posodobitve pravilnika</h2>
|
||||
<h2>Policy updates</h2>
|
||||
<p>
|
||||
Pravilnik se lahko posodobi, če se spremeni delovanje aplikacije, način gostovanja ali vključeni zunanji
|
||||
ponudniki. Na tej strani bo vedno objavljena veljavna različica besedila.
|
||||
This policy may be updated if the app's behavior, hosting setup, or external providers change. The
|
||||
current version of the text will always be published on this page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
/**
|
||||
* Managed catalog API endpoint.
|
||||
* Served at /api/managed-stations.json via .htaccess rewrite.
|
||||
* Returns the same JSON envelope as the Node backend: { schemaVersion, updatedAt, stations }.
|
||||
* Returns the same JSON envelope used by the Vite dev/preview middleware:
|
||||
* { schemaVersion, updatedAt, stations }.
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
@@ -51139,6 +51139,57 @@
|
||||
"source": "radio-browser",
|
||||
"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",
|
||||
"name": "Radio1",
|
||||
|
||||
@@ -51139,6 +51139,57 @@
|
||||
"source": "radio-browser",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "Radio Aktual - Romantika",
|
||||
|
||||
+82
-57
@@ -1,67 +1,23 @@
|
||||
// NOTE: This service worker is for the web/PWA build.
|
||||
// For development we aggressively unregister SWs in `src/player.js`.
|
||||
//
|
||||
// This value is rewritten automatically before each build so deployed clients
|
||||
// refresh to the newest shell and cached assets.
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316';
|
||||
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
|
||||
// This checked-in file stays stable. The build stamps the token below into
|
||||
// `dist/sw.js` so deployed clients still refresh to the newest shell.
|
||||
const CACHE_NAME = 'radioplayer-pwa-v5-__APP_BUILD_ID__';
|
||||
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1-__APP_BUILD_ID__';
|
||||
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
|
||||
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
|
||||
const STATION_SYNC_TAG = 'radio-stations-refresh';
|
||||
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
|
||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
|
||||
const STATION_SYNC_TAG = '__SW_STATION_SYNC_TAG__';
|
||||
const STATION_PERIODIC_SYNC_TAG = '__SW_STATION_PERIODIC_SYNC_TAG__';
|
||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = '__SW_MANAGED_CATALOG_PERIODIC_SYNC_TAG__';
|
||||
const STATION_SYNC_HEADERS = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
};
|
||||
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||
|
||||
const RADIO_COUNTRIES = [
|
||||
{ name: 'Austria', code: 'AT' },
|
||||
{ name: 'Belgium', code: 'BE' },
|
||||
{ name: 'Bulgaria', code: 'BG' },
|
||||
{ name: 'Cyprus', code: 'CY' },
|
||||
{ name: 'Czechia', code: 'CZ' },
|
||||
{ name: 'Denmark', code: 'DK' },
|
||||
{ name: 'Estonia', code: 'EE' },
|
||||
{ name: 'Finland', code: 'FI' },
|
||||
{ name: 'France', code: 'FR' },
|
||||
{ name: 'Germany', code: 'DE' },
|
||||
{ name: 'Greece', code: 'GR' },
|
||||
{ name: 'Russia', code: 'RU' },
|
||||
{ name: 'Hungary', code: 'HU' },
|
||||
{ name: 'Ireland', code: 'IE' },
|
||||
{ name: 'Italy', code: 'IT' },
|
||||
{ name: 'Japan', code: 'JP' },
|
||||
{ name: 'Latvia', code: 'LV' },
|
||||
{ name: 'Lithuania', code: 'LT' },
|
||||
{ name: 'Luxembourg', code: 'LU' },
|
||||
{ name: 'Malta', code: 'MT' },
|
||||
{ name: 'Mexico', code: 'MX' },
|
||||
{ name: 'Netherlands', code: 'NL' },
|
||||
{ name: 'Poland', code: 'PL' },
|
||||
{ name: 'Brazil', code: 'BR' },
|
||||
{ name: 'Portugal', code: 'PT' },
|
||||
{ name: 'Romania', code: 'RO' },
|
||||
{ name: 'Croatia', code: 'HR' },
|
||||
{ name: 'Serbia', code: 'RS' },
|
||||
{ name: 'Montenegro', code: 'ME' },
|
||||
{ name: 'Bosnia & Herzegovina', code: 'BA' },
|
||||
{ name: 'Argentina', code: 'AR' },
|
||||
{ name: 'United Kingdom', code: 'GB' },
|
||||
{ name: 'Slovenia', code: 'SI' },
|
||||
{ name: 'Slovakia', code: 'SK' },
|
||||
{ name: 'Spain', code: 'ES' },
|
||||
{ name: 'USA', code: 'US' },
|
||||
{ name: 'Canada', code: 'CA' },
|
||||
{ name: 'Australia', code: 'AU' },
|
||||
{ name: 'China', code: 'CN' },
|
||||
{ name: 'Sweden', code: 'SE' },
|
||||
{ name: 'Switzerland', code: 'CH' },
|
||||
{ name: 'Turkey', code: 'TR' },
|
||||
{ name: 'Ukraine', code: 'UA' },
|
||||
];
|
||||
const RADIO_COUNTRIES = __RADIO_COUNTRIES_JSON__;
|
||||
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 OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
|
||||
'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 SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href;
|
||||
const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname;
|
||||
const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href;
|
||||
const SYNC_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_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
|
||||
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' });
|
||||
if (isCacheableResponse(response)) {
|
||||
await managedCatalogCache.put(url, response);
|
||||
return { updated: true, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
updated: false,
|
||||
error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
|
||||
};
|
||||
} catch {
|
||||
// Network unavailable — keep existing cache as-is.
|
||||
}
|
||||
|
||||
return { updated: false, error: 'Managed catalog request failed.' };
|
||||
}
|
||||
|
||||
async function notifyClients(message) {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
await Promise.allSettled(clients.map((client) => client.postMessage(message)));
|
||||
}
|
||||
|
||||
async function respondWithManagedCatalog(request) {
|
||||
@@ -530,7 +499,23 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag !== STATION_SYNC_TAG) return;
|
||||
event.waitUntil(syncRadioStations('background-sync'));
|
||||
event.waitUntil(
|
||||
syncRadioStations('background-sync')
|
||||
.then((meta) => notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'background-sync',
|
||||
meta,
|
||||
}))
|
||||
.catch(async (error) => {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-update-failed',
|
||||
reason: 'background-sync',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
@@ -544,6 +529,14 @@ self.addEventListener('message', (event) => {
|
||||
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
||||
: null;
|
||||
|
||||
if (syncResult) {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'country-selection-update',
|
||||
meta: syncResult,
|
||||
});
|
||||
}
|
||||
|
||||
event.ports?.[0]?.postMessage({
|
||||
ok: true,
|
||||
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
||||
@@ -560,11 +553,43 @@ self.addEventListener('message', (event) => {
|
||||
|
||||
self.addEventListener('periodicsync', (event) => {
|
||||
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
||||
event.waitUntil(syncRadioStations('periodic-sync'));
|
||||
event.waitUntil(
|
||||
syncRadioStations('periodic-sync')
|
||||
.then((meta) => notifyClients({
|
||||
type: 'station-catalog-updated',
|
||||
reason: 'periodic-sync',
|
||||
meta,
|
||||
}))
|
||||
.catch(async (error) => {
|
||||
await notifyClients({
|
||||
type: 'station-catalog-update-failed',
|
||||
reason: 'periodic-sync',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
||||
event.waitUntil(refreshManagedCatalogCache());
|
||||
event.waitUntil(
|
||||
refreshManagedCatalogCache().then((result) => {
|
||||
if (!result.updated) {
|
||||
return notifyClients({
|
||||
type: 'managed-catalog-update-failed',
|
||||
reason: 'periodic-sync',
|
||||
error: result.error || 'Unknown error',
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return notifyClients({
|
||||
type: 'managed-catalog-updated',
|
||||
reason: 'periodic-sync',
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
+402
-36
@@ -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() {
|
||||
return (
|
||||
<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">
|
||||
<ListIcon />
|
||||
</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">
|
||||
<InstallIcon />
|
||||
</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() {
|
||||
return (
|
||||
<nav className="legal-links" aria-label="Legal">
|
||||
<a className="legal-link" href={`${import.meta.env.BASE_URL}privacy.html`}>
|
||||
Pravilnik o zasebnosti
|
||||
<a
|
||||
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>
|
||||
</nav>
|
||||
);
|
||||
@@ -181,8 +293,8 @@ function TrackInfo() {
|
||||
<div id="now-title" className="now-title" aria-hidden="false" />
|
||||
</div>
|
||||
<p id="station-subtitle" />
|
||||
<div className="station-meta-row">
|
||||
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
|
||||
<div id="station-health-detail" className="station-health-detail hidden" aria-live="polite" />
|
||||
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
|
||||
<span className="status-dot" />
|
||||
<span id="status-text" />
|
||||
@@ -198,7 +310,15 @@ function TrackInfo() {
|
||||
<span id="engine-label">HTML5</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
|
||||
</div>
|
||||
<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">
|
||||
<span className="cast-output-label">Output:</span>
|
||||
<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() {
|
||||
return (
|
||||
<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() {
|
||||
return (
|
||||
<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() {
|
||||
return (
|
||||
<aside id="station-library" className="station-library" aria-label="Station library">
|
||||
@@ -429,6 +638,23 @@ function StationLibrary() {
|
||||
<p className="library-eyebrow">Browse</p>
|
||||
<h2>Stations</h2>
|
||||
</div>
|
||||
<div className="library-top-actions">
|
||||
<button
|
||||
id="station-library-filters-toggle"
|
||||
className="icon-btn library-filters-toggle"
|
||||
aria-label="Collapse search and filters"
|
||||
aria-controls="station-library-controls"
|
||||
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">
|
||||
@@ -436,9 +662,25 @@ function StationLibrary() {
|
||||
<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 className="library-filter-grid">
|
||||
<div id="station-library-controls" className="library-filter-grid">
|
||||
<label className="library-search" htmlFor="station-search-input">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
@@ -457,7 +699,7 @@ function StationLibrary() {
|
||||
aria-expanded="false"
|
||||
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 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">
|
||||
@@ -467,26 +709,16 @@ function StationLibrary() {
|
||||
<div id="station-country-filter-menu" className="library-select-menu" role="listbox" aria-label="Country filter options" />
|
||||
</div>
|
||||
|
||||
<div className="library-select" data-sort-filter>
|
||||
<button
|
||||
id="station-sort-btn"
|
||||
className="library-select-trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="Sort stations"
|
||||
>
|
||||
<svg className="library-select-sort-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M7 12h10M11 18h2" />
|
||||
</svg>
|
||||
<span className="library-select-prefix">Sort</span>
|
||||
<span id="station-sort-text" className="library-select-value">Recommended</span>
|
||||
<svg className="library-select-caret" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
<button id="station-advanced-filters-btn" className="library-filter-settings-btn" aria-label="Open filter settings" title="Filter settings" 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">
|
||||
<line x1="4" y1="6" x2="20" y2="6" />
|
||||
<circle cx="9" cy="6" r="2" />
|
||||
<line x1="4" y1="12" x2="20" y2="12" />
|
||||
<circle cx="15" cy="12" r="2" />
|
||||
<line x1="4" y1="18" x2="20" y2="18" />
|
||||
<circle cx="11" cy="18" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="station-sort-menu" className="library-select-menu" role="listbox" aria-label="Sort options" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="library-tabs" role="tablist" aria-label="Station filters">
|
||||
@@ -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() {
|
||||
return (
|
||||
<div className="app-container">
|
||||
@@ -563,17 +925,21 @@ export default function App() {
|
||||
<HeaderControls />
|
||||
<ArtworkPanel />
|
||||
<TrackInfo />
|
||||
<ProgressBar />
|
||||
<PlayerControls />
|
||||
<VolumeControl />
|
||||
<SessionSummary />
|
||||
<QuickPickCarousel />
|
||||
<LegalLinks />
|
||||
<AdvancedFiltersOverlay />
|
||||
<StationsOverlay />
|
||||
<EditorOverlay />
|
||||
<SessionSchedulerOverlay />
|
||||
<ShortcutHelpOverlay />
|
||||
<CountrySelectionOverlay />
|
||||
</main>
|
||||
<InstallPromptBanner />
|
||||
</div>
|
||||
<SyncToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-9
@@ -1,18 +1,56 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import App from './App.jsx';
|
||||
import serviceWorkerConfig from './shared/serviceWorkerConfig.json';
|
||||
import './styles.css';
|
||||
|
||||
const APP_BUILD_ID = __APP_BUILD_ID__;
|
||||
|
||||
const hadServiceWorkerControllerAtLoad = 'serviceWorker' in navigator
|
||||
? Boolean(navigator.serviceWorker.controller)
|
||||
: false;
|
||||
let hasReloadedForServiceWorkerUpdate = false;
|
||||
const STATION_SYNC_TAG = 'radio-stations-refresh';
|
||||
const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh';
|
||||
const STATION_PERIODIC_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
|
||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh';
|
||||
const MANAGED_CATALOG_PERIODIC_SYNC_INTERVAL = 24 * 60 * 60 * 1000;
|
||||
const STATION_SYNC_TAG = serviceWorkerConfig.stationSyncTag;
|
||||
const STATION_PERIODIC_SYNC_TAG = serviceWorkerConfig.stationPeriodicSyncTag;
|
||||
const STATION_PERIODIC_SYNC_INTERVAL = serviceWorkerConfig.stationPeriodicSyncIntervalMs;
|
||||
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = serviceWorkerConfig.managedCatalogPeriodicSyncTag;
|
||||
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) {
|
||||
if (!registration) {
|
||||
@@ -21,8 +59,12 @@ async function registerStationCatalogSync(registration) {
|
||||
|
||||
if ('sync' in registration) {
|
||||
try {
|
||||
if (await shouldScheduleImmediateStationSync()) {
|
||||
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) {
|
||||
console.debug('ServiceWorker background sync registration failed:', error);
|
||||
}
|
||||
@@ -68,7 +110,7 @@ function setupServiceWorker() {
|
||||
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' })
|
||||
.then(async (reg) => {
|
||||
try {
|
||||
@@ -102,13 +144,11 @@ setupServiceWorker();
|
||||
const rootEl = document.getElementById('root');
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('app-loading')?.remove();
|
||||
|
||||
|
||||
+1133
-504
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_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||
|
||||
import {
|
||||
loadPersistedManagedStationCatalog,
|
||||
persistManagedStationCatalog,
|
||||
} from '../storage/stationCatalogPersistence.js';
|
||||
|
||||
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
||||
|
||||
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
||||
@@ -44,10 +49,26 @@ function setLastManagedCatalogSource(source: ManagedCatalogSource) {
|
||||
lastManagedCatalogSource = source;
|
||||
}
|
||||
|
||||
function normalizeManagedCatalogSource(source: unknown): ManagedCatalogSource {
|
||||
return source === 'remote' || source === 'cached-remote' || source === 'bundled'
|
||||
? source
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
||||
return lastManagedCatalogSource;
|
||||
}
|
||||
|
||||
export async function loadPersistedManagedStations(): Promise<unknown[] | null> {
|
||||
const persisted = await loadPersistedManagedStationCatalog();
|
||||
if (!persisted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLastManagedCatalogSource(normalizeManagedCatalogSource(persisted.source));
|
||||
return normalizeManagedStationsPayload(persisted.stations);
|
||||
}
|
||||
|
||||
export async function loadManagedStations(): Promise<unknown[]> {
|
||||
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
||||
@@ -92,6 +113,7 @@ export async function loadManagedStations(): Promise<unknown[]> {
|
||||
throw new Error(`Failed to load managed stations: ${response.status}`);
|
||||
}
|
||||
|
||||
const stations = await response.json();
|
||||
return normalizeManagedStationsPayload(stations);
|
||||
const stations = normalizeManagedStationsPayload(await response.json());
|
||||
await persistManagedStationCatalog(stations, getLastManagedCatalogSource());
|
||||
return stations;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { RadioStation } from './radioTypes.js';
|
||||
import {
|
||||
loadPersistedRadioStationCatalog,
|
||||
persistRadioStationCatalog,
|
||||
} from '../storage/stationCatalogPersistence.js';
|
||||
|
||||
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
||||
const APP_BUILD_ID = __APP_BUILD_ID__;
|
||||
|
||||
async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response | null> {
|
||||
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[]> {
|
||||
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'
|
||||
&& 'serviceWorker' in navigator
|
||||
&& Boolean(navigator.serviceWorker.controller);
|
||||
@@ -43,5 +53,7 @@ export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||
}
|
||||
|
||||
const stations = (await response.json()) as RadioStation[];
|
||||
return Array.isArray(stations) ? stations : [];
|
||||
const normalizedStations = Array.isArray(stations) ? stations : [];
|
||||
await persistRadioStationCatalog(normalizedStations);
|
||||
return normalizedStations;
|
||||
}
|
||||
@@ -0,0 +1,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 = [
|
||||
{ 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;
|
||||
import radioConfig from './radioConfig.json' with { type: 'json' };
|
||||
|
||||
export const managedCountryCode = 'SI';
|
||||
export const radioCountries = radioConfig.radioCountries;
|
||||
|
||||
export const managedCountryCode = radioConfig.managedCountryCode;
|
||||
export const defaultSelectedRadioCountryCodes = radioCountries.map((country) => country.code);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type PersistedSongHistoryEntry = {
|
||||
artist: string;
|
||||
title: string;
|
||||
seenAt: string;
|
||||
};
|
||||
|
||||
type PersistedSettings = {
|
||||
volume: number | null;
|
||||
lastStationId: string | null;
|
||||
@@ -28,6 +34,7 @@ type PersistedSettings = {
|
||||
favoriteStationIds: string[];
|
||||
stationUsageCounts: Record<string, number>;
|
||||
recentStationHistory: PersistedRecentStationEntry[];
|
||||
stationSongHistory: Record<string, PersistedSongHistoryEntry[]>;
|
||||
stationHealth: Record<string, PersistedStationHealth>;
|
||||
lastExportedAt: string | null;
|
||||
lastImportedAt: string | null;
|
||||
@@ -48,6 +55,7 @@ type SettingRecordMap = {
|
||||
favoriteStationIds: string[];
|
||||
stationUsageCounts: Record<string, number>;
|
||||
recentStationHistory: PersistedRecentStationEntry[];
|
||||
stationSongHistory: Record<string, PersistedSongHistoryEntry[]>;
|
||||
stationHealth: Record<string, PersistedStationHealth>;
|
||||
lastExportedAt: string | null;
|
||||
lastImportedAt: string | null;
|
||||
@@ -88,6 +96,7 @@ const DEFAULT_SNAPSHOT: PersistedSnapshot = {
|
||||
favoriteStationIds: [],
|
||||
stationUsageCounts: {},
|
||||
recentStationHistory: [],
|
||||
stationSongHistory: {},
|
||||
stationHealth: {},
|
||||
lastExportedAt: null,
|
||||
lastImportedAt: null,
|
||||
@@ -103,6 +112,7 @@ const LOCAL_STORAGE_KEYS = {
|
||||
favoriteStationIds: 'favoriteStationIds',
|
||||
stationUsageCounts: 'stationUsageCounts',
|
||||
recentStationHistory: 'recentStationHistory',
|
||||
stationSongHistory: 'stationSongHistory',
|
||||
stationHealth: 'stationHealth',
|
||||
lastExportedAt: 'lastExportedAt',
|
||||
lastImportedAt: 'lastImportedAt',
|
||||
@@ -238,6 +248,43 @@ function sanitizeRecentStationHistory(value: unknown): PersistedRecentStationEnt
|
||||
.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>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(map).map(([stationId, record]) => [stationId, { ...record }]),
|
||||
@@ -248,6 +295,12 @@ function cloneRecentStationHistory(history: PersistedRecentStationEntry[]) {
|
||||
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 {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
@@ -293,6 +346,9 @@ function mirrorSettingToLocalStorage<K extends SettingKey>(key: K, value: Settin
|
||||
case 'recentStationHistory':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.recentStationHistory, JSON.stringify(Array.isArray(value) ? value : []));
|
||||
break;
|
||||
case 'stationSongHistory':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationSongHistory, JSON.stringify(value && typeof value === 'object' ? value : {}));
|
||||
break;
|
||||
case 'stationHealth':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.stationHealth, JSON.stringify(value && typeof value === 'object' ? value : {}));
|
||||
break;
|
||||
@@ -357,6 +413,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
||||
})
|
||||
: [];
|
||||
const recentStationHistory = sanitizeRecentStationHistory(input.recentStationHistory);
|
||||
const stationSongHistory = sanitizeStationSongHistoryMap(input.stationSongHistory);
|
||||
const stationHealth = sanitizeStationHealthMap(input.stationHealth);
|
||||
|
||||
return {
|
||||
@@ -368,6 +425,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
||||
favoriteStationIds,
|
||||
stationUsageCounts,
|
||||
recentStationHistory,
|
||||
stationSongHistory,
|
||||
stationHealth,
|
||||
lastExportedAt,
|
||||
lastImportedAt,
|
||||
@@ -396,6 +454,7 @@ function readLegacyLocalStorage(): PersistedSnapshot {
|
||||
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
|
||||
stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}),
|
||||
recentStationHistory: sanitizeRecentStationHistory(safeJsonParse<unknown[]>(storage?.getItem('recentStationHistory') ?? null, [])),
|
||||
stationSongHistory: sanitizeStationSongHistoryMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationSongHistory') ?? null, {})),
|
||||
stationHealth: sanitizeStationHealthMap(safeJsonParse<Record<string, unknown>>(storage?.getItem('stationHealth') ?? null, {})),
|
||||
lastExportedAt: storage?.getItem('lastExportedAt') ?? 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.stationUsageCounts, 'stationUsageCounts'),
|
||||
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.lastExportedAt, 'lastExportedAt'),
|
||||
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 stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | 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 lastExportedAt = await settingsStore.get('lastExportedAt') as SettingRecordMap['lastExportedAt'] | undefined;
|
||||
const lastImportedAt = await settingsStore.get('lastImportedAt') as SettingRecordMap['lastImportedAt'] | undefined;
|
||||
@@ -479,6 +540,7 @@ export async function hydratePlayerPersistence() {
|
||||
? stationUsageCounts
|
||||
: {},
|
||||
recentStationHistory: sanitizeRecentStationHistory(recentStationHistory),
|
||||
stationSongHistory: sanitizeStationSongHistoryMap(stationSongHistory),
|
||||
stationHealth: sanitizeStationHealthMap(stationHealth),
|
||||
lastExportedAt: typeof lastExportedAt === 'string' ? lastExportedAt : null,
|
||||
lastImportedAt: typeof lastImportedAt === 'string' ? lastImportedAt : null,
|
||||
@@ -500,6 +562,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
||||
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
|
||||
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
|
||||
recentStationHistory: cloneRecentStationHistory(DEFAULT_SNAPSHOT.recentStationHistory),
|
||||
stationSongHistory: cloneSongHistoryMap(DEFAULT_SNAPSHOT.stationSongHistory),
|
||||
stationHealth: cloneStationHealthMap(DEFAULT_SNAPSHOT.stationHealth),
|
||||
lastExportedAt: DEFAULT_SNAPSHOT.lastExportedAt,
|
||||
lastImportedAt: DEFAULT_SNAPSHOT.lastImportedAt,
|
||||
@@ -516,6 +579,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
||||
favoriteStationIds: [...snapshot.favoriteStationIds],
|
||||
stationUsageCounts: { ...snapshot.stationUsageCounts },
|
||||
recentStationHistory: cloneRecentStationHistory(snapshot.recentStationHistory),
|
||||
stationSongHistory: cloneSongHistoryMap(snapshot.stationSongHistory),
|
||||
stationHealth: cloneStationHealthMap(snapshot.stationHealth),
|
||||
lastExportedAt: snapshot.lastExportedAt,
|
||||
lastImportedAt: snapshot.lastImportedAt,
|
||||
@@ -559,6 +623,10 @@ export function getPersistedRecentStationHistory() {
|
||||
return cloneRecentStationHistory(snapshot.recentStationHistory);
|
||||
}
|
||||
|
||||
export function getPersistedStationSongHistory() {
|
||||
return cloneSongHistoryMap(snapshot.stationSongHistory);
|
||||
}
|
||||
|
||||
export function getPersistedStationHealth() {
|
||||
return cloneStationHealthMap(snapshot.stationHealth);
|
||||
}
|
||||
@@ -620,6 +688,11 @@ export function persistRecentStationHistory(history: PersistedRecentStationEntry
|
||||
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>) {
|
||||
snapshot.stationHealth = cloneStationHealthMap(sanitizeStationHealthMap(health));
|
||||
void writeSetting('stationHealth', snapshot.stationHealth);
|
||||
@@ -745,6 +818,7 @@ export async function importPlayerPersistence(source: unknown) {
|
||||
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
|
||||
mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts);
|
||||
mirrorSettingToLocalStorage('recentStationHistory', snapshot.recentStationHistory);
|
||||
mirrorSettingToLocalStorage('stationSongHistory', snapshot.stationSongHistory);
|
||||
mirrorSettingToLocalStorage('stationHealth', snapshot.stationHealth);
|
||||
mirrorSettingToLocalStorage('lastExportedAt', snapshot.lastExportedAt);
|
||||
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.stationUsageCounts, 'stationUsageCounts'),
|
||||
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.lastExportedAt, 'lastExportedAt'),
|
||||
tx.objectStore('settings').put(snapshot.lastImportedAt, 'lastImportedAt'),
|
||||
|
||||
@@ -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
+2
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_BUILD_ID__: string;
|
||||
@@ -26,6 +26,7 @@ if [[ "${SKIP_DEPLOY:-0}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
rsync -avz \
|
||||
--delete \
|
||||
--chmod=D755,F644 \
|
||||
--exclude ".phpintel/" \
|
||||
--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()}`;
|
||||
|
||||
function htmlBuildVersionPlugin() {
|
||||
return {
|
||||
name: 'html-build-version',
|
||||
transformIndexHtml(html) {
|
||||
return html.replaceAll('__BUILD_STAMP__', buildStamp);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managedCatalogApiPlugin() {
|
||||
async function handleManagedCatalogRequest(req, res, next) {
|
||||
if (!req.url) {
|
||||
@@ -71,12 +80,18 @@ function managedCatalogApiPlugin() {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), managedCatalogApiPlugin()],
|
||||
define: {
|
||||
__APP_BUILD_ID__: JSON.stringify(buildStamp),
|
||||
},
|
||||
plugins: [react(), managedCatalogApiPlugin(), htmlBuildVersionPlugin()],
|
||||
base: './',
|
||||
build: {
|
||||
minify: 'terser',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(process.cwd(), 'index.html'),
|
||||
about: resolve(process.cwd(), 'about.html'),
|
||||
docs: resolve(process.cwd(), 'docs.html'),
|
||||
privacy: resolve(process.cwd(), 'privacy.html'),
|
||||
},
|
||||
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