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 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
|
||||
|
||||
- Station browser with search, categories, favourites, and recent stations
|
||||
- Audio playback with previous/next station controls
|
||||
- Cast support
|
||||
- Production service worker for app-shell caching, offline launch support, and faster repeat visits
|
||||
- App install prompt for supported browsers
|
||||
- Custom station editor
|
||||
- Live station metadata and artwork rendering
|
||||
- Station library with search, category tabs, favorites, recents, sorting, and pagination.
|
||||
- Country filtering plus a country picker to choose which Radio Browser countries are synced and shown.
|
||||
- Audio playback with previous/next controls, volume/mute, and coverflow quick picks.
|
||||
- Google Cast and AirPlay output support.
|
||||
- User station management with local import/export/reset tooling.
|
||||
- Managed catalog fallback chain: remote endpoint -> cached remote -> bundled `stations.json`.
|
||||
- Production service worker with app shell caching, station sync cache, and periodic refresh registration.
|
||||
- PWA install prompt and offline-friendly launch behavior.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18 or newer
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- For production managed endpoint: PHP-enabled web server with rewrite support (Apache + `.htaccess` in this repo)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -25,7 +28,7 @@ Install dependencies:
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
Run development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
@@ -37,35 +40,32 @@ Build for production:
|
||||
npm run build
|
||||
```
|
||||
|
||||
Preview the production build:
|
||||
Preview production build locally:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Run the production build with the bundled same-origin backend endpoint:
|
||||
|
||||
```bash
|
||||
npm run serve:backend
|
||||
```
|
||||
|
||||
Deploy the built frontend plus backend server files to the remote host:
|
||||
Deploy built assets with the included script:
|
||||
|
||||
```bash
|
||||
bash sync.sh
|
||||
```
|
||||
|
||||
## Station Data
|
||||
## Managed Catalog Endpoint
|
||||
|
||||
The app keeps the editorial managed list in `public/stations.json`.
|
||||
|
||||
At runtime, the frontend prefers the same-origin managed endpoint:
|
||||
Runtime managed endpoint path:
|
||||
|
||||
```text
|
||||
/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
|
||||
{
|
||||
@@ -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
|
||||
npm run update:stations
|
||||
```
|
||||
|
||||
That command fetches the latest station list from:
|
||||
Refresh and rebuild in one command:
|
||||
|
||||
```text
|
||||
https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d
|
||||
```bash
|
||||
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.
|
||||
|
||||
You can also pass a custom source URL or a custom output path if needed:
|
||||
Custom source and output:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```text
|
||||
index.html
|
||||
privacy.html
|
||||
package.json
|
||||
public/
|
||||
.htaccess
|
||||
api/
|
||||
managed-stations.php
|
||||
data/
|
||||
radio-stations.json
|
||||
radio-stations-sync.json
|
||||
manifest.json
|
||||
stations.json
|
||||
sw.js
|
||||
server/
|
||||
index.mjs
|
||||
managedCatalogData.mjs
|
||||
scripts/
|
||||
bump-sw-cache-version.mjs
|
||||
cron-refresh-stations.sh
|
||||
cron-update-stations.sh
|
||||
import-radio-stations.ts
|
||||
update-stations.mjs
|
||||
src/
|
||||
App.jsx
|
||||
main.jsx
|
||||
player.js
|
||||
styles.css
|
||||
scripts/
|
||||
update-stations.mjs
|
||||
radio/
|
||||
storage/
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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` 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 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`.
|
||||
- Service worker is only active in production builds. In dev, SW registrations and caches are cleared automatically.
|
||||
- `src/main.jsx` registers background sync / periodic sync where supported.
|
||||
- `src/player.js` also refreshes managed catalog on app focus after a timeout (fallback for browsers without periodic sync).
|
||||
- `sync.sh` deploys `dist/` only.
|
||||
- If you edit stations manually, rerun `npm run update:stations` to resync from upstream when needed.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview",
|
||||
"serve:backend": "node server/index.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",
|
||||
"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
|
||||
// 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 MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
|
||||
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: 'Ukraine', code: 'UA' },
|
||||
];
|
||||
const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code);
|
||||
const MANAGED_COUNTRY_CODE = 'SI';
|
||||
const MAX_TAGS = 12;
|
||||
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
|
||||
'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_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_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href;
|
||||
const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
|
||||
const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href;
|
||||
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) {
|
||||
const url = new URL(RADIO_BROWSER_API_ENDPOINT);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -223,15 +274,19 @@ async function fetchCountryStations(country) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function syncRadioStations(reason = 'sync') {
|
||||
async function syncRadioStations(reason = 'sync', selectedCountryCodesOverride = null) {
|
||||
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 seenStationIds = new Set();
|
||||
const seenStreamUrls = new Set();
|
||||
const failedCountries = [];
|
||||
let syncedCountries = 0;
|
||||
|
||||
for (const country of RADIO_COUNTRIES) {
|
||||
for (const country of syncCountries) {
|
||||
try {
|
||||
const stations = await fetchCountryStations(country);
|
||||
await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations);
|
||||
@@ -263,6 +318,7 @@ async function syncRadioStations(reason = 'sync') {
|
||||
reason,
|
||||
syncedAt: new Date().toISOString(),
|
||||
countryCount: syncedCountries,
|
||||
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
||||
failedCountries,
|
||||
stationCount: aggregatedStations.length,
|
||||
};
|
||||
@@ -477,6 +533,31 @@ self.addEventListener('sync', (event) => {
|
||||
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) => {
|
||||
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
||||
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() {
|
||||
return (
|
||||
<aside id="station-library" className="station-library" aria-label="Station library">
|
||||
@@ -534,6 +570,7 @@ export default function App() {
|
||||
<LegalLinks />
|
||||
<StationsOverlay />
|
||||
<EditorOverlay />
|
||||
<CountrySelectionOverlay />
|
||||
</main>
|
||||
<InstallPromptBanner />
|
||||
</div>
|
||||
|
||||
392
src/player.js
392
src/player.js
@@ -1,10 +1,11 @@
|
||||
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 {
|
||||
clearPlayerPersistence,
|
||||
deletePersistedUserStationById,
|
||||
getPersistenceBackend,
|
||||
getPersistedSelectedRadioCountryCodes,
|
||||
getPlayerPersistenceSnapshot,
|
||||
getPersistedCastBothMode,
|
||||
getPersistedFavoriteStationIds,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
persistLastImportedAt,
|
||||
persistLastStationCountry,
|
||||
persistLastStationId,
|
||||
persistSelectedRadioCountryCodes,
|
||||
persistRecentStationHistory,
|
||||
persistStationHealth,
|
||||
persistStationUsageCounts,
|
||||
@@ -58,6 +60,17 @@ let stationLibraryQuery = '';
|
||||
let stationLibraryCategory = 'all';
|
||||
let stationLibraryCountry = 'all';
|
||||
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 stationLibraryPageTotal = 1;
|
||||
let stationCatalogState = 'idle';
|
||||
@@ -123,6 +136,15 @@ const stationCountryFilterBtn = document.getElementById('station-country-filter-
|
||||
const stationCountryFilterMenu = document.getElementById('station-country-filter-menu');
|
||||
const stationCountryFilterText = document.getElementById('station-country-filter-text');
|
||||
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 stationSortBtn = document.getElementById('station-sort-btn');
|
||||
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 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 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_VERSION = 1;
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1091,6 +1116,324 @@ function restoreLastStationCountry() {
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
function saveCastBothMode(val) {
|
||||
@@ -1563,7 +1906,7 @@ function getStationTechnicalLabel(station) {
|
||||
}
|
||||
|
||||
function getStationDetails(station) {
|
||||
return [getStationCountry(station), getStationTechnicalLabel(station)].filter(Boolean).join(' • ');
|
||||
return [getCountryDisplayName(getStationCountry(station)), getStationTechnicalLabel(station)].filter(Boolean).join(' • ');
|
||||
}
|
||||
|
||||
function getStationSearchText(station) {
|
||||
@@ -1637,7 +1980,7 @@ function getCountryDisplayName(countryValue) {
|
||||
if (value === 'all') return 'All countries';
|
||||
if (value === 'SI') return 'Slovenia (managed)';
|
||||
if (/^[A-Z]{2}$/i.test(value)) {
|
||||
const countryName = radioCountryNameByCode.get(value.toUpperCase());
|
||||
const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
|
||||
if (countryName) return countryName;
|
||||
}
|
||||
return value;
|
||||
@@ -1649,7 +1992,7 @@ function getCountryFilterDisplayName(countryValue) {
|
||||
if (value === 'all') return 'All countries';
|
||||
if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)';
|
||||
if (/^[A-Z]{2}$/i.test(value)) {
|
||||
const countryName = radioCountryNameByCode.get(value.toUpperCase());
|
||||
const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
|
||||
if (countryName) return countryName;
|
||||
}
|
||||
return value;
|
||||
@@ -1659,7 +2002,9 @@ function getCountryCodeFromValue(countryValue) {
|
||||
const value = String(countryValue || '').trim();
|
||||
if (!value || value === 'all') return '';
|
||||
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() {
|
||||
@@ -1839,6 +2184,15 @@ function renderCountryFilterOptions() {
|
||||
});
|
||||
|
||||
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() {
|
||||
@@ -2262,6 +2616,7 @@ async function loadStations() {
|
||||
const managedRaw = await loadManagedStations().catch(() => []);
|
||||
|
||||
const normalizedRaw = raw
|
||||
.filter((station) => isEnabledRadioCountryStation(station))
|
||||
.map((s) => normalizeStationRecord(s))
|
||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||
|
||||
@@ -3293,6 +3648,26 @@ function setupEventListeners() {
|
||||
installAppBtn?.addEventListener('click', promptInstallApp);
|
||||
castBtn?.addEventListener('click', requestCastSession);
|
||||
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);
|
||||
importUserDataBtn?.addEventListener('click', () => importUserDataInput?.click());
|
||||
importUserDataInput?.addEventListener('change', () => {
|
||||
@@ -3358,6 +3733,11 @@ function setupEventListeners() {
|
||||
closeSortFilter();
|
||||
return;
|
||||
}
|
||||
if (countrySelectionOverlay && !countrySelectionOverlay.classList.contains('hidden') && e.code === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeCountrySelectionOverlay();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
|
||||
else if (e.code === 'ArrowRight') playNext();
|
||||
else if (e.code === 'ArrowLeft') playPrev();
|
||||
@@ -3412,8 +3792,10 @@ async function init() {
|
||||
restoreSavedVolume();
|
||||
restoreCastBothMode();
|
||||
restoreLastStationCountry();
|
||||
restoreSelectedRadioCountryCodes();
|
||||
await loadStations();
|
||||
setupEventListeners();
|
||||
void sendSelectedCountriesToServiceWorker();
|
||||
lockPortraitOrientation();
|
||||
ensureArtworkPointerFallback();
|
||||
scheduleArtworkShine();
|
||||
|
||||
@@ -44,4 +44,7 @@ export const radioCountries = [
|
||||
{ name: 'Ukraine', code: 'UA' },
|
||||
] as const;
|
||||
|
||||
export const managedCountryCode = 'SI';
|
||||
export const defaultSelectedRadioCountryCodes = radioCountries.map((country) => country.code);
|
||||
|
||||
export type RadioCountry = (typeof radioCountries)[number];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
import { defaultSelectedRadioCountryCodes, managedCountryCode } from '../radio/radioCountries.js';
|
||||
|
||||
export type PersistedStationHealth = {
|
||||
attempts: number;
|
||||
@@ -22,6 +23,7 @@ type PersistedSettings = {
|
||||
volume: number | null;
|
||||
lastStationId: string | null;
|
||||
lastStationCountry: string | null;
|
||||
selectedRadioCountryCodes: string[];
|
||||
castBothMode: boolean;
|
||||
favoriteStationIds: string[];
|
||||
stationUsageCounts: Record<string, number>;
|
||||
@@ -41,6 +43,7 @@ type SettingRecordMap = {
|
||||
volume: number | null;
|
||||
lastStationId: string | null;
|
||||
lastStationCountry: string | null;
|
||||
selectedRadioCountryCodes: string[];
|
||||
castBothMode: boolean;
|
||||
favoriteStationIds: string[];
|
||||
stationUsageCounts: Record<string, number>;
|
||||
@@ -80,6 +83,7 @@ const DEFAULT_SNAPSHOT: PersistedSnapshot = {
|
||||
volume: null,
|
||||
lastStationId: null,
|
||||
lastStationCountry: null,
|
||||
selectedRadioCountryCodes: [...defaultSelectedRadioCountryCodes],
|
||||
castBothMode: false,
|
||||
favoriteStationIds: [],
|
||||
stationUsageCounts: {},
|
||||
@@ -94,6 +98,7 @@ const LOCAL_STORAGE_KEYS = {
|
||||
volume: 'volume',
|
||||
lastStationId: 'lastStationId',
|
||||
lastStationCountry: 'lastStationCountry',
|
||||
selectedRadioCountryCodes: 'selectedRadioCountryCodes',
|
||||
castBothMode: 'castBothMode',
|
||||
favoriteStationIds: 'favoriteStationIds',
|
||||
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 {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -258,6 +278,9 @@ function mirrorSettingToLocalStorage<K extends SettingKey>(key: K, value: Settin
|
||||
case 'lastStationCountry':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.lastStationCountry, typeof value === 'string' ? value : null);
|
||||
break;
|
||||
case 'selectedRadioCountryCodes':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.selectedRadioCountryCodes, JSON.stringify(sanitizeSelectedRadioCountryCodes(value)));
|
||||
break;
|
||||
case 'castBothMode':
|
||||
writeLocalStorageValue(LOCAL_STORAGE_KEYS.castBothMode, value ? '1' : '0');
|
||||
break;
|
||||
@@ -303,6 +326,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
||||
const lastStationCountry = typeof input.lastStationCountry === 'string' && input.lastStationCountry.trim().length > 0
|
||||
? input.lastStationCountry
|
||||
: null;
|
||||
const selectedRadioCountryCodes = sanitizeSelectedRadioCountryCodes(input.selectedRadioCountryCodes);
|
||||
const castBothMode = input.castBothMode === true;
|
||||
const lastExportedAt = typeof input.lastExportedAt === 'string' && input.lastExportedAt.trim().length > 0
|
||||
? input.lastExportedAt
|
||||
@@ -339,6 +363,7 @@ function sanitizeImportedSnapshot(source: unknown): PersistedSnapshot {
|
||||
volume,
|
||||
lastStationId,
|
||||
lastStationCountry,
|
||||
selectedRadioCountryCodes,
|
||||
castBothMode,
|
||||
favoriteStationIds,
|
||||
stationUsageCounts,
|
||||
@@ -366,6 +391,7 @@ function readLegacyLocalStorage(): PersistedSnapshot {
|
||||
volume,
|
||||
lastStationId: storage?.getItem('lastStationId') ?? null,
|
||||
lastStationCountry: storage?.getItem('lastStationCountry') ?? null,
|
||||
selectedRadioCountryCodes: sanitizeSelectedRadioCountryCodes(safeJsonParse<string[]>(storage?.getItem('selectedRadioCountryCodes') ?? null, defaultSelectedRadioCountryCodes)),
|
||||
castBothMode: storage?.getItem('castBothMode') === '1',
|
||||
favoriteStationIds: safeJsonParse<string[]>(storage?.getItem('favoriteStationIds') ?? null, []).filter(Boolean),
|
||||
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.lastStationId, 'lastStationId'),
|
||||
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.favoriteStationIds, 'favoriteStationIds'),
|
||||
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 lastStationId = await settingsStore.get('lastStationId') as SettingRecordMap['lastStationId'] | 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 favoriteStationIds = await settingsStore.get('favoriteStationIds') as SettingRecordMap['favoriteStationIds'] | 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,
|
||||
lastStationId: typeof lastStationId === 'string' ? lastStationId : null,
|
||||
lastStationCountry: typeof lastStationCountry === 'string' ? lastStationCountry : null,
|
||||
selectedRadioCountryCodes: sanitizeSelectedRadioCountryCodes(selectedRadioCountryCodes),
|
||||
castBothMode: castBothMode === true,
|
||||
favoriteStationIds: Array.isArray(favoriteStationIds) ? favoriteStationIds.filter(Boolean) : [],
|
||||
stationUsageCounts: stationUsageCounts && typeof stationUsageCounts === 'object' && !Array.isArray(stationUsageCounts)
|
||||
@@ -466,6 +495,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
||||
volume: DEFAULT_SNAPSHOT.volume,
|
||||
lastStationId: DEFAULT_SNAPSHOT.lastStationId,
|
||||
lastStationCountry: DEFAULT_SNAPSHOT.lastStationCountry,
|
||||
selectedRadioCountryCodes: [...DEFAULT_SNAPSHOT.selectedRadioCountryCodes],
|
||||
castBothMode: DEFAULT_SNAPSHOT.castBothMode,
|
||||
favoriteStationIds: [...DEFAULT_SNAPSHOT.favoriteStationIds],
|
||||
stationUsageCounts: { ...DEFAULT_SNAPSHOT.stationUsageCounts },
|
||||
@@ -481,6 +511,7 @@ export function getPlayerPersistenceSnapshot(): PersistedSnapshot {
|
||||
volume: snapshot.volume,
|
||||
lastStationId: snapshot.lastStationId,
|
||||
lastStationCountry: snapshot.lastStationCountry,
|
||||
selectedRadioCountryCodes: [...snapshot.selectedRadioCountryCodes],
|
||||
castBothMode: snapshot.castBothMode,
|
||||
favoriteStationIds: [...snapshot.favoriteStationIds],
|
||||
stationUsageCounts: { ...snapshot.stationUsageCounts },
|
||||
@@ -508,6 +539,10 @@ export function getPersistedLastStationCountry() {
|
||||
return snapshot.lastStationCountry;
|
||||
}
|
||||
|
||||
export function getPersistedSelectedRadioCountryCodes() {
|
||||
return [...snapshot.selectedRadioCountryCodes];
|
||||
}
|
||||
|
||||
export function getPersistedCastBothMode() {
|
||||
return snapshot.castBothMode;
|
||||
}
|
||||
@@ -560,6 +595,11 @@ export function persistLastStationCountry(value: string | null) {
|
||||
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) {
|
||||
snapshot.castBothMode = value;
|
||||
void writeSetting('castBothMode', value);
|
||||
@@ -700,6 +740,7 @@ export async function importPlayerPersistence(source: unknown) {
|
||||
mirrorSettingToLocalStorage('volume', snapshot.volume);
|
||||
mirrorSettingToLocalStorage('lastStationId', snapshot.lastStationId);
|
||||
mirrorSettingToLocalStorage('lastStationCountry', snapshot.lastStationCountry);
|
||||
mirrorSettingToLocalStorage('selectedRadioCountryCodes', snapshot.selectedRadioCountryCodes);
|
||||
mirrorSettingToLocalStorage('castBothMode', snapshot.castBothMode);
|
||||
mirrorSettingToLocalStorage('favoriteStationIds', snapshot.favoriteStationIds);
|
||||
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.lastStationId, 'lastStationId'),
|
||||
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.favoriteStationIds, 'favoriteStationIds'),
|
||||
tx.objectStore('settings').put(snapshot.stationUsageCounts, 'stationUsageCounts'),
|
||||
|
||||
125
src/styles.css
125
src/styles.css
@@ -361,6 +361,28 @@ input:focus-visible,
|
||||
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 {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -886,7 +908,9 @@ input:focus-visible,
|
||||
overflow: hidden;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 12px;
|
||||
height: calc(100% - 12px);
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -2645,6 +2755,19 @@ input[type=range]::-webkit-slider-thumb {
|
||||
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-actions {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user