Refine player layout and station data
This commit is contained in:
450
.vscode/agents/radio-player-stations-importer_v1.md
vendored
Normal file
450
.vscode/agents/radio-player-stations-importer_v1.md
vendored
Normal file
@@ -0,0 +1,450 @@
|
||||
# RadioPlayer Stations Importer v1
|
||||
|
||||
## Goal
|
||||
|
||||
Build a production-ready radio station importer for our web RadioPlayer.
|
||||
|
||||
The importer must use the public Radio Browser API as the source for radio stations and generate a clean local station dataset that the RadioPlayer can use.
|
||||
|
||||
We need stations for these countries:
|
||||
|
||||
- Austria
|
||||
- Croatia
|
||||
- Serbia
|
||||
- Montenegro
|
||||
- Bosnia & Herzegovina
|
||||
- Germany
|
||||
- United Kingdom
|
||||
- Italy
|
||||
- France
|
||||
- Spain
|
||||
- USA
|
||||
- Canada
|
||||
- Australia
|
||||
- Luxembourg
|
||||
- Netherlands
|
||||
- Sweden
|
||||
- Switzerland
|
||||
- Hungary
|
||||
- Czechia
|
||||
- Poland
|
||||
|
||||
The importer must also include station logos when available.
|
||||
|
||||
---
|
||||
|
||||
## Important Context
|
||||
|
||||
Radio Browser station objects can contain:
|
||||
|
||||
- `stationuuid`
|
||||
- `name`
|
||||
- `url`
|
||||
- `url_resolved`
|
||||
- `homepage`
|
||||
- `favicon`
|
||||
- `country`
|
||||
- `countrycode`
|
||||
- `language`
|
||||
- `tags`
|
||||
- `codec`
|
||||
- `bitrate`
|
||||
- `votes`
|
||||
- `clickcount`
|
||||
|
||||
For our app:
|
||||
|
||||
- `url_resolved` should be preferred over `url`
|
||||
- `favicon` should be used as the station logo
|
||||
- broken/missing logos must not break the UI
|
||||
- HTTP streams should be avoided for browser compatibility
|
||||
- HTTPS streams should be preferred
|
||||
- broken streams should be filtered out where possible
|
||||
|
||||
---
|
||||
|
||||
## Countries
|
||||
|
||||
Create a shared country list:
|
||||
|
||||
```ts
|
||||
export const radioCountries = [
|
||||
{ name: "Austria", code: "AT" },
|
||||
{ name: "Croatia", code: "HR" },
|
||||
{ name: "Serbia", code: "RS" },
|
||||
{ name: "Montenegro", code: "ME" },
|
||||
{ name: "Bosnia & Herzegovina", code: "BA" },
|
||||
{ name: "Germany", code: "DE" },
|
||||
{ name: "United Kingdom", code: "GB" },
|
||||
{ name: "Italy", code: "IT" },
|
||||
{ name: "France", code: "FR" },
|
||||
{ name: "Spain", code: "ES" },
|
||||
{ name: "USA", code: "US" },
|
||||
{ name: "Canada", code: "CA" },
|
||||
{ name: "Australia", code: "AU" },
|
||||
{ name: "Luxembourg", code: "LU" },
|
||||
{ name: "Netherlands", code: "NL" },
|
||||
{ name: "Sweden", code: "SE" },
|
||||
{ name: "Switzerland", code: "CH" },
|
||||
{ name: "Hungary", code: "HU" },
|
||||
{ name: "Czechia", code: "CZ" },
|
||||
{ name: "Poland", code: "PL" },
|
||||
] as const;
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## Required Output Format
|
||||
|
||||
Normalize every imported station to this structure:
|
||||
|
||||
```ts
|
||||
export type RadioStation = {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
language: string | null;
|
||||
tags: string[];
|
||||
codec: string | null;
|
||||
bitrate: number | null;
|
||||
streamUrl: string;
|
||||
homepage: string | null;
|
||||
logoUrl: string | null;
|
||||
votes: number;
|
||||
clickcount: number;
|
||||
source: "radio-browser";
|
||||
sourceStationUuid: string;
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `id` must use `stationuuid`
|
||||
* `sourceStationUuid` must also store `stationuuid`
|
||||
* `streamUrl` must use `url_resolved || url`
|
||||
* `logoUrl` must use `favicon || null`
|
||||
* `tags` must be converted from comma-separated string to string array
|
||||
* empty strings must become `null`
|
||||
* invalid stations must be skipped
|
||||
* duplicate stations must be removed by `stationuuid`
|
||||
* duplicate stream URLs should also be avoided where possible
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint
|
||||
|
||||
Use this endpoint pattern:
|
||||
|
||||
```txt
|
||||
https://de1.api.radio-browser.info/json/stations/search
|
||||
```
|
||||
|
||||
Use these query params:
|
||||
|
||||
```txt
|
||||
countrycode={COUNTRY_CODE}
|
||||
hidebroken=true
|
||||
is_https=true
|
||||
order=clickcount
|
||||
reverse=true
|
||||
limit=100
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
https://de1.api.radio-browser.info/json/stations/search?countrycode=DE&hidebroken=true&is_https=true&order=clickcount&reverse=true&limit=100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Requirements
|
||||
|
||||
Create an importer script that:
|
||||
|
||||
1. Loops through all configured countries.
|
||||
2. Fetches up to 100 stations per country.
|
||||
3. Filters invalid stations.
|
||||
4. Normalizes station data.
|
||||
5. Deduplicates stations.
|
||||
6. Sorts stations by country, then clickcount descending.
|
||||
7. Saves the final dataset as JSON.
|
||||
8. Does not fail the whole import if one country fails.
|
||||
9. Logs a summary after import.
|
||||
|
||||
Expected output file:
|
||||
|
||||
```txt
|
||||
public/data/radio-stations.json
|
||||
```
|
||||
|
||||
If the project uses a different structure, place it in the closest appropriate public/static data directory and document the chosen path.
|
||||
|
||||
---
|
||||
|
||||
## Recommended File Structure
|
||||
|
||||
Create or adapt these files depending on the current project structure:
|
||||
|
||||
```txt
|
||||
src/radio/radioCountries.ts
|
||||
src/radio/radioTypes.ts
|
||||
src/radio/radioStationNormalizer.ts
|
||||
scripts/import-radio-stations.ts
|
||||
public/data/radio-stations.json
|
||||
```
|
||||
|
||||
If this is a Vite/React project, this structure is preferred.
|
||||
|
||||
If the project already has a different convention, follow the existing convention.
|
||||
|
||||
---
|
||||
|
||||
## Normalizer Requirements
|
||||
|
||||
Create a normalizer function:
|
||||
|
||||
```ts
|
||||
export function normalizeRadioBrowserStation(
|
||||
station: RadioBrowserStation,
|
||||
countryName: string
|
||||
): RadioStation | null
|
||||
```
|
||||
|
||||
The function must:
|
||||
|
||||
* return `null` if station has no `stationuuid`
|
||||
* return `null` if station has no valid name
|
||||
* return `null` if station has no valid stream URL
|
||||
* trim all string values
|
||||
* convert empty values to `null`
|
||||
* parse tags safely
|
||||
* limit tags to maximum 12 tags per station
|
||||
* prefer `url_resolved`
|
||||
* map `favicon` to `logoUrl`
|
||||
* preserve vote/click metadata
|
||||
|
||||
---
|
||||
|
||||
## Logo Handling
|
||||
|
||||
Station logos come from Radio Browser field:
|
||||
|
||||
```txt
|
||||
favicon
|
||||
```
|
||||
|
||||
Use it as:
|
||||
|
||||
```ts
|
||||
logoUrl: station.favicon || null
|
||||
```
|
||||
|
||||
Frontend must support fallback logo behavior:
|
||||
|
||||
```tsx
|
||||
<img
|
||||
src={station.logoUrl || "/images/radio-placeholder.svg"}
|
||||
alt={station.name}
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = "/images/radio-placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Create a fallback placeholder if one does not exist:
|
||||
|
||||
```txt
|
||||
public/images/radio-placeholder.svg
|
||||
```
|
||||
|
||||
The placeholder should be simple and lightweight.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
Update the RadioPlayer so it can load stations from:
|
||||
|
||||
```txt
|
||||
/data/radio-stations.json
|
||||
```
|
||||
|
||||
Add or update a loader function:
|
||||
|
||||
```ts
|
||||
export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||
const response = await fetch("/data/radio-stations.json");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load radio stations: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
The UI should support:
|
||||
|
||||
* country filter
|
||||
* station search by name
|
||||
* station logo
|
||||
* station name
|
||||
* country
|
||||
* tags
|
||||
* bitrate/codec where available
|
||||
* graceful empty state
|
||||
* graceful loading state
|
||||
* graceful error state
|
||||
|
||||
---
|
||||
|
||||
## Player Requirements
|
||||
|
||||
When user clicks a station:
|
||||
|
||||
* use `streamUrl`
|
||||
* display station name
|
||||
* display logo fallback if logo fails
|
||||
* show country
|
||||
* show codec/bitrate if available
|
||||
* do not crash if playback fails
|
||||
* display a user-friendly playback error
|
||||
|
||||
---
|
||||
|
||||
## Optional But Recommended
|
||||
|
||||
Add a script command to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"radio:import": "tsx scripts/import-radio-stations.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `tsx` is not installed and the project uses TypeScript scripts, add it as a dev dependency.
|
||||
|
||||
If the project does not use `tsx`, use the existing project script runner.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The importer must handle:
|
||||
|
||||
* network errors
|
||||
* invalid JSON
|
||||
* empty responses
|
||||
* country-specific failures
|
||||
* missing favicon
|
||||
* missing homepage
|
||||
* missing language
|
||||
* invalid station URLs
|
||||
* duplicated stations
|
||||
* duplicated stream URLs
|
||||
|
||||
Do not stop the whole import because one country fails.
|
||||
|
||||
Log errors like:
|
||||
|
||||
```txt
|
||||
[radio-import] Failed to import Germany (DE): {error message}
|
||||
```
|
||||
|
||||
At the end, log:
|
||||
|
||||
```txt
|
||||
[radio-import] Imported {total} stations from {successfulCountries}/{totalCountries} countries.
|
||||
[radio-import] Failed countries: DE, FR
|
||||
[radio-import] Output: public/data/radio-stations.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Quality Rules
|
||||
|
||||
Skip stations when:
|
||||
|
||||
* `stationuuid` is missing
|
||||
* name is missing
|
||||
* stream URL is missing
|
||||
* stream URL is not HTTPS
|
||||
* codec is obviously unsupported by browsers
|
||||
|
||||
Preferred codecs:
|
||||
|
||||
* MP3
|
||||
* AAC
|
||||
* OGG
|
||||
|
||||
Do not hard fail on unknown codec, but keep codec in the dataset.
|
||||
|
||||
---
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
Since this is a web RadioPlayer:
|
||||
|
||||
* prefer HTTPS streams
|
||||
* avoid HTTP streams
|
||||
* keep fallback image local
|
||||
* do not assume every logo loads
|
||||
* do not assume every stream can play in every browser
|
||||
* do not autoplay without user interaction
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The task is complete when:
|
||||
|
||||
* country list exists
|
||||
* importer script exists
|
||||
* normalized station type exists
|
||||
* radio-stations.json is generated
|
||||
* logos are included through `logoUrl`
|
||||
* UI can load the local JSON file
|
||||
* UI shows logo fallback on broken/missing logos
|
||||
* player can play a selected station
|
||||
* import failure for one country does not fail the whole script
|
||||
* `npm run radio:import` or equivalent command works
|
||||
* TypeScript build passes
|
||||
* lint passes if configured
|
||||
* existing app behavior is not broken
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Manually verify:
|
||||
|
||||
* Import script runs successfully
|
||||
* JSON file is generated
|
||||
* Germany has stations
|
||||
* Austria has stations
|
||||
* Croatia has stations
|
||||
* USA has stations
|
||||
* United Kingdom has stations
|
||||
* stations have `streamUrl`
|
||||
* many stations have `logoUrl`
|
||||
* missing logos show fallback
|
||||
* clicking a station starts playback
|
||||
* broken streams show a friendly error
|
||||
* country filtering works
|
||||
* search works
|
||||
* no console crash happens when logo or stream fails
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Do not manually hardcode hundreds of station stream URLs.
|
||||
|
||||
Use Radio Browser as the source of truth for imported stations.
|
||||
|
||||
Keep a curated featured station list separate later if needed.
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# RadioPlayer
|
||||
|
||||
RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. It loads its station catalog from `public/stations.json`, supports custom stations, and includes a built-in updater for refreshing that list from the live Radio.si feed.
|
||||
|
||||
## Features
|
||||
|
||||
- Station browser with search, categories, favourites, and recent stations
|
||||
- Audio playback with previous/next station controls
|
||||
- Cast support
|
||||
- App install prompt for supported browsers
|
||||
- Custom station editor
|
||||
- Live station metadata and artwork rendering
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18 or newer
|
||||
- npm
|
||||
|
||||
## Getting Started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Preview the production build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Station Data
|
||||
|
||||
The app reads station data from `public/stations.json`.
|
||||
|
||||
To refresh the file from the remote source, run:
|
||||
|
||||
```bash
|
||||
npm run update:stations
|
||||
```
|
||||
|
||||
That command fetches the latest station list from:
|
||||
|
||||
```text
|
||||
https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d
|
||||
```
|
||||
|
||||
and rewrites `public/stations.json` while preserving the existing JSON structure used by the app.
|
||||
|
||||
You can also pass a custom source URL or a custom output path if needed:
|
||||
|
||||
```bash
|
||||
node scripts/update-stations.mjs <source-url> <output-path>
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
index.html
|
||||
package.json
|
||||
public/
|
||||
manifest.json
|
||||
stations.json
|
||||
sw.js
|
||||
src/
|
||||
App.jsx
|
||||
main.jsx
|
||||
player.js
|
||||
styles.css
|
||||
scripts/
|
||||
update-stations.mjs
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point.
|
||||
- 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`.
|
||||
- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog.
|
||||
File diff suppressed because one or more lines are too long
2032
public/stations.json
2032
public/stations.json
File diff suppressed because it is too large
Load Diff
89
src/App.jsx
89
src/App.jsx
@@ -133,13 +133,19 @@ function ArtworkPanel() {
|
||||
<span className="station-logo-text">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickPickCarousel() {
|
||||
return (
|
||||
<section className="quickpick-section" aria-label="Favorite stations">
|
||||
<div id="artwork-coverflow" className="artwork-coverflow" aria-label="Stations">
|
||||
<button id="artwork-prev" className="coverflow-arrow left" aria-label="Previous station" type="button">‹</button>
|
||||
<div id="artwork-coverflow-stage" className="artwork-coverflow-stage" role="list" aria-label="Station icons" />
|
||||
<button id="artwork-next" className="coverflow-arrow right" aria-label="Next station" type="button">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -351,23 +357,83 @@ function StationLibrary() {
|
||||
<input id="station-search-input" type="search" placeholder="Search stations" autoComplete="off" />
|
||||
</label>
|
||||
|
||||
<label className="library-select" htmlFor="station-country-filter">
|
||||
<span className="library-select-label">Country</span>
|
||||
<select id="station-country-filter" defaultValue="all">
|
||||
<option value="all">All countries</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="library-select" data-country-filter>
|
||||
<button
|
||||
id="station-country-filter-btn"
|
||||
className="library-select-trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="Country filter"
|
||||
>
|
||||
<img id="station-country-filter-flag" className="library-select-flag" alt="" aria-hidden="true" src="https://flagcdn.com/w20/eu.png" />
|
||||
<span className="library-select-prefix">Filter</span>
|
||||
<span id="station-country-filter-text" className="library-select-value">All countries</span>
|
||||
<svg className="library-select-caret" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="station-country-filter-menu" className="library-select-menu" role="listbox" aria-label="Country filter options" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="library-tabs" role="tablist" aria-label="Station filters">
|
||||
<button className="library-tab active" data-station-tab="all" type="button">All</button>
|
||||
<button className="library-tab" data-station-tab="favourites" type="button">Favourites</button>
|
||||
<button className="library-tab" data-station-tab="recent" type="button">Recent</button>
|
||||
<button className="library-tab" data-station-tab="categories" type="button">Categories</button>
|
||||
<button className="library-tab active" data-station-tab="all" type="button">
|
||||
<span className="library-tab-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h7v7H4z" />
|
||||
<path d="M13 6h7v4h-7z" />
|
||||
<path d="M13 12h7v6h-7z" />
|
||||
<path d="M4 15h7v3H4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button className="library-tab" data-station-tab="favourites" type="button">
|
||||
<span className="library-tab-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 21s-7-4.4-9.4-8.7C.6 8.8 3 5 7.2 5c2 0 3.4 1 4.8 2.8C13.4 6 14.8 5 16.8 5 21 5 23.4 8.8 21.4 12.3 19 16.6 12 21 12 21z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button className="library-tab" data-station-tab="recent" type="button">
|
||||
<span className="library-tab-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 8v5l3 2" />
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button className="library-tab" data-station-tab="categories" type="button">
|
||||
<span className="library-tab-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 7h14" />
|
||||
<path d="M5 12h14" />
|
||||
<path d="M5 17h14" />
|
||||
<circle cx="8" cy="7" r="1" />
|
||||
<circle cx="16" cy="12" r="1" />
|
||||
<circle cx="10" cy="17" r="1" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="station-category-list" className="category-list" aria-label="Categories" />
|
||||
<div id="station-library-summary" className="library-summary" aria-live="polite">Loading stations...</div>
|
||||
<div id="station-library-pagination" className="library-pagination" aria-label="Station pagination">
|
||||
<button id="station-library-page-prev" className="library-page-btn" type="button" aria-label="Previous page">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
<span id="station-library-page-info" className="library-pagination-info">Page 1 of 1</span>
|
||||
<button id="station-library-page-next" className="library-page-btn" type="button" aria-label="Next page">
|
||||
<span>Next</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul id="station-library-list" className="library-list" />
|
||||
</aside>
|
||||
);
|
||||
@@ -388,6 +454,7 @@ export default function App() {
|
||||
<ProgressBar />
|
||||
<PlayerControls />
|
||||
<VolumeControl />
|
||||
<QuickPickCarousel />
|
||||
<StationsOverlay />
|
||||
<EditorOverlay />
|
||||
</main>
|
||||
|
||||
296
src/player.js
296
src/player.js
@@ -1,4 +1,6 @@
|
||||
import { loadRadioStations } from './radio/loadRadioStations.ts';
|
||||
import { radioCountries } from './radio/radioCountries.ts';
|
||||
import { loadManagedStations } from './radio/loadManagedStations.ts';
|
||||
|
||||
// Web version of RadioPlayer — HTML5 Audio + Google Cast Web Sender SDK.
|
||||
|
||||
@@ -26,9 +28,14 @@ let stationLibraryTab = 'all';
|
||||
let stationLibraryQuery = '';
|
||||
let stationLibraryCategory = 'all';
|
||||
let stationLibraryCountry = 'all';
|
||||
let stationLibraryPage = 0;
|
||||
let stationLibraryPageTotal = 1;
|
||||
let stationCatalogState = 'idle';
|
||||
let stationCatalogError = '';
|
||||
let playbackError = '';
|
||||
let stationCountryFilterOpen = false;
|
||||
|
||||
const STATION_LIBRARY_PAGE_SIZE = 12;
|
||||
|
||||
const RADIO_PLACEHOLDER_LOGO = '/images/radio-placeholder.svg';
|
||||
|
||||
@@ -68,9 +75,20 @@ const stationLibrarySummaryEl = document.getElementById('station-library-summary
|
||||
const stationSearchInput = document.getElementById('station-search-input');
|
||||
const stationLibraryCloseBtn = document.getElementById('station-library-close');
|
||||
const stationCategoryListEl = document.getElementById('station-category-list');
|
||||
const stationCountryFilterEl = document.getElementById('station-country-filter');
|
||||
const stationCountryFilterWrapEl = document.querySelector('[data-country-filter]');
|
||||
const stationCountryFilterBtn = document.getElementById('station-country-filter-btn');
|
||||
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 stationLibraryPaginationEl = document.getElementById('station-library-pagination');
|
||||
const stationLibraryPagePrevBtn = document.getElementById('station-library-page-prev');
|
||||
const stationLibraryPageNextBtn = document.getElementById('station-library-page-next');
|
||||
const stationLibraryPageInfo = document.getElementById('station-library-page-info');
|
||||
const stationTabBtns = document.querySelectorAll('[data-station-tab]');
|
||||
|
||||
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
|
||||
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
|
||||
|
||||
// Editor
|
||||
const editBtn = document.getElementById('edit-stations-btn');
|
||||
const stationsListBtn = document.getElementById('stations-list-btn');
|
||||
@@ -390,6 +408,19 @@ function getLastStationId() {
|
||||
try { return localStorage.getItem('lastStationId'); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function saveLastStationCountry(country) {
|
||||
try { if (country) localStorage.setItem('lastStationCountry', country); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getLastStationCountry() {
|
||||
try { return localStorage.getItem('lastStationCountry'); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function restoreLastStationCountry() {
|
||||
const savedCountry = getLastStationCountry();
|
||||
stationLibraryCountry = savedCountry || 'all';
|
||||
}
|
||||
|
||||
// ── castBothMode persistence & UI ────────────────────────────────────────────
|
||||
|
||||
function saveCastBothMode(val) {
|
||||
@@ -553,9 +584,7 @@ function getStationLogoCandidates(station) {
|
||||
function getStationSubtitle(station) {
|
||||
return station?.slogan
|
||||
|| station?.raw?.slogan
|
||||
|| getStationHomepage(station)
|
||||
|| station?.raw?.defaultText
|
||||
|| station?.id
|
||||
|| '';
|
||||
}
|
||||
|
||||
@@ -593,30 +622,164 @@ function getCountryNames() {
|
||||
return Array.from(new Set(stations.map(getStationCountry).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function getCountryDisplayName(countryValue) {
|
||||
const value = String(countryValue || '').trim();
|
||||
if (!value) return '';
|
||||
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());
|
||||
if (countryName) return countryName;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getCountryFilterDisplayName(countryValue) {
|
||||
const value = String(countryValue || '').trim();
|
||||
if (!value) return '';
|
||||
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());
|
||||
if (countryName) return countryName;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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) || '';
|
||||
}
|
||||
|
||||
function resetStationLibraryPage() {
|
||||
stationLibraryPage = 0;
|
||||
}
|
||||
|
||||
function goToPreviousStationLibraryPage() {
|
||||
if (stationLibraryPage <= 0) return;
|
||||
stationLibraryPage -= 1;
|
||||
renderStationLibrary();
|
||||
}
|
||||
|
||||
function goToNextStationLibraryPage() {
|
||||
if (stationLibraryPage >= stationLibraryPageTotal - 1) return;
|
||||
stationLibraryPage += 1;
|
||||
renderStationLibrary();
|
||||
}
|
||||
|
||||
function countryCodeToFlagEmoji(countryCode) {
|
||||
const code = String(countryCode || '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return '🌐';
|
||||
return String.fromCodePoint(...code.split('').map((char) => 127397 + char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
function countryCodeToFlagUrl(countryCode) {
|
||||
const code = String(countryCode || '').trim().toLowerCase();
|
||||
if (!/^[a-z]{2}$/.test(code)) return '';
|
||||
return `https://flagcdn.com/w20/${code}.png`;
|
||||
}
|
||||
|
||||
function getCountryFlag(countryName) {
|
||||
if (!countryName || countryName === 'all') return '🌐';
|
||||
const countryCode = getCountryCodeFromValue(countryName);
|
||||
return countryCode ? countryCodeToFlagEmoji(countryCode) : '🏳️';
|
||||
}
|
||||
|
||||
function getCountryFlagUrl(countryName) {
|
||||
if (!countryName || countryName === 'all') return 'https://flagcdn.com/w20/eu.png';
|
||||
const countryCode = getCountryCodeFromValue(countryName);
|
||||
return countryCode ? countryCodeToFlagUrl(countryCode) : '';
|
||||
}
|
||||
|
||||
function toggleStationCountryFilter(forceOpen) {
|
||||
stationCountryFilterOpen = typeof forceOpen === 'boolean' ? forceOpen : !stationCountryFilterOpen;
|
||||
renderStationLibrary();
|
||||
}
|
||||
|
||||
function closeStationCountryFilter() {
|
||||
if (!stationCountryFilterOpen) return;
|
||||
stationCountryFilterOpen = false;
|
||||
renderStationLibrary();
|
||||
}
|
||||
|
||||
function renderCountryFilterOptions() {
|
||||
if (!stationCountryFilterEl) return;
|
||||
if (!stationCountryFilterMenu || !stationCountryFilterBtn || !stationCountryFilterText) return;
|
||||
|
||||
const countries = getCountryNames();
|
||||
if (stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
|
||||
if (stationCatalogState === 'ready' && stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
|
||||
stationLibraryCountry = 'all';
|
||||
saveLastStationCountry('all');
|
||||
resetStationLibraryPage();
|
||||
}
|
||||
|
||||
stationCountryFilterEl.innerHTML = '';
|
||||
stationCountryFilterBtn.setAttribute('aria-expanded', stationCountryFilterOpen ? 'true' : 'false');
|
||||
stationCountryFilterText.textContent = getCountryFilterDisplayName(stationLibraryCountry);
|
||||
if (stationCountryFilterFlag) {
|
||||
stationCountryFilterFlag.src = getCountryFlagUrl(stationLibraryCountry);
|
||||
}
|
||||
stationCountryFilterWrapEl?.classList.toggle('open', stationCountryFilterOpen);
|
||||
stationCountryFilterMenu.innerHTML = '';
|
||||
stationCountryFilterMenu.classList.toggle('open', stationCountryFilterOpen);
|
||||
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = 'all';
|
||||
allOption.textContent = 'All countries';
|
||||
stationCountryFilterEl.appendChild(allOption);
|
||||
const addOption = (label, value) => {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'library-select-option' + (stationLibraryCountry === value ? ' active' : '');
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('aria-selected', stationLibraryCountry === value ? 'true' : 'false');
|
||||
|
||||
countries.forEach((country) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = country;
|
||||
option.textContent = country;
|
||||
stationCountryFilterEl.appendChild(option);
|
||||
const flag = document.createElement('span');
|
||||
const flagUrl = getCountryFlagUrl(value);
|
||||
flag.className = 'library-select-option-flag';
|
||||
flag.setAttribute('aria-hidden', 'true');
|
||||
if (flagUrl) {
|
||||
const flagImg = document.createElement('img');
|
||||
flagImg.src = flagUrl;
|
||||
flagImg.alt = '';
|
||||
flagImg.setAttribute('aria-hidden', 'true');
|
||||
flagImg.className = 'library-select-option-flag-img';
|
||||
flagImg.loading = 'lazy';
|
||||
flagImg.referrerPolicy = 'no-referrer';
|
||||
flagImg.addEventListener('error', () => {
|
||||
flagImg.remove();
|
||||
flag.textContent = value === 'all' ? '🌐' : '🏳️';
|
||||
}, { once: true });
|
||||
flag.appendChild(flagImg);
|
||||
} else {
|
||||
flag.textContent = value === 'all' ? '🌐' : '🏳️';
|
||||
}
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'library-select-option-text';
|
||||
text.textContent = label;
|
||||
|
||||
option.appendChild(flag);
|
||||
option.appendChild(text);
|
||||
|
||||
option.addEventListener('click', () => {
|
||||
stationLibraryCountry = value;
|
||||
stationLibraryCategory = 'all';
|
||||
saveLastStationCountry(value);
|
||||
resetStationLibraryPage();
|
||||
stationCountryFilterOpen = false;
|
||||
renderStationLibrary();
|
||||
});
|
||||
stationCountryFilterMenu.appendChild(option);
|
||||
};
|
||||
|
||||
addOption('All countries', 'all');
|
||||
const orderedCountries = countries
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftPriority = left.toUpperCase() === 'SI' ? -1 : 0;
|
||||
const rightPriority = right.toUpperCase() === 'SI' ? -1 : 0;
|
||||
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
||||
return getCountryFilterDisplayName(left).localeCompare(getCountryFilterDisplayName(right));
|
||||
});
|
||||
|
||||
stationCountryFilterEl.value = stationLibraryCountry;
|
||||
stationCountryFilterEl.disabled = stationCatalogState === 'loading';
|
||||
orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country));
|
||||
}
|
||||
|
||||
function getFilteredStationEntries() {
|
||||
@@ -668,6 +831,7 @@ function getQuickPickEntries() {
|
||||
function setStationLibraryTab(tab) {
|
||||
stationLibraryTab = tab || 'all';
|
||||
if (stationLibraryTab !== 'categories') stationLibraryCategory = 'all';
|
||||
resetStationLibraryPage();
|
||||
renderStationLibrary();
|
||||
}
|
||||
|
||||
@@ -716,6 +880,7 @@ function renderCategoryChips() {
|
||||
btn.textContent = category === 'all' ? 'All categories' : category;
|
||||
btn.addEventListener('click', () => {
|
||||
stationLibraryCategory = category;
|
||||
resetStationLibraryPage();
|
||||
renderStationLibrary();
|
||||
});
|
||||
stationCategoryListEl.appendChild(btn);
|
||||
@@ -726,6 +891,7 @@ function renderStationLibrary() {
|
||||
try {
|
||||
if (!stationLibraryListEl) return;
|
||||
stationLibraryListEl.innerHTML = '';
|
||||
stationLibraryListEl.scrollTop = 0;
|
||||
stationLibraryEl?.classList.toggle('show-categories', stationLibraryTab === 'categories');
|
||||
|
||||
stationTabBtns.forEach((btn) => {
|
||||
@@ -739,6 +905,10 @@ function renderStationLibrary() {
|
||||
|
||||
if (stationCatalogState === 'loading') {
|
||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Loading stations...';
|
||||
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
|
||||
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
|
||||
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
|
||||
stationLibraryPaginationEl?.classList.add('hidden');
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'library-empty';
|
||||
empty.textContent = 'Loading the local radio catalog...';
|
||||
@@ -748,6 +918,10 @@ function renderStationLibrary() {
|
||||
|
||||
if (stationCatalogState === 'error') {
|
||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Unable to load stations';
|
||||
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
|
||||
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
|
||||
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
|
||||
stationLibraryPaginationEl?.classList.add('hidden');
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'library-empty';
|
||||
empty.textContent = stationCatalogError || 'The local station catalog could not be loaded.';
|
||||
@@ -757,6 +931,10 @@ function renderStationLibrary() {
|
||||
|
||||
if (!stations.length) {
|
||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'No stations available';
|
||||
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
|
||||
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
|
||||
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
|
||||
stationLibraryPaginationEl?.classList.add('hidden');
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'library-empty';
|
||||
empty.textContent = 'The local station catalog is empty.';
|
||||
@@ -770,11 +948,23 @@ function renderStationLibrary() {
|
||||
|
||||
const tabLabel = stationLibraryTab === 'favourites' ? 'favourite' : stationLibraryTab === 'recent' ? 'recent' : 'available';
|
||||
if (stationLibrarySummaryEl) {
|
||||
const countryLabel = stationLibraryCountry === 'all' ? '' : ` in ${stationLibraryCountry}`;
|
||||
const countryLabel = stationLibraryCountry === 'all' ? '' : ` in ${getCountryDisplayName(stationLibraryCountry)}`;
|
||||
stationLibrarySummaryEl.textContent = `${entries.length} ${tabLabel} station${entries.length === 1 ? '' : 's'}${countryLabel}`;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(entries.length / STATION_LIBRARY_PAGE_SIZE));
|
||||
stationLibraryPageTotal = totalPages;
|
||||
stationLibraryPaginationEl?.classList.toggle('hidden', totalPages <= 1);
|
||||
if (stationLibraryPage >= totalPages) {
|
||||
stationLibraryPage = totalPages - 1;
|
||||
}
|
||||
const pageStart = stationLibraryPage * STATION_LIBRARY_PAGE_SIZE;
|
||||
const pageEntries = entries.slice(pageStart, pageStart + STATION_LIBRARY_PAGE_SIZE);
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
|
||||
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
|
||||
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'library-empty';
|
||||
empty.textContent = stationLibraryTab === 'favourites'
|
||||
@@ -784,7 +974,21 @@ function renderStationLibrary() {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(({ station, index, count }) => {
|
||||
if (stationLibraryPageInfo) {
|
||||
stationLibraryPageInfo.textContent = `Page ${stationLibraryPage + 1} of ${totalPages}`;
|
||||
}
|
||||
|
||||
if (stationLibraryPagePrevBtn) {
|
||||
stationLibraryPagePrevBtn.disabled = stationLibraryPage <= 0;
|
||||
stationLibraryPagePrevBtn.setAttribute('aria-disabled', stationLibraryPage <= 0 ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (stationLibraryPageNextBtn) {
|
||||
stationLibraryPageNextBtn.disabled = stationLibraryPage >= totalPages - 1;
|
||||
stationLibraryPageNextBtn.setAttribute('aria-disabled', stationLibraryPage >= totalPages - 1 ? 'true' : 'false');
|
||||
}
|
||||
|
||||
pageEntries.forEach(({ station, index, count }) => {
|
||||
const title = getStationTitle(station);
|
||||
const li = document.createElement('li');
|
||||
|
||||
@@ -806,7 +1010,7 @@ function renderStationLibrary() {
|
||||
meta.className = 'library-station-meta';
|
||||
const country = document.createElement('span');
|
||||
country.className = 'library-station-country';
|
||||
country.textContent = getStationCountry(station) || getStationCategory(station);
|
||||
country.textContent = getCountryDisplayName(getStationCountry(station)) || getStationCategory(station);
|
||||
const tech = document.createElement('span');
|
||||
tech.className = 'library-station-tech';
|
||||
tech.textContent = getStationTechnicalLabel(station) || getStationCategory(station);
|
||||
@@ -952,11 +1156,17 @@ async function loadStations() {
|
||||
try {
|
||||
stationCatalogState = 'loading';
|
||||
stationCatalogError = '';
|
||||
resetStationLibraryPage();
|
||||
renderStationLibrary();
|
||||
stopCurrentSongPollers();
|
||||
const raw = await loadRadioStations();
|
||||
const managedRaw = await loadManagedStations().catch(() => []);
|
||||
|
||||
stations = raw
|
||||
const normalizedRaw = raw
|
||||
.map((s) => normalizeStationRecord(s))
|
||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||
|
||||
const normalizedManaged = managedRaw
|
||||
.map((s) => normalizeStationRecord(s))
|
||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||
|
||||
@@ -964,7 +1174,23 @@ async function loadStations() {
|
||||
.map((s) => normalizeStationRecord(s, true))
|
||||
.filter((s) => s.url && s.url.length > 0);
|
||||
|
||||
stations = stations.concat(userNormalized);
|
||||
const mergedStations = [];
|
||||
const seenStationIds = new Set();
|
||||
const seenStreamUrls = new Set();
|
||||
|
||||
const pushStation = (station) => {
|
||||
if (!station?.id || !station?.url) return;
|
||||
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
|
||||
seenStationIds.add(station.id);
|
||||
seenStreamUrls.add(station.url);
|
||||
mergedStations.push(station);
|
||||
};
|
||||
|
||||
normalizedManaged.forEach(pushStation);
|
||||
normalizedRaw.forEach(pushStation);
|
||||
userNormalized.forEach(pushStation);
|
||||
|
||||
stations = mergedStations;
|
||||
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
|
||||
console.debug('loadStations: loaded', stations.length, 'stations');
|
||||
|
||||
@@ -1144,11 +1370,11 @@ function updateCoverflowTransforms() {
|
||||
const stageWidth = coverflowStageEl.clientWidth || 320;
|
||||
const isMobile = window.matchMedia('(max-width: 760px)').matches;
|
||||
const isNarrow = window.matchMedia('(max-width: 380px)').matches;
|
||||
const maxVisible = 1;
|
||||
const spacing = isMobile ? Math.min(78, Math.max(62, stageWidth / 3.25)) : Math.min(116, Math.max(94, stageWidth / 3.1));
|
||||
const depth = isMobile ? 26 : 36;
|
||||
const rotation = isMobile ? 0 : 8;
|
||||
const scaleStep = isMobile ? 0.08 : 0.1;
|
||||
const maxVisible = isMobile ? 1 : isNarrow ? 1 : Math.max(1, Math.min(4, Math.floor((stageWidth - 120) / (spacing * 0.95))));
|
||||
|
||||
items.forEach((el, railIndex) => {
|
||||
const idx = Number(el.dataset.idx);
|
||||
@@ -1877,6 +2103,8 @@ function setupEventListeners() {
|
||||
nextBtn?.addEventListener('click', playNext);
|
||||
volumeSlider?.addEventListener('input', handleVolumeInput);
|
||||
muteBtn?.addEventListener('click', toggleMute);
|
||||
stationLibraryPagePrevBtn?.addEventListener('click', goToPreviousStationLibraryPage);
|
||||
stationLibraryPageNextBtn?.addEventListener('click', goToNextStationLibraryPage);
|
||||
|
||||
closeOverlayBtn?.addEventListener('click', closeCastOverlay);
|
||||
castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); });
|
||||
@@ -1887,13 +2115,14 @@ function setupEventListeners() {
|
||||
castBtn?.addEventListener('click', requestCastSession);
|
||||
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
|
||||
stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary);
|
||||
stationCountryFilterBtn?.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggleStationCountryFilter();
|
||||
});
|
||||
stationSearchInput?.addEventListener('input', () => {
|
||||
stationLibraryQuery = stationSearchInput.value || '';
|
||||
renderStationLibrary();
|
||||
});
|
||||
stationCountryFilterEl?.addEventListener('change', () => {
|
||||
stationLibraryCountry = stationCountryFilterEl.value || 'all';
|
||||
stationLibraryCategory = 'all';
|
||||
resetStationLibraryPage();
|
||||
renderStationLibrary();
|
||||
});
|
||||
stationTabBtns.forEach((btn) => {
|
||||
@@ -1903,10 +2132,20 @@ function setupEventListeners() {
|
||||
artworkPlaceholder?.addEventListener('click', openStationLibrary);
|
||||
castOutputBtn?.addEventListener('click', toggleCastBothMode);
|
||||
window.addEventListener('resize', updateCoverflowTransforms);
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!stationCountryFilterOpen) return;
|
||||
if (stationCountryFilterWrapEl?.contains(ev.target)) return;
|
||||
closeStationCountryFilter();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (stationCountryFilterOpen && e.code === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeStationCountryFilter();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
|
||||
else if (e.code === 'ArrowRight') playNext();
|
||||
else if (e.code === 'ArrowLeft') playPrev();
|
||||
@@ -1959,6 +2198,7 @@ async function init() {
|
||||
|
||||
restoreSavedVolume();
|
||||
restoreCastBothMode();
|
||||
restoreLastStationCountry();
|
||||
await loadStations();
|
||||
setupEventListeners();
|
||||
ensureArtworkPointerFallback();
|
||||
|
||||
10
src/radio/loadManagedStations.ts
Normal file
10
src/radio/loadManagedStations.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export async function loadManagedStations(): Promise<unknown[]> {
|
||||
const response = await fetch('/stations.json');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load managed stations: ${response.status}`);
|
||||
}
|
||||
|
||||
const stations = await response.json();
|
||||
return Array.isArray(stations) ? stations : [];
|
||||
}
|
||||
@@ -1,24 +1,47 @@
|
||||
export const radioCountries = [
|
||||
{ name: 'Austria', code: 'AT' },
|
||||
{ name: 'Belgium', code: 'BE' },
|
||||
{ name: 'Bulgaria', code: 'BG' },
|
||||
{ name: 'Cyprus', code: 'CY' },
|
||||
{ name: 'Czechia', code: 'CZ' },
|
||||
{ name: 'Denmark', code: 'DK' },
|
||||
{ name: 'Estonia', code: 'EE' },
|
||||
{ name: 'Finland', code: 'FI' },
|
||||
{ name: 'France', code: 'FR' },
|
||||
{ name: 'Germany', code: 'DE' },
|
||||
{ name: 'Greece', code: 'GR' },
|
||||
{ name: 'Russia', code: 'RU' },
|
||||
{ name: 'Hungary', code: 'HU' },
|
||||
{ name: 'Ireland', code: 'IE' },
|
||||
{ name: 'Italy', code: 'IT' },
|
||||
{ name: 'Japan', code: 'JP' },
|
||||
{ name: 'Latvia', code: 'LV' },
|
||||
{ name: 'Lithuania', code: 'LT' },
|
||||
{ name: 'Luxembourg', code: 'LU' },
|
||||
{ name: 'Malta', code: 'MT' },
|
||||
{ name: 'Mexico', code: 'MX' },
|
||||
{ name: 'Netherlands', code: 'NL' },
|
||||
{ name: 'Poland', code: 'PL' },
|
||||
{ name: 'Brazil', code: 'BR' },
|
||||
{ name: 'Portugal', code: 'PT' },
|
||||
{ name: 'Romania', code: 'RO' },
|
||||
{ name: 'Croatia', code: 'HR' },
|
||||
{ name: 'Serbia', code: 'RS' },
|
||||
{ name: 'Montenegro', code: 'ME' },
|
||||
{ name: 'Bosnia & Herzegovina', code: 'BA' },
|
||||
{ name: 'Germany', code: 'DE' },
|
||||
{ name: 'Argentina', code: 'AR' },
|
||||
{ name: 'United Kingdom', code: 'GB' },
|
||||
{ name: 'Italy', code: 'IT' },
|
||||
{ name: 'France', code: 'FR' },
|
||||
{ name: 'Slovenia', code: 'SI' },
|
||||
{ name: 'Slovakia', code: 'SK' },
|
||||
{ name: 'Spain', code: 'ES' },
|
||||
{ name: 'USA', code: 'US' },
|
||||
{ name: 'Canada', code: 'CA' },
|
||||
{ name: 'Australia', code: 'AU' },
|
||||
{ name: 'Luxembourg', code: 'LU' },
|
||||
{ name: 'Netherlands', code: 'NL' },
|
||||
{ name: 'China', code: 'CN' },
|
||||
{ name: 'Sweden', code: 'SE' },
|
||||
{ name: 'Switzerland', code: 'CH' },
|
||||
{ name: 'Hungary', code: 'HU' },
|
||||
{ name: 'Czechia', code: 'CZ' },
|
||||
{ name: 'Poland', code: 'PL' },
|
||||
{ name: 'Turkey', code: 'TR' },
|
||||
{ name: 'Ukraine', code: 'UA' },
|
||||
] as const;
|
||||
|
||||
export type RadioCountry = (typeof radioCountries)[number];
|
||||
298
src/styles.css
298
src/styles.css
@@ -147,20 +147,20 @@ input:focus-visible,
|
||||
.player-layout {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: min(1420px, 100%);
|
||||
height: clamp(680px, calc(100vh - 72px), 880px);
|
||||
width: min(1340px, 100%);
|
||||
height: clamp(600px, calc(100vh - 84px), 760px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
width: 360px;
|
||||
margin-right: 18px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
will-change: width, margin-right;
|
||||
transition:
|
||||
width 0.46s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
@@ -252,35 +252,151 @@ input:focus-visible,
|
||||
|
||||
.library-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 148px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-select {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.library-select-trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px 0 14px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.26);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.09), rgba(255,255,255,0.04)),
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.14), rgba(var(--accent-3-rgb), 0.08));
|
||||
color: var(--text-main);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.library-select-flag,
|
||||
.library-select-option-flag {
|
||||
flex: 0 0 auto;
|
||||
width: 1.4rem;
|
||||
height: 1rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.library-select-flag,
|
||||
.library-select-option-flag-img {
|
||||
display: block;
|
||||
width: 1.4rem;
|
||||
height: 1rem;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.library-select-trigger:hover,
|
||||
.library-select.open .library-select-trigger {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
box-shadow: 0 16px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.library-select-prefix {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-select-value {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.library-select-caret {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.library-select-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 12;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.18);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(16, 20, 27, 0.96), rgba(16, 20, 27, 0.9)),
|
||||
rgba(255,255,255,0.04);
|
||||
box-shadow: 0 24px 48px rgba(0,0,0,0.34);
|
||||
backdrop-filter: blur(20px) saturate(130%);
|
||||
overflow: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-select-menu.open {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.library-select-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-select select {
|
||||
.library-select-option {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.065);
|
||||
color: var(--text-main);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.library-select-option-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-select-option:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.24);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--text-main);
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.library-select-option.active {
|
||||
border-color: rgba(var(--accent-rgb), 0.42);
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-3-rgb), 0.12));
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.library-select-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.library-search input {
|
||||
@@ -299,23 +415,40 @@ input:focus-visible,
|
||||
|
||||
.library-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.library-tab {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.055);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.library-tab-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.library-tab-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.library-tab:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-strong);
|
||||
@@ -365,6 +498,56 @@ input:focus-visible,
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.library-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-pagination-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-page-btn {
|
||||
min-width: 88px;
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 11px;
|
||||
background: rgba(255,255,255,0.055);
|
||||
color: var(--text-main);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.library-page-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-strong);
|
||||
background: rgba(255,255,255,0.085);
|
||||
}
|
||||
|
||||
.library-page-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.library-page-btn svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.library-list {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
@@ -433,6 +616,7 @@ input:focus-visible,
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
@@ -475,9 +659,11 @@ input:focus-visible,
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.library-station-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-main);
|
||||
font-size: 0.94rem;
|
||||
@@ -489,18 +675,20 @@ input:focus-visible,
|
||||
|
||||
.library-station-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.library-station-country,
|
||||
.library-station-tech {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -669,9 +857,10 @@ input:focus-visible,
|
||||
"artwork info"
|
||||
"artwork progress"
|
||||
"artwork controls"
|
||||
"artwork volume";
|
||||
"artwork volume"
|
||||
"quickpick quickpick";
|
||||
gap: 18px 28px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding: clamp(18px, 3vw, 34px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 28px;
|
||||
@@ -820,6 +1009,14 @@ header {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.quickpick-section {
|
||||
grid-area: quickpick;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
width: min(100%, 360px);
|
||||
aspect-ratio: 1;
|
||||
@@ -909,7 +1106,8 @@ header {
|
||||
|
||||
.artwork-coverflow {
|
||||
position: relative;
|
||||
width: min(100%, 430px);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 128px;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -994,10 +1192,10 @@ header {
|
||||
.track-info {
|
||||
grid-area: info;
|
||||
min-width: 0;
|
||||
min-height: 232px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1005,7 +1203,7 @@ header {
|
||||
.track-info h2 {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: clamp(2.1rem, 5vw, 4.6rem);
|
||||
font-size: clamp(2.1rem, 4.5vw, 3.1rem);
|
||||
font-weight: 850;
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0;
|
||||
@@ -1166,7 +1364,7 @@ header {
|
||||
grid-area: controls;
|
||||
display: grid;
|
||||
grid-template-columns: 64px 96px 64px;
|
||||
justify-content: start;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
@@ -1232,8 +1430,9 @@ header {
|
||||
|
||||
.volume-section {
|
||||
grid-area: volume;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(120px, 320px) 44px;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 44px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1591,6 +1790,10 @@ input[type=range]::-webkit-slider-thumb {
|
||||
.library-close {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.library-tabs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -1615,12 +1818,28 @@ input[type=range]::-webkit-slider-thumb {
|
||||
"info"
|
||||
"progress"
|
||||
"controls"
|
||||
"volume";
|
||||
"volume"
|
||||
"quickpick";
|
||||
gap: 13px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.library-tabs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.library-tab {
|
||||
min-height: 36px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.library-tab-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.station-library {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
@@ -1638,18 +1857,19 @@ input[type=range]::-webkit-slider-thumb {
|
||||
.library-tabs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.library-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.library-select-menu {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.library-tab {
|
||||
min-width: 82px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding-inline: 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.library-station {
|
||||
|
||||
Reference in New Issue
Block a user