feat: add country selection, cron automation, sparkle effects and layout fixes

This commit is contained in:
2026-04-29 16:34:09 +02:00
parent c8f8c76e8a
commit 8bd9106ff3
11 changed files with 1130 additions and 52 deletions

226
.vscode/agents/git-auto-commit.md vendored Normal file
View File

@@ -0,0 +1,226 @@
# Git Auto Commit Agent
You are working inside this VS Code workspace.
Your task is to inspect the current Git changes, generate a clear commit message, stage the safe changed files, and create a local Git commit.
## Goal
Create one clean Git commit that accurately describes the current workspace changes.
## Important Rules
* Do not push.
* Do not run `git push`.
* Do not create tags.
* Do not rewrite history.
* Do not run `git reset --hard`.
* Do not run destructive cleanup commands.
* Do not modify source files unless absolutely necessary.
* Do not bypass Git hooks.
* Do not commit secrets or private files.
* Do not commit files that are clearly generated, temporary, cached, or environment-specific unless they are already intentionally tracked by the project.
## Files That Must Not Be Committed
Before staging, carefully check for sensitive or unsafe files.
Never commit files like:
```text
.env
.env.*
*.key
*.pem
*.p12
*.pfx
*.crt
*.sql
*.sqlite
*.db
id_rsa
id_ed25519
npm-debug.log
yarn-error.log
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
node_modules/*
vendor/*
.DS_Store
Thumbs.db
```
If such files appear in the changes, do not stage them. Report them clearly.
## Git Checks To Run
First inspect the repository state:
```bash
git branch --show-current
git status --short
git diff --stat
git diff
```
Also check staged changes if needed:
```bash
git diff --cached --stat
git diff --cached
```
## Commit Message Style
Use Conventional Commits format:
```text
type: short summary
```
Allowed types:
```text
feat
fix
refactor
chore
docs
style
test
build
perf
ci
```
Choose the most accurate type.
## Commit Message Examples
```text
feat: add managed stations JSON endpoint
fix: route managed stations JSON request to PHP endpoint
chore: update Apache vhost rewrite config
refactor: simplify station API routing
docs: add deployment notes
build: update frontend build config
```
## How To Choose Commit Type
Use:
* `feat` for a new feature or visible capability
* `fix` for a bug fix
* `refactor` for code restructuring without behavior change
* `chore` for maintenance, config, cleanup, or small project updates
* `docs` for documentation-only changes
* `style` for formatting-only changes
* `test` for test changes
* `build` for build system, dependencies, Vite, npm, Composer, Docker, or CI-related changes
* `perf` for performance improvements
* `ci` for GitHub Actions, GitLab CI, or deployment pipeline changes
## Summary Before Commit
Before committing, show a short summary with:
```text
Branch:
Changed files:
Main changes:
Proposed commit message:
Skipped files:
```
## Staging Rules
Stage all safe changes using:
```bash
git add -A
```
If unsafe files are detected, stage only safe files individually.
Example:
```bash
git add path/to/safe-file.php path/to/another-safe-file.js
```
Do not stage unsafe files.
## Commit Command
Create the commit using the generated message.
For a simple commit:
```bash
git commit -m "type: short summary"
```
For a commit that needs more detail:
```bash
git commit -m "type: short summary" -m "Additional explanation of the important changes."
```
## Commit Body Rules
Use a commit body only when helpful.
The body should explain:
* what changed
* why it changed
* any important technical notes
Keep it short and useful.
## If There Are No Changes
If the working tree is clean, say:
```text
No changes to commit.
```
Do not create an empty commit.
## If Commit Fails
If the commit fails:
1. Show the error.
2. Explain what probably caused it.
3. Do not bypass hooks.
4. Do not force the commit.
5. Stop and wait for the user.
## Final Response
After creating the commit, respond with:
```text
Commit created.
Branch: <branch-name>
Commit: <commit-hash>
Message: <commit-message>
```
Also mention any skipped files if there were any.
## Absolute Prohibition
Never run:
```bash
git push
```
Only create the local commit.

138
README.md
View File

@@ -1,21 +1,24 @@
# RadioPlayer # RadioPlayer
RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. It loads its bundled managed catalog from `public/stations.json`, exposes that catalog through a same-origin backend endpoint at `/api/managed-stations.json`, supports custom stations, and includes a built-in updater for refreshing the managed list from the live Radio.si feed. RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations.
It uses a managed catalog from `public/stations.json`, exposes a same-origin managed endpoint at `/api/managed-stations.json`, supports user stations with local persistence, and includes scripts to refresh station data from Radio.si.
## Features ## Features
- Station browser with search, categories, favourites, and recent stations - Station library with search, category tabs, favorites, recents, sorting, and pagination.
- Audio playback with previous/next station controls - Country filtering plus a country picker to choose which Radio Browser countries are synced and shown.
- Cast support - Audio playback with previous/next controls, volume/mute, and coverflow quick picks.
- Production service worker for app-shell caching, offline launch support, and faster repeat visits - Google Cast and AirPlay output support.
- App install prompt for supported browsers - User station management with local import/export/reset tooling.
- Custom station editor - Managed catalog fallback chain: remote endpoint -> cached remote -> bundled `stations.json`.
- Live station metadata and artwork rendering - Production service worker with app shell caching, station sync cache, and periodic refresh registration.
- PWA install prompt and offline-friendly launch behavior.
## Requirements ## Requirements
- Node.js 18 or newer - Node.js 18+
- npm - npm
- For production managed endpoint: PHP-enabled web server with rewrite support (Apache + `.htaccess` in this repo)
## Getting Started ## Getting Started
@@ -25,7 +28,7 @@ Install dependencies:
npm install npm install
``` ```
Start the development server: Run development server:
```bash ```bash
npm run dev npm run dev
@@ -37,35 +40,32 @@ Build for production:
npm run build npm run build
``` ```
Preview the production build: Preview production build locally:
```bash ```bash
npm run preview npm run preview
``` ```
Run the production build with the bundled same-origin backend endpoint: Deploy built assets with the included script:
```bash
npm run serve:backend
```
Deploy the built frontend plus backend server files to the remote host:
```bash ```bash
bash sync.sh bash sync.sh
``` ```
## Station Data ## Managed Catalog Endpoint
The app keeps the editorial managed list in `public/stations.json`. Runtime managed endpoint path:
At runtime, the frontend prefers the same-origin managed endpoint:
```text ```text
/api/managed-stations.json /api/managed-stations.json
``` ```
That endpoint returns: In production this is served by:
- `public/.htaccess` rewrite rule
- `public/api/managed-stations.php`
Response shape:
```json ```json
{ {
@@ -75,55 +75,107 @@ That endpoint returns:
} }
``` ```
If the backend endpoint is unavailable, the frontend and service worker fall back to the bundled `stations.json` file. In dev/preview, Vite middleware in `vite.config.js` serves the same envelope from `stations.json`.
To refresh the file from the remote source, run: If the endpoint is unavailable, frontend and service worker fall back to bundled `public/stations.json`.
## Station Data Refresh
Refresh managed stations from Radio.si:
```bash ```bash
npm run update:stations npm run update:stations
``` ```
That command fetches the latest station list from: Refresh and rebuild in one command:
```text ```bash
https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d npm run update:stations:build
``` ```
and rewrites `public/stations.json` while preserving the existing JSON structure used by the app. The backend endpoint reads from that file and wraps it in the runtime envelope shown above. Custom source and output:
You can also pass a custom source URL or a custom output path if needed:
```bash ```bash
node scripts/update-stations.mjs <source-url> <output-path> node scripts/update-stations.mjs <source-url> <output-path>
``` ```
## Cron Automation (Server)
Helper script:
```bash
bash scripts/cron-refresh-stations.sh
```
Update-only helper script:
```bash
bash scripts/cron-update-stations.sh
```
The script:
- acquires a lock file to prevent overlapping runs,
- runs station refresh and build,
- optionally runs `DEPLOY_CMD`,
- writes logs to `/tmp/radioplayer-refresh-stations.log` by default,
- rotates logs automatically when they exceed 1 MB, keeping 5 archives by default.
The update-only script:
- acquires its own lock file,
- runs only `npm run update:stations`,
- optionally runs `POST_UPDATE_CMD`,
- writes logs to `/tmp/radioplayer-update-stations.log`,
- uses the same log rotation behavior.
Example crontab (every 6 hours):
```cron
0 */6 * * * REPO_DIR=/opt/www/virtual/RadioPlayer DEPLOY_CMD='rsync -av --delete /opt/www/virtual/RadioPlayer/dist/ /opt/www/virtual/RadioPlayer/' /opt/www/virtual/RadioPlayer/scripts/cron-refresh-stations.sh
```
Example update-only crontab (every 2 hours):
```cron
0 */2 * * * REPO_DIR=/opt/www/virtual/RadioPlayer /opt/www/virtual/RadioPlayer/scripts/cron-update-stations.sh
```
## Project Structure ## Project Structure
```text ```text
index.html index.html
privacy.html
package.json package.json
public/ public/
.htaccess
api/
managed-stations.php
data/
radio-stations.json
radio-stations-sync.json
manifest.json manifest.json
stations.json stations.json
sw.js sw.js
server/ scripts/
index.mjs bump-sw-cache-version.mjs
managedCatalogData.mjs cron-refresh-stations.sh
cron-update-stations.sh
import-radio-stations.ts
update-stations.mjs
src/ src/
App.jsx App.jsx
main.jsx main.jsx
player.js player.js
styles.css styles.css
scripts/ radio/
update-stations.mjs storage/
``` ```
## Notes ## Notes
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point. - Service worker is only active in production builds. In dev, SW registrations and caches are cleared automatically.
- The service worker is registered only in production builds. During development, existing service workers and caches are cleared automatically to avoid stale assets while iterating. - `src/main.jsx` registers background sync / periodic sync where supported.
- The updater script uses the remote feed as the source of truth for the station list and writes the merged result into `public/stations.json`. - `src/player.js` also refreshes managed catalog on app focus after a timeout (fallback for browsers without periodic sync).
- Vite dev and preview both expose `/api/managed-stations.json` on the same origin via middleware, and `npm run serve:backend` serves the built app plus the same endpoint from Node. - `sync.sh` deploys `dist/` only.
- `sync.sh` now deploys both the built frontend files and the `server/` runtime so the same-origin backend can be started remotely with `node /opt/www/virtual/RadioPlayer/server/index.mjs`. - If you edit stations manually, rerun `npm run update:stations` to resync from upstream when needed.
- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog.
- Static-only deployments still work because the frontend falls back to bundled `stations.json`, but a true same-origin backend endpoint requires deploying a server that answers `/api/managed-stations.json`.

View File

@@ -10,6 +10,7 @@
"preview": "vite preview", "preview": "vite preview",
"serve:backend": "node server/index.mjs", "serve:backend": "node server/index.mjs",
"update:stations": "node scripts/update-stations.mjs", "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", "radio:import": "tsx scripts/import-radio-stations.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@@ -3,7 +3,7 @@
// //
// This value is rewritten automatically before each build so deployed clients // This value is rewritten automatically before each build so deployed clients
// refresh to the newest shell and cached assets. // refresh to the newest shell and cached assets.
const CACHE_NAME = 'radioplayer-pwa-v5-1777463324180'; const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316';
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1'; const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1'; const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search'; const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
@@ -60,6 +60,8 @@ const RADIO_COUNTRIES = [
{ name: 'Turkey', code: 'TR' }, { name: 'Turkey', code: 'TR' },
{ name: 'Ukraine', code: 'UA' }, { name: 'Ukraine', code: 'UA' },
]; ];
const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code);
const MANAGED_COUNTRY_CODE = 'SI';
const MAX_TAGS = 12; const MAX_TAGS = 12;
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([ const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
'wma', 'wma',
@@ -96,6 +98,7 @@ const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registr
const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href; const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href;
const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname; const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname;
const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href; const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href;
const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href;
const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname; const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href; const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href;
const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname; const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname;
@@ -190,6 +193,54 @@ async function writeJsonToCache(cache, requestUrl, payload) {
})); }));
} }
function sanitizeSelectedCountryCodes(countryCodes) {
const uniqueCodes = Array.isArray(countryCodes)
? Array.from(new Set(
countryCodes
.map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : ''))
.filter((entry) => /^[A-Z]{2}$/.test(entry)),
))
: [];
if (!uniqueCodes.includes(MANAGED_COUNTRY_CODE)) {
uniqueCodes.unshift(MANAGED_COUNTRY_CODE);
}
return uniqueCodes.length > 0 ? uniqueCodes : [...DEFAULT_SYNC_COUNTRY_CODES];
}
async function readSyncSettings(cache) {
const cached = await cache.match(SYNC_SETTINGS_URL);
if (!cached) {
return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] };
}
try {
const payload = await cached.json();
return {
selectedCountryCodes: sanitizeSelectedCountryCodes(payload?.selectedCountryCodes),
};
} catch {
return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] };
}
}
async function writeSyncSettings(cache, countryCodes) {
const payload = {
selectedCountryCodes: sanitizeSelectedCountryCodes(countryCodes),
updatedAt: new Date().toISOString(),
};
await writeJsonToCache(cache, SYNC_SETTINGS_URL, payload);
return payload;
}
function getSyncCountries(selectedCountryCodes) {
return sanitizeSelectedCountryCodes(selectedCountryCodes).map((code) => {
const existing = RADIO_COUNTRIES.find((country) => country.code === code);
return existing || { name: code, code };
});
}
async function fetchCountryStations(country) { async function fetchCountryStations(country) {
const url = new URL(RADIO_BROWSER_API_ENDPOINT); const url = new URL(RADIO_BROWSER_API_ENDPOINT);
url.search = new URLSearchParams({ url.search = new URLSearchParams({
@@ -223,15 +274,19 @@ async function fetchCountryStations(country) {
.filter(Boolean); .filter(Boolean);
} }
async function syncRadioStations(reason = 'sync') { async function syncRadioStations(reason = 'sync', selectedCountryCodesOverride = null) {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const syncSettings = selectedCountryCodesOverride
? { selectedCountryCodes: sanitizeSelectedCountryCodes(selectedCountryCodesOverride) }
: await readSyncSettings(syncCache);
const syncCountries = getSyncCountries(syncSettings.selectedCountryCodes);
const aggregatedStations = []; const aggregatedStations = [];
const seenStationIds = new Set(); const seenStationIds = new Set();
const seenStreamUrls = new Set(); const seenStreamUrls = new Set();
const failedCountries = []; const failedCountries = [];
let syncedCountries = 0; let syncedCountries = 0;
for (const country of RADIO_COUNTRIES) { for (const country of syncCountries) {
try { try {
const stations = await fetchCountryStations(country); const stations = await fetchCountryStations(country);
await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations); await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations);
@@ -263,6 +318,7 @@ async function syncRadioStations(reason = 'sync') {
reason, reason,
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),
countryCount: syncedCountries, countryCount: syncedCountries,
selectedCountryCodes: syncSettings.selectedCountryCodes,
failedCountries, failedCountries,
stationCount: aggregatedStations.length, stationCount: aggregatedStations.length,
}; };
@@ -477,6 +533,31 @@ self.addEventListener('sync', (event) => {
event.waitUntil(syncRadioStations('background-sync')); event.waitUntil(syncRadioStations('background-sync'));
}); });
self.addEventListener('message', (event) => {
if (event.data?.type !== 'set-sync-countries') return;
event.waitUntil((async () => {
try {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const syncSettings = await writeSyncSettings(syncCache, event.data?.countryCodes);
const syncResult = event.data?.syncNow
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
: null;
event.ports?.[0]?.postMessage({
ok: true,
selectedCountryCodes: syncSettings.selectedCountryCodes,
syncResult,
});
} catch (error) {
event.ports?.[0]?.postMessage({
ok: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
})());
});
self.addEventListener('periodicsync', (event) => { self.addEventListener('periodicsync', (event) => {
if (event.tag === STATION_PERIODIC_SYNC_TAG) { if (event.tag === STATION_PERIODIC_SYNC_TAG) {
event.waitUntil(syncRadioStations('periodic-sync')); event.waitUntil(syncRadioStations('periodic-sync'));

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Cron helper for refreshing station catalog and rebuilding assets.
# Optional env vars:
# - REPO_DIR: repository root (defaults to script parent)
# - LOCK_FILE: lock path (defaults to /tmp/radioplayer-refresh-stations.lock)
# - LOG_FILE: log path (defaults to /tmp/radioplayer-refresh-stations.log)
# - LOG_MAX_BYTES: rotate log when it grows beyond this size (default 1048576)
# - LOG_KEEP_COUNT: number of rotated logs to keep (default 5)
# - DEPLOY_CMD: optional shell command executed after successful build
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
LOCK_FILE="${LOCK_FILE:-/tmp/radioplayer-refresh-stations.lock}"
LOG_FILE="${LOG_FILE:-/tmp/radioplayer-refresh-stations.log}"
LOG_MAX_BYTES="${LOG_MAX_BYTES:-1048576}"
LOG_KEEP_COUNT="${LOG_KEEP_COUNT:-5}"
rotate_log_if_needed() {
if [[ ! -f "$LOG_FILE" ]]; then
return
fi
local size
size=$(wc -c < "$LOG_FILE")
if [[ "$size" -lt "$LOG_MAX_BYTES" ]]; then
return
fi
local i
for ((i=LOG_KEEP_COUNT; i>=1; i--)); do
if [[ -f "$LOG_FILE.$i" ]]; then
if [[ "$i" -ge "$LOG_KEEP_COUNT" ]]; then
rm -f "$LOG_FILE.$i"
else
mv "$LOG_FILE.$i" "$LOG_FILE.$((i + 1))"
fi
fi
done
mv "$LOG_FILE" "$LOG_FILE.1"
}
mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")"
rotate_log_if_needed
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "[$(date -Is)] refresh skipped: another run is active" >> "$LOG_FILE"
exit 0
fi
{
echo "[$(date -Is)] refresh start"
cd "$REPO_DIR"
npm ci --silent
npm run update:stations
npm run build
if [[ -n "${DEPLOY_CMD:-}" ]]; then
echo "[$(date -Is)] running deploy command"
bash -lc "$DEPLOY_CMD"
fi
echo "[$(date -Is)] refresh complete"
} >> "$LOG_FILE" 2>&1

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Cron helper for refreshing only the managed station catalog.
# Optional env vars:
# - REPO_DIR: repository root (defaults to script parent)
# - LOCK_FILE: lock path (defaults to /tmp/radioplayer-update-stations.lock)
# - LOG_FILE: log path (defaults to /tmp/radioplayer-update-stations.log)
# - LOG_MAX_BYTES: rotate log when it grows beyond this size (default 1048576)
# - LOG_KEEP_COUNT: number of rotated logs to keep (default 5)
# - POST_UPDATE_CMD: optional shell command executed after successful station update
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
LOCK_FILE="${LOCK_FILE:-/tmp/radioplayer-update-stations.lock}"
LOG_FILE="${LOG_FILE:-/tmp/radioplayer-update-stations.log}"
LOG_MAX_BYTES="${LOG_MAX_BYTES:-1048576}"
LOG_KEEP_COUNT="${LOG_KEEP_COUNT:-5}"
rotate_log_if_needed() {
if [[ ! -f "$LOG_FILE" ]]; then
return
fi
local size
size=$(wc -c < "$LOG_FILE")
if [[ "$size" -lt "$LOG_MAX_BYTES" ]]; then
return
fi
local i
for ((i=LOG_KEEP_COUNT; i>=1; i--)); do
if [[ -f "$LOG_FILE.$i" ]]; then
if [[ "$i" -ge "$LOG_KEEP_COUNT" ]]; then
rm -f "$LOG_FILE.$i"
else
mv "$LOG_FILE.$i" "$LOG_FILE.$((i + 1))"
fi
fi
done
mv "$LOG_FILE" "$LOG_FILE.1"
}
mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")"
rotate_log_if_needed
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "[$(date -Is)] update skipped: another run is active" >> "$LOG_FILE"
exit 0
fi
{
echo "[$(date -Is)] update start"
cd "$REPO_DIR"
npm ci --silent
npm run update:stations
if [[ -n "${POST_UPDATE_CMD:-}" ]]; then
echo "[$(date -Is)] running post-update command"
bash -lc "$POST_UPDATE_CMD"
fi
echo "[$(date -Is)] update complete"
} >> "$LOG_FILE" 2>&1

View File

@@ -385,6 +385,42 @@ function EditorOverlay() {
); );
} }
function CountrySelectionOverlay() {
return (
<div id="country-selection-overlay" className="overlay hidden" aria-hidden="true">
<div className="modal country-selection-modal" role="dialog" aria-modal="true" aria-labelledby="countrySelectionTitle">
<h2 id="countrySelectionTitle">Select Countries</h2>
<p id="country-selection-note" className="editor-note" aria-live="polite">
These choices control which Radio Browser countries are synced and shown. Managed Slovenia stays available.
</p>
<div className="country-selection-toolbar">
<label className="library-search country-selection-search" htmlFor="country-selection-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">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input id="country-selection-search-input" type="search" placeholder="Search countries" autoComplete="off" />
</label>
<div className="country-selection-actions">
<button id="country-selection-defaults-btn" className="btn secondary" type="button">Restore Defaults</button>
<button id="country-selection-all-btn" className="btn secondary" type="button">Select All</button>
</div>
</div>
<p id="country-selection-summary" className="editor-note editor-note-subtle" aria-live="polite">
Loading countries...
</p>
<p id="country-selection-empty" className="library-empty hidden">No countries found.</p>
<ul id="country-selection-list" className="device-list country-selection-list" />
<div className="editor-actions country-selection-footer">
<button id="country-selection-save-btn" className="btn cancel" type="button">Save</button>
<button id="country-selection-cancel-btn" className="btn secondary" type="button">Cancel</button>
</div>
</div>
</div>
);
}
function StationLibrary() { function StationLibrary() {
return ( return (
<aside id="station-library" className="station-library" aria-label="Station library"> <aside id="station-library" className="station-library" aria-label="Station library">
@@ -534,6 +570,7 @@ export default function App() {
<LegalLinks /> <LegalLinks />
<StationsOverlay /> <StationsOverlay />
<EditorOverlay /> <EditorOverlay />
<CountrySelectionOverlay />
</main> </main>
<InstallPromptBanner /> <InstallPromptBanner />
</div> </div>

View File

@@ -1,10 +1,11 @@
import { loadRadioStations } from './radio/loadRadioStations.ts'; import { loadRadioStations } from './radio/loadRadioStations.ts';
import { radioCountries } from './radio/radioCountries.ts'; import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts'; import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts';
import { import {
clearPlayerPersistence, clearPlayerPersistence,
deletePersistedUserStationById, deletePersistedUserStationById,
getPersistenceBackend, getPersistenceBackend,
getPersistedSelectedRadioCountryCodes,
getPlayerPersistenceSnapshot, getPlayerPersistenceSnapshot,
getPersistedCastBothMode, getPersistedCastBothMode,
getPersistedFavoriteStationIds, getPersistedFavoriteStationIds,
@@ -23,6 +24,7 @@ import {
persistLastImportedAt, persistLastImportedAt,
persistLastStationCountry, persistLastStationCountry,
persistLastStationId, persistLastStationId,
persistSelectedRadioCountryCodes,
persistRecentStationHistory, persistRecentStationHistory,
persistStationHealth, persistStationHealth,
persistStationUsageCounts, persistStationUsageCounts,
@@ -58,6 +60,17 @@ let stationLibraryQuery = '';
let stationLibraryCategory = 'all'; let stationLibraryCategory = 'all';
let stationLibraryCountry = 'all'; let stationLibraryCountry = 'all';
let stationLibrarySort = 'recommended'; let stationLibrarySort = 'recommended';
let selectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
let draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
let availableRadioCountries = radioCountries.map((country) => ({
...country,
stationCount: null,
pinned: country.code === managedCountryCode,
}));
let availableRadioCountriesLoaded = false;
let availableRadioCountriesRequest = null;
let countrySelectionQuery = '';
let countrySelectionSaving = false;
let stationLibraryPage = 0; let stationLibraryPage = 0;
let stationLibraryPageTotal = 1; let stationLibraryPageTotal = 1;
let stationCatalogState = 'idle'; let stationCatalogState = 'idle';
@@ -123,6 +136,15 @@ const stationCountryFilterBtn = document.getElementById('station-country-filter-
const stationCountryFilterMenu = document.getElementById('station-country-filter-menu'); const stationCountryFilterMenu = document.getElementById('station-country-filter-menu');
const stationCountryFilterText = document.getElementById('station-country-filter-text'); const stationCountryFilterText = document.getElementById('station-country-filter-text');
const stationCountryFilterFlag = document.getElementById('station-country-filter-flag'); const stationCountryFilterFlag = document.getElementById('station-country-filter-flag');
const countrySelectionOverlay = document.getElementById('country-selection-overlay');
const countrySelectionSearchInput = document.getElementById('country-selection-search-input');
const countrySelectionSummaryEl = document.getElementById('country-selection-summary');
const countrySelectionEmptyEl = document.getElementById('country-selection-empty');
const countrySelectionListEl = document.getElementById('country-selection-list');
const countrySelectionDefaultsBtn = document.getElementById('country-selection-defaults-btn');
const countrySelectionAllBtn = document.getElementById('country-selection-all-btn');
const countrySelectionSaveBtn = document.getElementById('country-selection-save-btn');
const countrySelectionCancelBtn = document.getElementById('country-selection-cancel-btn');
const stationSortSelect = document.getElementById('station-sort-select'); const stationSortSelect = document.getElementById('station-sort-select');
const stationSortBtn = document.getElementById('station-sort-btn'); const stationSortBtn = document.getElementById('station-sort-btn');
const stationSortText = document.getElementById('station-sort-text'); const stationSortText = document.getElementById('station-sort-text');
@@ -138,6 +160,7 @@ const installPromptBannerEl = document.getElementById('install-prompt-banner');
const installPromptActionBtn = document.getElementById('install-prompt-action'); const installPromptActionBtn = document.getElementById('install-prompt-action');
const installPromptDismissBtn = document.getElementById('install-prompt-dismiss'); const installPromptDismissBtn = document.getElementById('install-prompt-dismiss');
const radioCountryByCode = new Map(radioCountries.map((country) => [country.code, country]));
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code])); const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name])); const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
@@ -175,6 +198,8 @@ const castOutputText = document.getElementById('cast-output-text');
const IMPORT_EXPORT_SCHEMA = 'radioplayer-local-data'; const IMPORT_EXPORT_SCHEMA = 'radioplayer-local-data';
const IMPORT_EXPORT_VERSION = 1; const IMPORT_EXPORT_VERSION = 1;
const PLAYBACK_START_TIMEOUT_MS = 12000; const PLAYBACK_START_TIMEOUT_MS = 12000;
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
// ── Utilities ──────────────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────────────
@@ -1091,6 +1116,324 @@ function restoreLastStationCountry() {
stationLibraryCountry = savedCountry || 'all'; stationLibraryCountry = savedCountry || 'all';
} }
function normalizeSelectedRadioCountryCodes(codes) {
const normalized = Array.isArray(codes) ? codes : Array.from(codes || []);
const uniqueCodes = Array.from(new Set(
normalized
.map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : ''))
.filter((entry) => /^[A-Z]{2}$/.test(entry)),
));
if (!uniqueCodes.includes(managedCountryCode)) {
uniqueCodes.unshift(managedCountryCode);
}
return uniqueCodes.length > 0 ? uniqueCodes : [...defaultSelectedRadioCountryCodes];
}
function getSelectedRadioCountryCodes() {
return normalizeSelectedRadioCountryCodes(selectedRadioCountryCodes);
}
function restoreSelectedRadioCountryCodes() {
selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(getPersistedSelectedRadioCountryCodes()));
draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes());
}
function getAvailableRadioCountryNameByCode(countryCode) {
const code = String(countryCode || '').trim().toUpperCase();
if (!code) return '';
return availableRadioCountries.find((country) => country.code === code)?.name
|| radioCountryNameByCode.get(code)
|| radioCountryByCode.get(code)?.name
|| code;
}
function mergeAvailableRadioCountries(countries) {
const mergedCountries = new Map();
radioCountries.forEach((country) => {
mergedCountries.set(country.code, {
name: country.name,
code: country.code,
stationCount: null,
pinned: country.code === managedCountryCode,
});
});
countries.forEach((country) => {
const code = String(country?.code || '').trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return;
const existing = mergedCountries.get(code);
mergedCountries.set(code, {
name: String(country?.name || existing?.name || code).trim() || code,
code,
stationCount: Number.isFinite(country?.stationCount) && Number(country.stationCount) >= 0
? Number(country.stationCount)
: existing?.stationCount ?? null,
pinned: code === managedCountryCode,
});
});
availableRadioCountries = Array.from(mergedCountries.values()).sort((left, right) => {
if (left.code === managedCountryCode) return -1;
if (right.code === managedCountryCode) return 1;
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
}
async function ensureAvailableRadioCountriesLoaded() {
if (availableRadioCountriesLoaded) {
return availableRadioCountries;
}
if (availableRadioCountriesRequest) {
return availableRadioCountriesRequest;
}
availableRadioCountriesRequest = (async () => {
try {
const response = await fetch(RADIO_BROWSER_COUNTRIES_API_ENDPOINT, {
cache: 'no-store',
headers: {
accept: 'application/json',
},
mode: 'cors',
});
if (!response.ok) {
throw new Error(`Failed loading countries: ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('Invalid country list response');
}
const remoteCountries = payload
.map((entry) => ({
code: String(entry?.iso_3166_1 || '').trim().toUpperCase(),
name: String(entry?.name || '').trim(),
stationCount: Number(entry?.stationcount),
}))
.filter((entry) => /^[A-Z]{2}$/.test(entry.code) && entry.name.length > 0);
mergeAvailableRadioCountries(remoteCountries);
} catch (error) {
console.debug('Falling back to bundled country list', error);
mergeAvailableRadioCountries([]);
} finally {
availableRadioCountriesLoaded = true;
availableRadioCountriesRequest = null;
}
return availableRadioCountries;
})();
return availableRadioCountriesRequest;
}
function renderCountrySelectionOverlay() {
if (!countrySelectionListEl || !countrySelectionSummaryEl || !countrySelectionEmptyEl) return;
if (countrySelectionSaveBtn) {
countrySelectionSaveBtn.disabled = countrySelectionSaving;
countrySelectionSaveBtn.textContent = countrySelectionSaving ? 'Saving...' : 'Save';
}
if (countrySelectionDefaultsBtn) countrySelectionDefaultsBtn.disabled = countrySelectionSaving;
if (countrySelectionAllBtn) countrySelectionAllBtn.disabled = countrySelectionSaving;
if (countrySelectionCancelBtn) countrySelectionCancelBtn.disabled = countrySelectionSaving;
const normalizedQuery = countrySelectionQuery.trim().toLowerCase();
const entries = availableRadioCountries.filter((country) => {
if (!normalizedQuery) return true;
return country.name.toLowerCase().includes(normalizedQuery) || country.code.toLowerCase().includes(normalizedQuery);
});
const selectedCount = normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes).length;
const loadingLabel = availableRadioCountriesLoaded ? '' : ' Loading available countries...';
countrySelectionSummaryEl.textContent = countrySelectionSaving
? 'Saving country selection...'
: `${selectedCount} countries enabled.${loadingLabel}`;
countrySelectionEmptyEl.classList.toggle('hidden', entries.length > 0);
countrySelectionListEl.innerHTML = '';
if (entries.length === 0) {
return;
}
entries.forEach((country) => {
const item = document.createElement('li');
item.className = 'device country-selection-item';
const row = document.createElement('label');
row.className = 'country-selection-option';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = country.code === managedCountryCode || draftSelectedRadioCountryCodes.has(country.code);
checkbox.disabled = country.code === managedCountryCode || countrySelectionSaving;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
draftSelectedRadioCountryCodes.add(country.code);
} else {
draftSelectedRadioCountryCodes.delete(country.code);
}
renderCountrySelectionOverlay();
});
const flag = document.createElement('span');
flag.className = 'country-selection-flag';
flag.setAttribute('aria-hidden', 'true');
const flagUrl = countryCodeToFlagUrl(country.code);
if (flagUrl) {
const flagImg = document.createElement('img');
flagImg.src = flagUrl;
flagImg.alt = '';
flagImg.className = 'country-selection-flag-img';
flagImg.loading = 'lazy';
flagImg.referrerPolicy = 'no-referrer';
flagImg.addEventListener('error', () => {
flagImg.remove();
flag.textContent = country.code === managedCountryCode ? '🇸🇮' : countryCodeToFlagEmoji(country.code);
}, { once: true });
flag.appendChild(flagImg);
} else {
flag.textContent = country.code === managedCountryCode ? '🇸🇮' : countryCodeToFlagEmoji(country.code);
}
const copy = document.createElement('div');
copy.className = 'country-selection-copy';
const title = document.createElement('div');
title.className = 'device-main';
title.textContent = country.code === managedCountryCode ? `${country.name} (managed)` : country.name;
const sub = document.createElement('div');
sub.className = 'device-sub';
if (country.code === managedCountryCode) {
sub.textContent = 'Always enabled managed catalog';
} else if (Number.isFinite(country.stationCount) && country.stationCount > 0) {
sub.textContent = `${country.code}${country.stationCount.toLocaleString()} stations available`;
} else {
sub.textContent = country.code;
}
copy.appendChild(title);
copy.appendChild(sub);
row.appendChild(checkbox);
row.appendChild(flag);
row.appendChild(copy);
item.appendChild(row);
countrySelectionListEl.appendChild(item);
});
}
async function openCountrySelectionOverlay() {
if (!countrySelectionOverlay) return;
draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes());
countrySelectionQuery = '';
countrySelectionSaving = false;
if (countrySelectionSearchInput) {
countrySelectionSearchInput.value = '';
}
renderCountrySelectionOverlay();
countrySelectionOverlay.classList.remove('hidden');
countrySelectionOverlay.setAttribute('aria-hidden', 'false');
closeStationCountryFilter();
void ensureAvailableRadioCountriesLoaded().then(() => {
renderCountrySelectionOverlay();
});
window.setTimeout(() => countrySelectionSearchInput?.focus(), 30);
}
function closeCountrySelectionOverlay() {
if (!countrySelectionOverlay) return;
countrySelectionOverlay.classList.add('hidden');
countrySelectionOverlay.setAttribute('aria-hidden', 'true');
countrySelectionSaving = false;
}
function getNormalizedStationCountryCode(station) {
const directCode = typeof station?.countryCode === 'string' ? station.countryCode.trim().toUpperCase() : '';
if (/^[A-Z]{2}$/.test(directCode)) return directCode;
const countryValue = typeof station?.country === 'string' ? station.country.trim() : '';
if (/^[A-Z]{2}$/.test(countryValue)) return countryValue.toUpperCase();
return '';
}
function isEnabledRadioCountryStation(station) {
const countryCode = getNormalizedStationCountryCode(station);
if (!countryCode) return true;
return getSelectedRadioCountryCodes().includes(countryCode);
}
async function sendSelectedCountriesToServiceWorker({ syncNow = false } = {}) {
if (!('serviceWorker' in navigator)) {
return { ok: false, skipped: true };
}
const registration = await navigator.serviceWorker.ready.catch(() => null);
const target = navigator.serviceWorker.controller || registration?.active || registration?.waiting;
if (!target) {
return { ok: false, skipped: true };
}
return new Promise((resolve) => {
const channel = new MessageChannel();
const timeoutId = window.setTimeout(() => {
resolve({ ok: false, timedOut: true });
}, syncNow ? 25000 : 8000);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
resolve(event.data || { ok: true });
};
target.postMessage({
type: SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE,
countryCodes: getSelectedRadioCountryCodes(),
syncNow,
}, [channel.port2]);
});
}
async function saveCountrySelection() {
if (countrySelectionSaving) return;
countrySelectionSaving = true;
renderCountrySelectionOverlay();
try {
selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes));
persistSelectedRadioCountryCodes(selectedRadioCountryCodes);
await sendSelectedCountriesToServiceWorker({ syncNow: true });
await loadStations();
if (statusTextEl) {
statusTextEl.textContent = 'Country selection updated';
}
closeCountrySelectionOverlay();
} catch (error) {
console.error('Saving country selection failed', error);
if (statusTextEl) {
statusTextEl.textContent = 'Unable to refresh selected countries';
}
} finally {
countrySelectionSaving = false;
renderCountrySelectionOverlay();
}
}
// ── castBothMode persistence & UI ──────────────────────────────────────────── // ── castBothMode persistence & UI ────────────────────────────────────────────
function saveCastBothMode(val) { function saveCastBothMode(val) {
@@ -1563,7 +1906,7 @@ function getStationTechnicalLabel(station) {
} }
function getStationDetails(station) { function getStationDetails(station) {
return [getStationCountry(station), getStationTechnicalLabel(station)].filter(Boolean).join(' • '); return [getCountryDisplayName(getStationCountry(station)), getStationTechnicalLabel(station)].filter(Boolean).join(' • ');
} }
function getStationSearchText(station) { function getStationSearchText(station) {
@@ -1637,7 +1980,7 @@ function getCountryDisplayName(countryValue) {
if (value === 'all') return 'All countries'; if (value === 'all') return 'All countries';
if (value === 'SI') return 'Slovenia (managed)'; if (value === 'SI') return 'Slovenia (managed)';
if (/^[A-Z]{2}$/i.test(value)) { if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase()); const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
if (countryName) return countryName; if (countryName) return countryName;
} }
return value; return value;
@@ -1649,7 +1992,7 @@ function getCountryFilterDisplayName(countryValue) {
if (value === 'all') return 'All countries'; if (value === 'all') return 'All countries';
if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)'; if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)';
if (/^[A-Z]{2}$/i.test(value)) { if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase()); const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
if (countryName) return countryName; if (countryName) return countryName;
} }
return value; return value;
@@ -1659,7 +2002,9 @@ function getCountryCodeFromValue(countryValue) {
const value = String(countryValue || '').trim(); const value = String(countryValue || '').trim();
if (!value || value === 'all') return ''; if (!value || value === 'all') return '';
if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase(); if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase();
return radioCountryCodeByName.get(value) || ''; return radioCountryCodeByName.get(value)
|| availableRadioCountries.find((country) => country.name === value)?.code
|| '';
} }
function resetStationLibraryPage() { function resetStationLibraryPage() {
@@ -1839,6 +2184,15 @@ function renderCountryFilterOptions() {
}); });
orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country)); orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country));
const manageButton = document.createElement('button');
manageButton.type = 'button';
manageButton.className = 'library-select-manage-btn';
manageButton.textContent = 'Choose countries...';
manageButton.addEventListener('click', () => {
void openCountrySelectionOverlay();
});
stationCountryFilterMenu.appendChild(manageButton);
} }
function getFilteredStationEntries() { function getFilteredStationEntries() {
@@ -2262,6 +2616,7 @@ async function loadStations() {
const managedRaw = await loadManagedStations().catch(() => []); const managedRaw = await loadManagedStations().catch(() => []);
const normalizedRaw = raw const normalizedRaw = raw
.filter((station) => isEnabledRadioCountryStation(station))
.map((s) => normalizeStationRecord(s)) .map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0); .filter((s) => s.enabled !== false && s.url && s.url.length > 0);
@@ -3293,6 +3648,26 @@ function setupEventListeners() {
installAppBtn?.addEventListener('click', promptInstallApp); installAppBtn?.addEventListener('click', promptInstallApp);
castBtn?.addEventListener('click', requestCastSession); castBtn?.addEventListener('click', requestCastSession);
editorCloseBtn?.addEventListener('click', closeEditorOverlay); editorCloseBtn?.addEventListener('click', closeEditorOverlay);
countrySelectionCancelBtn?.addEventListener('click', closeCountrySelectionOverlay);
countrySelectionOverlay?.addEventListener('click', (e) => {
if (e.target === countrySelectionOverlay) closeCountrySelectionOverlay();
});
countrySelectionSearchInput?.addEventListener('input', () => {
countrySelectionQuery = countrySelectionSearchInput.value || '';
renderCountrySelectionOverlay();
});
countrySelectionDefaultsBtn?.addEventListener('click', () => {
draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
renderCountrySelectionOverlay();
});
countrySelectionAllBtn?.addEventListener('click', async () => {
await ensureAvailableRadioCountriesLoaded();
draftSelectedRadioCountryCodes = new Set(availableRadioCountries.map((country) => country.code));
renderCountrySelectionOverlay();
});
countrySelectionSaveBtn?.addEventListener('click', () => {
void saveCountrySelection();
});
exportUserDataBtn?.addEventListener('click', handleExportUserData); exportUserDataBtn?.addEventListener('click', handleExportUserData);
importUserDataBtn?.addEventListener('click', () => importUserDataInput?.click()); importUserDataBtn?.addEventListener('click', () => importUserDataInput?.click());
importUserDataInput?.addEventListener('change', () => { importUserDataInput?.addEventListener('change', () => {
@@ -3358,6 +3733,11 @@ function setupEventListeners() {
closeSortFilter(); closeSortFilter();
return; return;
} }
if (countrySelectionOverlay && !countrySelectionOverlay.classList.contains('hidden') && e.code === 'Escape') {
e.preventDefault();
closeCountrySelectionOverlay();
return;
}
if (e.code === 'Space') { e.preventDefault(); togglePlay(); } if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
else if (e.code === 'ArrowRight') playNext(); else if (e.code === 'ArrowRight') playNext();
else if (e.code === 'ArrowLeft') playPrev(); else if (e.code === 'ArrowLeft') playPrev();
@@ -3412,8 +3792,10 @@ async function init() {
restoreSavedVolume(); restoreSavedVolume();
restoreCastBothMode(); restoreCastBothMode();
restoreLastStationCountry(); restoreLastStationCountry();
restoreSelectedRadioCountryCodes();
await loadStations(); await loadStations();
setupEventListeners(); setupEventListeners();
void sendSelectedCountriesToServiceWorker();
lockPortraitOrientation(); lockPortraitOrientation();
ensureArtworkPointerFallback(); ensureArtworkPointerFallback();
scheduleArtworkShine(); scheduleArtworkShine();

View File

@@ -44,4 +44,7 @@ export const radioCountries = [
{ name: 'Ukraine', code: 'UA' }, { name: 'Ukraine', code: 'UA' },
] as const; ] as const;
export const managedCountryCode = 'SI';
export const defaultSelectedRadioCountryCodes = radioCountries.map((country) => country.code);
export type RadioCountry = (typeof radioCountries)[number]; export type RadioCountry = (typeof radioCountries)[number];

View File

@@ -1,4 +1,5 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
import { defaultSelectedRadioCountryCodes, managedCountryCode } from '../radio/radioCountries.js';
export type PersistedStationHealth = { export type PersistedStationHealth = {
attempts: number; attempts: number;
@@ -22,6 +23,7 @@ type PersistedSettings = {
volume: number | null; volume: number | null;
lastStationId: string | null; lastStationId: string | null;
lastStationCountry: string | null; lastStationCountry: string | null;
selectedRadioCountryCodes: string[];
castBothMode: boolean; castBothMode: boolean;
favoriteStationIds: string[]; favoriteStationIds: string[];
stationUsageCounts: Record<string, number>; stationUsageCounts: Record<string, number>;
@@ -41,6 +43,7 @@ type SettingRecordMap = {
volume: number | null; volume: number | null;
lastStationId: string | null; lastStationId: string | null;
lastStationCountry: string | null; lastStationCountry: string | null;
selectedRadioCountryCodes: string[];
castBothMode: boolean; castBothMode: boolean;
favoriteStationIds: string[]; favoriteStationIds: string[];
stationUsageCounts: Record<string, number>; stationUsageCounts: Record<string, number>;
@@ -80,6 +83,7 @@ const DEFAULT_SNAPSHOT: PersistedSnapshot = {
volume: null, volume: null,
lastStationId: null, lastStationId: null,
lastStationCountry: null, lastStationCountry: null,
selectedRadioCountryCodes: [...defaultSelectedRadioCountryCodes],
castBothMode: false, castBothMode: false,
favoriteStationIds: [], favoriteStationIds: [],
stationUsageCounts: {}, stationUsageCounts: {},
@@ -94,6 +98,7 @@ const LOCAL_STORAGE_KEYS = {
volume: 'volume', volume: 'volume',
lastStationId: 'lastStationId', lastStationId: 'lastStationId',
lastStationCountry: 'lastStationCountry', lastStationCountry: 'lastStationCountry',
selectedRadioCountryCodes: 'selectedRadioCountryCodes',
castBothMode: 'castBothMode', castBothMode: 'castBothMode',
favoriteStationIds: 'favoriteStationIds', favoriteStationIds: 'favoriteStationIds',
stationUsageCounts: 'stationUsageCounts', stationUsageCounts: 'stationUsageCounts',
@@ -142,6 +147,21 @@ function safeJsonParse<T>(value: string | null, fallback: T): T {
} }
} }
function sanitizeSelectedRadioCountryCodes(value: unknown): string[] {
const codes = Array.isArray(value)
? value
.map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : ''))
.filter((entry) => /^[A-Z]{2}$/.test(entry))
: [];
const uniqueCodes = Array.from(new Set(codes));
if (!uniqueCodes.includes(managedCountryCode)) {
uniqueCodes.unshift(managedCountryCode);
}
return uniqueCodes.length > 0 ? uniqueCodes : [...defaultSelectedRadioCountryCodes];
}
function sanitizeIsoDate(value: unknown): string | null { function sanitizeIsoDate(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -258,6 +278,9 @@ function mirrorSettingToLocalStorage<K extends SettingKey>(key: K, value: Settin
case 'lastStationCountry': case 'lastStationCountry':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastStationCountry, typeof value === 'string' ? value : null); writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastStationCountry, typeof value === 'string' ? value : null);
break; break;
case 'selectedRadioCountryCodes':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.selectedRadioCountryCodes, JSON.stringify(sanitizeSelectedRadioCountryCodes(value)));
break;
case 'castBothMode': case 'castBothMode':
writeLocalStorageValue(LOCAL_STORAGE_KEYS.castBothMode, value ? '1' : '0'); writeLocalStorageValue(LOCAL_STORAGE_KEYS.castBothMode, value ? '1' : '0');
break; break;
@@ -303,6 +326,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
const lastStationCountry = typeof input.lastStationCountry === 'string' && input.lastStationCountry.trim().length > 0 const lastStationCountry = typeof input.lastStationCountry === 'string' && input.lastStationCountry.trim().length > 0
? input.lastStationCountry ? input.lastStationCountry
: null; : null;
const selectedRadioCountryCodes = sanitizeSelectedRadioCountryCodes(input.selectedRadioCountryCodes);
const castBothMode = input.castBothMode === true; const castBothMode = input.castBothMode === true;
const lastExportedAt = typeof input.lastExportedAt === 'string' && input.lastExportedAt.trim().length > 0 const lastExportedAt = typeof input.lastExportedAt === 'string' && input.lastExportedAt.trim().length > 0
? input.lastExportedAt ? input.lastExportedAt
@@ -339,6 +363,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
volume, volume,
lastStationId, lastStationId,
lastStationCountry, lastStationCountry,
selectedRadioCountryCodes,
castBothMode, castBothMode,
favoriteStationIds, favoriteStationIds,
stationUsageCounts, stationUsageCounts,
@@ -366,6 +391,7 @@ function readLegacyLocalStorage(): PersistedSnapshot {
volume, volume,
lastStationId: storage?.getItem('lastStationId') ?? null, lastStationId: storage?.getItem('lastStationId') ?? null,
lastStationCountry: storage?.getItem('lastStationCountry') ?? null, lastStationCountry: storage?.getItem('lastStationCountry') ?? null,
selectedRadioCountryCodes: sanitizeSelectedRadioCountryCodes(safeJsonParse<string[]>(storage?.getItem('selectedRadioCountryCodes') ?? null, defaultSelectedRadioCountryCodes)),
castBothMode: storage?.getItem('castBothMode') === '1', castBothMode: storage?.getItem('castBothMode') === '1',
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean), favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}), stationUsageCounts: safeJsonParse<Record<string, number>>(storage?.getItem('stationUsageCounts') ?? null, {}),
@@ -392,6 +418,7 @@ async function migrateFromLocalStorageIfNeeded(db: IDBPDatabase<RadioPlayerDb>)
tx.objectStore('settings').put(legacy.volume, 'volume'), tx.objectStore('settings').put(legacy.volume, 'volume'),
tx.objectStore('settings').put(legacy.lastStationId, 'lastStationId'), tx.objectStore('settings').put(legacy.lastStationId, 'lastStationId'),
tx.objectStore('settings').put(legacy.lastStationCountry, 'lastStationCountry'), tx.objectStore('settings').put(legacy.lastStationCountry, 'lastStationCountry'),
tx.objectStore('settings').put(legacy.selectedRadioCountryCodes, 'selectedRadioCountryCodes'),
tx.objectStore('settings').put(legacy.castBothMode, 'castBothMode'), tx.objectStore('settings').put(legacy.castBothMode, 'castBothMode'),
tx.objectStore('settings').put(legacy.favoriteStationIds, 'favoriteStationIds'), tx.objectStore('settings').put(legacy.favoriteStationIds, 'favoriteStationIds'),
tx.objectStore('settings').put(legacy.stationUsageCounts, 'stationUsageCounts'), tx.objectStore('settings').put(legacy.stationUsageCounts, 'stationUsageCounts'),
@@ -429,6 +456,7 @@ export async function hydratePlayerPersistence() {
const volume = await settingsStore.get('volume') as SettingRecordMap['volume'] | undefined; const volume = await settingsStore.get('volume') as SettingRecordMap['volume'] | undefined;
const lastStationId = await settingsStore.get('lastStationId') as SettingRecordMap['lastStationId'] | undefined; const lastStationId = await settingsStore.get('lastStationId') as SettingRecordMap['lastStationId'] | undefined;
const lastStationCountry = await settingsStore.get('lastStationCountry') as SettingRecordMap['lastStationCountry'] | undefined; const lastStationCountry = await settingsStore.get('lastStationCountry') as SettingRecordMap['lastStationCountry'] | undefined;
const selectedRadioCountryCodes = await settingsStore.get('selectedRadioCountryCodes') as SettingRecordMap['selectedRadioCountryCodes'] | undefined;
const castBothMode = await settingsStore.get('castBothMode') as SettingRecordMap['castBothMode'] | undefined; const castBothMode = await settingsStore.get('castBothMode') as SettingRecordMap['castBothMode'] | undefined;
const favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | undefined; const favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | undefined;
const stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | undefined; const stationUsageCounts = await settingsStore.get('stationUsageCounts') as SettingRecordMap['stationUsageCounts'] | undefined;
@@ -444,6 +472,7 @@ export async function hydratePlayerPersistence() {
volume: typeof volume === 'number' ? volume : null, volume: typeof volume === 'number' ? volume : null,
lastStationId: typeof lastStationId === 'string' ? lastStationId : null, lastStationId: typeof lastStationId === 'string' ? lastStationId : null,
lastStationCountry: typeof lastStationCountry === 'string' ? lastStationCountry : null, lastStationCountry: typeof lastStationCountry === 'string' ? lastStationCountry : null,
selectedRadioCountryCodes: sanitizeSelectedRadioCountryCodes(selectedRadioCountryCodes),
castBothMode: castBothMode === true, castBothMode: castBothMode === true,
favoriteStationIds: Array.isArray(favoriteStationIds) ? favoriteStationIds.filter(Boolean) : [], favoriteStationIds: Array.isArray(favoriteStationIds) ? favoriteStationIds.filter(Boolean) : [],
stationUsageCounts: stationUsageCounts && typeof stationUsageCounts === 'object' && !Array.isArray(stationUsageCounts) stationUsageCounts: stationUsageCounts && typeof stationUsageCounts === 'object' && !Array.isArray(stationUsageCounts)
@@ -466,6 +495,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
volume: DEFAULT_SNAPSHOT.volume, volume: DEFAULT_SNAPSHOT.volume,
lastStationId: DEFAULT_SNAPSHOT.lastStationId, lastStationId: DEFAULT_SNAPSHOT.lastStationId,
lastStationCountry: DEFAULT_SNAPSHOT.lastStationCountry, lastStationCountry: DEFAULT_SNAPSHOT.lastStationCountry,
selectedRadioCountryCodes: [...DEFAULT_SNAPSHOT.selectedRadioCountryCodes],
castBothMode: DEFAULT_SNAPSHOT.castBothMode, castBothMode: DEFAULT_SNAPSHOT.castBothMode,
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds], favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts }, stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
@@ -481,6 +511,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
volume: snapshot.volume, volume: snapshot.volume,
lastStationId: snapshot.lastStationId, lastStationId: snapshot.lastStationId,
lastStationCountry: snapshot.lastStationCountry, lastStationCountry: snapshot.lastStationCountry,
selectedRadioCountryCodes: [...snapshot.selectedRadioCountryCodes],
castBothMode: snapshot.castBothMode, castBothMode: snapshot.castBothMode,
favoriteStationIds: [...snapshot.favoriteStationIds], favoriteStationIds: [...snapshot.favoriteStationIds],
stationUsageCounts: { ...snapshot.stationUsageCounts }, stationUsageCounts: { ...snapshot.stationUsageCounts },
@@ -508,6 +539,10 @@ export function getPersistedLastStationCountry() {
return snapshot.lastStationCountry; return snapshot.lastStationCountry;
} }
export function getPersistedSelectedRadioCountryCodes() {
return [...snapshot.selectedRadioCountryCodes];
}
export function getPersistedCastBothMode() { export function getPersistedCastBothMode() {
return snapshot.castBothMode; return snapshot.castBothMode;
} }
@@ -560,6 +595,11 @@ export function persistLastStationCountry(value: string | null) {
void writeSetting('lastStationCountry', value); void writeSetting('lastStationCountry', value);
} }
export function persistSelectedRadioCountryCodes(value: Iterable<string>) {
snapshot.selectedRadioCountryCodes = sanitizeSelectedRadioCountryCodes(Array.from(value));
void writeSetting('selectedRadioCountryCodes', snapshot.selectedRadioCountryCodes);
}
export function persistCastBothMode(value: boolean) { export function persistCastBothMode(value: boolean) {
snapshot.castBothMode = value; snapshot.castBothMode = value;
void writeSetting('castBothMode', value); void writeSetting('castBothMode', value);
@@ -700,6 +740,7 @@ export async function importPlayerPersistence(source: unknown) {
mirrorSettingToLocalStorage('volume', snapshot.volume); mirrorSettingToLocalStorage('volume', snapshot.volume);
mirrorSettingToLocalStorage('lastStationId', snapshot.lastStationId); mirrorSettingToLocalStorage('lastStationId', snapshot.lastStationId);
mirrorSettingToLocalStorage('lastStationCountry', snapshot.lastStationCountry); mirrorSettingToLocalStorage('lastStationCountry', snapshot.lastStationCountry);
mirrorSettingToLocalStorage('selectedRadioCountryCodes', snapshot.selectedRadioCountryCodes);
mirrorSettingToLocalStorage('castBothMode', snapshot.castBothMode); mirrorSettingToLocalStorage('castBothMode', snapshot.castBothMode);
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds); mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts); mirrorSettingToLocalStorage('stationUsageCounts', snapshot.stationUsageCounts);
@@ -722,6 +763,7 @@ export async function importPlayerPersistence(source: unknown) {
tx.objectStore('settings').put(snapshot.volume, 'volume'), tx.objectStore('settings').put(snapshot.volume, 'volume'),
tx.objectStore('settings').put(snapshot.lastStationId, 'lastStationId'), tx.objectStore('settings').put(snapshot.lastStationId, 'lastStationId'),
tx.objectStore('settings').put(snapshot.lastStationCountry, 'lastStationCountry'), tx.objectStore('settings').put(snapshot.lastStationCountry, 'lastStationCountry'),
tx.objectStore('settings').put(snapshot.selectedRadioCountryCodes, 'selectedRadioCountryCodes'),
tx.objectStore('settings').put(snapshot.castBothMode, 'castBothMode'), tx.objectStore('settings').put(snapshot.castBothMode, 'castBothMode'),
tx.objectStore('settings').put(snapshot.favoriteStationIds, 'favoriteStationIds'), tx.objectStore('settings').put(snapshot.favoriteStationIds, 'favoriteStationIds'),
tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'), tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'),

View File

@@ -361,6 +361,28 @@ input:focus-visible,
gap: 6px; gap: 6px;
} }
.library-select-manage-btn {
width: 100%;
min-height: 38px;
margin-top: 4px;
padding: 0 12px;
border: 1px dashed rgba(var(--accent-rgb), 0.24);
border-radius: 12px;
background: rgba(var(--accent-rgb), 0.08);
color: var(--text-main);
font: inherit;
font-size: 0.84rem;
font-weight: 800;
text-align: left;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.library-select-manage-btn:hover {
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(var(--accent-rgb), 0.14);
transform: translateY(-1px);
}
.library-select-option { .library-select-option {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -886,7 +908,9 @@ input:focus-visible,
overflow: hidden; overflow: hidden;
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
height: 100%; align-self: flex-start;
margin-bottom: 12px;
height: calc(100% - 12px);
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: minmax(300px, 0.92fr) minmax(340px, 1.08fr); grid-template-columns: minmax(300px, 0.92fr) minmax(340px, 1.08fr);
@@ -2047,6 +2071,92 @@ input[type=range]::-webkit-slider-thumb {
accent-color: var(--accent); accent-color: var(--accent);
} }
.country-selection-modal {
width: min(780px, 100%);
}
.country-selection-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.country-selection-search {
margin: 0;
}
.country-selection-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.country-selection-actions .btn {
min-width: 148px;
}
.country-selection-list {
max-height: min(48vh, 420px);
padding-right: 4px;
}
.country-selection-item {
margin-bottom: 8px;
padding: 0;
overflow: hidden;
}
.country-selection-option {
display: grid;
grid-template-columns: 18px 1.4rem minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 13px 14px;
cursor: pointer;
}
.country-selection-option input {
width: 18px;
height: 18px;
margin: 3px 0 0;
accent-color: var(--accent);
}
.country-selection-option input:disabled {
cursor: not-allowed;
}
.country-selection-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1rem;
margin-top: 3px;
font-size: 1rem;
line-height: 1;
flex: 0 0 auto;
}
.country-selection-flag-img {
display: block;
width: 1.4rem;
height: 1rem;
object-fit: cover;
border-radius: 2px;
}
.country-selection-copy {
min-width: 0;
}
.country-selection-footer {
justify-content: flex-end;
margin-top: 8px;
}
.field-row { .field-row {
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -2645,6 +2755,19 @@ input[type=range]::-webkit-slider-thumb {
flex-direction: column; flex-direction: column;
} }
.country-selection-toolbar {
grid-template-columns: 1fr;
}
.country-selection-actions {
width: 100%;
}
.country-selection-actions .btn {
flex: 1;
min-width: 0;
}
.editor-station-actions, .editor-station-actions,
.editor-actions { .editor-actions {
width: 100%; width: 100%;