Refine player layout and station data

This commit is contained in:
2026-04-26 15:18:41 +02:00
parent 972164bba7
commit 0864a28593
9 changed files with 44675 additions and 9944 deletions

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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">&lsaquo;</button> <button id="artwork-prev" className="coverflow-arrow left" aria-label="Previous station" type="button">&lsaquo;</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">&rsaquo;</button> <button id="artwork-next" className="coverflow-arrow right" aria-label="Next station" type="button">&rsaquo;</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>

View File

@@ -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();

View 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 : [];
}

View File

@@ -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];

View File

@@ -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 {