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
+269 -29
View File
@@ -1,4 +1,6 @@
import { loadRadioStations } from './radio/loadRadioStations.ts';
import { radioCountries } from './radio/radioCountries.ts';
import { loadManagedStations } from './radio/loadManagedStations.ts';
// Web version of RadioPlayer — HTML5 Audio + Google Cast Web Sender SDK.
@@ -26,9 +28,14 @@ let stationLibraryTab = 'all';
let stationLibraryQuery = '';
let stationLibraryCategory = 'all';
let stationLibraryCountry = 'all';
let stationLibraryPage = 0;
let stationLibraryPageTotal = 1;
let stationCatalogState = 'idle';
let stationCatalogError = '';
let playbackError = '';
let stationCountryFilterOpen = false;
const STATION_LIBRARY_PAGE_SIZE = 12;
const RADIO_PLACEHOLDER_LOGO = '/images/radio-placeholder.svg';
@@ -68,9 +75,20 @@ const stationLibrarySummaryEl = document.getElementById('station-library-summary
const stationSearchInput = document.getElementById('station-search-input');
const stationLibraryCloseBtn = document.getElementById('station-library-close');
const stationCategoryListEl = document.getElementById('station-category-list');
const stationCountryFilterEl = document.getElementById('station-country-filter');
const stationCountryFilterWrapEl = document.querySelector('[data-country-filter]');
const stationCountryFilterBtn = document.getElementById('station-country-filter-btn');
const stationCountryFilterMenu = document.getElementById('station-country-filter-menu');
const stationCountryFilterText = document.getElementById('station-country-filter-text');
const stationCountryFilterFlag = document.getElementById('station-country-filter-flag');
const stationLibraryPaginationEl = document.getElementById('station-library-pagination');
const stationLibraryPagePrevBtn = document.getElementById('station-library-page-prev');
const stationLibraryPageNextBtn = document.getElementById('station-library-page-next');
const stationLibraryPageInfo = document.getElementById('station-library-page-info');
const stationTabBtns = document.querySelectorAll('[data-station-tab]');
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
// Editor
const editBtn = document.getElementById('edit-stations-btn');
const stationsListBtn = document.getElementById('stations-list-btn');
@@ -390,6 +408,19 @@ function getLastStationId() {
try { return localStorage.getItem('lastStationId'); } catch (e) { return null; }
}
function saveLastStationCountry(country) {
try { if (country) localStorage.setItem('lastStationCountry', country); } catch (e) { /* ignore */ }
}
function getLastStationCountry() {
try { return localStorage.getItem('lastStationCountry'); } catch (e) { return null; }
}
function restoreLastStationCountry() {
const savedCountry = getLastStationCountry();
stationLibraryCountry = savedCountry || 'all';
}
// ── castBothMode persistence & UI ────────────────────────────────────────────
function saveCastBothMode(val) {
@@ -553,9 +584,7 @@ function getStationLogoCandidates(station) {
function getStationSubtitle(station) {
return station?.slogan
|| station?.raw?.slogan
|| getStationHomepage(station)
|| station?.raw?.defaultText
|| station?.id
|| '';
}
@@ -593,30 +622,164 @@ function getCountryNames() {
return Array.from(new Set(stations.map(getStationCountry).filter(Boolean))).sort((a, b) => a.localeCompare(b));
}
function getCountryDisplayName(countryValue) {
const value = String(countryValue || '').trim();
if (!value) return '';
if (value === 'all') return 'All countries';
if (value === 'SI') return 'Slovenia (managed)';
if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase());
if (countryName) return countryName;
}
return value;
}
function getCountryFilterDisplayName(countryValue) {
const value = String(countryValue || '').trim();
if (!value) return '';
if (value === 'all') return 'All countries';
if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)';
if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase());
if (countryName) return countryName;
}
return value;
}
function getCountryCodeFromValue(countryValue) {
const value = String(countryValue || '').trim();
if (!value || value === 'all') return '';
if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase();
return radioCountryCodeByName.get(value) || '';
}
function resetStationLibraryPage() {
stationLibraryPage = 0;
}
function goToPreviousStationLibraryPage() {
if (stationLibraryPage <= 0) return;
stationLibraryPage -= 1;
renderStationLibrary();
}
function goToNextStationLibraryPage() {
if (stationLibraryPage >= stationLibraryPageTotal - 1) return;
stationLibraryPage += 1;
renderStationLibrary();
}
function countryCodeToFlagEmoji(countryCode) {
const code = String(countryCode || '').trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return '🌐';
return String.fromCodePoint(...code.split('').map((char) => 127397 + char.charCodeAt(0)));
}
function countryCodeToFlagUrl(countryCode) {
const code = String(countryCode || '').trim().toLowerCase();
if (!/^[a-z]{2}$/.test(code)) return '';
return `https://flagcdn.com/w20/${code}.png`;
}
function getCountryFlag(countryName) {
if (!countryName || countryName === 'all') return '🌐';
const countryCode = getCountryCodeFromValue(countryName);
return countryCode ? countryCodeToFlagEmoji(countryCode) : '🏳️';
}
function getCountryFlagUrl(countryName) {
if (!countryName || countryName === 'all') return 'https://flagcdn.com/w20/eu.png';
const countryCode = getCountryCodeFromValue(countryName);
return countryCode ? countryCodeToFlagUrl(countryCode) : '';
}
function toggleStationCountryFilter(forceOpen) {
stationCountryFilterOpen = typeof forceOpen === 'boolean' ? forceOpen : !stationCountryFilterOpen;
renderStationLibrary();
}
function closeStationCountryFilter() {
if (!stationCountryFilterOpen) return;
stationCountryFilterOpen = false;
renderStationLibrary();
}
function renderCountryFilterOptions() {
if (!stationCountryFilterEl) return;
if (!stationCountryFilterMenu || !stationCountryFilterBtn || !stationCountryFilterText) return;
const countries = getCountryNames();
if (stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
if (stationCatalogState === 'ready' && stationLibraryCountry !== 'all' && !countries.includes(stationLibraryCountry)) {
stationLibraryCountry = 'all';
saveLastStationCountry('all');
resetStationLibraryPage();
}
stationCountryFilterEl.innerHTML = '';
stationCountryFilterBtn.setAttribute('aria-expanded', stationCountryFilterOpen ? 'true' : 'false');
stationCountryFilterText.textContent = getCountryFilterDisplayName(stationLibraryCountry);
if (stationCountryFilterFlag) {
stationCountryFilterFlag.src = getCountryFlagUrl(stationLibraryCountry);
}
stationCountryFilterWrapEl?.classList.toggle('open', stationCountryFilterOpen);
stationCountryFilterMenu.innerHTML = '';
stationCountryFilterMenu.classList.toggle('open', stationCountryFilterOpen);
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = 'All countries';
stationCountryFilterEl.appendChild(allOption);
const addOption = (label, value) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'library-select-option' + (stationLibraryCountry === value ? ' active' : '');
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', stationLibraryCountry === value ? 'true' : 'false');
countries.forEach((country) => {
const option = document.createElement('option');
option.value = country;
option.textContent = country;
stationCountryFilterEl.appendChild(option);
});
const flag = document.createElement('span');
const flagUrl = getCountryFlagUrl(value);
flag.className = 'library-select-option-flag';
flag.setAttribute('aria-hidden', 'true');
if (flagUrl) {
const flagImg = document.createElement('img');
flagImg.src = flagUrl;
flagImg.alt = '';
flagImg.setAttribute('aria-hidden', 'true');
flagImg.className = 'library-select-option-flag-img';
flagImg.loading = 'lazy';
flagImg.referrerPolicy = 'no-referrer';
flagImg.addEventListener('error', () => {
flagImg.remove();
flag.textContent = value === 'all' ? '🌐' : '🏳️';
}, { once: true });
flag.appendChild(flagImg);
} else {
flag.textContent = value === 'all' ? '🌐' : '🏳️';
}
stationCountryFilterEl.value = stationLibraryCountry;
stationCountryFilterEl.disabled = stationCatalogState === 'loading';
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));
});
orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country));
}
function getFilteredStationEntries() {
@@ -668,6 +831,7 @@ function getQuickPickEntries() {
function setStationLibraryTab(tab) {
stationLibraryTab = tab || 'all';
if (stationLibraryTab !== 'categories') stationLibraryCategory = 'all';
resetStationLibraryPage();
renderStationLibrary();
}
@@ -716,6 +880,7 @@ function renderCategoryChips() {
btn.textContent = category === 'all' ? 'All categories' : category;
btn.addEventListener('click', () => {
stationLibraryCategory = category;
resetStationLibraryPage();
renderStationLibrary();
});
stationCategoryListEl.appendChild(btn);
@@ -726,6 +891,7 @@ function renderStationLibrary() {
try {
if (!stationLibraryListEl) return;
stationLibraryListEl.innerHTML = '';
stationLibraryListEl.scrollTop = 0;
stationLibraryEl?.classList.toggle('show-categories', stationLibraryTab === 'categories');
stationTabBtns.forEach((btn) => {
@@ -739,6 +905,10 @@ function renderStationLibrary() {
if (stationCatalogState === 'loading') {
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Loading stations...';
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
stationLibraryPaginationEl?.classList.add('hidden');
const empty = document.createElement('li');
empty.className = 'library-empty';
empty.textContent = 'Loading the local radio catalog...';
@@ -748,6 +918,10 @@ function renderStationLibrary() {
if (stationCatalogState === 'error') {
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'Unable to load stations';
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
stationLibraryPaginationEl?.classList.add('hidden');
const empty = document.createElement('li');
empty.className = 'library-empty';
empty.textContent = stationCatalogError || 'The local station catalog could not be loaded.';
@@ -757,6 +931,10 @@ function renderStationLibrary() {
if (!stations.length) {
if (stationLibrarySummaryEl) stationLibrarySummaryEl.textContent = 'No stations available';
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
stationLibraryPaginationEl?.classList.add('hidden');
const empty = document.createElement('li');
empty.className = 'library-empty';
empty.textContent = 'The local station catalog is empty.';
@@ -770,11 +948,23 @@ function renderStationLibrary() {
const tabLabel = stationLibraryTab === 'favourites' ? 'favourite' : stationLibraryTab === 'recent' ? 'recent' : 'available';
if (stationLibrarySummaryEl) {
const countryLabel = stationLibraryCountry === 'all' ? '' : ` in ${stationLibraryCountry}`;
const countryLabel = stationLibraryCountry === 'all' ? '' : ` in ${getCountryDisplayName(stationLibraryCountry)}`;
stationLibrarySummaryEl.textContent = `${entries.length} ${tabLabel} station${entries.length === 1 ? '' : 's'}${countryLabel}`;
}
const totalPages = Math.max(1, Math.ceil(entries.length / STATION_LIBRARY_PAGE_SIZE));
stationLibraryPageTotal = totalPages;
stationLibraryPaginationEl?.classList.toggle('hidden', totalPages <= 1);
if (stationLibraryPage >= totalPages) {
stationLibraryPage = totalPages - 1;
}
const pageStart = stationLibraryPage * STATION_LIBRARY_PAGE_SIZE;
const pageEntries = entries.slice(pageStart, pageStart + STATION_LIBRARY_PAGE_SIZE);
if (entries.length === 0) {
if (stationLibraryPageInfo) stationLibraryPageInfo.textContent = 'Page 1 of 1';
if (stationLibraryPagePrevBtn) stationLibraryPagePrevBtn.disabled = true;
if (stationLibraryPageNextBtn) stationLibraryPageNextBtn.disabled = true;
const empty = document.createElement('li');
empty.className = 'library-empty';
empty.textContent = stationLibraryTab === 'favourites'
@@ -784,7 +974,21 @@ function renderStationLibrary() {
return;
}
entries.forEach(({ station, index, count }) => {
if (stationLibraryPageInfo) {
stationLibraryPageInfo.textContent = `Page ${stationLibraryPage + 1} of ${totalPages}`;
}
if (stationLibraryPagePrevBtn) {
stationLibraryPagePrevBtn.disabled = stationLibraryPage <= 0;
stationLibraryPagePrevBtn.setAttribute('aria-disabled', stationLibraryPage <= 0 ? 'true' : 'false');
}
if (stationLibraryPageNextBtn) {
stationLibraryPageNextBtn.disabled = stationLibraryPage >= totalPages - 1;
stationLibraryPageNextBtn.setAttribute('aria-disabled', stationLibraryPage >= totalPages - 1 ? 'true' : 'false');
}
pageEntries.forEach(({ station, index, count }) => {
const title = getStationTitle(station);
const li = document.createElement('li');
@@ -806,7 +1010,7 @@ function renderStationLibrary() {
meta.className = 'library-station-meta';
const country = document.createElement('span');
country.className = 'library-station-country';
country.textContent = getStationCountry(station) || getStationCategory(station);
country.textContent = getCountryDisplayName(getStationCountry(station)) || getStationCategory(station);
const tech = document.createElement('span');
tech.className = 'library-station-tech';
tech.textContent = getStationTechnicalLabel(station) || getStationCategory(station);
@@ -952,11 +1156,17 @@ async function loadStations() {
try {
stationCatalogState = 'loading';
stationCatalogError = '';
resetStationLibraryPage();
renderStationLibrary();
stopCurrentSongPollers();
const raw = await loadRadioStations();
const managedRaw = await loadManagedStations().catch(() => []);
stations = raw
const normalizedRaw = raw
.map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
const normalizedManaged = managedRaw
.map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
@@ -964,7 +1174,23 @@ async function loadStations() {
.map((s) => normalizeStationRecord(s, true))
.filter((s) => s.url && s.url.length > 0);
stations = stations.concat(userNormalized);
const mergedStations = [];
const seenStationIds = new Set();
const seenStreamUrls = new Set();
const pushStation = (station) => {
if (!station?.id || !station?.url) return;
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
seenStationIds.add(station.id);
seenStreamUrls.add(station.url);
mergedStations.push(station);
};
normalizedManaged.forEach(pushStation);
normalizedRaw.forEach(pushStation);
userNormalized.forEach(pushStation);
stations = mergedStations;
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
console.debug('loadStations: loaded', stations.length, 'stations');
@@ -1144,11 +1370,11 @@ function updateCoverflowTransforms() {
const stageWidth = coverflowStageEl.clientWidth || 320;
const isMobile = window.matchMedia('(max-width: 760px)').matches;
const isNarrow = window.matchMedia('(max-width: 380px)').matches;
const maxVisible = 1;
const spacing = isMobile ? Math.min(78, Math.max(62, stageWidth / 3.25)) : Math.min(116, Math.max(94, stageWidth / 3.1));
const depth = isMobile ? 26 : 36;
const rotation = isMobile ? 0 : 8;
const scaleStep = isMobile ? 0.08 : 0.1;
const maxVisible = isMobile ? 1 : isNarrow ? 1 : Math.max(1, Math.min(4, Math.floor((stageWidth - 120) / (spacing * 0.95))));
items.forEach((el, railIndex) => {
const idx = Number(el.dataset.idx);
@@ -1877,6 +2103,8 @@ function setupEventListeners() {
nextBtn?.addEventListener('click', playNext);
volumeSlider?.addEventListener('input', handleVolumeInput);
muteBtn?.addEventListener('click', toggleMute);
stationLibraryPagePrevBtn?.addEventListener('click', goToPreviousStationLibraryPage);
stationLibraryPageNextBtn?.addEventListener('click', goToNextStationLibraryPage);
closeOverlayBtn?.addEventListener('click', closeCastOverlay);
castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); });
@@ -1887,13 +2115,14 @@ function setupEventListeners() {
castBtn?.addEventListener('click', requestCastSession);
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary);
stationCountryFilterBtn?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
toggleStationCountryFilter();
});
stationSearchInput?.addEventListener('input', () => {
stationLibraryQuery = stationSearchInput.value || '';
renderStationLibrary();
});
stationCountryFilterEl?.addEventListener('change', () => {
stationLibraryCountry = stationCountryFilterEl.value || 'all';
stationLibraryCategory = 'all';
resetStationLibraryPage();
renderStationLibrary();
});
stationTabBtns.forEach((btn) => {
@@ -1903,10 +2132,20 @@ function setupEventListeners() {
artworkPlaceholder?.addEventListener('click', openStationLibrary);
castOutputBtn?.addEventListener('click', toggleCastBothMode);
window.addEventListener('resize', updateCoverflowTransforms);
document.addEventListener('click', (ev) => {
if (!stationCountryFilterOpen) return;
if (stationCountryFilterWrapEl?.contains(ev.target)) return;
closeStationCountryFilter();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (stationCountryFilterOpen && e.code === 'Escape') {
e.preventDefault();
closeStationCountryFilter();
return;
}
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
else if (e.code === 'ArrowRight') playNext();
else if (e.code === 'ArrowLeft') playPrev();
@@ -1959,6 +2198,7 @@ async function init() {
restoreSavedVolume();
restoreCastBothMode();
restoreLastStationCountry();
await loadStations();
setupEventListeners();
ensureArtworkPointerFallback();