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>
|
<span className="station-logo-text">1</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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" />
|
<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>
|
<button id="artwork-next" className="coverflow-arrow right" aria-label="Next station" type="button">›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,23 +357,83 @@ function StationLibrary() {
|
|||||||
<input id="station-search-input" type="search" placeholder="Search stations" autoComplete="off" />
|
<input id="station-search-input" type="search" placeholder="Search stations" autoComplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="library-select" htmlFor="station-country-filter">
|
<div className="library-select" data-country-filter>
|
||||||
<span className="library-select-label">Country</span>
|
<button
|
||||||
<select id="station-country-filter" defaultValue="all">
|
id="station-country-filter-btn"
|
||||||
<option value="all">All countries</option>
|
className="library-select-trigger"
|
||||||
</select>
|
type="button"
|
||||||
</label>
|
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>
|
||||||
|
|
||||||
<div className="library-tabs" role="tablist" aria-label="Station filters">
|
<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 active" data-station-tab="all" type="button">
|
||||||
<button className="library-tab" data-station-tab="favourites" type="button">Favourites</button>
|
<span className="library-tab-icon" aria-hidden="true">
|
||||||
<button className="library-tab" data-station-tab="recent" type="button">Recent</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<button className="library-tab" data-station-tab="categories" type="button">Categories</button>
|
<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>
|
||||||
|
|
||||||
<div id="station-category-list" className="category-list" aria-label="Categories" />
|
<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-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" />
|
<ul id="station-library-list" className="library-list" />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
@@ -388,6 +454,7 @@ export default function App() {
|
|||||||
<ProgressBar />
|
<ProgressBar />
|
||||||
<PlayerControls />
|
<PlayerControls />
|
||||||
<VolumeControl />
|
<VolumeControl />
|
||||||
|
<QuickPickCarousel />
|
||||||
<StationsOverlay />
|
<StationsOverlay />
|
||||||
<EditorOverlay />
|
<EditorOverlay />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
304
src/player.js
304
src/player.js
@@ -1,4 +1,6 @@
|
|||||||
import { loadRadioStations } from './radio/loadRadioStations.ts';
|
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.
|
// Web version of RadioPlayer — HTML5 Audio + Google Cast Web Sender SDK.
|
||||||
|
|
||||||
@@ -26,9 +28,14 @@ let stationLibraryTab = 'all';
|
|||||||
let stationLibraryQuery = '';
|
let stationLibraryQuery = '';
|
||||||
let stationLibraryCategory = 'all';
|
let stationLibraryCategory = 'all';
|
||||||
let stationLibraryCountry = 'all';
|
let stationLibraryCountry = 'all';
|
||||||
|
let stationLibraryPage = 0;
|
||||||
|
let stationLibraryPageTotal = 1;
|
||||||
let stationCatalogState = 'idle';
|
let stationCatalogState = 'idle';
|
||||||
let stationCatalogError = '';
|
let stationCatalogError = '';
|
||||||
let playbackError = '';
|
let playbackError = '';
|
||||||
|
let stationCountryFilterOpen = false;
|
||||||
|
|
||||||
|
const STATION_LIBRARY_PAGE_SIZE = 12;
|
||||||
|
|
||||||
const RADIO_PLACEHOLDER_LOGO = '/images/radio-placeholder.svg';
|
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 stationSearchInput = document.getElementById('station-search-input');
|
||||||
const stationLibraryCloseBtn = document.getElementById('station-library-close');
|
const stationLibraryCloseBtn = document.getElementById('station-library-close');
|
||||||
const stationCategoryListEl = document.getElementById('station-category-list');
|
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 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
|
// Editor
|
||||||
const editBtn = document.getElementById('edit-stations-btn');
|
const editBtn = document.getElementById('edit-stations-btn');
|
||||||
const stationsListBtn = document.getElementById('stations-list-btn');
|
const stationsListBtn = document.getElementById('stations-list-btn');
|
||||||
@@ -390,6 +408,19 @@ function getLastStationId() {
|
|||||||
try { return localStorage.getItem('lastStationId'); } catch (e) { return null; }
|
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 ────────────────────────────────────────────
|
// ── castBothMode persistence & UI ────────────────────────────────────────────
|
||||||
|
|
||||||
function saveCastBothMode(val) {
|
function saveCastBothMode(val) {
|
||||||
@@ -553,9 +584,7 @@ function getStationLogoCandidates(station) {
|
|||||||
function getStationSubtitle(station) {
|
function getStationSubtitle(station) {
|
||||||
return station?.slogan
|
return station?.slogan
|
||||||
|| station?.raw?.slogan
|
|| station?.raw?.slogan
|
||||||
|| getStationHomepage(station)
|
|
||||||
|| station?.raw?.defaultText
|
|| 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));
|
return Array.from(new Set(stations.map(getStationCountry).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCountryFilterOptions() {
|
function getCountryDisplayName(countryValue) {
|
||||||
if (!stationCountryFilterEl) return;
|
const value = String(countryValue || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
const countries = getCountryNames();
|
if (value === 'all') return 'All countries';
|
||||||
if (stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
|
if (value === 'SI') return 'Slovenia (managed)';
|
||||||
stationLibraryCountry = 'all';
|
if (/^[A-Z]{2}$/i.test(value)) {
|
||||||
|
const countryName = radioCountryNameByCode.get(value.toUpperCase());
|
||||||
|
if (countryName) return countryName;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
stationCountryFilterEl.innerHTML = '';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const allOption = document.createElement('option');
|
function getCountryCodeFromValue(countryValue) {
|
||||||
allOption.value = 'all';
|
const value = String(countryValue || '').trim();
|
||||||
allOption.textContent = 'All countries';
|
if (!value || value === 'all') return '';
|
||||||
stationCountryFilterEl.appendChild(allOption);
|
if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase();
|
||||||
|
return radioCountryCodeByName.get(value) || '';
|
||||||
|
}
|
||||||
|
|
||||||
countries.forEach((country) => {
|
function resetStationLibraryPage() {
|
||||||
const option = document.createElement('option');
|
stationLibraryPage = 0;
|
||||||
option.value = country;
|
}
|
||||||
option.textContent = country;
|
|
||||||
stationCountryFilterEl.appendChild(option);
|
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 (!stationCountryFilterMenu || !stationCountryFilterBtn || !stationCountryFilterText) return;
|
||||||
|
|
||||||
|
const countries = getCountryNames();
|
||||||
|
if (stationCatalogState === 'ready' && stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
|
||||||
|
stationLibraryCountry = 'all';
|
||||||
|
saveLastStationCountry('all');
|
||||||
|
resetStationLibraryPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
|
||||||
|
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;
|
orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country));
|
||||||
stationCountryFilterEl.disabled = stationCatalogState === 'loading';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilteredStationEntries() {
|
function getFilteredStationEntries() {
|
||||||
@@ -668,6 +831,7 @@ function getQuickPickEntries() {
|
|||||||
function setStationLibraryTab(tab) {
|
function setStationLibraryTab(tab) {
|
||||||
stationLibraryTab = tab || 'all';
|
stationLibraryTab = tab || 'all';
|
||||||
if (stationLibraryTab !== 'categories') stationLibraryCategory = 'all';
|
if (stationLibraryTab !== 'categories') stationLibraryCategory = 'all';
|
||||||
|
resetStationLibraryPage();
|
||||||
renderStationLibrary();
|
renderStationLibrary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +880,7 @@ function renderCategoryChips() {
|
|||||||
btn.textContent = category === 'all' ? 'All categories' : category;
|
btn.textContent = category === 'all' ? 'All categories' : category;
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
stationLibraryCategory = category;
|
stationLibraryCategory = category;
|
||||||
|
resetStationLibraryPage();
|
||||||
renderStationLibrary();
|
renderStationLibrary();
|
||||||
});
|
});
|
||||||
stationCategoryListEl.appendChild(btn);
|
stationCategoryListEl.appendChild(btn);
|
||||||
@@ -726,6 +891,7 @@ function renderStationLibrary() {
|
|||||||
try {
|
try {
|
||||||
if (!stationLibraryListEl) return;
|
if (!stationLibraryListEl) return;
|
||||||
stationLibraryListEl.innerHTML = '';
|
stationLibraryListEl.innerHTML = '';
|
||||||
|
stationLibraryListEl.scrollTop = 0;
|
||||||
stationLibraryEl?.classList.toggle('show-categories', stationLibraryTab === 'categories');
|
stationLibraryEl?.classList.toggle('show-categories', stationLibraryTab === 'categories');
|
||||||
|
|
||||||
stationTabBtns.forEach((btn) => {
|
stationTabBtns.forEach((btn) => {
|
||||||
@@ -739,6 +905,10 @@ function renderStationLibrary() {
|
|||||||
|
|
||||||
if (stationCatalogState === 'loading') {
|
if (stationCatalogState === 'loading') {
|
||||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Loading stations...';
|
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');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'library-empty';
|
empty.className = 'library-empty';
|
||||||
empty.textContent = 'Loading the local radio catalog...';
|
empty.textContent = 'Loading the local radio catalog...';
|
||||||
@@ -748,6 +918,10 @@ function renderStationLibrary() {
|
|||||||
|
|
||||||
if (stationCatalogState === 'error') {
|
if (stationCatalogState === 'error') {
|
||||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Unable to load stations';
|
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');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'library-empty';
|
empty.className = 'library-empty';
|
||||||
empty.textContent = stationCatalogError || 'The local station catalog could not be loaded.';
|
empty.textContent = stationCatalogError || 'The local station catalog could not be loaded.';
|
||||||
@@ -757,6 +931,10 @@ function renderStationLibrary() {
|
|||||||
|
|
||||||
if (!stations.length) {
|
if (!stations.length) {
|
||||||
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'No stations available';
|
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');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'library-empty';
|
empty.className = 'library-empty';
|
||||||
empty.textContent = 'The local station catalog is empty.';
|
empty.textContent = 'The local station catalog is empty.';
|
||||||
@@ -770,11 +948,23 @@ function renderStationLibrary() {
|
|||||||
|
|
||||||
const tabLabel = stationLibraryTab === 'favourites' ? 'favourite' : stationLibraryTab === 'recent' ? 'recent' : 'available';
|
const tabLabel = stationLibraryTab === 'favourites' ? 'favourite' : stationLibraryTab === 'recent' ? 'recent' : 'available';
|
||||||
if (stationLibrarySummaryEl) {
|
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}`;
|
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 (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');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'library-empty';
|
empty.className = 'library-empty';
|
||||||
empty.textContent = stationLibraryTab === 'favourites'
|
empty.textContent = stationLibraryTab === 'favourites'
|
||||||
@@ -784,7 +974,21 @@ function renderStationLibrary() {
|
|||||||
return;
|
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 title = getStationTitle(station);
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
|
|
||||||
@@ -806,7 +1010,7 @@ function renderStationLibrary() {
|
|||||||
meta.className = 'library-station-meta';
|
meta.className = 'library-station-meta';
|
||||||
const country = document.createElement('span');
|
const country = document.createElement('span');
|
||||||
country.className = 'library-station-country';
|
country.className = 'library-station-country';
|
||||||
country.textContent = getStationCountry(station) || getStationCategory(station);
|
country.textContent = getCountryDisplayName(getStationCountry(station)) || getStationCategory(station);
|
||||||
const tech = document.createElement('span');
|
const tech = document.createElement('span');
|
||||||
tech.className = 'library-station-tech';
|
tech.className = 'library-station-tech';
|
||||||
tech.textContent = getStationTechnicalLabel(station) || getStationCategory(station);
|
tech.textContent = getStationTechnicalLabel(station) || getStationCategory(station);
|
||||||
@@ -952,11 +1156,17 @@ async function loadStations() {
|
|||||||
try {
|
try {
|
||||||
stationCatalogState = 'loading';
|
stationCatalogState = 'loading';
|
||||||
stationCatalogError = '';
|
stationCatalogError = '';
|
||||||
|
resetStationLibraryPage();
|
||||||
renderStationLibrary();
|
renderStationLibrary();
|
||||||
stopCurrentSongPollers();
|
stopCurrentSongPollers();
|
||||||
const raw = await loadRadioStations();
|
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))
|
.map((s) => normalizeStationRecord(s))
|
||||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
||||||
|
|
||||||
@@ -964,7 +1174,23 @@ async function loadStations() {
|
|||||||
.map((s) => normalizeStationRecord(s, true))
|
.map((s) => normalizeStationRecord(s, true))
|
||||||
.filter((s) => s.url && s.url.length > 0);
|
.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';
|
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
|
||||||
console.debug('loadStations: loaded', stations.length, 'stations');
|
console.debug('loadStations: loaded', stations.length, 'stations');
|
||||||
|
|
||||||
@@ -1144,11 +1370,11 @@ function updateCoverflowTransforms() {
|
|||||||
const stageWidth = coverflowStageEl.clientWidth || 320;
|
const stageWidth = coverflowStageEl.clientWidth || 320;
|
||||||
const isMobile = window.matchMedia('(max-width: 760px)').matches;
|
const isMobile = window.matchMedia('(max-width: 760px)').matches;
|
||||||
const isNarrow = window.matchMedia('(max-width: 380px)').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 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 depth = isMobile ? 26 : 36;
|
||||||
const rotation = isMobile ? 0 : 8;
|
const rotation = isMobile ? 0 : 8;
|
||||||
const scaleStep = isMobile ? 0.08 : 0.1;
|
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) => {
|
items.forEach((el, railIndex) => {
|
||||||
const idx = Number(el.dataset.idx);
|
const idx = Number(el.dataset.idx);
|
||||||
@@ -1877,6 +2103,8 @@ function setupEventListeners() {
|
|||||||
nextBtn?.addEventListener('click', playNext);
|
nextBtn?.addEventListener('click', playNext);
|
||||||
volumeSlider?.addEventListener('input', handleVolumeInput);
|
volumeSlider?.addEventListener('input', handleVolumeInput);
|
||||||
muteBtn?.addEventListener('click', toggleMute);
|
muteBtn?.addEventListener('click', toggleMute);
|
||||||
|
stationLibraryPagePrevBtn?.addEventListener('click', goToPreviousStationLibraryPage);
|
||||||
|
stationLibraryPageNextBtn?.addEventListener('click', goToNextStationLibraryPage);
|
||||||
|
|
||||||
closeOverlayBtn?.addEventListener('click', closeCastOverlay);
|
closeOverlayBtn?.addEventListener('click', closeCastOverlay);
|
||||||
castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); });
|
castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); });
|
||||||
@@ -1887,13 +2115,14 @@ function setupEventListeners() {
|
|||||||
castBtn?.addEventListener('click', requestCastSession);
|
castBtn?.addEventListener('click', requestCastSession);
|
||||||
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
|
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
|
||||||
stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary);
|
stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary);
|
||||||
|
stationCountryFilterBtn?.addEventListener('click', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggleStationCountryFilter();
|
||||||
|
});
|
||||||
stationSearchInput?.addEventListener('input', () => {
|
stationSearchInput?.addEventListener('input', () => {
|
||||||
stationLibraryQuery = stationSearchInput.value || '';
|
stationLibraryQuery = stationSearchInput.value || '';
|
||||||
renderStationLibrary();
|
resetStationLibraryPage();
|
||||||
});
|
|
||||||
stationCountryFilterEl?.addEventListener('change', () => {
|
|
||||||
stationLibraryCountry = stationCountryFilterEl.value || 'all';
|
|
||||||
stationLibraryCategory = 'all';
|
|
||||||
renderStationLibrary();
|
renderStationLibrary();
|
||||||
});
|
});
|
||||||
stationTabBtns.forEach((btn) => {
|
stationTabBtns.forEach((btn) => {
|
||||||
@@ -1903,10 +2132,20 @@ function setupEventListeners() {
|
|||||||
artworkPlaceholder?.addEventListener('click', openStationLibrary);
|
artworkPlaceholder?.addEventListener('click', openStationLibrary);
|
||||||
castOutputBtn?.addEventListener('click', toggleCastBothMode);
|
castOutputBtn?.addEventListener('click', toggleCastBothMode);
|
||||||
window.addEventListener('resize', updateCoverflowTransforms);
|
window.addEventListener('resize', updateCoverflowTransforms);
|
||||||
|
document.addEventListener('click', (ev) => {
|
||||||
|
if (!stationCountryFilterOpen) return;
|
||||||
|
if (stationCountryFilterWrapEl?.contains(ev.target)) return;
|
||||||
|
closeStationCountryFilter();
|
||||||
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
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(); }
|
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
|
||||||
else if (e.code === 'ArrowRight') playNext();
|
else if (e.code === 'ArrowRight') playNext();
|
||||||
else if (e.code === 'ArrowLeft') playPrev();
|
else if (e.code === 'ArrowLeft') playPrev();
|
||||||
@@ -1959,6 +2198,7 @@ async function init() {
|
|||||||
|
|
||||||
restoreSavedVolume();
|
restoreSavedVolume();
|
||||||
restoreCastBothMode();
|
restoreCastBothMode();
|
||||||
|
restoreLastStationCountry();
|
||||||
await loadStations();
|
await loadStations();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
ensureArtworkPointerFallback();
|
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 = [
|
export const radioCountries = [
|
||||||
{ name: 'Austria', code: 'AT' },
|
{ 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: 'Croatia', code: 'HR' },
|
||||||
{ name: 'Serbia', code: 'RS' },
|
{ name: 'Serbia', code: 'RS' },
|
||||||
{ name: 'Montenegro', code: 'ME' },
|
{ name: 'Montenegro', code: 'ME' },
|
||||||
{ name: 'Bosnia & Herzegovina', code: 'BA' },
|
{ name: 'Bosnia & Herzegovina', code: 'BA' },
|
||||||
{ name: 'Germany', code: 'DE' },
|
{ name: 'Argentina', code: 'AR' },
|
||||||
{ name: 'United Kingdom', code: 'GB' },
|
{ name: 'United Kingdom', code: 'GB' },
|
||||||
{ name: 'Italy', code: 'IT' },
|
{ name: 'Slovenia', code: 'SI' },
|
||||||
{ name: 'France', code: 'FR' },
|
{ name: 'Slovakia', code: 'SK' },
|
||||||
{ name: 'Spain', code: 'ES' },
|
{ name: 'Spain', code: 'ES' },
|
||||||
{ name: 'USA', code: 'US' },
|
{ name: 'USA', code: 'US' },
|
||||||
{ name: 'Canada', code: 'CA' },
|
{ name: 'Canada', code: 'CA' },
|
||||||
{ name: 'Australia', code: 'AU' },
|
{ name: 'Australia', code: 'AU' },
|
||||||
{ name: 'Luxembourg', code: 'LU' },
|
{ name: 'China', code: 'CN' },
|
||||||
{ name: 'Netherlands', code: 'NL' },
|
|
||||||
{ name: 'Sweden', code: 'SE' },
|
{ name: 'Sweden', code: 'SE' },
|
||||||
{ name: 'Switzerland', code: 'CH' },
|
{ name: 'Switzerland', code: 'CH' },
|
||||||
{ name: 'Hungary', code: 'HU' },
|
{ name: 'Turkey', code: 'TR' },
|
||||||
{ name: 'Czechia', code: 'CZ' },
|
{ name: 'Ukraine', code: 'UA' },
|
||||||
{ name: 'Poland', code: 'PL' },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type RadioCountry = (typeof radioCountries)[number];
|
export type RadioCountry = (typeof radioCountries)[number];
|
||||||
298
src/styles.css
298
src/styles.css
@@ -147,20 +147,20 @@ input:focus-visible,
|
|||||||
.player-layout {
|
.player-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
width: min(1420px, 100%);
|
width: min(1340px, 100%);
|
||||||
height: clamp(680px, calc(100vh - 72px), 880px);
|
height: clamp(600px, calc(100vh - 84px), 760px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-wrap {
|
.sidebar-wrap {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 320px;
|
width: 360px;
|
||||||
margin-right: 18px;
|
margin-right: 18px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
will-change: width, margin-right;
|
will-change: width, margin-right;
|
||||||
transition:
|
transition:
|
||||||
width 0.46s cubic-bezier(0.22, 1, 0.36, 1),
|
width 0.46s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
@@ -252,35 +252,151 @@ input:focus-visible,
|
|||||||
|
|
||||||
.library-filter-grid {
|
.library-filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 148px;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-select {
|
.library-select {
|
||||||
min-width: 0;
|
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;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-select-label {
|
.library-select-option {
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-select select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 44px;
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid transparent;
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background: rgba(255,255,255,0.065);
|
background: transparent;
|
||||||
color: var(--text-main);
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
font: inherit;
|
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 {
|
.library-search input {
|
||||||
@@ -299,23 +415,40 @@ input:focus-visible,
|
|||||||
|
|
||||||
.library-tabs {
|
.library-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab {
|
.library-tab {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
padding: 8px 10px;
|
padding: 0;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255,255,255,0.055);
|
background: rgba(255,255,255,0.055);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 800;
|
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;
|
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 {
|
.library-tab:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
@@ -365,6 +498,56 @@ input:focus-visible,
|
|||||||
font-weight: 700;
|
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 {
|
.library-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -433,6 +616,7 @@ input:focus-visible,
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +659,11 @@ input:focus-visible,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-station-title {
|
.library-station-title {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-size: 0.94rem;
|
font-size: 0.94rem;
|
||||||
@@ -489,18 +675,20 @@ input:focus-visible,
|
|||||||
|
|
||||||
.library-station-meta {
|
.library-station-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-station-country,
|
.library-station-country,
|
||||||
.library-station-tech {
|
.library-station-tech {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -669,9 +857,10 @@ input:focus-visible,
|
|||||||
"artwork info"
|
"artwork info"
|
||||||
"artwork progress"
|
"artwork progress"
|
||||||
"artwork controls"
|
"artwork controls"
|
||||||
"artwork volume";
|
"artwork volume"
|
||||||
|
"quickpick quickpick";
|
||||||
gap: 18px 28px;
|
gap: 18px 28px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
padding: clamp(18px, 3vw, 34px);
|
padding: clamp(18px, 3vw, 34px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
@@ -820,6 +1009,14 @@ header {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quickpick-section {
|
||||||
|
grid-area: quickpick;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
width: min(100%, 360px);
|
width: min(100%, 360px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
@@ -909,7 +1106,8 @@ header {
|
|||||||
|
|
||||||
.artwork-coverflow {
|
.artwork-coverflow {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(100%, 430px);
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
height: 128px;
|
height: 128px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
@@ -994,10 +1192,10 @@ header {
|
|||||||
.track-info {
|
.track-info {
|
||||||
grid-area: info;
|
grid-area: info;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 232px;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: end;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -1005,7 +1203,7 @@ header {
|
|||||||
.track-info h2 {
|
.track-info h2 {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(2.1rem, 5vw, 4.6rem);
|
font-size: clamp(2.1rem, 4.5vw, 3.1rem);
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 0.96;
|
line-height: 0.96;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -1166,7 +1364,7 @@ header {
|
|||||||
grid-area: controls;
|
grid-area: controls;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px 96px 64px;
|
grid-template-columns: 64px 96px 64px;
|
||||||
justify-content: start;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -1232,8 +1430,9 @@ header {
|
|||||||
|
|
||||||
.volume-section {
|
.volume-section {
|
||||||
grid-area: volume;
|
grid-area: volume;
|
||||||
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(120px, 320px) 44px;
|
grid-template-columns: 40px minmax(0, 1fr) 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -1591,6 +1790,10 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
.library-close {
|
.library-close {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-tabs {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -1615,12 +1818,28 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
"info"
|
"info"
|
||||||
"progress"
|
"progress"
|
||||||
"controls"
|
"controls"
|
||||||
"volume";
|
"volume"
|
||||||
|
"quickpick";
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 22px;
|
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 {
|
.station-library {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
@@ -1638,18 +1857,19 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
.library-tabs {
|
.library-tabs {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-filter-grid {
|
.library-filter-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-select-menu {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-tab {
|
.library-tab {
|
||||||
min-width: 82px;
|
min-width: 0;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding-inline: 8px;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-station {
|
.library-station {
|
||||||
|
|||||||
Reference in New Issue
Block a user