feat: add country selection, cron automation, sparkle effects and layout fixes

This commit is contained in:
2026-04-29 16:34:09 +02:00
parent c8f8c76e8a
commit 8bd9106ff3
11 changed files with 1130 additions and 52 deletions
+387 -5
View File
@@ -1,10 +1,11 @@
import { loadRadioStations } from './radio/loadRadioStations.ts';
import { radioCountries } from './radio/radioCountries.ts';
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts';
import {
clearPlayerPersistence,
deletePersistedUserStationById,
getPersistenceBackend,
getPersistedSelectedRadioCountryCodes,
getPlayerPersistenceSnapshot,
getPersistedCastBothMode,
getPersistedFavoriteStationIds,
@@ -23,6 +24,7 @@ import {
persistLastImportedAt,
persistLastStationCountry,
persistLastStationId,
persistSelectedRadioCountryCodes,
persistRecentStationHistory,
persistStationHealth,
persistStationUsageCounts,
@@ -58,6 +60,17 @@ let stationLibraryQuery = '';
let stationLibraryCategory = 'all';
let stationLibraryCountry = 'all';
let stationLibrarySort = 'recommended';
let selectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
let draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
let availableRadioCountries = radioCountries.map((country) => ({
...country,
stationCount: null,
pinned: country.code === managedCountryCode,
}));
let availableRadioCountriesLoaded = false;
let availableRadioCountriesRequest = null;
let countrySelectionQuery = '';
let countrySelectionSaving = false;
let stationLibraryPage = 0;
let stationLibraryPageTotal = 1;
let stationCatalogState = 'idle';
@@ -123,6 +136,15 @@ const stationCountryFilterBtn = document.getElementById('station-country-filter-
const stationCountryFilterMenu = document.getElementById('station-country-filter-menu');
const stationCountryFilterText = document.getElementById('station-country-filter-text');
const stationCountryFilterFlag = document.getElementById('station-country-filter-flag');
const countrySelectionOverlay = document.getElementById('country-selection-overlay');
const countrySelectionSearchInput = document.getElementById('country-selection-search-input');
const countrySelectionSummaryEl = document.getElementById('country-selection-summary');
const countrySelectionEmptyEl = document.getElementById('country-selection-empty');
const countrySelectionListEl = document.getElementById('country-selection-list');
const countrySelectionDefaultsBtn = document.getElementById('country-selection-defaults-btn');
const countrySelectionAllBtn = document.getElementById('country-selection-all-btn');
const countrySelectionSaveBtn = document.getElementById('country-selection-save-btn');
const countrySelectionCancelBtn = document.getElementById('country-selection-cancel-btn');
const stationSortSelect = document.getElementById('station-sort-select');
const stationSortBtn = document.getElementById('station-sort-btn');
const stationSortText = document.getElementById('station-sort-text');
@@ -138,6 +160,7 @@ const installPromptBannerEl = document.getElementById('install-prompt-banner');
const installPromptActionBtn = document.getElementById('install-prompt-action');
const installPromptDismissBtn = document.getElementById('install-prompt-dismiss');
const radioCountryByCode = new Map(radioCountries.map((country) => [country.code, country]));
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
@@ -175,6 +198,8 @@ const castOutputText = document.getElementById('cast-output-text');
const IMPORT_EXPORT_SCHEMA = 'radioplayer-local-data';
const IMPORT_EXPORT_VERSION = 1;
const PLAYBACK_START_TIMEOUT_MS = 12000;
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
// ── Utilities ────────────────────────────────────────────────────────────────
@@ -1091,6 +1116,324 @@ function restoreLastStationCountry() {
stationLibraryCountry = savedCountry || 'all';
}
function normalizeSelectedRadioCountryCodes(codes) {
const normalized = Array.isArray(codes) ? codes : Array.from(codes || []);
const uniqueCodes = Array.from(new Set(
normalized
.map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : ''))
.filter((entry) => /^[A-Z]{2}$/.test(entry)),
));
if (!uniqueCodes.includes(managedCountryCode)) {
uniqueCodes.unshift(managedCountryCode);
}
return uniqueCodes.length > 0 ? uniqueCodes : [...defaultSelectedRadioCountryCodes];
}
function getSelectedRadioCountryCodes() {
return normalizeSelectedRadioCountryCodes(selectedRadioCountryCodes);
}
function restoreSelectedRadioCountryCodes() {
selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(getPersistedSelectedRadioCountryCodes()));
draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes());
}
function getAvailableRadioCountryNameByCode(countryCode) {
const code = String(countryCode || '').trim().toUpperCase();
if (!code) return '';
return availableRadioCountries.find((country) => country.code === code)?.name
|| radioCountryNameByCode.get(code)
|| radioCountryByCode.get(code)?.name
|| code;
}
function mergeAvailableRadioCountries(countries) {
const mergedCountries = new Map();
radioCountries.forEach((country) => {
mergedCountries.set(country.code, {
name: country.name,
code: country.code,
stationCount: null,
pinned: country.code === managedCountryCode,
});
});
countries.forEach((country) => {
const code = String(country?.code || '').trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return;
const existing = mergedCountries.get(code);
mergedCountries.set(code, {
name: String(country?.name || existing?.name || code).trim() || code,
code,
stationCount: Number.isFinite(country?.stationCount) && Number(country.stationCount) >= 0
? Number(country.stationCount)
: existing?.stationCount ?? null,
pinned: code === managedCountryCode,
});
});
availableRadioCountries = Array.from(mergedCountries.values()).sort((left, right) => {
if (left.code === managedCountryCode) return -1;
if (right.code === managedCountryCode) return 1;
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
}
async function ensureAvailableRadioCountriesLoaded() {
if (availableRadioCountriesLoaded) {
return availableRadioCountries;
}
if (availableRadioCountriesRequest) {
return availableRadioCountriesRequest;
}
availableRadioCountriesRequest = (async () => {
try {
const response = await fetch(RADIO_BROWSER_COUNTRIES_API_ENDPOINT, {
cache: 'no-store',
headers: {
accept: 'application/json',
},
mode: 'cors',
});
if (!response.ok) {
throw new Error(`Failed loading countries: ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('Invalid country list response');
}
const remoteCountries = payload
.map((entry) => ({
code: String(entry?.iso_3166_1 || '').trim().toUpperCase(),
name: String(entry?.name || '').trim(),
stationCount: Number(entry?.stationcount),
}))
.filter((entry) => /^[A-Z]{2}$/.test(entry.code) && entry.name.length > 0);
mergeAvailableRadioCountries(remoteCountries);
} catch (error) {
console.debug('Falling back to bundled country list', error);
mergeAvailableRadioCountries([]);
} finally {
availableRadioCountriesLoaded = true;
availableRadioCountriesRequest = null;
}
return availableRadioCountries;
})();
return availableRadioCountriesRequest;
}
function renderCountrySelectionOverlay() {
if (!countrySelectionListEl || !countrySelectionSummaryEl || !countrySelectionEmptyEl) return;
if (countrySelectionSaveBtn) {
countrySelectionSaveBtn.disabled = countrySelectionSaving;
countrySelectionSaveBtn.textContent = countrySelectionSaving ? 'Saving...' : 'Save';
}
if (countrySelectionDefaultsBtn) countrySelectionDefaultsBtn.disabled = countrySelectionSaving;
if (countrySelectionAllBtn) countrySelectionAllBtn.disabled = countrySelectionSaving;
if (countrySelectionCancelBtn) countrySelectionCancelBtn.disabled = countrySelectionSaving;
const normalizedQuery = countrySelectionQuery.trim().toLowerCase();
const entries = availableRadioCountries.filter((country) => {
if (!normalizedQuery) return true;
return country.name.toLowerCase().includes(normalizedQuery) || country.code.toLowerCase().includes(normalizedQuery);
});
const selectedCount = normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes).length;
const loadingLabel = availableRadioCountriesLoaded ? '' : ' Loading available countries...';
countrySelectionSummaryEl.textContent = countrySelectionSaving
? 'Saving country selection...'
: `${selectedCount} countries enabled.${loadingLabel}`;
countrySelectionEmptyEl.classList.toggle('hidden', entries.length > 0);
countrySelectionListEl.innerHTML = '';
if (entries.length === 0) {
return;
}
entries.forEach((country) => {
const item = document.createElement('li');
item.className = 'device country-selection-item';
const row = document.createElement('label');
row.className = 'country-selection-option';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = country.code === managedCountryCode || draftSelectedRadioCountryCodes.has(country.code);
checkbox.disabled = country.code === managedCountryCode || countrySelectionSaving;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
draftSelectedRadioCountryCodes.add(country.code);
} else {
draftSelectedRadioCountryCodes.delete(country.code);
}
renderCountrySelectionOverlay();
});
const flag = document.createElement('span');
flag.className = 'country-selection-flag';
flag.setAttribute('aria-hidden', 'true');
const flagUrl = countryCodeToFlagUrl(country.code);
if (flagUrl) {
const flagImg = document.createElement('img');
flagImg.src = flagUrl;
flagImg.alt = '';
flagImg.className = 'country-selection-flag-img';
flagImg.loading = 'lazy';
flagImg.referrerPolicy = 'no-referrer';
flagImg.addEventListener('error', () => {
flagImg.remove();
flag.textContent = country.code === managedCountryCode ? '🇸🇮' : countryCodeToFlagEmoji(country.code);
}, { once: true });
flag.appendChild(flagImg);
} else {
flag.textContent = country.code === managedCountryCode ? '🇸🇮' : countryCodeToFlagEmoji(country.code);
}
const copy = document.createElement('div');
copy.className = 'country-selection-copy';
const title = document.createElement('div');
title.className = 'device-main';
title.textContent = country.code === managedCountryCode ? `${country.name} (managed)` : country.name;
const sub = document.createElement('div');
sub.className = 'device-sub';
if (country.code === managedCountryCode) {
sub.textContent = 'Always enabled managed catalog';
} else if (Number.isFinite(country.stationCount) && country.stationCount > 0) {
sub.textContent = `${country.code}${country.stationCount.toLocaleString()} stations available`;
} else {
sub.textContent = country.code;
}
copy.appendChild(title);
copy.appendChild(sub);
row.appendChild(checkbox);
row.appendChild(flag);
row.appendChild(copy);
item.appendChild(row);
countrySelectionListEl.appendChild(item);
});
}
async function openCountrySelectionOverlay() {
if (!countrySelectionOverlay) return;
draftSelectedRadioCountryCodes = new Set(getSelectedRadioCountryCodes());
countrySelectionQuery = '';
countrySelectionSaving = false;
if (countrySelectionSearchInput) {
countrySelectionSearchInput.value = '';
}
renderCountrySelectionOverlay();
countrySelectionOverlay.classList.remove('hidden');
countrySelectionOverlay.setAttribute('aria-hidden', 'false');
closeStationCountryFilter();
void ensureAvailableRadioCountriesLoaded().then(() => {
renderCountrySelectionOverlay();
});
window.setTimeout(() => countrySelectionSearchInput?.focus(), 30);
}
function closeCountrySelectionOverlay() {
if (!countrySelectionOverlay) return;
countrySelectionOverlay.classList.add('hidden');
countrySelectionOverlay.setAttribute('aria-hidden', 'true');
countrySelectionSaving = false;
}
function getNormalizedStationCountryCode(station) {
const directCode = typeof station?.countryCode === 'string' ? station.countryCode.trim().toUpperCase() : '';
if (/^[A-Z]{2}$/.test(directCode)) return directCode;
const countryValue = typeof station?.country === 'string' ? station.country.trim() : '';
if (/^[A-Z]{2}$/.test(countryValue)) return countryValue.toUpperCase();
return '';
}
function isEnabledRadioCountryStation(station) {
const countryCode = getNormalizedStationCountryCode(station);
if (!countryCode) return true;
return getSelectedRadioCountryCodes().includes(countryCode);
}
async function sendSelectedCountriesToServiceWorker({ syncNow = false } = {}) {
if (!('serviceWorker' in navigator)) {
return { ok: false, skipped: true };
}
const registration = await navigator.serviceWorker.ready.catch(() => null);
const target = navigator.serviceWorker.controller || registration?.active || registration?.waiting;
if (!target) {
return { ok: false, skipped: true };
}
return new Promise((resolve) => {
const channel = new MessageChannel();
const timeoutId = window.setTimeout(() => {
resolve({ ok: false, timedOut: true });
}, syncNow ? 25000 : 8000);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
resolve(event.data || { ok: true });
};
target.postMessage({
type: SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE,
countryCodes: getSelectedRadioCountryCodes(),
syncNow,
}, [channel.port2]);
});
}
async function saveCountrySelection() {
if (countrySelectionSaving) return;
countrySelectionSaving = true;
renderCountrySelectionOverlay();
try {
selectedRadioCountryCodes = new Set(normalizeSelectedRadioCountryCodes(draftSelectedRadioCountryCodes));
persistSelectedRadioCountryCodes(selectedRadioCountryCodes);
await sendSelectedCountriesToServiceWorker({ syncNow: true });
await loadStations();
if (statusTextEl) {
statusTextEl.textContent = 'Country selection updated';
}
closeCountrySelectionOverlay();
} catch (error) {
console.error('Saving country selection failed', error);
if (statusTextEl) {
statusTextEl.textContent = 'Unable to refresh selected countries';
}
} finally {
countrySelectionSaving = false;
renderCountrySelectionOverlay();
}
}
// ── castBothMode persistence & UI ────────────────────────────────────────────
function saveCastBothMode(val) {
@@ -1563,7 +1906,7 @@ function getStationTechnicalLabel(station) {
}
function getStationDetails(station) {
return [getStationCountry(station), getStationTechnicalLabel(station)].filter(Boolean).join(' • ');
return [getCountryDisplayName(getStationCountry(station)), getStationTechnicalLabel(station)].filter(Boolean).join(' • ');
}
function getStationSearchText(station) {
@@ -1637,7 +1980,7 @@ function getCountryDisplayName(countryValue) {
if (value === 'all') return 'All countries';
if (value === 'SI') return 'Slovenia (managed)';
if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase());
const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
if (countryName) return countryName;
}
return value;
@@ -1649,7 +1992,7 @@ function getCountryFilterDisplayName(countryValue) {
if (value === 'all') return 'All countries';
if (value.toUpperCase() === 'SI') return 'SLOVENIA (managed)';
if (/^[A-Z]{2}$/i.test(value)) {
const countryName = radioCountryNameByCode.get(value.toUpperCase());
const countryName = getAvailableRadioCountryNameByCode(value.toUpperCase());
if (countryName) return countryName;
}
return value;
@@ -1659,7 +2002,9 @@ function getCountryCodeFromValue(countryValue) {
const value = String(countryValue || '').trim();
if (!value || value === 'all') return '';
if (/^[A-Z]{2}$/i.test(value)) return value.toUpperCase();
return radioCountryCodeByName.get(value) || '';
return radioCountryCodeByName.get(value)
|| availableRadioCountries.find((country) => country.name === value)?.code
|| '';
}
function resetStationLibraryPage() {
@@ -1839,6 +2184,15 @@ function renderCountryFilterOptions() {
});
orderedCountries.forEach((country) => addOption(getCountryFilterDisplayName(country), country));
const manageButton = document.createElement('button');
manageButton.type = 'button';
manageButton.className = 'library-select-manage-btn';
manageButton.textContent = 'Choose countries...';
manageButton.addEventListener('click', () => {
void openCountrySelectionOverlay();
});
stationCountryFilterMenu.appendChild(manageButton);
}
function getFilteredStationEntries() {
@@ -2262,6 +2616,7 @@ async function loadStations() {
const managedRaw = await loadManagedStations().catch(() => []);
const normalizedRaw = raw
.filter((station) => isEnabledRadioCountryStation(station))
.map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
@@ -3293,6 +3648,26 @@ function setupEventListeners() {
installAppBtn?.addEventListener('click', promptInstallApp);
castBtn?.addEventListener('click', requestCastSession);
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
countrySelectionCancelBtn?.addEventListener('click', closeCountrySelectionOverlay);
countrySelectionOverlay?.addEventListener('click', (e) => {
if (e.target === countrySelectionOverlay) closeCountrySelectionOverlay();
});
countrySelectionSearchInput?.addEventListener('input', () => {
countrySelectionQuery = countrySelectionSearchInput.value || '';
renderCountrySelectionOverlay();
});
countrySelectionDefaultsBtn?.addEventListener('click', () => {
draftSelectedRadioCountryCodes = new Set(defaultSelectedRadioCountryCodes);
renderCountrySelectionOverlay();
});
countrySelectionAllBtn?.addEventListener('click', async () => {
await ensureAvailableRadioCountriesLoaded();
draftSelectedRadioCountryCodes = new Set(availableRadioCountries.map((country) => country.code));
renderCountrySelectionOverlay();
});
countrySelectionSaveBtn?.addEventListener('click', () => {
void saveCountrySelection();
});
exportUserDataBtn?.addEventListener('click', handleExportUserData);
importUserDataBtn?.addEventListener('click', () => importUserDataInput?.click());
importUserDataInput?.addEventListener('change', () => {
@@ -3358,6 +3733,11 @@ function setupEventListeners() {
closeSortFilter();
return;
}
if (countrySelectionOverlay && !countrySelectionOverlay.classList.contains('hidden') && e.code === 'Escape') {
e.preventDefault();
closeCountrySelectionOverlay();
return;
}
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
else if (e.code === 'ArrowRight') playNext();
else if (e.code === 'ArrowLeft') playPrev();
@@ -3412,8 +3792,10 @@ async function init() {
restoreSavedVolume();
restoreCastBothMode();
restoreLastStationCountry();
restoreSelectedRadioCountryCodes();
await loadStations();
setupEventListeners();
void sendSelectedCountriesToServiceWorker();
lockPortraitOrientation();
ensureArtworkPointerFallback();
scheduleArtworkShine();