fix
This commit is contained in:
@@ -69,8 +69,9 @@
|
||||
</header>
|
||||
|
||||
<section class="artwork-section">
|
||||
<div class="artwork-container">
|
||||
<div class="artwork-placeholder">
|
||||
<div class="artwork-stack">
|
||||
<div class="artwork-container">
|
||||
<div class="artwork-placeholder">
|
||||
<!-- Gooey SVG filter for fluid blob blending -->
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
@@ -99,13 +100,15 @@
|
||||
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||
<span class="station-logo-text">1</span>
|
||||
|
||||
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
||||
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
||||
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
|
||||
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverflow-style station carousel under the artwork (drag or use arrows) -->
|
||||
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
||||
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
|
||||
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
193
src/main.js
193
src/main.js
@@ -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.
|
||||
|
||||
115
src/styles.css
115
src/styles.css
@@ -101,7 +101,7 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 10px; /* Slight padding from window edges if desired, or 0 */
|
||||
padding: 8px; /* Slight padding from window edges if desired, or 0 */
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@@ -115,7 +115,7 @@ body {
|
||||
border-radius: var(--card-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
padding: 11px 24px 24px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ body {
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
-webkit-app-region: drag; /* Draggable area */
|
||||
padding: 10px 14px 8px 14px;
|
||||
padding: 1px 14px 8px 14px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(60,84,255,0.14), rgba(123,127,216,0.10));
|
||||
border: 1px solid rgba(120,130,255,0.12);
|
||||
@@ -285,9 +285,16 @@ body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.artwork-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
border-radius: 24px;
|
||||
padding: 6px; /* spacing for ring */
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
|
||||
@@ -354,6 +361,103 @@ body {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* When we don't have an icon, show the station name nicely */
|
||||
.station-logo-text.logo-name {
|
||||
font-size: clamp(1.1rem, 5.5vw, 2.2rem);
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
max-width: 88%;
|
||||
text-align: center;
|
||||
line-height: 1.12;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Artwork coverflow (station carousel inside artwork) */
|
||||
.artwork-coverflow {
|
||||
position: relative;
|
||||
width: min(320px, 92vw);
|
||||
height: 108px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.artwork-coverflow-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.coverflow-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
box-shadow: 0 10px 26px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
transform-style: preserve-3d;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.coverflow-item.selected {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-color: rgba(255,255,255,0.18);
|
||||
}
|
||||
|
||||
.coverflow-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.coverflow-item.fallback {
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.35);
|
||||
font-weight: 800;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2px;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.coverflow-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(30, 30, 40, 0.35);
|
||||
color: rgba(255,255,255,0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.coverflow-arrow.left { left: 10px; }
|
||||
.coverflow-arrow.right { right: 10px; }
|
||||
|
||||
.station-logo-img {
|
||||
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||
width: 100%;
|
||||
@@ -484,6 +588,7 @@ body {
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 2px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user