feat: add country selection, cron automation, sparkle effects and layout fixes
This commit is contained in:
226
.vscode/agents/git-auto-commit.md
vendored
Normal file
226
.vscode/agents/git-auto-commit.md
vendored
Normal 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
138
README.md
@@ -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`.
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
87
public/sw.js
87
public/sw.js
@@ -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'));
|
||||||
|
|||||||
66
scripts/cron-refresh-stations.sh
Normal file
66
scripts/cron-refresh-stations.sh
Normal 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
|
||||||
65
scripts/cron-update-stations.sh
Normal file
65
scripts/cron-update-stations.sh
Normal 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
|
||||||
37
src/App.jsx
37
src/App.jsx
@@ -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>
|
||||||
|
|||||||
392
src/player.js
392
src/player.js
@@ -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();
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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'),
|
||||||
|
|||||||
125
src/styles.css
125
src/styles.css
@@ -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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user