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

@@ -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');
} }
} }

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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,

View File

@@ -69,8 +69,9 @@
</header> </header>
<section class="artwork-section"> <section class="artwork-section">
<div class="artwork-container"> <div class="artwork-stack">
<div class="artwork-placeholder"> <div class="artwork-container">
<div class="artwork-placeholder">
<!-- Gooey SVG filter for fluid blob blending --> <!-- Gooey SVG filter for fluid blob blending -->
<svg width="0" height="0" style="position:absolute"> <svg width="0" height="0" style="position:absolute">
<defs> <defs>
@@ -99,13 +100,15 @@
<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 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>
</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> </div>
</section> </section>

View File

@@ -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([
logoImgEl.onload = () => { toHttpsIfHttp(rawLogo),
logoImgEl.classList.remove('hidden'); rawLogo,
if (logoTextEl) logoTextEl.classList.add('hidden'); toHttpsIfHttp(rawPoster),
}; rawPoster,
logoImgEl.onerror = () => { ]);
logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden');
};
logoImgEl.src = logoUrl; setImgWithFallback(logoImgEl, candidates, () => {
// Show fallback until load completes.
logoImgEl.classList.add('hidden'); logoImgEl.classList.add('hidden');
if (logoTextEl) logoTextEl.classList.remove('hidden'); if (logoTextEl) logoTextEl.classList.remove('hidden');
} else { }, { dataFallbackUrls: [rawLogo, rawPoster] });
logoImgEl.src = '';
logoImgEl.classList.add('hidden'); // If something loads successfully, show it.
if (logoTextEl) logoTextEl.classList.remove('hidden'); logoImgEl.onload = () => {
} logoImgEl.classList.remove('hidden');
if (logoTextEl) logoTextEl.classList.add('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.

View File

@@ -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;
} }