This commit is contained in:
2026-01-11 09:53:28 +01:00
parent 9c7f04d197
commit f9b9ce0994
5 changed files with 515 additions and 73 deletions

View File

@@ -28,8 +28,9 @@ const castBtn = document.getElementById('cast-toggle-btn');
const castOverlay = document.getElementById('cast-overlay');
const closeOverlayBtn = document.getElementById('close-overlay');
const deviceListEl = document.getElementById('device-list');
const logoTextEl = document.querySelector('.station-logo-text');
const logoImgEl = document.getElementById('station-logo-img');
const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
const coverflowPrevBtn = document.getElementById('artwork-prev');
const coverflowNextBtn = document.getElementById('artwork-next');
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
// Global error handlers to avoid silent white screen and show errors in UI
window.addEventListener('error', (ev) => {
@@ -170,6 +171,7 @@ async function loadStations() {
console.debug('loadStations: loading station index', currentIndex);
loadStation(currentIndex);
renderCoverflow();
// start polling for currentSong endpoints (if any)
startCurrentSongPollers();
}
@@ -178,6 +180,179 @@ async function loadStations() {
statusTextEl.textContent = 'Error loading stations';
}
}
// --- Coverflow UI (3D-ish station cards like your reference image) ---
let coverflowPointerId = null;
let coverflowStartX = 0;
let coverflowLastX = 0;
let coverflowAccum = 0;
let coverflowMoved = false;
let coverflowWheelLock = false;
function renderCoverflow() {
try {
if (!coverflowStageEl) return;
coverflowStageEl.innerHTML = '';
stations.forEach((s, idx) => {
const item = document.createElement('div');
item.className = 'coverflow-item';
item.dataset.idx = String(idx);
const logoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
if (logoUrl) {
const img = document.createElement('img');
img.alt = `${s.name} logo`;
img.src = logoUrl;
img.addEventListener('error', () => {
item.innerHTML = '';
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
});
item.appendChild(img);
} else {
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
}
// Click a card: if it's not selected, select it.
// Double-click the selected card to open the full station grid.
item.addEventListener('click', async (ev) => {
if (coverflowMoved) return;
const idxClicked = Number(item.dataset.idx);
if (idxClicked !== currentIndex) {
await setStationByIndex(idxClicked);
}
});
item.addEventListener('dblclick', (ev) => {
const idxClicked = Number(item.dataset.idx);
if (idxClicked === currentIndex) openStationsOverlay();
});
coverflowStageEl.appendChild(item);
});
wireCoverflowInteractions();
updateCoverflowTransforms();
} catch (e) {
console.debug('renderCoverflow failed', e);
}
}
function wireCoverflowInteractions() {
try {
const host = document.getElementById('artwork-coverflow');
if (!host) return;
// Buttons
if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length);
// Pointer drag (mouse/touch)
host.onpointerdown = (ev) => {
if (!stations || stations.length <= 1) return;
coverflowPointerId = ev.pointerId;
coverflowStartX = ev.clientX;
coverflowLastX = ev.clientX;
coverflowAccum = 0;
coverflowMoved = false;
try { host.setPointerCapture(ev.pointerId); } catch (e) {}
};
host.onpointermove = (ev) => {
if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return;
const dx = ev.clientX - coverflowLastX;
coverflowLastX = ev.clientX;
if (Math.abs(ev.clientX - coverflowStartX) > 6) coverflowMoved = true;
// Accumulate movement; change station when threshold passed.
coverflowAccum += dx;
const threshold = 36;
if (coverflowAccum >= threshold) {
coverflowAccum = 0;
setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
} else if (coverflowAccum <= -threshold) {
coverflowAccum = 0;
setStationByIndex((currentIndex + 1) % stations.length);
}
};
host.onpointerup = (ev) => {
if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return;
coverflowPointerId = null;
// reset moved flag after click would have fired
setTimeout(() => { coverflowMoved = false; }, 0);
try { host.releasePointerCapture(ev.pointerId); } catch (e) {}
};
host.onpointercancel = (ev) => {
coverflowPointerId = null;
coverflowMoved = false;
};
// Wheel: next/prev with debounce
host.onwheel = (ev) => {
if (!stations || stations.length <= 1) return;
if (coverflowWheelLock) return;
const delta = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
if (Math.abs(delta) < 6) return;
ev.preventDefault();
coverflowWheelLock = true;
if (delta > 0) setStationByIndex((currentIndex + 1) % stations.length);
else setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
setTimeout(() => { coverflowWheelLock = false; }, 160);
};
} catch (e) {
console.debug('wireCoverflowInteractions failed', e);
}
}
function updateCoverflowTransforms() {
try {
if (!coverflowStageEl) return;
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
const maxVisible = 3;
items.forEach((el) => {
const idx = Number(el.dataset.idx);
const offset = idx - currentIndex;
if (Math.abs(offset) > maxVisible) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
el.style.transform = 'translate(-50%, -50%) scale(0.6)';
return;
}
const abs = Math.abs(offset);
const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1);
const translateX = dir * (abs * 78);
const translateZ = -abs * 70;
const rotateY = dir * (-28 * abs);
const scale = 1 - abs * 0.12;
const opacity = 1 - abs * 0.18;
const zIndex = 100 - abs;
el.style.opacity = String(opacity);
el.style.zIndex = String(zIndex);
el.style.pointerEvents = 'auto';
el.style.transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`;
if (offset === 0) el.classList.add('selected');
else el.classList.remove('selected');
});
} catch (e) {
console.debug('updateCoverflowTransforms failed', e);
}
}
async function setStationByIndex(idx) {
if (idx < 0 || idx >= stations.length) return;
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = idx;
saveLastStationId(stations[currentIndex].id);
loadStation(currentIndex);
updateCoverflowTransforms();
if (wasPlaying) await play();
}
// --- Current Song Polling ---
@@ -628,8 +803,6 @@ function ensureArtworkPointerFallback() {
// Quick inline style fallback (helps when CSS is overridden)
try { ap.style.cursor = 'pointer'; } catch (e) {}
try { if (logoImgEl) logoImgEl.style.cursor = 'pointer'; } catch (e) {}
try { if (logoTextEl) logoTextEl.style.cursor = 'pointer'; } catch (e) {}
let active = false;
const onMove = (ev) => {
@@ -668,34 +841,8 @@ function loadStation(index) {
if (nowArtistEl) nowArtistEl.textContent = '';
if (nowTitleEl) nowTitleEl.textContent = '';
// Update Logo Text (First letter or number)
// Simple heuristic: if name has a number, use it, else first letter
// If station has a logo URL, show the image; otherwise show the text fallback
if (station.logo && station.logo.length > 0) {
// Verify the logo exists before showing it
checkImageExists(station.logo).then((exists) => {
if (exists) {
logoImgEl.src = station.logo;
logoImgEl.classList.remove('hidden');
logoTextEl.classList.add('hidden');
} else {
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
logoTextEl.classList.remove('hidden');
}
});
} else {
// Fallback to single-letter/logo text
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
const numberMatch = station.name.match(/\d+/);
if (numberMatch) {
logoTextEl.textContent = numberMatch[0];
} else {
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
}
logoTextEl.classList.remove('hidden');
}
// Sync coverflow transforms (if present)
try { updateCoverflowTransforms(); } catch (e) {}
// When loading a station, ensure only this station's poller runs
try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); }
}
@@ -798,33 +945,15 @@ async function playNext() {
// If playing, stop first? Or seamless?
// For radio, seamless switch requires stop then play new URL
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = (currentIndex + 1) % stations.length;
loadStation(currentIndex);
// persist selection
saveLastStationId(stations[currentIndex].id);
if (wasPlaying) await play();
const nextIndex = (currentIndex + 1) % stations.length;
await setStationByIndex(nextIndex);
}
async function playPrev() {
if (stations.length === 0) return;
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
loadStation(currentIndex);
// persist selection
saveLastStationId(stations[currentIndex].id);
if (wasPlaying) await play();
const prevIndex = (currentIndex - 1 + stations.length) % stations.length;
await setStationByIndex(prevIndex);
}
function updateUI() {
@@ -999,10 +1128,7 @@ async function openStationsOverlay() {
currentMode = 'local';
currentCastDevice = null;
castBtn.style.color = 'var(--text-main)';
currentIndex = idx;
// Remember this selection
saveLastStationId(stations[idx].id);
loadStation(currentIndex);
await setStationByIndex(idx);
closeCastOverlay();
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
};