This commit is contained in:
2026-01-11 19:25:02 +01:00
parent d45fe0fbde
commit abb7cafaed
8 changed files with 362 additions and 59 deletions

View File

@@ -1,6 +1,10 @@
const { invoke } = window.__TAURI__.core;
const { getCurrentWindow } = window.__TAURI__.window;
// In Tauri, the WebView may block insecure (http) images as mixed-content.
// We can optionally fetch such images via backend and render as data: URLs.
const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core);
// State
let stations = [];
let currentIndex = 0;
@@ -38,6 +42,93 @@ const coverflowNextBtn = document.getElementById('artwork-next');
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
const logoTextEl = document.querySelector('.station-logo-text');
const logoImgEl = document.getElementById('station-logo-img');
function toHttpsIfHttp(url) {
if (!url || typeof url !== 'string') return '';
return url.startsWith('http://') ? ('https://' + url.slice('http://'.length)) : url;
}
function uniqueNonEmpty(urls) {
const out = [];
const seen = new Set();
for (const u of urls) {
if (!u || typeof u !== 'string') continue;
const trimmed = u.trim();
if (!trimmed) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed);
}
return out;
}
function setImgWithFallback(imgEl, urls, onFinalError) {
let dataFallbackUrls = [];
// Backward compatible signature; allow passing { dataFallbackUrls } as 4th param.
// (Implemented below via arguments inspection.)
if (arguments.length >= 4 && arguments[3] && typeof arguments[3] === 'object') {
const opt = arguments[3];
if (Array.isArray(opt.dataFallbackUrls)) dataFallbackUrls = opt.dataFallbackUrls;
}
const candidates = uniqueNonEmpty(urls);
let i = 0;
let dataIdx = 0;
let triedData = false;
if (!imgEl || candidates.length === 0) {
if (imgEl) {
imgEl.onload = null;
imgEl.onerror = null;
imgEl.src = '';
}
if (onFinalError) onFinalError();
return;
}
const tryNext = () => {
if (i >= candidates.length) {
// If direct loads failed and we're in Tauri, try fetching via backend and set as data URL.
if (runningInTauri && !triedData && dataFallbackUrls && dataFallbackUrls.length > 0) {
triedData = true;
const dataCandidates = uniqueNonEmpty(dataFallbackUrls);
const tryData = () => {
if (dataIdx >= dataCandidates.length) {
if (onFinalError) onFinalError();
return;
}
const u = dataCandidates[dataIdx++];
invoke('fetch_image_data_url', { url: u })
.then((dataUrl) => {
// Once we have a data URL, we can stop the fallback chain.
imgEl.src = dataUrl;
})
.catch(() => tryData());
};
tryData();
return;
}
if (onFinalError) onFinalError();
return;
}
const nextUrl = candidates[i++];
imgEl.src = nextUrl;
};
imgEl.onload = () => {
// keep last successful src
};
imgEl.onerror = () => {
tryNext();
};
// Some CDNs block referrers; this can improve logo load reliability.
try { imgEl.referrerPolicy = 'no-referrer'; } catch (e) {}
tryNext();
}
// Global error handlers to avoid silent white screen and show errors in UI
window.addEventListener('error', (ev) => {
try {
@@ -286,22 +377,29 @@ function renderCoverflow() {
item.dataset.idx = String(idx);
const rawLogoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
const logoUrl = rawLogoUrl && rawLogoUrl.startsWith('http://')
? ('https://' + rawLogoUrl.slice('http://'.length))
: rawLogoUrl;
if (logoUrl) {
const fallbackLabel = (s && s.name ? String(s.name) : '?').trim();
item.title = fallbackLabel;
if (rawLogoUrl) {
const img = document.createElement('img');
img.alt = `${s.name} logo`;
img.src = logoUrl;
img.addEventListener('error', () => {
// Try https first (avoids mixed-content blocks), then fall back to original.
const candidates = [
toHttpsIfHttp(rawLogoUrl),
rawLogoUrl,
];
setImgWithFallback(img, candidates, () => {
item.innerHTML = '';
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
});
item.textContent = fallbackLabel;
}, { dataFallbackUrls: [rawLogoUrl] });
item.appendChild(img);
} else {
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
item.textContent = fallbackLabel;
}
// Click a card: if it's not selected, select it.
@@ -334,12 +432,32 @@ function wireCoverflowInteractions() {
if (!host) return;
// Buttons
if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length);
// IMPORTANT: prevent the coverflow drag handler (pointer capture) from swallowing button clicks.
if (coverflowPrevBtn) {
coverflowPrevBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} };
coverflowPrevBtn.onclick = (ev) => {
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {}
setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
};
}
if (coverflowNextBtn) {
coverflowNextBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} };
coverflowNextBtn.onclick = (ev) => {
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {}
setStationByIndex((currentIndex + 1) % stations.length);
};
}
// Pointer drag (mouse/touch)
host.onpointerdown = (ev) => {
if (!stations || stations.length <= 1) return;
// If the user clicked the arrow buttons, let the button handler run.
// Otherwise pointer capture can prevent the click from reaching the button.
try {
if (ev.target && ev.target.closest && ev.target.closest('.coverflow-arrow')) return;
} catch (e) {}
coverflowPointerId = ev.pointerId;
coverflowStartX = ev.clientX;
coverflowLastX = ev.clientX;
@@ -397,10 +515,17 @@ function updateCoverflowTransforms() {
try {
if (!coverflowStageEl) return;
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
const n = stations ? stations.length : 0;
if (n <= 0) return;
const maxVisible = 3;
items.forEach((el) => {
const idx = Number(el.dataset.idx);
const offset = idx - currentIndex;
// Treat the station list as circular so the coverflow loops infinitely.
// This makes the "previous" of index 0 be the last station, etc.
let offset = idx - currentIndex;
const half = Math.floor(n / 2);
if (offset > half) offset -= n;
if (offset < -half) offset += n;
if (Math.abs(offset) > maxVisible) {
el.style.opacity = '0';
@@ -933,36 +1058,35 @@ function loadStation(index) {
// Update main artwork logo (best-effort). Many station logo URLs are http; try https first.
try {
if (logoTextEl && station && station.name) {
const numberMatch = station.name.match(/\d+/);
logoTextEl.textContent = numberMatch ? numberMatch[0] : station.name.charAt(0).toUpperCase();
logoTextEl.textContent = String(station.name).trim();
logoTextEl.classList.add('logo-name');
}
const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || station.raw.poster)))) || '';
const logoUrl = rawLogo && rawLogo.startsWith('http://') ? ('https://' + rawLogo.slice('http://'.length)) : rawLogo;
const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || '')))) || '';
const rawPoster = (station && ((station.raw && station.raw.poster) || station.poster || '')) || '';
if (logoImgEl) {
logoImgEl.onload = null;
logoImgEl.onerror = null;
// Show fallback until load completes.
logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden');
if (logoUrl) {
logoImgEl.onload = () => {
logoImgEl.classList.remove('hidden');
if (logoTextEl) logoTextEl.classList.add('hidden');
};
logoImgEl.onerror = () => {
logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden');
};
const candidates = uniqueNonEmpty([
toHttpsIfHttp(rawLogo),
rawLogo,
toHttpsIfHttp(rawPoster),
rawPoster,
]);
logoImgEl.src = logoUrl;
// Show fallback until load completes.
setImgWithFallback(logoImgEl, candidates, () => {
logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden');
} else {
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden');
}
}, { dataFallbackUrls: [rawLogo, rawPoster] });
// If something loads successfully, show it.
logoImgEl.onload = () => {
logoImgEl.classList.remove('hidden');
if (logoTextEl) logoTextEl.classList.add('hidden');
};
}
} catch (e) {
// non-fatal
@@ -1193,7 +1317,6 @@ window.addEventListener('DOMContentLoaded', init);
// Service worker is useful for the PWA, but it can cause confusing caching during
// Tauri development because it may serve an older cached `index.html`.
const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core);
if ('serviceWorker' in navigator) {
if (runningInTauri) {
// Best-effort cleanup so the desktop app always reflects local file changes.