Visually makeover

This commit is contained in:
2026-07-03 21:05:07 +02:00
parent 10a239a155
commit c7fceee11f
40 changed files with 6851 additions and 1201 deletions
-1
View File
@@ -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
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+328
View File
@@ -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>
+61
View File
@@ -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
View File
@@ -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">
+3036 -259
View File
File diff suppressed because it is too large Load Diff
+22 -9
View File
@@ -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"
}
}
+28
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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');
+51
View File
@@ -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",
+51
View File
@@ -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

+8
View File
@@ -0,0 +1,8 @@
User-agent: facebookexternalhit
Allow: /
User-agent: Facebot
Allow: /
User-agent: *
Allow: /
-24
View File
@@ -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",
+10 -54
View File
@@ -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-1777474420392';
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;
-17
View File
@@ -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');
+32
View File
@@ -0,0 +1,32 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const repoRoot = process.cwd();
const publicSwPath = resolve(repoRoot, 'public', 'sw.js');
const distSwPath = resolve(repoRoot, 'dist', 'sw.js');
const distIndexHtmlPath = resolve(repoRoot, 'dist', 'index.html');
const radioConfigPath = resolve(repoRoot, 'src', 'radio', 'radioConfig.json');
const serviceWorkerConfigPath = resolve(repoRoot, 'src', 'shared', 'serviceWorkerConfig.json');
const distIndexHtml = await readFile(distIndexHtmlPath, 'utf8');
const buildStampMatch = distIndexHtml.match(/assets\/(?:main|player)-(\d+)-/);
if (!buildStampMatch) {
throw new Error('Unable to infer build stamp from dist/index.html.');
}
const buildStamp = buildStampMatch[1];
const radioConfig = JSON.parse(await readFile(radioConfigPath, 'utf8'));
const serviceWorkerConfig = JSON.parse(await readFile(serviceWorkerConfigPath, 'utf8'));
const swTemplate = await readFile(publicSwPath, 'utf8');
const stampedSource = swTemplate
.replaceAll('__APP_BUILD_ID__', buildStamp)
.replace('__RADIO_COUNTRIES_JSON__', JSON.stringify(radioConfig.radioCountries))
.replaceAll('__MANAGED_COUNTRY_CODE__', String(radioConfig.managedCountryCode ?? 'SI'))
.replaceAll('__SW_STATION_SYNC_TAG__', serviceWorkerConfig.stationSyncTag)
.replaceAll('__SW_STATION_PERIODIC_SYNC_TAG__', serviceWorkerConfig.stationPeriodicSyncTag)
.replaceAll('__SW_MANAGED_CATALOG_PERIODIC_SYNC_TAG__', serviceWorkerConfig.managedCatalogPeriodicSyncTag)
.replaceAll('__SW_STATION_SYNC_META_PATH__', serviceWorkerConfig.stationSyncMetaPath);
await writeFile(distSwPath, stampedSource, 'utf8');
console.log(`[stamp-dist-sw] Updated dist/sw.js with build stamp ${buildStamp}`);
+426 -65
View File
@@ -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,29 +293,32 @@ function TrackInfo() {
<div id="now-title" className="now-title" aria-hidden="false" />
</div>
<p id="station-subtitle" />
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
<div className="station-meta-row">
<div id="station-health-summary" className="station-health-summary hidden" aria-live="polite" />
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
<span className="status-dot" />
<span id="status-text" />
<span id="engine-badge" className="engine-badge engine-html" title="HTML5 Audio playback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true">
<path d="M4 15V9" />
<path d="M8 19V5" />
<path d="M12 16V8" />
<path d="M16 18V6" />
<path d="M20 15V9" />
</svg>
<span id="engine-label">HTML5</span>
</span>
</div>
</div>
<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" />
<span id="engine-badge" className="engine-badge engine-html" title="HTML5 Audio playback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true">
<path d="M4 15V9" />
<path d="M8 19V5" />
<path d="M12 16V8" />
<path d="M16 18V6" />
<path d="M20 15V9" />
</svg>
<span id="engine-label">HTML5</span>
</span>
</div>
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
<div id="catalog-sync-status" className="catalog-sync-status hidden" aria-live="polite">
<span id="catalog-sync-status-text" />
<time id="catalog-sync-status-time" className="catalog-sync-status-time" />
</div>
<div id="catalog-sync-history" className="catalog-sync-history hidden" aria-live="polite" />
<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"
@@ -218,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">
@@ -274,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">
@@ -426,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">
@@ -434,16 +638,49 @@ function StationLibrary() {
<p className="library-eyebrow">Browse</p>
<h2>Stations</h2>
</div>
<button id="station-library-close" className="icon-btn library-close" aria-label="Close station library" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
<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">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
{import.meta.env.DEV && (
<button
id="clear-managed-catalog-btn"
className="icon-btn library-clear-managed"
aria-label="Clear persisted managed catalog"
title="Clear persisted managed catalog"
type="button"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 0-9 9" />
<path d="M21 3v6h-6" />
</svg>
</button>
)}
</div>
</div>
<div 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">
@@ -462,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">
@@ -472,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" />
</svg>
</button>
<div id="station-sort-menu" className="library-select-menu" role="listbox" aria-label="Sort options" />
</div>
<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>
<div className="library-tabs" role="tablist" aria-label="Station filters">
@@ -556,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">
@@ -568,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>
);
}
+56 -16
View File
@@ -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 {
await registration.sync.register(STATION_SYNC_TAG);
console.debug('ServiceWorker background sync registered:', STATION_SYNC_TAG);
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>,
);
});
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
document.getElementById('app-loading')?.remove();
+913 -536
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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' },
];
+134
View File
@@ -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;
+220
View File
@@ -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();
}
+24
View File
@@ -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']);
});
});
+2 -1
View File
@@ -5,6 +5,7 @@ import {
} 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)) {
@@ -32,7 +33,7 @@ export async function loadPersistedRadioStations(): Promise<RadioStation[] | nul
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);
+48
View File
@@ -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" }
]
}
+9
View File
@@ -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);
});
});
+4 -46
View File
@@ -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];
+16
View File
@@ -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"
}
}
+75
View File
@@ -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'),
+63
View File
@@ -23,6 +23,8 @@ const LOCAL_STORAGE_KEYS = {
managed: 'radioplayer.stationCatalog.managed',
};
const BUILD_ID_KEY = 'radioplayer.stationCatalog.buildId';
let dbPromise: Promise<IDBPDatabase<StationCatalogDb> | null> | null = null;
function getDb() {
@@ -109,7 +111,59 @@ function writeCatalogToLocalStorage(record: StationCatalogRecord) {
}
}
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);
@@ -133,6 +187,11 @@ async function writeCatalog(key: StationCatalogKey, stations: unknown[], source:
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;
@@ -167,3 +226,7 @@ export async function loadPersistedManagedStationCatalog(): Promise<{ stations:
export async function persistManagedStationCatalog(stations: unknown[], source: string | null) {
await writeCatalog('managed', stations, source);
}
export async function clearPersistedManagedStationCatalog(): Promise<void> {
await clearManagedCatalogPersistence();
}
+879 -112
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_BUILD_ID__: string;
+1
View File
@@ -26,6 +26,7 @@ if [[ "${SKIP_DEPLOY:-0}" == "1" ]]; then
fi
rsync -avz \
--delete \
--chmod=D755,F644 \
--exclude ".phpintel/" \
--exclude ".env" \
+28
View File
@@ -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
View File
@@ -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: {
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.test.{js,ts}'],
coverage: {
reporter: ['text', 'html'],
},
},
});