overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them
This commit is contained in:
1 parent
ad2c912b71
commit
6a7a5c49c0
7 files changed
+534
-39
No files matched your search
+161
-20
@@ -563,6 +563,36 @@ pub async fn connect_and_login(
|
|||||||
&update.state,
|
&update.state,
|
||||||
DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled
|
DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled
|
||||||
);
|
);
|
||||||
|
// Mirror every update for the debug overlay's
|
||||||
|
// QUEUE section — these transfers are otherwise
|
||||||
|
// invisible by design.
|
||||||
|
let state_label = match &update.state {
|
||||||
|
DS::Connecting => "connecting".to_string(),
|
||||||
|
DS::Requested => "requested".to_string(),
|
||||||
|
DS::Queued { .. } => "queued".to_string(),
|
||||||
|
DS::Transferring => "transferring".to_string(),
|
||||||
|
DS::Complete { .. } => "done".to_string(),
|
||||||
|
DS::Failed { reason } => format!("failed: {reason}"),
|
||||||
|
DS::Cancelled => "cancelled".to_string(),
|
||||||
|
};
|
||||||
|
let place = match &update.state {
|
||||||
|
DS::Queued { place } => *place,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
crate::diagnostics::note_preload(
|
||||||
|
&event_app,
|
||||||
|
update.id,
|
||||||
|
crate::diagnostics::PreloadDl {
|
||||||
|
username: update.username.clone(),
|
||||||
|
path: update.filename.clone(),
|
||||||
|
state: state_label,
|
||||||
|
place,
|
||||||
|
received: update.received,
|
||||||
|
total: update.total,
|
||||||
|
speed_bps: update.speed_bps,
|
||||||
|
},
|
||||||
|
terminal,
|
||||||
|
);
|
||||||
if terminal {
|
if terminal {
|
||||||
let cache = ps.preloads.lock().unwrap().remove(&update.id);
|
let cache = ps.preloads.lock().unwrap().remove(&update.id);
|
||||||
let save_cache =
|
let save_cache =
|
||||||
@@ -3829,6 +3859,9 @@ pub async fn play_stream(
|
|||||||
.map(|(id, _)| *id);
|
.map(|(id, _)| *id);
|
||||||
if let Some(id) = preload_id {
|
if let Some(id) = preload_id {
|
||||||
players.preloads.lock().unwrap().remove(&id);
|
players.preloads.lock().unwrap().remove(&id);
|
||||||
|
// From here the transfer is the CURRENT track's stream; the overlay's
|
||||||
|
// DL line (note_stream below) reports it, not the on-deck list.
|
||||||
|
crate::diagnostics::retire_preload(&app, id);
|
||||||
let progress = player::StreamProgress::new();
|
let progress = player::StreamProgress::new();
|
||||||
if let Ok(md) = std::fs::metadata(&partial) {
|
if let Ok(md) = std::fs::metadata(&partial) {
|
||||||
progress.update(md.len(), false, None);
|
progress.update(md.len(), false, None);
|
||||||
@@ -4252,26 +4285,7 @@ pub async fn album_art(
|
|||||||
let Some(edge) = max_edge else {
|
let Some(edge) = max_edge else {
|
||||||
return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None));
|
return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None));
|
||||||
};
|
};
|
||||||
let target = probe_dir.and_then(|dir| {
|
let target = probe_dir.and_then(|dir| art_thumb_target(&dir, &probe_path, edge));
|
||||||
let meta = std::fs::metadata(&probe_path).ok()?;
|
|
||||||
let mtime = meta
|
|
||||||
.modified()
|
|
||||||
.ok()?
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.ok()?
|
|
||||||
.as_nanos();
|
|
||||||
let mut hash: u64 = 0xcbf29ce484222325;
|
|
||||||
for b in probe_path
|
|
||||||
.as_bytes()
|
|
||||||
.iter()
|
|
||||||
.chain(mtime.to_le_bytes().iter())
|
|
||||||
.chain(meta.len().to_le_bytes().iter())
|
|
||||||
{
|
|
||||||
hash ^= u64::from(*b);
|
|
||||||
hash = hash.wrapping_mul(0x100000001b3);
|
|
||||||
}
|
|
||||||
Some(dir.join(format!("{hash:016x}-{edge}.jpg")))
|
|
||||||
});
|
|
||||||
if let Some(t) = &target {
|
if let Some(t) = &target {
|
||||||
if let Ok(cached) = std::fs::read(t) {
|
if let Ok(cached) = std::fs::read(t) {
|
||||||
return Ok((cached, None));
|
return Ok((cached, None));
|
||||||
@@ -4324,6 +4338,133 @@ pub async fn album_art(
|
|||||||
/// requests from ever owning more than two cores.
|
/// requests from ever owning more than two cores.
|
||||||
static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
|
static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
|
||||||
|
|
||||||
|
/// The disk-cache file for one (source, edge) thumbnail, keyed on
|
||||||
|
/// (path, mtime, len, edge) so a replaced cover self-invalidates. ONE
|
||||||
|
/// implementation on purpose: `album_art` reads through this key and
|
||||||
|
/// `library_art_prewarm` writes through it — if they computed it separately
|
||||||
|
/// and drifted, the warm work would be invisible to the tiles it was done
|
||||||
|
/// for. None when the source file cannot be statted.
|
||||||
|
fn art_thumb_target(dir: &std::path::Path, src: &str, edge: u32) -> Option<std::path::PathBuf> {
|
||||||
|
let meta = std::fs::metadata(src).ok()?;
|
||||||
|
let mtime = meta
|
||||||
|
.modified()
|
||||||
|
.ok()?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()?
|
||||||
|
.as_nanos();
|
||||||
|
let mut hash: u64 = 0xcbf29ce484222325;
|
||||||
|
for b in src
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.chain(mtime.to_le_bytes().iter())
|
||||||
|
.chain(meta.len().to_le_bytes().iter())
|
||||||
|
{
|
||||||
|
hash ^= u64::from(*b);
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
Some(dir.join(format!("{hash:016x}-{edge}.jpg")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the thumbnail cache hold every library cover (and artist portrait) at
|
||||||
|
/// each requested edge, so the Library's first-ever scroll is a disk read
|
||||||
|
/// instead of a master decode — [{warm, decoded}] per edge, in the order
|
||||||
|
/// given, so the caller puts the size it paints first.
|
||||||
|
///
|
||||||
|
/// Already-warm entries cost one stat each. Cold ones decode ONE at a time,
|
||||||
|
/// through the same 2-permit gate the tiles use — a background warm can never
|
||||||
|
/// own more than one core, and an on-screen tile waits behind at most one
|
||||||
|
/// decode. Edges walk outermost so the first edge finishes across the whole
|
||||||
|
/// library before the next begins.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn library_art_prewarm(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
lib: State<'_, LibraryState>,
|
||||||
|
edges: Vec<u32>,
|
||||||
|
) -> Result<Vec<(usize, usize)>, String> {
|
||||||
|
let Some(thumb_dir) = app
|
||||||
|
.path()
|
||||||
|
.app_cache_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.join("art-thumbs-v1"))
|
||||||
|
else {
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
let l = lib.library.clone();
|
||||||
|
let albums =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
let mut paths: Vec<String> = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for a in &albums {
|
||||||
|
// Portraits too: the shelf heads load through the same tile path.
|
||||||
|
for p in [a.art_path.as_ref(), a.artist_art_path.as_ref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if seen.insert(p.clone()) {
|
||||||
|
paths.push(p.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for edge in edges {
|
||||||
|
if edge == 0 {
|
||||||
|
out.push((0, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// One pass of cheap stats finds the cold set; the warm majority never
|
||||||
|
// touches a permit (the same shape as album_art's phase 1).
|
||||||
|
let dir = thumb_dir.clone();
|
||||||
|
let batch = paths.clone();
|
||||||
|
let (warm, cold) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut warm = 0usize;
|
||||||
|
let mut cold: Vec<(String, std::path::PathBuf)> = Vec::new();
|
||||||
|
for path in &batch {
|
||||||
|
match art_thumb_target(&dir, path, edge) {
|
||||||
|
Some(t) if t.exists() => warm += 1,
|
||||||
|
Some(t) => cold.push((path.clone(), t)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(warm, cold)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut decoded = 0usize;
|
||||||
|
for (path, target) in cold {
|
||||||
|
let permit = ART_DECODE_SLOTS
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let ok = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Same fallback as album_art: an undecodable or already-small
|
||||||
|
// source caches its original bytes, so the discovery is paid
|
||||||
|
// once there too.
|
||||||
|
let out = match pipeline::thumbnail_jpeg(&bytes, edge) {
|
||||||
|
Ok(Some(thumb)) => thumb,
|
||||||
|
_ => bytes,
|
||||||
|
};
|
||||||
|
if let Some(dir) = target.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
std::fs::write(&target, &out).is_ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
drop(permit);
|
||||||
|
if ok {
|
||||||
|
decoded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((warm, decoded));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// The cover a streamed track carries INSIDE its own bytes, extracted to a
|
/// The cover a streamed track carries INSIDE its own bytes, extracted to a
|
||||||
/// file the ordinary art path can read.
|
/// file the ordinary art path can read.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! Process figures aggregate the WHOLE process tree: the WebView2 children
|
//! Process figures aggregate the WHOLE process tree: the WebView2 children
|
||||||
//! do the actual rendering, so app.exe alone would tell a comforting lie.
|
//! do the actual rendering, so app.exe alone would tell a comforting lie.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -27,6 +28,48 @@ struct Inner {
|
|||||||
/// StreamProgress — the overlay must read download truth at the source,
|
/// StreamProgress — the overlay must read download truth at the source,
|
||||||
/// not the UI store's window-activity-gated mirror.
|
/// not the UI store's window-activity-gated mirror.
|
||||||
current_stream: Option<(u64, Arc<StreamProgress>)>,
|
current_stream: Option<(u64, Arc<StreamProgress>)>,
|
||||||
|
/// On-deck pre-buffer transfers by transfer id, mirrored from the
|
||||||
|
/// download event stream (those updates are otherwise swallowed — no
|
||||||
|
/// player, no Downloads view). The Instant is set when the transfer
|
||||||
|
/// reaches a terminal state, so finished rows linger long enough to be
|
||||||
|
/// seen and then fall off.
|
||||||
|
preloads: HashMap<u64, (PreloadDl, Option<Instant>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One on-deck transfer as the overlay sees it.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PreloadDl {
|
||||||
|
pub username: String,
|
||||||
|
/// Full remote path — the frontend matches queue rows by this.
|
||||||
|
pub path: String,
|
||||||
|
pub state: String,
|
||||||
|
/// Remote queue position, when the peer reported one.
|
||||||
|
pub place: Option<u32>,
|
||||||
|
pub received: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub speed_bps: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the download event stream for every preload update.
|
||||||
|
pub fn note_preload(app: &tauri::AppHandle, id: u64, dl: PreloadDl, terminal: bool) {
|
||||||
|
if let Some(state) = app.try_state::<DiagState>() {
|
||||||
|
let finished = if terminal { Some(Instant::now()) } else { None };
|
||||||
|
state
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.expect("diag lock")
|
||||||
|
.preloads
|
||||||
|
.insert(id, (dl, finished));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a play path takes the transfer over: it is the CURRENT
|
||||||
|
/// track's stream from here on (note_stream reports it), not an on-deck row.
|
||||||
|
pub fn retire_preload(app: &tauri::AppHandle, id: u64) {
|
||||||
|
if let Some(state) = app.try_state::<DiagState>() {
|
||||||
|
state.0.lock().expect("diag lock").preloads.remove(&id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by the play paths: this track streams from a peer.
|
/// Called by the play paths: this track streams from a peer.
|
||||||
@@ -51,6 +94,7 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
gpu: GpuCounter::open(),
|
gpu: GpuCounter::open(),
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
current_stream: None,
|
current_stream: None,
|
||||||
|
preloads: HashMap::new(),
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +142,9 @@ pub struct DebugStats {
|
|||||||
pub preview_volume: Option<f32>,
|
pub preview_volume: Option<f32>,
|
||||||
/// The active preview clip's loudness-normalization gain in dB.
|
/// The active preview clip's loudness-normalization gain in dB.
|
||||||
pub preview_norm_db: Option<f32>,
|
pub preview_norm_db: Option<f32>,
|
||||||
|
/// On-deck pre-buffer transfers, in arrival order (finished rows linger
|
||||||
|
/// ~15s so a completion is visible before it drops off).
|
||||||
|
pub preload_dl: Vec<PreloadDl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -167,6 +214,18 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
|||||||
let tree_u32: Vec<u32> = tree.iter().map(|p| p.as_u32()).collect();
|
let tree_u32: Vec<u32> = tree.iter().map(|p| p.as_u32()).collect();
|
||||||
let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32));
|
let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32));
|
||||||
|
|
||||||
|
// On-deck transfers: drop terminal rows after they've been visible a
|
||||||
|
// while, then report the rest in arrival (transfer-id) order.
|
||||||
|
inner
|
||||||
|
.preloads
|
||||||
|
.retain(|_, v| v.1.is_none_or(|t| t.elapsed().as_secs() < 15));
|
||||||
|
let mut preload_ids: Vec<u64> = inner.preloads.keys().copied().collect();
|
||||||
|
preload_ids.sort_unstable();
|
||||||
|
let preload_dl: Vec<PreloadDl> = preload_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| inner.preloads.get(id).map(|(dl, _)| dl.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
DebugStats {
|
DebugStats {
|
||||||
cpu_process,
|
cpu_process,
|
||||||
cpu_system: inner.sys.global_cpu_usage(),
|
cpu_system: inner.sys.global_cpu_usage(),
|
||||||
@@ -201,6 +260,7 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
|||||||
main_fade_gain: app
|
main_fade_gain: app
|
||||||
.try_state::<crate::playback::PlayerState>()
|
.try_state::<crate::playback::PlayerState>()
|
||||||
.map(|p| p.player.fade_gain()),
|
.map(|p| p.player.fade_gain()),
|
||||||
|
preload_dl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ pub fn run() {
|
|||||||
commands::library_album_stats,
|
commands::library_album_stats,
|
||||||
commands::album_tracks,
|
commands::album_tracks,
|
||||||
commands::album_art,
|
commands::album_art,
|
||||||
|
commands::library_art_prewarm,
|
||||||
commands::stream_embedded_art,
|
commands::stream_embedded_art,
|
||||||
commands::refresh_album_art,
|
commands::refresh_album_art,
|
||||||
commands::refresh_all_album_art,
|
commands::refresh_all_album_art,
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
<script>
|
<script>
|
||||||
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
|
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
|
||||||
// system + process-tree CPU/GPU/RAM, network rates, the active track's
|
// system + process-tree CPU/GPU/RAM, network rates, the active track's
|
||||||
// download progress/speed and loudness-normalization state, renderer
|
// download progress/speed, the on-deck queue window (what the pre-buffer
|
||||||
// FPS, and the preview session when one is open. Polls the backend only
|
// is doing about each upcoming track), loudness-normalization state,
|
||||||
// while visible; costs nothing when hidden.
|
// renderer FPS, and the preview session when one is open. Polls the
|
||||||
|
// backend only while visible; costs nothing when hidden.
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { player } from "../lib/state.svelte.js";
|
import { player, onDeckSnapshot } from "../lib/state.svelte.js";
|
||||||
import { previewUi } from "../lib/preview/previewSession.svelte.js";
|
import { previewUi } from "../lib/preview/previewSession.svelte.js";
|
||||||
|
|
||||||
let visible = $state(false);
|
let visible = $state(false);
|
||||||
let stats = $state(null);
|
let stats = $state(null);
|
||||||
|
// The on-deck window (QUEUE section): indices + module-private markers
|
||||||
|
// from the player machinery, refreshed on the same 1s tick as stats.
|
||||||
|
let deck = $state(null);
|
||||||
|
// Cache-file byte lengths for on-deck rows with no live transfer —
|
||||||
|
// tells "cached ✓" from "prefix n MB" from "nothing yet".
|
||||||
|
let cacheLens = $state({});
|
||||||
let fps = $state(0);
|
let fps = $state(0);
|
||||||
let drops = $state(0); // main-thread frames >25ms since overlay opened
|
let drops = $state(0); // main-thread frames >25ms since overlay opened
|
||||||
let worstGap = $state(0);
|
let worstGap = $state(0);
|
||||||
@@ -41,6 +48,29 @@
|
|||||||
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
|
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
|
||||||
lastReceived = cur;
|
lastReceived = cur;
|
||||||
lastAt = now;
|
lastAt = now;
|
||||||
|
// On-deck window. For stream rows with no live transfer reporting
|
||||||
|
// bytes, ask the cache file itself — that covers tracks that were
|
||||||
|
// fully cached before this session ever preloaded anything.
|
||||||
|
deck = onDeckSnapshot();
|
||||||
|
const lens = {};
|
||||||
|
await Promise.all(
|
||||||
|
deck.indices.map(async (i) => {
|
||||||
|
const it = player.queue[i];
|
||||||
|
if (!it?.stream || !it.path) return;
|
||||||
|
const live = stats?.preloadDl?.some(
|
||||||
|
(p) =>
|
||||||
|
p.path === it.path
|
||||||
|
&& p.username === it.stream.username
|
||||||
|
&& p.state !== "done",
|
||||||
|
);
|
||||||
|
if (live) return;
|
||||||
|
try {
|
||||||
|
const len = await api.streamCacheLen(it.stream.username, it.path);
|
||||||
|
if (len != null) lens[it.path] = len;
|
||||||
|
} catch {}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cacheLens = lens;
|
||||||
};
|
};
|
||||||
void tick();
|
void tick();
|
||||||
const id = setInterval(tick, 1000);
|
const id = setInterval(tick, 1000);
|
||||||
@@ -125,6 +155,87 @@
|
|||||||
? { db: loud.headStartDb, from: loud.prefixGainDb }
|
? { db: loud.headStartDb, from: loud.prefixGainDb }
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- QUEUE: the on-deck pre-buffer window ----
|
||||||
|
const base = (p) => (p ? p.split("\\").pop() : "?");
|
||||||
|
|
||||||
|
/** One readout line per queue row: what the on-deck machinery is doing
|
||||||
|
* about it right now. Live transfer state comes from the backend's own
|
||||||
|
* mirror (preloadDl); rows without a transfer fall back to the cache
|
||||||
|
* file's length; pending rows report the lookahead's posture. */
|
||||||
|
function classifyRow(item, slot, isCurrent) {
|
||||||
|
const name = `${item.artist ? `${item.artist} — ` : ""}${item.title ?? base(item.path)}`;
|
||||||
|
let status;
|
||||||
|
let pct = null;
|
||||||
|
let cls = "dim";
|
||||||
|
if (isCurrent) {
|
||||||
|
status = player.status;
|
||||||
|
cls = "";
|
||||||
|
} else if (item.unplayable) {
|
||||||
|
status = "unplayable";
|
||||||
|
cls = "warn";
|
||||||
|
} else if (!item.path && !item.stream) {
|
||||||
|
// A pending playlist row: no source yet. One lookahead search per
|
||||||
|
// row — spent means it came up dry and waits for its real turn.
|
||||||
|
const tried = deck.wasLookaheadTried(item);
|
||||||
|
status = tried ? "searched · dry (retries on reach)" : "resolving source…";
|
||||||
|
cls = tried ? "warn" : "";
|
||||||
|
} else if (!item.stream) {
|
||||||
|
status = "local file";
|
||||||
|
} else {
|
||||||
|
const dl = stats?.preloadDl?.find(
|
||||||
|
(p) => p.path === item.path && p.username === item.stream.username,
|
||||||
|
);
|
||||||
|
const size = dl?.total || item.stream.size || 0;
|
||||||
|
const len = cacheLens[item.path];
|
||||||
|
if (dl?.state === "transferring") {
|
||||||
|
status = `${mb(dl.received)}/${mb(size)} MB · ${rate(dl.speedBps)}`;
|
||||||
|
pct = size > 0 ? (dl.received / size) * 100 : null;
|
||||||
|
cls = "";
|
||||||
|
} else if (dl?.state === "queued") {
|
||||||
|
status = dl.place != null ? `queued at peer #${dl.place}` : "queued at peer";
|
||||||
|
} else if (dl && dl.state !== "done") {
|
||||||
|
// connecting / requested / failed: reason / cancelled
|
||||||
|
status = dl.state;
|
||||||
|
cls = dl.state === "connecting" || dl.state === "requested" ? "dim" : "warn";
|
||||||
|
} else if (dl?.state === "done" || (len != null && size > 0 && len >= size)) {
|
||||||
|
status = "cached ✓";
|
||||||
|
cls = "ok";
|
||||||
|
} else if (len > 0) {
|
||||||
|
status = `prefix ${mb(len)} MB`;
|
||||||
|
} else if (deck.isRequested(item.path)) {
|
||||||
|
status = "requested…";
|
||||||
|
} else {
|
||||||
|
status = "idle";
|
||||||
|
}
|
||||||
|
if (deck.appendedNextPath === item.path) status += " · in sink";
|
||||||
|
}
|
||||||
|
return { slot, name, status, pct, cls, isCurrent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueRows = $derived.by(() => {
|
||||||
|
if (!deck) return [];
|
||||||
|
const rows = [];
|
||||||
|
const cur = player.queue[player.queueIndex];
|
||||||
|
if (cur) rows.push(classifyRow(cur, "▶", true));
|
||||||
|
deck.indices.forEach((i, k) => {
|
||||||
|
const item = player.queue[i];
|
||||||
|
if (item) rows.push(classifyRow(item, String(k + 1), false));
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Backend transfers for paths no longer in the window — the sliding
|
||||||
|
* window moved on (a jump, a reshuffle) but the bytes keep arriving. */
|
||||||
|
const offWindow = $derived.by(() => {
|
||||||
|
if (!deck || !stats?.preloadDl?.length) return [];
|
||||||
|
const winPaths = new Set(
|
||||||
|
[player.queue[player.queueIndex], ...deck.indices.map((i) => player.queue[i])]
|
||||||
|
.map((it) => it?.path)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return stats.preloadDl.filter((p) => !winPaths.has(p.path));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
<svelte:window onkeydown={onKeydown} />
|
||||||
@@ -163,6 +274,36 @@
|
|||||||
<div class="dim">DL — no active stream</div>
|
<div class="dim">DL — no active stream</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sec">
|
||||||
|
<div>
|
||||||
|
<b>QUEUE</b>
|
||||||
|
{#if player.queue.length}
|
||||||
|
{player.queueIndex + 1}/{player.queue.length}
|
||||||
|
· on-deck {deck?.indices?.length ?? 0}
|
||||||
|
{#if player.shuffle}· shuffle{/if}
|
||||||
|
{#if player.repeat && player.repeat !== "off"}· repeat {player.repeat}{/if}
|
||||||
|
{:else}
|
||||||
|
empty
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each queueRows as r}
|
||||||
|
<div class="qrow">
|
||||||
|
<span class="qslot" class:cur={r.isCurrent}>{r.slot}</span>
|
||||||
|
<span class="qname" title={r.name}>{r.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qstat {r.cls}">{r.status}</div>
|
||||||
|
{#if r.pct != null}
|
||||||
|
<div class="bar q"><i style="width:{Math.min(100, r.pct)}%"></i></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each offWindow as p}
|
||||||
|
<!-- State + bytes lead: the filename is the part that can
|
||||||
|
afford to truncate. -->
|
||||||
|
<div class="dim qoff" title={p.path}>
|
||||||
|
↳ {p.state}{p.state === "transferring" ? ` ${mb(p.received)}${p.total ? `/${mb(p.total)}` : ""} MB` : ""} · {base(p.path)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
<div class="sec">
|
<div class="sec">
|
||||||
<div>
|
<div>
|
||||||
<b>NORM</b>
|
<b>NORM</b>
|
||||||
@@ -302,6 +443,47 @@
|
|||||||
.num.hot {
|
.num.hot {
|
||||||
color: #ffc46b;
|
color: #ffc46b;
|
||||||
}
|
}
|
||||||
|
/* QUEUE rows: name line, then an indented status line (and a mini bar
|
||||||
|
while bytes are moving) — two short lines read better at 11px than
|
||||||
|
one truncated long one. */
|
||||||
|
.qrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.qslot {
|
||||||
|
min-width: 12px;
|
||||||
|
text-align: right;
|
||||||
|
color: rgba(215, 224, 234, 0.45);
|
||||||
|
}
|
||||||
|
.qslot.cur {
|
||||||
|
color: #7fd0ff;
|
||||||
|
}
|
||||||
|
.qname {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.qstat {
|
||||||
|
margin-left: 18px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.qstat.warn {
|
||||||
|
color: #ffc46b;
|
||||||
|
}
|
||||||
|
.qstat.ok {
|
||||||
|
color: #7fd0ff;
|
||||||
|
}
|
||||||
|
.bar.q {
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
.qoff {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
/* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the
|
/* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the
|
||||||
distance between them is the correction still to travel. */
|
distance between them is the correction still to travel. */
|
||||||
.bar.slew {
|
.bar.slew {
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export const openExternal = (url) => invoke("open_external", { url });
|
|||||||
// pixels on the long side. Omit it only where true pixels matter (the
|
// pixels on the long side. Omit it only where true pixels matter (the
|
||||||
// fullscreen viewer, the import picker's comparison and loupe).
|
// fullscreen viewer, the import picker's comparison and loupe).
|
||||||
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
|
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
|
||||||
|
/** Warm the disk thumbnail cache for the whole library at the given edges —
|
||||||
|
* [{warm, decoded}] per edge. Slow by design (one decode at a time); callers
|
||||||
|
* fire-and-forget it. */
|
||||||
|
export const libraryArtPrewarm = (edges) => invoke("library_art_prewarm", { edges });
|
||||||
/** The cover embedded in a streamed track's own cache file, extracted to a
|
/** The cover embedded in a streamed track's own cache file, extracted to a
|
||||||
* local image path — null while the transfer is too young to read, or when
|
* local image path — null while the transfer is too young to read, or when
|
||||||
* the peer's file simply carries no picture. */
|
* the peer's file simply carries no picture. */
|
||||||
|
|||||||
+96
-7
@@ -541,7 +541,11 @@ function gatedArtFetch(fn) {
|
|||||||
.then(resolve, reject)
|
.then(resolve, reject)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
artFetchActive -= 1;
|
artFetchActive -= 1;
|
||||||
const next = artFetchQueue.shift();
|
// Newest first: on a fast scroll the queue's tail is the
|
||||||
|
// tiles on screen RIGHT NOW and its head is the screens
|
||||||
|
// scrolled past. FIFO served the wake behind the viewer;
|
||||||
|
// the passed-by tiles still drain once the burst settles.
|
||||||
|
const next = artFetchQueue.pop();
|
||||||
if (next) next();
|
if (next) next();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1176,12 +1180,53 @@ export async function refreshLibrary() {
|
|||||||
libraryStore.albums = await api.libraryAlbums();
|
libraryStore.albums = await api.libraryAlbums();
|
||||||
libraryStore.loaded = true;
|
libraryStore.loaded = true;
|
||||||
window.__libErr = null;
|
window.__libErr = null;
|
||||||
|
scheduleArtPrewarm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// not connected to backend (browser preview) — leave empty
|
// not connected to backend (browser preview) — leave empty
|
||||||
window.__libErr = String(e);
|
window.__libErr = String(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- thumbnail pre-warm ----
|
||||||
|
// The disk cache makes a REVISITED cover a 1.5ms read, but the first-ever
|
||||||
|
// visit still decoded on scroll — so a section you had never scrolled to
|
||||||
|
// showed placeholders while its masters decoded two at a time. Warm the whole
|
||||||
|
// library once in the background instead (the Library's tile edge first, then
|
||||||
|
// the queue-card size) and every scroll lands on the warm path. Re-runs are
|
||||||
|
// ~one stat per cover, so this simply re-fires after every library change to
|
||||||
|
// pick up new arrivals; the guard coalesces bursts.
|
||||||
|
let artPrewarmRunning = false;
|
||||||
|
let artPrewarmAgain = false;
|
||||||
|
const ART_PREWARM_DELAY_MS = 4000;
|
||||||
|
let artPrewarmTimer = null;
|
||||||
|
function scheduleArtPrewarm() {
|
||||||
|
// Delay past the boot/refresh paint so the first screen's own art
|
||||||
|
// requests hit the decode permits before the background walk does.
|
||||||
|
clearTimeout(artPrewarmTimer);
|
||||||
|
artPrewarmTimer = setTimeout(() => {
|
||||||
|
artPrewarmTimer = null;
|
||||||
|
void runArtPrewarm();
|
||||||
|
}, ART_PREWARM_DELAY_MS);
|
||||||
|
}
|
||||||
|
async function runArtPrewarm() {
|
||||||
|
if (artPrewarmRunning) {
|
||||||
|
artPrewarmAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
artPrewarmRunning = true;
|
||||||
|
try {
|
||||||
|
await api.libraryArtPrewarm([ART_EDGE_DEFAULT, 96]);
|
||||||
|
} catch {
|
||||||
|
// browser preview / old backend — tiles still load on demand
|
||||||
|
} finally {
|
||||||
|
artPrewarmRunning = false;
|
||||||
|
}
|
||||||
|
if (artPrewarmAgain) {
|
||||||
|
artPrewarmAgain = false;
|
||||||
|
scheduleArtPrewarm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- catalog browser (Deezer-backed streaming-style search) ----
|
// ---- catalog browser (Deezer-backed streaming-style search) ----
|
||||||
|
|
||||||
export const catalog = $state({
|
export const catalog = $state({
|
||||||
@@ -3274,20 +3319,45 @@ export async function openAlbumByName(artist, title, opts = {}) {
|
|||||||
if (!artist || !title) return null;
|
if (!artist || !title) return null;
|
||||||
const na = catNorm(artist);
|
const na = catNorm(artist);
|
||||||
const nt = catNorm(title);
|
const nt = catNorm(title);
|
||||||
const key = `${na}|${nt}`;
|
// A year hint changes which record the same name resolves to (the
|
||||||
|
// self-titled-trio case), so it is part of the answer's identity — a
|
||||||
|
// shared key would let one LP's answer poison the others'.
|
||||||
|
const key = opts.year != null ? `${na}|${nt}|${opts.year}` : `${na}|${nt}`;
|
||||||
if (albumNameResolves.has(key)) {
|
if (albumNameResolves.has(key)) {
|
||||||
const id = albumNameResolves.get(key);
|
const id = albumNameResolves.get(key);
|
||||||
if (id != null) openCatalogAlbum(id, opts);
|
if (id != null) openCatalogAlbum(id, opts);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
/** Same-titled rows from a DATED list (the artist's own discography),
|
||||||
|
* preferring the hinted year. Self-titled multiples (American Football ×3,
|
||||||
|
* Weezer ×7) all normalize to one title, so without a date the first row
|
||||||
|
* simply wins — and the first row is the wrong record two times in three. */
|
||||||
|
const pickByYear = (albums) => {
|
||||||
|
const same = (albums ?? []).filter((al) => catNorm(al.title) === nt);
|
||||||
|
if (!same.length) return null;
|
||||||
|
if (opts.year != null) {
|
||||||
|
const dated = same.find((al) =>
|
||||||
|
String(al.release_date ?? "").startsWith(String(opts.year)),
|
||||||
|
);
|
||||||
|
if (dated) return dated;
|
||||||
|
}
|
||||||
|
return same[0];
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await api.catalogSearch(`${artist} ${title}`);
|
const res = await api.catalogSearch(`${artist} ${title}`);
|
||||||
if (request !== albumResolveRequest) return null;
|
if (request !== albumResolveRequest) return null;
|
||||||
// A title-only or first-result fallback can cross into a namesake's
|
// A title-only or first-result fallback can cross into a namesake's
|
||||||
// catalog. Without a verified id, both artist and title must match.
|
// catalog. Without a verified id, both artist and title must match.
|
||||||
let match = res.albums?.find(
|
const searchMatches = (res.albums ?? []).filter(
|
||||||
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
|
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
|
||||||
);
|
);
|
||||||
|
// Deezer's /search/album strips release dates, so a year hint cannot
|
||||||
|
// be checked here — and a single hit is no proof of the RIGHT record
|
||||||
|
// when namesake editions exist. A unique, unhinted hit keeps the fast
|
||||||
|
// path; ambiguity (a hint, or several same-titled hits) falls through
|
||||||
|
// to the artist's own dated discography below.
|
||||||
|
let match =
|
||||||
|
searchMatches.length === 1 && opts.year == null ? searchMatches[0] : null;
|
||||||
// Deezer's SEARCH index is not the whole discography. The artist page
|
// Deezer's SEARCH index is not the whole discography. The artist page
|
||||||
// merges a MusicBrainz supplement on top of it, so records Deezer does
|
// merges a MusicBrainz supplement on top of it, so records Deezer does
|
||||||
// not carry at all still have a real id, a real release date and full
|
// not carry at all still have a real id, a real release date and full
|
||||||
@@ -3303,7 +3373,7 @@ export async function openAlbumByName(artist, title, opts = {}) {
|
|||||||
if (seed?.id != null) {
|
if (seed?.id != null) {
|
||||||
const page = await getCatalogArtistPage(seed.id).catch(() => null);
|
const page = await getCatalogArtistPage(seed.id).catch(() => null);
|
||||||
if (request !== albumResolveRequest) return null;
|
if (request !== albumResolveRequest) return null;
|
||||||
match = page?.albums?.find((album) => catNorm(album.title) === nt);
|
match = pickByYear(page?.albums);
|
||||||
// The cached page is only SUPPLEMENTED once the artist page
|
// The cached page is only SUPPLEMENTED once the artist page
|
||||||
// has been visited — cold, it is the raw Deezer list and the
|
// has been visited — cold, it is the raw Deezer list and the
|
||||||
// MB-only records are still missing. Run the same merge the
|
// MB-only records are still missing. Run the same merge the
|
||||||
@@ -3324,12 +3394,14 @@ export async function openAlbumByName(artist, title, opts = {}) {
|
|||||||
},
|
},
|
||||||
identity,
|
identity,
|
||||||
);
|
);
|
||||||
match = merged?.albums?.find(
|
match = pickByYear(merged?.albums);
|
||||||
(album) => catNorm(album.title) === nt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The discography had nothing to say (unresolvable artist, or the
|
||||||
|
// record missing from it) — a bare search hit is still better than
|
||||||
|
// a dead click, year-verified or not.
|
||||||
|
if (!match) match = searchMatches[0] ?? null;
|
||||||
}
|
}
|
||||||
// A settled miss is an answer ABOUT THE RECORD: neither the catalog nor
|
// A settled miss is an answer ABOUT THE RECORD: neither the catalog nor
|
||||||
// the artist's own discography has it. Negative-cache that. A throw
|
// the artist's own discography has it. Negative-cache that. A throw
|
||||||
@@ -6895,6 +6967,22 @@ export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Debug-overlay window into the on-deck machinery. Read-only: the indices
|
||||||
|
* the pre-buffer is arming, plus the module-private bits a QUEUE readout
|
||||||
|
* needs — which path is already appended to the sink, whether a path has
|
||||||
|
* been asked of the backend this session, and whether the lookahead already
|
||||||
|
* spent its one search try on a still-pending row. The membership checks are
|
||||||
|
* closures because both containers are reassigned/forgotten on queue
|
||||||
|
* rebuilds — a copied array would go stale mid-tick. */
|
||||||
|
export function onDeckSnapshot() {
|
||||||
|
return {
|
||||||
|
indices: peekNextIndices(STREAM_PRELOAD_DEPTH),
|
||||||
|
appendedNextPath,
|
||||||
|
isRequested: (path) => preloadRequestedPaths.has(path),
|
||||||
|
wasLookaheadTried: (item) => (item ? lookaheadTried.has(item) : false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The queue index that plays after the current track, or -1 when playback
|
/** The queue index that plays after the current track, or -1 when playback
|
||||||
* would stop there. THE one answer for what comes next — the up-next card,
|
* would stop there. THE one answer for what comes next — the up-next card,
|
||||||
* the gapless preload/append, the pending-row lookahead and the advance
|
* the gapless preload/append, the pending-row lookahead and the advance
|
||||||
@@ -7314,6 +7402,7 @@ if (import.meta.env.DEV) {
|
|||||||
// beyond convenience: the server ignores the handshake after a burst
|
// beyond convenience: the server ignores the handshake after a burst
|
||||||
// of reconnects, so a verification run that streams costs a login.
|
// of reconnects, so a verification run that streams costs a login.
|
||||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||||
|
jumpToQueueIndex,
|
||||||
catalogGo, refreshLibrary, refreshRecents,
|
catalogGo, refreshLibrary, refreshRecents,
|
||||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||||
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
getCatalogArtistPage,
|
getCatalogArtistPage,
|
||||||
openCatalogArtist,
|
openCatalogArtist,
|
||||||
openCatalogAlbum,
|
openCatalogAlbum,
|
||||||
|
openAlbumByName,
|
||||||
RECENTS_FLOOR_SECS,
|
RECENTS_FLOOR_SECS,
|
||||||
recentsStore,
|
recentsStore,
|
||||||
importResume,
|
importResume,
|
||||||
@@ -778,9 +779,19 @@
|
|||||||
const wanted = album.id;
|
const wanted = album.id;
|
||||||
try {
|
try {
|
||||||
const page = await getCatalogArtistPage(anchored.artistId);
|
const page = await getCatalogArtistPage(anchored.artistId);
|
||||||
const match = (page?.albums ?? []).find(
|
// Same-titled records collapse to one normalized key (American
|
||||||
|
// Football × 3, Weezer × 7). The artist page's rows are dated, so
|
||||||
|
// the local folder's year picks the right one; only then does the
|
||||||
|
// first row stand in.
|
||||||
|
const same = (page?.albums ?? []).filter(
|
||||||
(r) => normKey(r.title) === normKey(album.title),
|
(r) => normKey(r.title) === normKey(album.title),
|
||||||
);
|
);
|
||||||
|
const match =
|
||||||
|
(album.year != null
|
||||||
|
&& same.find((r) =>
|
||||||
|
String(r.release_date ?? "").startsWith(String(album.year)),
|
||||||
|
))
|
||||||
|
|| same[0];
|
||||||
if (openId === wanted && match) openCatalogId = match.id;
|
if (openId === wanted && match) openCatalogId = match.id;
|
||||||
} catch { /* no link; the title stays plain */ }
|
} catch { /* no link; the title stays plain */ }
|
||||||
}
|
}
|
||||||
@@ -1610,17 +1621,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!-- The name opens the record's own page, as it does
|
<!-- The name opens the record's own page, as it does
|
||||||
on the artist page — but only once the local
|
on the artist page. An anchored (followed)
|
||||||
folder has been joined to a catalogue row. Until
|
artist's join gives the id up front; otherwise
|
||||||
then (or for an artist with no anchored id) it
|
the click resolves through openAlbumByName —
|
||||||
stays plain text rather than a link that would
|
the now-playing links' guarded resolver, which
|
||||||
have to guess where it goes. -->
|
verifies artist AND title (never guesses) and
|
||||||
|
stays put on a settled miss. Plain text only
|
||||||
|
when the row has no artist+title to resolve. -->
|
||||||
<h3 class="rail-title">
|
<h3 class="rail-title">
|
||||||
{#if openCatalogId != null}
|
{#if openCatalogId != null || (openAlbum?.artist && openAlbum?.title)}
|
||||||
<button
|
<button
|
||||||
class="rail-title-link"
|
class="rail-title-link"
|
||||||
title="Open album"
|
title="Open album"
|
||||||
onclick={() => openCatalogAlbum(openCatalogId)}
|
onclick={() =>
|
||||||
|
openCatalogId != null
|
||||||
|
? openCatalogAlbum(openCatalogId)
|
||||||
|
: openAlbumByName(openAlbum.artist, openAlbum.title, {
|
||||||
|
year: openAlbum.year ?? null,
|
||||||
|
})}
|
||||||
>{openAlbum?.title ?? ""}</button>
|
>{openAlbum?.title ?? ""}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="rail-title-text">{openAlbum?.title ?? ""}</span>
|
<span class="rail-title-text">{openAlbum?.title ?? ""}</span>
|
||||||
|
|||||||
Reference in new issue
Block a user