From abb7cafaed3ba1cc499c876ef55d14ab5893dd5a Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 11 Jan 2026 19:25:02 +0100 Subject: [PATCH] fix --- android/app/src/main/assets/main.js | 8 +- android/app/src/main/assets/styles.css | 36 ++++- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 50 +++++++ src/index.html | 17 ++- src/main.js | 193 ++++++++++++++++++++----- src/styles.css | 115 ++++++++++++++- 8 files changed, 362 insertions(+), 59 deletions(-) diff --git a/android/app/src/main/assets/main.js b/android/app/src/main/assets/main.js index d40faf7..116e77e 100644 --- a/android/app/src/main/assets/main.js +++ b/android/app/src/main/assets/main.js @@ -150,12 +150,8 @@ function loadStation(index) { // 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.textContent = String(station.name).trim(); + logoTextEl.classList.add('logo-name'); logoTextEl.classList.remove('hidden'); } } diff --git a/android/app/src/main/assets/styles.css b/android/app/src/main/assets/styles.css index 43c0d02..608aa2b 100644 --- a/android/app/src/main/assets/styles.css +++ b/android/app/src/main/assets/styles.css @@ -93,7 +93,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 { @@ -107,7 +107,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); } @@ -120,6 +120,11 @@ header { -webkit-app-region: drag; /* Draggable area */ } +/* Nudge toolbar a bit higher */ +.header-top-row { + padding-top: 2px; +} + .header-info { text-align: center; flex: 1; @@ -216,8 +221,8 @@ header { } .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.1), rgba(255,255,255,0)); @@ -247,8 +252,11 @@ header { align-items: center; position: relative; overflow: hidden; - box-shadow: inset 0 0 20px rgba(0,0,0,0.2); -} + left: 0; + right: 0; + bottom: 0; + height: 128px; + z-index: 2; .station-logo-text { font-size: 5rem; @@ -260,6 +268,21 @@ header { z-index: 3; } +.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; +} + .station-logo-img { /* Fill the artwork placeholder while keeping aspect ratio and inner padding */ width: 100%; @@ -366,6 +389,7 @@ header { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; + margin-top: 12px; margin-bottom: 30px; position: relative; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5c7dcf..7197aca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3284,6 +3284,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" name = "radio-tauri" version = "0.1.0" dependencies = [ + "base64 0.22.1", "cpal", "mdns-sd", "reqwest 0.11.27", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec8fd79..f0a2fd9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ mdns-sd = "0.17.1" tokio = { version = "1.48.0", features = ["full"] } tauri-plugin-shell = "2.3.3" reqwest = { version = "0.11", features = ["json", "rustls-tls"] } +base64 = "0.22" cpal = "0.15" ringbuf = "0.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd9dfae..84afd35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use tauri::{AppHandle, Manager, State}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; use reqwest; +use base64::{engine::general_purpose, Engine as _}; mod player; use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState}; @@ -223,6 +224,53 @@ async fn fetch_url(_app: AppHandle, url: String) -> Result { } } +#[tauri::command] +async fn fetch_image_data_url(url: String) -> Result { + // Fetch remote images via backend and return a data: URL. + // This helps when WebView blocks http images (mixed-content) or some hosts block hotlinking. + let parsed = reqwest::Url::parse(&url).map_err(|e| e.to_string())?; + match parsed.scheme() { + "http" | "https" => {} + _ => return Err("Only http/https URLs are allowed".to_string()), + } + + let resp = reqwest::Client::new() + .get(parsed) + .header(reqwest::header::USER_AGENT, "RadioPlayer/1.0") + .send() + .await + .map_err(|e| e.to_string())?; + + let status = resp.status(); + if !status.is_success() { + return Err(format!("HTTP {} while fetching image", status)); + } + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + const MAX_BYTES: usize = 2 * 1024 * 1024; + if bytes.len() > MAX_BYTES { + return Err("Image too large".to_string()); + } + + // Be conservative: prefer image/* content types, but allow svg even if mislabelled. + let looks_like_image = content_type.starts_with("image/") + || content_type == "application/svg+xml" + || url.to_lowercase().ends_with(".svg"); + if !looks_like_image { + return Err(format!("Not an image content-type: {}", content_type)); + } + + let b64 = general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{};base64,{}", content_type, b64)) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -296,6 +344,8 @@ pub fn run() { cast_set_volume, // allow frontend to request arbitrary URLs via backend (bypass CORS) fetch_url, + // fetch remote images via backend (data: URL), helps with mixed-content + fetch_image_data_url, // native player commands (step 1 scaffold) player_play, player_stop, diff --git a/src/index.html b/src/index.html index 7b4e53a..db403fc 100644 --- a/src/index.html +++ b/src/index.html @@ -69,8 +69,9 @@
-
-
+
+
+
@@ -99,13 +100,15 @@ 1 - -
- -
-
+ + +
+ +
+ +
diff --git a/src/main.js b/src/main.js index b850685..3da50e9 100644 --- a/src/main.js +++ b/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. diff --git a/src/styles.css b/src/styles.css index fb73155..05ba5c0 100644 --- a/src/styles.css +++ b/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; }