fix
This commit is contained in:
@@ -150,12 +150,8 @@ function loadStation(index) {
|
|||||||
// Fallback to single-letter/logo text
|
// Fallback to single-letter/logo text
|
||||||
logoImgEl.src = '';
|
logoImgEl.src = '';
|
||||||
logoImgEl.classList.add('hidden');
|
logoImgEl.classList.add('hidden');
|
||||||
const numberMatch = station.name.match(/\d+/);
|
logoTextEl.textContent = String(station.name).trim();
|
||||||
if (numberMatch) {
|
logoTextEl.classList.add('logo-name');
|
||||||
logoTextEl.textContent = numberMatch[0];
|
|
||||||
} else {
|
|
||||||
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
logoTextEl.classList.remove('hidden');
|
logoTextEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
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 {
|
.glass-card {
|
||||||
@@ -107,7 +107,7 @@ body {
|
|||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px;
|
padding: 11px 24px 24px;
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +120,11 @@ header {
|
|||||||
-webkit-app-region: drag; /* Draggable area */
|
-webkit-app-region: drag; /* Draggable area */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nudge toolbar a bit higher */
|
||||||
|
.header-top-row {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-info {
|
.header-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -216,8 +221,8 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
width: 220px;
|
width: 190px;
|
||||||
height: 220px;
|
height: 190px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 6px; /* spacing for ring */
|
padding: 6px; /* spacing for ring */
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0));
|
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0));
|
||||||
@@ -247,8 +252,11 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
.station-logo-text {
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
@@ -260,6 +268,21 @@ header {
|
|||||||
z-index: 3;
|
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 {
|
.station-logo-img {
|
||||||
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -366,6 +389,7 @@ header {
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
margin-top: 12px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -3284,6 +3284,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
|||||||
name = "radio-tauri"
|
name = "radio-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"cpal",
|
"cpal",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mdns-sd = "0.17.1"
|
|||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.3"
|
||||||
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
|
base64 = "0.22"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
ringbuf = "0.3"
|
ringbuf = "0.3"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tauri::{AppHandle, Manager, State};
|
|||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use reqwest;
|
use reqwest;
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
|
||||||
mod player;
|
mod player;
|
||||||
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
||||||
@@ -223,6 +224,53 @@ async fn fetch_url(_app: AppHandle, url: String) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_image_data_url(url: String) -> Result<String, String> {
|
||||||
|
// 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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -296,6 +344,8 @@ pub fn run() {
|
|||||||
cast_set_volume,
|
cast_set_volume,
|
||||||
// allow frontend to request arbitrary URLs via backend (bypass CORS)
|
// allow frontend to request arbitrary URLs via backend (bypass CORS)
|
||||||
fetch_url,
|
fetch_url,
|
||||||
|
// fetch remote images via backend (data: URL), helps with mixed-content
|
||||||
|
fetch_image_data_url,
|
||||||
// native player commands (step 1 scaffold)
|
// native player commands (step 1 scaffold)
|
||||||
player_play,
|
player_play,
|
||||||
player_stop,
|
player_stop,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="artwork-section">
|
<section class="artwork-section">
|
||||||
|
<div class="artwork-stack">
|
||||||
<div class="artwork-container">
|
<div class="artwork-container">
|
||||||
<div class="artwork-placeholder">
|
<div class="artwork-placeholder">
|
||||||
<!-- Gooey SVG filter for fluid blob blending -->
|
<!-- Gooey SVG filter for fluid blob blending -->
|
||||||
@@ -99,14 +100,16 @@
|
|||||||
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||||
<span class="station-logo-text">1</span>
|
<span class="station-logo-text">1</span>
|
||||||
|
|
||||||
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coverflow-style station carousel under the artwork (drag or use arrows) -->
|
||||||
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||||
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
<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>
|
<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>
|
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="track-info">
|
<section class="track-info">
|
||||||
|
|||||||
191
src/main.js
191
src/main.js
@@ -1,6 +1,10 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
const { getCurrentWindow } = window.__TAURI__.window;
|
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
|
// State
|
||||||
let stations = [];
|
let stations = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
@@ -38,6 +42,93 @@ const coverflowNextBtn = document.getElementById('artwork-next');
|
|||||||
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
||||||
const logoTextEl = document.querySelector('.station-logo-text');
|
const logoTextEl = document.querySelector('.station-logo-text');
|
||||||
const logoImgEl = document.getElementById('station-logo-img');
|
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
|
// Global error handlers to avoid silent white screen and show errors in UI
|
||||||
window.addEventListener('error', (ev) => {
|
window.addEventListener('error', (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -286,22 +377,29 @@ function renderCoverflow() {
|
|||||||
item.dataset.idx = String(idx);
|
item.dataset.idx = String(idx);
|
||||||
|
|
||||||
const rawLogoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
|
const rawLogoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
|
||||||
const logoUrl = rawLogoUrl && rawLogoUrl.startsWith('http://')
|
const fallbackLabel = (s && s.name ? String(s.name) : '?').trim();
|
||||||
? ('https://' + rawLogoUrl.slice('http://'.length))
|
item.title = fallbackLabel;
|
||||||
: rawLogoUrl;
|
|
||||||
if (logoUrl) {
|
if (rawLogoUrl) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = `${s.name} logo`;
|
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.innerHTML = '';
|
||||||
item.classList.add('fallback');
|
item.classList.add('fallback');
|
||||||
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
|
item.textContent = fallbackLabel;
|
||||||
});
|
}, { dataFallbackUrls: [rawLogoUrl] });
|
||||||
|
|
||||||
item.appendChild(img);
|
item.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
item.classList.add('fallback');
|
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.
|
// Click a card: if it's not selected, select it.
|
||||||
@@ -334,12 +432,32 @@ function wireCoverflowInteractions() {
|
|||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
|
// IMPORTANT: prevent the coverflow drag handler (pointer capture) from swallowing button clicks.
|
||||||
if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length);
|
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)
|
// Pointer drag (mouse/touch)
|
||||||
host.onpointerdown = (ev) => {
|
host.onpointerdown = (ev) => {
|
||||||
if (!stations || stations.length <= 1) return;
|
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;
|
coverflowPointerId = ev.pointerId;
|
||||||
coverflowStartX = ev.clientX;
|
coverflowStartX = ev.clientX;
|
||||||
coverflowLastX = ev.clientX;
|
coverflowLastX = ev.clientX;
|
||||||
@@ -397,10 +515,17 @@ function updateCoverflowTransforms() {
|
|||||||
try {
|
try {
|
||||||
if (!coverflowStageEl) return;
|
if (!coverflowStageEl) return;
|
||||||
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
|
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
|
||||||
|
const n = stations ? stations.length : 0;
|
||||||
|
if (n <= 0) return;
|
||||||
const maxVisible = 3;
|
const maxVisible = 3;
|
||||||
items.forEach((el) => {
|
items.forEach((el) => {
|
||||||
const idx = Number(el.dataset.idx);
|
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) {
|
if (Math.abs(offset) > maxVisible) {
|
||||||
el.style.opacity = '0';
|
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.
|
// Update main artwork logo (best-effort). Many station logo URLs are http; try https first.
|
||||||
try {
|
try {
|
||||||
if (logoTextEl && station && station.name) {
|
if (logoTextEl && station && station.name) {
|
||||||
const numberMatch = station.name.match(/\d+/);
|
logoTextEl.textContent = String(station.name).trim();
|
||||||
logoTextEl.textContent = numberMatch ? numberMatch[0] : station.name.charAt(0).toUpperCase();
|
logoTextEl.classList.add('logo-name');
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || station.raw.poster)))) || '';
|
const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || '')))) || '';
|
||||||
const logoUrl = rawLogo && rawLogo.startsWith('http://') ? ('https://' + rawLogo.slice('http://'.length)) : rawLogo;
|
const rawPoster = (station && ((station.raw && station.raw.poster) || station.poster || '')) || '';
|
||||||
|
|
||||||
if (logoImgEl) {
|
if (logoImgEl) {
|
||||||
logoImgEl.onload = null;
|
// Show fallback until load completes.
|
||||||
logoImgEl.onerror = null;
|
logoImgEl.classList.add('hidden');
|
||||||
|
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
||||||
|
|
||||||
if (logoUrl) {
|
const candidates = uniqueNonEmpty([
|
||||||
|
toHttpsIfHttp(rawLogo),
|
||||||
|
rawLogo,
|
||||||
|
toHttpsIfHttp(rawPoster),
|
||||||
|
rawPoster,
|
||||||
|
]);
|
||||||
|
|
||||||
|
setImgWithFallback(logoImgEl, candidates, () => {
|
||||||
|
logoImgEl.classList.add('hidden');
|
||||||
|
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
||||||
|
}, { dataFallbackUrls: [rawLogo, rawPoster] });
|
||||||
|
|
||||||
|
// If something loads successfully, show it.
|
||||||
logoImgEl.onload = () => {
|
logoImgEl.onload = () => {
|
||||||
logoImgEl.classList.remove('hidden');
|
logoImgEl.classList.remove('hidden');
|
||||||
if (logoTextEl) logoTextEl.classList.add('hidden');
|
if (logoTextEl) logoTextEl.classList.add('hidden');
|
||||||
};
|
};
|
||||||
logoImgEl.onerror = () => {
|
|
||||||
logoImgEl.classList.add('hidden');
|
|
||||||
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
logoImgEl.src = logoUrl;
|
|
||||||
// Show fallback until load completes.
|
|
||||||
logoImgEl.classList.add('hidden');
|
|
||||||
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
logoImgEl.src = '';
|
|
||||||
logoImgEl.classList.add('hidden');
|
|
||||||
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// non-fatal
|
// non-fatal
|
||||||
@@ -1193,7 +1317,6 @@ window.addEventListener('DOMContentLoaded', init);
|
|||||||
|
|
||||||
// Service worker is useful for the PWA, but it can cause confusing caching during
|
// 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`.
|
// Tauri development because it may serve an older cached `index.html`.
|
||||||
const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core);
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
if (runningInTauri) {
|
if (runningInTauri) {
|
||||||
// Best-effort cleanup so the desktop app always reflects local file changes.
|
// 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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
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 {
|
.glass-card {
|
||||||
@@ -115,7 +115,7 @@ body {
|
|||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px;
|
padding: 11px 24px 24px;
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
-webkit-app-region: drag; /* Draggable area */
|
-webkit-app-region: drag; /* Draggable area */
|
||||||
padding: 10px 14px 8px 14px;
|
padding: 1px 14px 8px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(135deg, rgba(60,84,255,0.14), rgba(123,127,216,0.10));
|
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);
|
border: 1px solid rgba(120,130,255,0.12);
|
||||||
@@ -285,9 +285,16 @@ body {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artwork-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
width: 220px;
|
width: 190px;
|
||||||
height: 220px;
|
height: 190px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 6px; /* spacing for ring */
|
padding: 6px; /* spacing for ring */
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
|
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
|
||||||
@@ -354,6 +361,103 @@ body {
|
|||||||
z-index: 3;
|
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 {
|
.station-logo-img {
|
||||||
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -484,6 +588,7 @@ body {
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
margin-top: 12px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user