feat(slint): post-lyrics UI polish sweep + correctness fixes

- TrackRow: Korean/CJK titles render via layout-assigned width+height
  (SongCard parity, never femtovg-measured); explicit "E" badge hugs the
  title text; duration + position number dropped to 13px so they read
  smaller than the 14px title.
- AlbumView: label/awards rail 214->160px with smaller card/icon/padding
  and centered headings in the compact rail; removed the dark right gutter
  (full-width Flickable, content clears the scrollbar via the 32px page
  padding); column headers homologated to Playlist casing (Title/Duration/
  Quality), same in LocalAlbumView.
- ArtistView: network sidebar auto-collapses on ShellState.content-
  constrained (the AlbumView signal) and no longer re-opens on artist->
  artist navigation (reset_network_sidebar respects the constraint).
- Hero titles homologated to Typography.section (18px) across MyQBZ detail,
  Musician, Label releases, Artists-by-location, Offline Manager.
- Lyrics: Qobuz || LRCLIB parallel fetch, next-track prefetch, source
  footer, asymmetric line fade.

cc
This commit is contained in:
vicrodh committed 2026-06-11 22:03:46 -06:00
1 parent 67a0e39841
commit 999ded11ff
32 files changed
+1440 -334

No files matched your search

+54 -47
View File
@@ -349,14 +349,53 @@ async fn run_chain(
cached: false,
};
// 2. QOBUZ primary — Qobuz-source tracks with a real track_id only.
// wsync = authoritative synced (ends the chain); plain = HELD while
// LRCLIB is probed for synced; every failure degrades silently.
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
// 2+3. QOBUZ primary + LRCLIB run CONCURRENTLY. Running them in parallel
// means a Qobuz MISS adds NO latency before LRCLIB (matching Tauri's
// speed, which has no Qobuz step) while still preferring Qobuz wsync
// when present. The SELECTION below is unchanged from the sequential
// version: Qobuz wsync wins outright; otherwise an LRCLIB *synced*
// result beats a held Qobuz-plain (no-sync-regression), which beats
// an LRCLIB plain-only / miss; lyrics.ovh is the last resort. Qobuz
// only runs for Qobuz-source tracks with a real track_id.
let qobuz_fut = async {
if request.source == LyricsSourceKind::Qobuz {
if let Some(track_id) = request.track_id {
match inner.providers.qobuz(track_id).await {
Ok(Some(document)) => {
Ok(Some(document)) => return Some(document),
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
Err(e) => {
// Offline gate / transport / auth — degrade like a
// miss; never a user-facing error (spec §1.2).
log::debug!("[Lyrics] Qobuz degraded for track {}: {}", track_id, e)
}
}
}
}
None
};
// LRCLIB with exactly 1 retry on transport error (parity:
// legacy_compat.rs:519-536 — Ok(None) is a miss, NOT a retry).
let lrclib_fut = async {
match inner.providers.lrclib(&title, &artist, request.duration_secs).await {
Ok(data) => data,
Err(e) => {
log::warn!("[Lyrics] LRCLIB attempt 1 failed: {}, retrying…", e);
match inner.providers.lrclib(&title, &artist, request.duration_secs).await {
Ok(data) => data,
Err(e2) => {
log::warn!("[Lyrics] LRCLIB attempt 2 failed: {}, falling back", e2);
None
}
}
}
}
};
let (qobuz_doc, lrclib_data) = tokio::join!(qobuz_fut, lrclib_fut);
// Apply the Qobuz result: wsync ends the chain; plain is HELD as a
// candidate while we consider an LRCLIB synced result below.
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
if let Some(document) = qobuz_doc {
if let Some(wsync) = QobuzWsync::from_document(&document) {
let doc = wsync.to_doc();
if !doc.lines.is_empty() {
@@ -370,22 +409,16 @@ async fn run_chain(
let db = guard.as_ref().ok_or(NO_SESSION)?;
db.upsert(&cache_key, &payload, Some(&wsync_json))?;
return Ok(respond(LyricsOutcome::Found(LyricsResult {
payload,
doc,
})));
return Ok(respond(LyricsOutcome::Found(LyricsResult { payload, doc })));
}
} else if let Some(QobuzLyricsContent::Plain { lines, .. }) =
document.original.as_ref()
{
} else if let Some(QobuzLyricsContent::Plain { lines, .. }) = document.original.as_ref() {
let blob = lines
.iter()
.map(|line| line.line.as_str())
.collect::<Vec<_>>()
.join("\n");
if !blob.trim().is_empty() {
let mut doc =
LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
doc.translation_langs = document.translation_langs.clone();
doc.writers = document.writers.clone();
let mut payload = base_payload(LyricsProvider::Qobuz);
@@ -394,36 +427,6 @@ async fn run_chain(
}
}
}
Ok(None) => {
log::debug!("[Lyrics] Qobuz miss for track {}", track_id);
}
Err(e) => {
// Offline gate / transport / auth — same degradation path
// as a miss; never a user-facing error (spec §1.2).
log::debug!("[Lyrics] Qobuz degraded for track {}: {}", track_id, e);
}
}
}
}
// 3. LRCLIB with exactly 1 retry on transport error (parity:
// legacy_compat.rs:519-536 — Ok(None) is a miss, NOT a retry).
let lrclib_data = match inner.providers.lrclib(&title, &artist, request.duration_secs).await {
Ok(data) => data,
Err(e) => {
log::warn!("[Lyrics] LRCLIB attempt 1 failed: {}, retrying…", e);
match inner.providers.lrclib(&title, &artist, request.duration_secs).await {
Ok(data) => data,
Err(e2) => {
log::warn!(
"[Lyrics] LRCLIB attempt 2 failed: {}, falling back",
e2
);
None
}
}
}
};
if let Some(data) = lrclib_data {
let has_synced = data
@@ -629,8 +632,10 @@ mod tests {
// LRC emitted for cross-frontend compat; plain joined.
assert!(result.payload.synced_lrc.as_deref().unwrap().contains("[00:01.000] first line"));
assert_eq!(result.payload.plain.as_deref(), Some("first line\nsecond line"));
// Chain ended at Qobuz: no external calls.
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 0);
// LRCLIB now runs CONCURRENTLY with Qobuz (parallel resolution), so it
// is called once — but Qobuz wsync still wins the selection and its
// result is discarded; ovh never runs once something is found.
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 1);
assert_eq!(providers.ovh_calls.load(Ordering::SeqCst), 0);
// Stale-guard echo.
assert_eq!(response.request_track_id, Some(100));
@@ -657,7 +662,9 @@ mod tests {
// Reader preferred the native wsync column: words survive the cache.
assert!(result.doc.lines[0].words.is_some());
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 1);
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 0);
// LRCLIB ran once on the FIRST call (parallel with Qobuz); the second
// call was a synced cache hit, so it added no provider traffic.
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
+179 -18
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::{Album, Track};
use slint::{ComponentHandle, ModelRc, VecModel};
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::{AlbumState, TrackItem, AppWindow};
@@ -19,6 +19,11 @@ thread_local! {
/// track search can filter against it without a re-fetch. UI thread
/// only, hence `thread_local`.
static FULL_TRACKS: RefCell<Vec<TrackItem>> = RefCell::new(Vec::new());
/// The current album's RAW catalog tracks (qbz_models::Track), kept so
/// the multi-select bulk actions (enqueue / cache) can resolve the
/// selected rows to full Track objects without a re-fetch — mirrors the
/// `play` Vec the favorites tab keeps. UI thread only.
static PLAY_TRACKS: RefCell<Vec<Track>> = RefCell::new(Vec::new());
}
/// Plain, `Send` album data produced on the worker thread.
@@ -29,6 +34,11 @@ pub struct AlbumData {
pub artist_id: String,
/// Pre-formatted "year • label • genre • N tracks • duration".
pub info_line: String,
/// Meta-line segment BEFORE the label (the year) — rendered with the
/// label as a clickable link in the header.
pub meta_pre: String,
/// Meta-line segment AFTER the label (genre • N tracks • duration).
pub meta_post: String,
pub quality_tier: String,
/// "24-bit / 96 kHz" — the quality-badge detail line.
pub quality_detail: String,
@@ -36,6 +46,8 @@ pub struct AlbumData {
pub description: String,
/// Short, truncated description for the header (full text in a modal).
pub description_short: String,
/// Half-length truncation used when the content area is space-constrained.
pub description_shorter: String,
pub artwork_url: String,
/// Record label name, for the sidebar (empty when unknown).
pub label: String,
@@ -44,7 +56,12 @@ pub struct AlbumData {
pub label_id: String,
/// Editorial award names, for the sidebar.
pub awards: Vec<String>,
/// True when the album bundles a downloadable booklet/liner-notes PDF
/// (Qobuz goodies) — gates the header booklet button.
pub has_booklet: bool,
pub tracks: Vec<TrackData>,
/// Raw catalog tracks, kept for the multi-select bulk actions.
pub raw_tracks: Vec<Track>,
}
pub struct TrackData {
@@ -89,23 +106,49 @@ fn map_album(album: Album) -> AlbumData {
.unwrap_or("")
.to_string();
let mut parts: Vec<String> = Vec::new();
// Meta line, split around the label so the header can render the label
// as a clickable link (Tauri's `date • [label] • genre • N tracks •
// duration`). `meta_pre` = the segment before the label (the year);
// `meta_post` = everything after (genre / tracks / duration). The full
// `info_line` (label inlined as plain text) stays the fallback for when
// there is no label id to navigate to.
let label_name = album
.label
.as_ref()
.filter(|l| !l.name.is_empty())
.map(|l| l.name.clone());
let genre_str = album
.genre
.as_ref()
.filter(|g| !g.name.is_empty())
.map(|g| g.name.clone());
let tracks_str = album.tracks_count.map(|count| format!("{count} tracks"));
let duration_str = album.duration.map(format_duration);
let mut pre_parts: Vec<String> = Vec::new();
if !year.is_empty() {
parts.push(year);
pre_parts.push(year.clone());
}
if let Some(label) = album.label.as_ref().filter(|l| !l.name.is_empty()) {
parts.push(label.name.clone());
let mut post_parts: Vec<String> = Vec::new();
if let Some(g) = &genre_str {
post_parts.push(g.clone());
}
if let Some(genre) = album.genre.as_ref().filter(|g| !g.name.is_empty()) {
parts.push(genre.name.clone());
if let Some(tc) = &tracks_str {
post_parts.push(tc.clone());
}
if let Some(count) = album.tracks_count {
parts.push(format!("{count} tracks"));
if let Some(d) = &duration_str {
post_parts.push(d.clone());
}
if let Some(duration) = album.duration {
parts.push(format_duration(duration));
let meta_pre = pre_parts.join(" • ");
let meta_post = post_parts.join(" • ");
let mut all_parts = pre_parts.clone();
if let Some(l) = &label_name {
all_parts.push(l.clone());
}
let info_line = parts.join(" • ");
all_parts.extend(post_parts.clone());
let info_line = all_parts.join(" • ");
let quality_tier = tier(album.maximum_bit_depth).to_string();
let quality_detail = crate::quality::detail(album.maximum_bit_depth, album.maximum_sampling_rate);
@@ -118,6 +161,8 @@ fn map_album(album: Album) -> AlbumData {
// artwork, so a longer truncation keeps it from looking like a thin
// strip; the Read more modal still holds the complete text.
let description_short = truncate_words(&description, 360);
// Half the cutoff for the space-constrained layout (tracks get priority).
let description_shorter = truncate_words(&description, 180);
let artwork_url = album.image.best().cloned().unwrap_or_default();
let label = album
.label
@@ -137,13 +182,17 @@ fn map_album(album: Album) -> AlbumData {
.map(|a| a.name.clone())
.filter(|n| !n.is_empty())
.collect();
let tracks = album
// Booklet present when the album carries any goody with a usable URL.
let has_booklet = album
.goodies
.as_deref()
.map(|goodies| goodies.iter().any(|g| !g.url.is_empty()))
.unwrap_or(false);
let raw_tracks: Vec<Track> = album
.tracks
.map(|container| container.items)
.unwrap_or_default()
.into_iter()
.map(map_track)
.collect();
.unwrap_or_default();
let tracks = raw_tracks.iter().cloned().map(map_track).collect();
AlbumData {
id: album.id,
@@ -151,15 +200,20 @@ fn map_album(album: Album) -> AlbumData {
artist,
artist_id,
info_line,
meta_pre,
meta_post,
quality_tier,
quality_detail,
description,
description_short,
description_shorter,
artwork_url,
label,
label_id,
awards,
has_booklet,
tracks,
raw_tracks,
}
}
@@ -282,16 +336,34 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
state.set_artist(data.artist.into());
state.set_artist_id(data.artist_id.into());
state.set_info_line(data.info_line.into());
state.set_meta_pre(data.meta_pre.into());
state.set_meta_post(data.meta_post.into());
state.set_quality_tier(data.quality_tier.into());
state.set_quality_detail(data.quality_detail.into());
state.set_description(data.description.into());
state.set_description_short(data.description_short.into());
state.set_description_shorter(data.description_shorter.into());
state.set_label(data.label.into());
state.set_label_id(data.label_id.into());
state.set_awards(ModelRc::new(VecModel::from(awards)));
state.set_has_booklet(data.has_booklet);
// Fully cached = every track already has a ready (3) offline copy. Kept
// live afterwards by set_row_cache_status as downloads complete.
let album_fully_cached =
!tracks.is_empty() && tracks.iter().all(|t| t.cache_status == 3);
state.set_album_fully_cached(album_fully_cached);
// Seed the header heart from the favorite-album cache (kept in sync with
// the server at login + on every toggle).
state.set_is_favorite(crate::fav_cache::is_album_favorite(album_id.as_str()));
state.set_favorite_loading(false);
// Keep the unfiltered list for the track search, then show it all.
// Keep the unfiltered list for the track search + the raw tracks for the
// multi-select bulk actions, then show them all.
FULL_TRACKS.with(|cell| *cell.borrow_mut() = tracks.clone());
PLAY_TRACKS.with(|cell| *cell.borrow_mut() = data.raw_tracks);
// A freshly loaded album starts out of select mode with nothing selected.
state.set_multi_select(false);
state.set_selected_count(0);
state.set_tracks(ModelRc::new(VecModel::from(tracks)));
}
@@ -320,14 +392,103 @@ pub fn filter_tracks(window: &AppWindow, query: &str) {
/// album so the previous one does not flash).
pub fn reset_album(window: &AppWindow) {
FULL_TRACKS.with(|cell| cell.borrow_mut().clear());
PLAY_TRACKS.with(|cell| cell.borrow_mut().clear());
let state = window.global::<AlbumState>();
state.set_multi_select(false);
state.set_selected_count(0);
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_artwork(slint::Image::default());
// Clear the booklet gate so the previous album's value doesn't linger.
state.set_has_booklet(false);
state.set_album_fully_cached(false);
state.set_is_favorite(false);
state.set_favorite_loading(false);
// Default to a Qobuz album; the local-album loader opts in.
state.set_is_local(false);
state.set_loading(true);
}
// ==================== Multi-select (track selection) ====================
/// Toggle multi-select mode on the album track list. Leaving the mode
/// clears any current selection.
pub fn set_multi_select(window: &AppWindow, on: bool) {
let state = window.global::<AlbumState>();
state.set_multi_select(on);
if !on {
clear_selection(window);
}
}
/// Recompute the "N selected" count from the track rows.
pub fn recount_selected(window: &AppWindow) {
let state = window.global::<AlbumState>();
let model = state.get_tracks();
let count = (0..model.row_count())
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
.count();
state.set_selected_count(count as i32);
}
/// Select every row, or clear if all are already selected (the toggle the
/// "Select all" bulk button drives — same semantics as the favorites bar).
pub fn select_all(window: &AppWindow) {
let model = window.global::<AlbumState>().get_tracks();
let total = model.row_count();
let selected = (0..total)
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
.count();
let target = selected != total;
for i in 0..total {
if let Some(mut item) = model.row_data(i) {
if item.selected != target {
item.selected = target;
model.set_row_data(i, item);
}
}
}
recount_selected(window);
}
/// Clear the selection (uncheck all), keeping multi-select mode on.
pub fn clear_selection(window: &AppWindow) {
let model = window.global::<AlbumState>().get_tracks();
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.selected {
item.selected = false;
model.set_row_data(i, item);
}
}
}
window.global::<AlbumState>().set_selected_count(0);
}
/// The catalog ids of the currently selected rows (for add-to-playlist /
/// add-to-favorites — Qobuz catalog ids only).
pub fn selected_ids(window: &AppWindow) -> Vec<String> {
let model = window.global::<AlbumState>().get_tracks();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|t| t.selected)
.map(|t| t.id.to_string())
.filter(|s| s.parse::<u64>().is_ok())
.collect()
}
/// The full catalog Track objects for the selected rows (for enqueue /
/// cache), resolved from the stashed raw album tracks by id.
pub fn selected_play_tracks(window: &AppWindow) -> Vec<Track> {
let ids: std::collections::HashSet<String> = selected_ids(window).into_iter().collect();
PLAY_TRACKS.with(|cell| {
cell.borrow()
.iter()
.filter(|t| ids.contains(&t.id.to_string()))
.cloned()
.collect()
})
}
/// Apply decoded header artwork pixels. Runs on the Slint event loop.
pub fn apply_artwork(window: &AppWindow, pixels: &[u8], width: u32, height: u32) {
let mut buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
+96 -7
View File
@@ -20,7 +20,7 @@ use crate::home::CardData;
use crate::{
AlbumCardItem, TrackItem, AppWindow, ArtistState, DiscoverSection, DiscoveryArtist,
JumpNavTab, LabelEntry, MbOriginData, MbRelationship, MbRelationshipsData,
NetworkSidebarState, SimilarEntry,
NetworkSidebarState, ShellState, SimilarEntry,
};
/// Plain, `Send` artist data produced on the worker thread.
@@ -697,9 +697,87 @@ pub fn reset_artist(window: &AppWindow) {
state.set_name("".into());
state.set_bio("".into());
state.set_bio_source("".into());
state.set_top_tracks_multi_select(false);
state.set_top_tracks_selected_count(0);
state.set_loading(true);
}
// ============== Popular Tracks multi-select (mirrors AlbumState) ==========
/// Toggle Popular Tracks multi-select; leaving the mode clears the selection.
pub fn set_multi_select(window: &AppWindow, on: bool) {
let state = window.global::<ArtistState>();
state.set_top_tracks_multi_select(on);
if !on {
clear_selection(window);
}
}
/// Recompute the "N selected" count from the Popular Tracks rows.
pub fn recount_selected(window: &AppWindow) {
let state = window.global::<ArtistState>();
let model = state.get_top_tracks();
let count = (0..model.row_count())
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
.count();
state.set_top_tracks_selected_count(count as i32);
}
/// Select every row, or clear if all are already selected.
pub fn select_all(window: &AppWindow) {
let model = window.global::<ArtistState>().get_top_tracks();
let total = model.row_count();
let selected = (0..total)
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
.count();
let target = selected != total;
for i in 0..total {
if let Some(mut item) = model.row_data(i) {
if item.selected != target {
item.selected = target;
model.set_row_data(i, item);
}
}
}
recount_selected(window);
}
/// Clear the selection (uncheck all), keeping multi-select mode on.
pub fn clear_selection(window: &AppWindow) {
let model = window.global::<ArtistState>().get_top_tracks();
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.selected {
item.selected = false;
model.set_row_data(i, item);
}
}
}
window.global::<ArtistState>().set_top_tracks_selected_count(0);
}
/// Catalog ids of the selected Popular Tracks rows (Qobuz ids only).
pub fn selected_ids(window: &AppWindow) -> Vec<String> {
let model = window.global::<ArtistState>().get_top_tracks();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|t| t.selected)
.map(|t| t.id.to_string())
.filter(|s| s.parse::<u64>().is_ok())
.collect()
}
/// Catalog ids of ALL Popular Tracks rows (for the section "more" menu's
/// all-tracks actions).
pub fn all_top_track_ids(window: &AppWindow) -> Vec<String> {
let model = window.global::<ArtistState>().get_top_tracks();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.map(|t| t.id.to_string())
.filter(|s| s.parse::<u64>().is_ok())
.collect()
}
/// Apply decoded portrait artwork. Runs on the Slint event loop.
pub fn apply_artwork(window: &AppWindow, pixels: &[u8], width: u32, height: u32) {
let mut buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
@@ -732,19 +810,30 @@ pub struct MbOrigin {
pub location_clickable: bool,
}
/// Reset the network sidebar's MB-driven state and force-open the
/// panel. Called when the artist changes so a stale Origin /
/// Relationships / Discovery never bleeds across artists, and so the
/// sidebar always starts open on every artist visit (per user policy
/// — close is per-session, never persisted).
/// Reset the network sidebar's MB-driven state and (re)apply the open
/// state on artist change so a stale Origin / Relationships / Discovery
/// never bleeds across artists. The sidebar opens fresh on every artist
/// visit (per user policy — close is per-session, never persisted)
/// EXCEPT when the content area is space-constrained (a Queue / Lyrics
/// right panel is open on a non-wide window). In that case it stays
/// collapsed so the Popular Tracks list keeps priority — mirroring the
/// `!net-cramped` rule the ArtistPageView Slint handlers use. Reading
/// the constraint here (instead of unconditionally force-opening) is
/// what fixes the artist->artist navigation case: when a panel was
/// already open, navigating to a new artist no longer re-opens the
/// sidebar over the Slint handler.
pub fn reset_network_sidebar(window: &AppWindow) {
// Drop the previous artist's cached location params so a stale
// scene-view click can't fire for the wrong artist.
if let Ok(mut guard) = LOCATION_PARAMS.lock() {
*guard = None;
}
// Open only when there's room — mirror ShellState.content-constrained
// (the same signal AlbumView + the ArtistPageView `net-cramped`
// handlers use). Constrained => keep collapsed.
let constrained = window.global::<ShellState>().get_content_constrained();
let state = window.global::<NetworkSidebarState>();
state.set_open(true);
state.set_open(!constrained);
state.set_mb_available(true);
state.set_mb_mbid("".into());
state.set_origin_loading(false);
+22 -2
View File
@@ -587,11 +587,31 @@ fn apply_artwork(
}
}
ArtworkTarget::SearchArtist { idx } => {
let model = window.global::<SearchState>().get_artists();
let state = window.global::<SearchState>();
let model = state.get_artists();
let mut artist_id = None;
if let Some(mut item) = model.row_data(idx) {
item.artwork = image;
artist_id = Some(item.id.clone());
item.artwork = image.clone();
model.set_row_data(idx, item);
}
// The All-tab carousel is a SEPARATE model (`artists_carousel`,
// built as a clone with the hero dup dropped), so the artwork
// pipeline never reaches it. Mirror the cover into the matching
// carousel row by id (indices differ when the artist hero drops
// the first entry) so the carousel cards show their images.
if let Some(aid) = artist_id {
let carousel = state.get_artists_carousel();
for i in 0..carousel.row_count() {
if let Some(mut c) = carousel.row_data(i) {
if c.id == aid {
c.artwork = image.clone();
carousel.set_row_data(i, c);
break;
}
}
}
}
}
ArtworkTarget::SearchPlaylistCover { idx, slot } => {
let model = window.global::<SearchState>().get_playlists();
+75
View File
@@ -21,6 +21,13 @@ use qbz_app::settings::favorites_cache::FavoritesCacheStore;
static FAVORITES: LazyLock<RwLock<HashSet<u64>>> =
LazyLock::new(|| RwLock::new(HashSet::new()));
/// Process-wide set of the user's favorite ALBUM ids (string catalog ids).
/// Same disk-first + network-refresh lifecycle as [`FAVORITES`], so the
/// album header heart renders the right state from first paint and stays
/// live across toggles. Mirrors Tauri's `albumFavoritesStore`.
static FAV_ALBUMS: LazyLock<RwLock<HashSet<String>>> =
LazyLock::new(|| RwLock::new(HashSet::new()));
/// Per-user persistent ID store. `None` until a session (online or offline)
/// is activated; pure in-memory behavior in that window.
static STORE: Mutex<Option<FavoritesCacheStore>> = Mutex::new(None);
@@ -54,6 +61,19 @@ pub fn init_for_user(base_dir: &Path) {
}
Err(e) => log::warn!("[qbz-slint] favorites cache disk seed failed: {e}"),
}
match store.get_favorite_album_ids() {
Ok(ids) => {
let set: HashSet<String> = ids.into_iter().collect();
log::info!(
"[qbz-slint] favorites cache: {} album ids seeded from disk",
set.len()
);
if let Ok(mut guard) = FAV_ALBUMS.write() {
*guard = set;
}
}
Err(e) => log::warn!("[qbz-slint] favorites cache album disk seed failed: {e}"),
}
if let Ok(mut guard) = STORE.lock() {
*guard = Some(store);
}
@@ -67,6 +87,9 @@ pub fn teardown() {
if let Ok(mut guard) = FAVORITES.write() {
guard.clear();
}
if let Ok(mut guard) = FAV_ALBUMS.write() {
guard.clear();
}
}
/// Replace the cache with a freshly-fetched set and mirror it to the
@@ -135,3 +158,55 @@ pub fn set(track_id: u64, favorite: bool) {
}
}
}
// ==================== Favorite albums ====================
/// True when the album catalog id is in the user's favorite-album set.
pub fn is_album_favorite(album_id: &str) -> bool {
FAV_ALBUMS
.read()
.map(|g| g.contains(album_id))
.unwrap_or(false)
}
/// Replace the favorite-album set with a freshly-fetched id list and mirror
/// it to the per-user store (full replace — Tauri's
/// `v2_sync_cached_favorite_albums`). Blocking disk write; call off the UI
/// thread.
pub fn set_all_albums(ids: HashSet<String>) {
if let Ok(guard) = STORE.lock() {
if let Some(store) = guard.as_ref() {
let disk: Vec<String> = ids.iter().cloned().collect();
if let Err(e) = store.sync_favorite_albums(&disk) {
log::warn!("[qbz-slint] favorites cache album sync failed: {e}");
}
}
}
if let Ok(mut guard) = FAV_ALBUMS.write() {
*guard = ids;
}
}
/// Insert / remove a single album id (optimistic toggle) and mirror the
/// change to the per-user store so the heart survives a restart.
pub fn set_album(album_id: &str, favorite: bool) {
if let Ok(mut guard) = FAV_ALBUMS.write() {
if favorite {
guard.insert(album_id.to_string());
} else {
guard.remove(album_id);
}
}
if let Ok(guard) = STORE.lock() {
if let Some(store) = guard.as_ref() {
let res = if favorite {
store.add_favorite_album(album_id)
} else {
store.remove_favorite_album(album_id)
};
if let Err(e) = res {
log::warn!("[qbz-slint] favorites cache album disk update failed: {e}");
}
}
}
}
+57
View File
@@ -141,6 +141,63 @@ pub struct LabelCard {
pub image_url: String,
}
/// Fetch the full set of favorite ALBUM ids from the server (paginated),
/// for seeding `fav_cache` at login so the album header heart is correct
/// without first visiting the Favorites view. Mirrors Tauri's
/// `albumFavoritesStore.syncFromApi`.
pub async fn favorite_album_ids<A>(
runtime: &Arc<AppRuntime<A>>,
) -> std::collections::HashSet<String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let mut ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut offset = 0u32;
loop {
let value = match runtime.core().get_favorites("albums", PAGE_SIZE, offset).await {
Ok(v) => v,
Err(e) => {
log::warn!("[qbz-slint] favorite album ids fetch failed: {e}");
break;
}
};
let branch = value.get("albums");
let total = branch
.and_then(|b| b.get("total"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as usize;
let page: Vec<serde_json::Value> = branch
.and_then(|b| b.get("items"))
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let page_len = page.len();
for item in &page {
match item.get("id") {
Some(v) if v.is_string() => {
if let Some(s) = v.as_str() {
ids.insert(s.to_string());
}
}
Some(v) if v.is_u64() => {
if let Some(n) = v.as_u64() {
ids.insert(n.to_string());
}
}
_ => {}
}
}
offset += page_len as u32;
let exhausted = page_len < PAGE_SIZE as usize
|| (total > 0 && offset as usize >= total)
|| ids.len() >= MAX_ITEMS;
if exhausted {
break;
}
}
ids
}
/// Fetch + parse one favorites tab.
pub async fn load_favorites<A>(
runtime: &Arc<AppRuntime<A>>,
+29
View File
@@ -278,6 +278,35 @@ fn provider_label(provider: LyricsProvider) -> &'static str {
/// panel-open path for the current track. Fire-and-forget: pushes the
/// loading state immediately, resolves through the engine off-loop, and
/// commits the response only if the track is still current (F2).
/// Resolve + CACHE lyrics for a track in the BACKGROUND without touching the
/// UI — warms the lyrics cache for an upcoming queued track so the panel is
/// instant when that track becomes current (the user's "prefetch the next
/// track's lyrics" request; Tauri only fetches the current one). Fire-and-
/// forget; skipped offline so we never spend network warming ahead.
pub fn prefetch_lyrics(track: &QueueTrack) {
let Some(service) = SERVICE.get().cloned() else {
return;
};
if crate::offline_mode::engine().is_offline() {
return;
}
let source = source_kind(track);
let request = LyricsRequest {
track_id: (source == LyricsSourceKind::Qobuz).then_some(track.id),
source,
title: track.title.clone(),
artist: track.artist.clone(),
album: (!track.album.is_empty()).then(|| track.album.clone()),
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
offline: false,
};
tokio::spawn(async move {
// Resolution upserts into lyrics.db; the result is discarded (no UI).
// On advance, on_track_changed hits this warm cache instantly.
let _ = service.get(request).await;
});
}
pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
let Some(service) = SERVICE.get().cloned() else {
log::debug!("[qbz-slint] lyrics fetch skipped: service not installed");
+93
View File
@@ -1371,6 +1371,12 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
// peer's track (Q7). Fire-and-forget; the stale-response guard (F2)
// lives in `lyrics::on_track_changed`.
crate::lyrics::on_track_changed(weak.clone(), &track);
// Warm the NEXT queued track's lyrics in the background so the panel is
// instant when it becomes current (cache-only; no UI). Generated here
// because Tauri only ever fetches the CURRENT track.
if let Some(next) = runtime.core().queue().read().await.peek_next() {
crate::lyrics::prefetch_lyrics(&next);
}
let notify_meta = qbz_media_controls::NotificationMeta {
title: title.clone(),
artist: artist.clone(),
@@ -1740,6 +1746,93 @@ pub fn play_artist_top_tracks(
});
}
/// Enqueue (play-next or append) a subset of the artist's Popular tracks,
/// identified by catalog id. Re-fetches the page (like the play-all path),
/// filters to `ids`, preserves the page order, and queues — QConnect-aware
/// (mirrors `enqueue_queue_tracks`). Drives both the bulk bar (selection)
/// and the section "more" menu (all ids).
pub fn enqueue_artist_top_selected(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
artist_id: String,
ids: Vec<String>,
next: bool,
) {
if ids.is_empty() {
return;
}
handle.spawn(async move {
let Some(all) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else {
return;
};
let want: std::collections::HashSet<u64> =
ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
let tracks: Vec<QueueTrack> =
all.into_iter().filter(|qt| want.contains(&qt.id)).collect();
if tracks.is_empty() {
return;
}
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
let handled = if next {
svc.play_next_batch_on_peer_if_active(&routed).await
} else {
svc.add_to_queue_batch_on_peer_if_active(&routed).await
};
if handled {
return;
}
}
if next {
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
} else {
runtime.core().add_tracks(tracks).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, if next { "Playing next" } else { "Added to queue" });
});
}
/// Shuffle-play ALL of the artist's Popular tracks (section "more" menu).
/// Re-fetches, xorshift-shuffles (same seedless mix as `play_album_shuffled`),
/// and replaces the queue.
pub fn play_artist_top_shuffled(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
artist_id: String,
) {
handle.spawn(async move {
let Some(mut tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else {
return;
};
if tracks.is_empty() {
return;
}
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
for i in (1..tracks.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
let start_track_id = tracks[0].id;
set_now_playing_context(&weak, "artist", &artist_id);
runtime.core().set_queue(tracks, Some(0)).await;
after_track_change(&runtime, &weak, start_track_id).await;
refresh_sidebar(true);
});
}
/// Play the artist's Popular tracks starting at the clicked track id (queues
/// the tracks that follow it). `visible_ids` is the Popular-tracks VISIBLE row
/// order — the queue is reordered/filtered to match, so the in-page search
+7
View File
@@ -11,6 +11,13 @@ pub fn qobuz_playlist_url(playlist_id: &str) -> String {
format!("https://play.qobuz.com/playlist/{playlist_id}")
}
/// Qobuz web-player album URL (matches Tauri's `shareAlbumQobuzLink`,
/// `https://play.qobuz.com/album/{id}`). Also the source URL fed to
/// Song.link for the album-level "Album.link".
pub fn qobuz_album_url(album_id: &str) -> String {
format!("https://play.qobuz.com/album/{album_id}")
}
/// Copy `text` to the system clipboard. Runs on a blocking thread —
/// clipboard backends (X11/Wayland) can block, and arboard keeps an
/// owner thread alive so the contents persist after this returns.
+206 -47
View File
@@ -9,7 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState } from "../state.slint";
import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState, ShellState, TooltipState } from "../state.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
@@ -17,50 +17,57 @@ import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { AlbumContextMenu } from "../primitives/AlbumContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// One label/award entry in the album sidebar — a round gradient avatar
// and a name.
// and a name. In `compact` mode the name is dropped (the rail is an
// icon-only strip) and shown in a hover tooltip instead.
component SidebarCard inherits Rectangle {
in property <image> icon;
in property <string> name;
in property <brush> avatar-bg;
in property <bool> compact: false;
callback clicked();
height: 56px;
height: 48px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding: 8px;
padding: 6px;
spacing: 10px;
alignment: root.compact ? center : start;
VerticalLayout {
alignment: center;
Rectangle {
width: 36px;
height: 36px;
border-radius: 18px;
width: 28px;
height: 28px;
border-radius: 14px;
background: root.avatar-bg;
QbzIcon {
source: root.icon;
width: 18px;
height: 18px;
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
}
VerticalLayout {
if !root.compact: VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.name;
color: Theme.text-secondary;
font-size: Typography.link;
// -2pts vs the Typography.link (14px) base — the label/award
// NAME is what needed trimming, not the LABEL/AWARDS heading.
font-size: 12px;
font-weight: Typography.medium;
wrap: word-wrap;
}
@@ -69,6 +76,17 @@ component SidebarCard inherits Rectangle {
ta := TouchArea {
mouse-cursor: pointer;
// Compact rail: the name lives in a hover tooltip.
changed has-hover => {
if (self.has-hover && root.compact && root.name != "") {
TooltipState.text = root.name;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
clicked => {
root.clicked();
}
@@ -88,10 +106,27 @@ export component AlbumPageView inherits Rectangle {
// Whether the full-description modal is open.
property <bool> show-description: false;
// Header description picks the SHORTER cutoff when the content area is
// space-constrained (window <1366 AND a right panel open), so the tracks
// keep priority; back to the full short text when there's room.
property <string> desc-display:
ShellState.content-constrained
? AlbumState.description-shorter
: AlbumState.description-short;
background: Theme.surface-main;
flick := Flickable {
// Full-width: the gradient + page background bleed edge-to-edge so
// there is no dark right gutter. Content stays clear of the scrollbar
// via the page's 32px right padding — the scrollbar lives in the
// outer 18px (parent.width-18 .. parent.width-4), and every text /
// track / label-rail child ends at parent.width-32, a 14px gap, so
// nothing paints under it. The gradient is the only thing reaching
// the edge, which is exactly the desired full-bleed colour. (The
// earlier `parent.width - 20px` clipped the gradient too, leaving an
// ugly dark gutter strip on the right.)
width: parent.width;
// Freeze scroll during a track drag so the ghost doesn't drift.
interactive: !DragState.active;
viewport-width: self.width;
@@ -262,7 +297,41 @@ export component AlbumPageView inherits Rectangle {
}
}
Rectangle { height: 10px; }
// Meta line. With a navigable label, render it inline as a
// clickable link (Tauri label-link: meta color, accent on
// hover) between the pre (year) and post (genre • tracks •
// duration) segments. Otherwise the plain full line.
if AlbumState.label-id != "" && AlbumState.label != "": HorizontalLayout {
alignment: start;
spacing: 0px;
Text {
text: AlbumState.meta-pre != "" ? AlbumState.meta-pre + " • " : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
label-area := TouchArea {
width: label-text.preferred-width;
height: label-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.media-action("label", AlbumState.label-id, "open");
}
label-text := Text {
text: AlbumState.label;
color: label-area.has-hover ? Theme.accent : Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
Text {
text: AlbumState.meta-post != "" ? " • " + AlbumState.meta-post : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
if AlbumState.label-id == "" || AlbumState.label == "": Text {
text: AlbumState.info-line;
color: Theme.text-secondary;
font-size: Typography.body;
@@ -280,13 +349,13 @@ export component AlbumPageView inherits Rectangle {
// (not the parent layout) to avoid a binding loop:
// page padding 32+32, artwork 224, header gap 32.
width: root.width - 320px;
text: AlbumState.description-short;
text: root.desc-display;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
if AlbumState.description != root.desc-display: Rectangle { height: 4px; }
if AlbumState.description != root.desc-display: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
@@ -310,7 +379,6 @@ export component AlbumPageView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
// Spin only while THIS album's first track is fetching.
loading: NowPlayingState.loading
&& NowPlayingState.album-id == AlbumState.id;
@@ -341,18 +409,17 @@ export component AlbumPageView inherits Rectangle {
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
// Live library state: filled accent heart when
// favorited, outline otherwise; spinner while a
// toggle is in flight. "favorite-toggle" adds or
// removes (the grid hearts stay add-only).
icon: AlbumState.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
active: AlbumState.is-favorite;
loading: AlbumState.favorite-loading;
clicked => {
root.media-action("album", AlbumState.id, "favorite");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-download.svg");
clicked => {
root.media-action("album", AlbumState.id, "cache");
root.media-action("album", AlbumState.id, "favorite-toggle");
}
}
}
@@ -365,16 +432,7 @@ export component AlbumPageView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/user.svg");
clicked => {
root.open-artist(AlbumState.artist-id);
}
}
}
VerticalLayout {
if AlbumState.has-booklet: VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/book-open.svg"); }
}
@@ -387,13 +445,36 @@ export component AlbumPageView inherits Rectangle {
}
}
}
// Album info (i) — icon only for now; the detail panel
// logic lands later.
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); }
CircleAction { icon: @image-url("../assets/icons/info.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); }
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
album-menu.show();
}
}
// Opens below the ⋯, right edges aligned — the
// button sits at the right end of the action row
// so the menu drops leftward (negative x).
album-menu := AlbumContextMenu {
x: 32px - self.width;
y: 38px;
album-id: AlbumState.id;
fully-cached: AlbumState.album-fully-cached;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
}
}
@@ -410,7 +491,7 @@ export component AlbumPageView inherits Rectangle {
// No `alignment: start` here: it would defeat the track list's
// horizontal-stretch and leave the sidebar floating mid-row
// with dead space to its right. The track list stretches and
// the 254px sidebar is pushed flush to the right edge; when a
// the 214px sidebar is pushed flush to the right edge; when a
// main right sidebar opens, the whole content region narrows
// and the sidebar rides along with it.
HorizontalLayout {
@@ -454,7 +535,9 @@ export component AlbumPageView inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 280px;
// 60% of the previous 280px so the toolbar gives
// room to the square select button beside it.
width: 168px;
height: 34px;
border-radius: 6px;
border-width: 1px;
@@ -502,7 +585,62 @@ export component AlbumPageView inherits Rectangle {
}
}
}
// Multi-select toggle — square (matches the search box,
// not the round CircleActions), slightly smaller. Active
// (accent) while select mode is on; toggles it.
VerticalLayout {
alignment: center;
Rectangle {
width: 30px;
height: 30px;
border-radius: 6px;
border-width: 1px;
border-color: AlbumState.multi-select
? Theme.accent
: Theme.border-subtle;
background: (AlbumState.multi-select || select-ta.has-hover)
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/square-check-big.svg");
width: 15px;
height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: AlbumState.multi-select
? Theme.accent
: (select-ta.has-hover
? Theme.text-primary
: Theme.text-secondary);
}
select-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
AlbumActions.toggle-multi-select();
}
}
}
}
}
// Bulk action bar — visible in multi-select mode, over the
// selected catalog rows.
if AlbumState.multi-select: MultiSelectBar {
selected-count: AlbumState.selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to favorites"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
AlbumActions.bulk-action(id);
}
}
if AlbumState.multi-select: Rectangle { height: 8px; }
// Column header.
HorizontalLayout {
@@ -519,7 +657,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
// Capital-case to match PlaylistView's column headers.
text: @tr("Title");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -527,7 +666,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
// Capital-case to match PlaylistView's column headers.
text: @tr("Duration");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -536,7 +676,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
// Capital-case to match PlaylistView's column headers.
text: @tr("Quality");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -585,24 +726,38 @@ export component AlbumPageView inherits Rectangle {
show-favorite: !AlbumState.is-local;
show-download: !AlbumState.is-local;
show-source: AlbumState.is-local;
multi-select-mode: AlbumState.multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// Label / awards sidebar.
// Label / awards sidebar. Collapses to a slim ICON RAIL (56px)
// when the content area is space-constrained (window <1366 AND
// a right panel open), so the tracks — what matters in a music
// app — keep priority. In the rail, only the LABEL/AWARDS
// headings + the avatars show; names move to hover tooltips.
if AlbumState.label != "" || AlbumState.awards.length > 0: VerticalLayout {
width: 254px;
// 160px (was 214px): the LABEL/AWARDS column was too wide
// and the track TITLE — the most important column — paid
// for it. Narrower here returns that width to the list.
width: ShellState.content-constrained ? 56px : 160px;
spacing: 24px;
alignment: start;
if AlbumState.label != "": VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("LABEL"); }
SidebarHeading {
text: @tr("LABEL");
// Centered over the avatar when collapsed to the
// icon rail; left-aligned in the full column.
horizontal-alignment: ShellState.content-constrained ? center : left;
}
SidebarCard {
icon: @image-url("../assets/icons/disc.svg");
name: AlbumState.label;
compact: ShellState.content-constrained;
avatar-bg: @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
clicked => {
if (AlbumState.label-id != "") {
@@ -614,10 +769,14 @@ export component AlbumPageView inherits Rectangle {
if AlbumState.awards.length > 0: VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("AWARDS"); }
SidebarHeading {
text: @tr("AWARDS");
horizontal-alignment: ShellState.content-constrained ? center : left;
}
for award in AlbumState.awards: SidebarCard {
icon: @image-url("../assets/laurels.svg");
name: award;
compact: ShellState.content-constrained;
avatar-bg: @linear-gradient(135deg, #b45309 0%, #eab308 100%);
}
}
@@ -270,7 +270,6 @@ export component LocalAlbumView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
loading: NowPlayingState.loading
&& NowPlayingState.album-id == LocalAlbumState.id;
clicked => {
@@ -442,7 +441,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
// Capital-case to match PlaylistView's column headers.
text: @tr("Title");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -450,7 +450,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
// Capital-case to match PlaylistView's column headers.
text: @tr("Duration");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -459,7 +460,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
// Capital-case to match PlaylistView's column headers.
text: @tr("Quality");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
+122 -70
View File
@@ -18,6 +18,7 @@ import {
NetworkSidebarState,
NetworkSidebarActions,
DragState,
ShellState,
} from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
@@ -27,6 +28,7 @@ import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ReleaseGrid } from "ReleaseGrid.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint";
@@ -109,10 +111,32 @@ export component ArtistPageView inherits Rectangle {
// Popular Tracks shows the first 5; Load more expands to all.
property <bool> top-tracks-expanded: false;
property <int> top-tracks-preview: 5;
// Popular Tracks multi-select mode — toggled by the section header's
// Select button. When true, each row reveals a leading checkbox column
// (TrackRow's `multi-select-mode` prop). Mirrors Tauri's flow.
property <bool> multi-select-mode: false;
// Popular Tracks multi-select now lives in ArtistState
// (top-tracks-multi-select / top-tracks-selected-count), so it resets on
// artist change and drives the shared MultiSelectBar.
// Responsiveness: the 300px Artist Network sidebar AUTO-collapses when the
// content area is space-constrained — a right panel (Queue / Lyrics) is
// open on a non-wide window — so the Popular Tracks list keeps priority
// (tracks win — it's a music app), and AUTO-opens when there's room again.
// The user can still toggle it manually (the button below); that override
// sticks until the next constrain-edge or artist change.
//
// Uses ShellState.content-constrained (the SAME signal AlbumView uses),
// NOT a raw `root.width < N`. The old `root.width < 1080px` trigger WAS
// the regression: at a normal window the content area is already < 1080px
// WITHOUT any panel, so `net-cramped` was permanently true — opening the
// Queue produced no transition, the sidebar never auto-closed, and (being
// a 300px overlay) it covered the Popular Tracks action cluster instead of
// yielding to it.
property <bool> net-cramped: ShellState.content-constrained;
property <string> net-nav-watch: ArtistState.id;
changed net-cramped => {
NetworkSidebarState.open = !root.net-cramped;
}
changed net-nav-watch => {
NetworkSidebarState.open = !root.net-cramped;
}
background: Theme.surface-main;
@@ -337,8 +361,8 @@ export component ArtistPageView inherits Rectangle {
// vs Qobuz radio (qobuz-curated). Both call
// distinct media-actions; main.rs wires them.
Rectangle {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
clicked => {
@@ -347,7 +371,7 @@ export component ArtistPageView inherits Rectangle {
}
radio-popup := PopupWindow {
x: 0px;
y: 50px;
y: 38px;
width: 180px;
close-policy: close-on-click;
ContextMenu {
@@ -443,79 +467,105 @@ export component ArtistPageView inherits Rectangle {
HorizontalLayout {
horizontal-stretch: 0;
spacing: 12px;
// Wrapped in VerticalLayout (alignment:center) so the round
// buttons keep their 40px size — an unwrapped CircleAction
// in this row was stretching tall ("giant button").
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 40px;
clicked => {
root.media-action(
"artist", ArtistState.id, "play-top");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: ArtistState.top-tracks-multi-select;
clicked => {
root.multi-select-mode = !root.multi-select-mode;
ArtistActions.toggle-top-tracks-select();
}
}
}
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
root.media-action("artist", "", "top-tracks-menu");
top-tracks-menu.show();
}
}
// Popular Tracks section menu (all-tracks actions),
// opens below the ⋯ with right edges aligned.
top-tracks-menu := PopupWindow {
x: 32px - self.width;
y: 38px;
width: 224px;
height: 4 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play all next");
clicked => {
ArtistActions.top-tracks-menu-action("next-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add all to queue");
clicked => {
ArtistActions.top-tracks-menu-action("queue-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/shuffle.svg");
label: @tr("Shuffle all");
clicked => {
ArtistActions.top-tracks-menu-action("shuffle-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add all to playlist");
clicked => {
ArtistActions.top-tracks-menu-action("playlist-all");
}
}
}
}
}
}
}
}
if ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
// Check-all row — shown above the list whenever the
// section is in multi-select mode. Matches the QBZ
// pattern: leading checkbox on the left of the row, just
// like the per-row selectors below.
if root.multi-select-mode
&& ArtistState.top-tracks.length > 0:
HorizontalLayout {
height: 36px;
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
check-all-box := Rectangle {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 22px;
height: 22px;
border-radius: 11px;
background: transparent;
border-width: 1.5px;
border-color: check-all-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
check-all-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action(
"artist", "", "select-all-top-tracks");
}
}
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Select all");
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
}
// Bulk action bar — homologated icon bar, shown in multi-select
// mode over the selected Popular Tracks.
if ArtistState.top-tracks-multi-select
&& ArtistState.top-tracks.length > 0: MultiSelectBar {
selected-count: ArtistState.top-tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to favorites"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
ArtistActions.top-tracks-bulk-action(id);
}
}
if ArtistState.top-tracks-multi-select
&& ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
for track[index] in ArtistState.top-tracks: TrackRow {
visible: root.top-tracks-expanded || index < root.top-tracks-preview;
@@ -525,7 +575,7 @@ export component ArtistPageView inherits Rectangle {
// Artist Popular Tracks: number cell (no cover), with
// the multi-select checkbox.
show-artwork: false;
multi-select-mode: root.multi-select-mode;
multi-select-mode: ArtistState.top-tracks-multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
@@ -587,19 +637,6 @@ export component ArtistPageView inherits Rectangle {
} // close page VerticalLayout
} // close Flickable
// Shared scrollbar in the right gutter (homologated cross-view).
// Hidden while the network sidebar is open — that panel pins to the
// same right edge and carries its own scroll, so the page scrollbar
// there would overlap it.
if !NetworkSidebarState.open: ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Sticky JUMP TO bar -------------------------------------------------
// Overlay anchored to the top of ArtistPageView with the same
// sticky math as the sidebar: tracks the page scroll until the
@@ -647,6 +684,21 @@ export component ArtistPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it —
// the full-width jump bar was overlapping the scrollbar. Still BELOW
// the modals/dialogs below (those must stay on top). Hidden while the
// network sidebar is open — that panel pins to the same right edge and
// carries its own scroll.
if !NetworkSidebarState.open: ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// Network sidebar — overlay anchored to the right edge of the
// ArtistPageView, with sticky-style y positioning so it tracks
// the page scroll while the header is visible and then pins
@@ -935,13 +935,13 @@ export component FavoritesView inherits Rectangle {
if FavoritesState.tracks-multi-select: MultiSelectBar {
selected-count: FavoritesState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from favorites"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
FavoritesActions.bulk-action(id);
+13 -11
View File
@@ -169,7 +169,6 @@ export component LabelPageView inherits Rectangle {
HorizontalLayout {
alignment: start;
CircleAction {
diameter: 44px;
active: LabelState.is-following;
icon: LabelState.is-following
? @image-url("../assets/icons/check.svg")
@@ -409,16 +408,6 @@ export component LabelPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Sticky JUMP TO bar -------------------------------------------------
// Same sticky math as ArtistPageView: tracks the page scroll until the
// header scrolls away, then pins to y = 0. No inline search (Tauri's
@@ -438,6 +427,19 @@ export component LabelPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it (the
// full-width jump bar was overlapping it), but BEFORE the modal below
// so the modal still stacks above the scrollbar.
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Full-description modal ---------------------------------------------
if root.show-desc: Rectangle {
background: #000000bf;
@@ -103,7 +103,8 @@ export component LabelReleasesView inherits Rectangle {
Text {
text: LabelState.name;
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
}
if LabelState.total > 0: Text {
@@ -150,7 +150,6 @@ component EphemeralPane inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
tooltip: @tr("Play all");
clicked => {
LocalLibraryActions.ephemeral-play-all();
@@ -161,7 +160,6 @@ component EphemeralPane inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/x.svg");
diameter: 40px;
tooltip: @tr("Clear session");
clicked => {
LocalLibraryActions.ephemeral-clear();
@@ -1326,12 +1324,12 @@ export component LocalLibraryView inherits Rectangle {
if LocalLibraryState.tracks-multi-select: MultiSelectBar {
selected-count: LocalLibraryState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
LocalLibraryActions.tracks-bulk-action(id);
@@ -1860,7 +1858,6 @@ export component LocalLibraryView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 40px;
tooltip: @tr("Play all");
clicked => {
LocalLibraryActions.folders-play-node(
@@ -136,7 +136,8 @@ export component ArtistsByLocationView inherits Rectangle {
? LocationViewState.scene-label
: @tr("Scene");
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
vertical-alignment: center;
}
-1
View File
@@ -160,7 +160,6 @@ export component MixView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: MixState.tracks.length > 0;
clicked => {
root.play-all();
@@ -147,7 +147,9 @@ export component MusicianPageView inherits Rectangle {
Text {
text: MusicianState.name;
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section), like
// the Album / Artist / Playlist / MyQBZ detail heroes.
font-size: Typography.section;
font-weight: Typography.bold;
}
Text {
@@ -23,7 +23,6 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LineEdit } from "std-widgets.slint";
// A single labelled radio option in the create-panel Kind toggle.
component KindRadio inherits TouchArea {
@@ -308,11 +307,48 @@ export component AddToMixtapeModal inherits Rectangle {
tint: Theme.text-muted;
}
}
search-input := LineEdit {
placeholder-text: @tr("Search…");
// Plain Rectangle + TextInput recipe (see
// PlaylistPickerModal) — std-widgets LineEdit
// misrenders here.
search-box := Rectangle {
height: 43px;
horizontal-stretch: 1;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: search-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Rectangle {
horizontal-stretch: 1;
clip: true;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
text <=> MyQbzAddState.search;
edited(t) => {
MyQbzAddActions.search-changed(t);
edited => {
MyQbzAddActions.search-changed(self.text);
}
}
Text {
// Placeholder — Slint TextInput has no
// placeholder property; a sibling Text
// gated on empty text.
visible: search-input.text == "";
width: 100%;
height: 100%;
text: @tr("Search…");
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
@@ -409,16 +445,47 @@ export component AddToMixtapeModal inherits Rectangle {
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
create-name-input := LineEdit {
placeholder-text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
text <=> MyQbzAddState.create-name;
// Same Rectangle + TextInput recipe as the search box.
create-name-box := Rectangle {
height: 43px;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: create-name-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Rectangle {
horizontal-stretch: 1;
clip: true;
create-name-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
enabled: !MyQbzAddState.create-busy;
text <=> MyQbzAddState.create-name;
accepted => {
if (root.can-create) {
MyQbzAddActions.create-and-add();
}
}
}
Text {
visible: create-name-input.text == "";
width: 100%;
height: 100%;
text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
// Kind toggle.
VerticalLayout {
@@ -701,11 +701,14 @@ export component MixtapeDetailView inherits Rectangle {
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
// Title — canonical hero title size (Album/Playlist heroes).
// Title — canonical hero size, homologated to the Album /
// Artist / Playlist detail heroes (Typography.section, 18px).
// Was Typography.title (25px) despite this comment already
// claiming Album/Playlist parity — a homologation bug.
Text {
text: MyQbzDetailState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-size: Typography.section;
font-weight: Typography.bold;
wrap: word-wrap;
}
@@ -737,7 +740,6 @@ export component MixtapeDetailView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.play-all(); }
}
@@ -746,7 +748,6 @@ export component MixtapeDetailView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.shuffle(); }
}
@@ -757,7 +758,6 @@ export component MixtapeDetailView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/turntable.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.dj-mix(); }
}
@@ -766,16 +766,15 @@ export component MixtapeDetailView inherits Rectangle {
VerticalLayout {
alignment: center;
overflow-anchor := Rectangle {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
diameter: 40px;
clicked => { overflow-menu.show(); }
}
overflow-menu := PopupWindow {
x: 0;
y: 44px;
y: 36px;
width: 220px;
// Explicit height (same fix as row-menu) sized
// to the MAX item set: 7 items (32px) + 2
@@ -1245,12 +1244,12 @@ export component MixtapeDetailView inherits Rectangle {
width: 100%;
selected-count: MyQbzDetailState.selected-count;
actions: [
{ id: "add-to-queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
MyQbzDetailActions.bulk-action(id);
@@ -13,7 +13,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { OfflineManagerState, OfflineManagerActions, OfflineRow, OfflineArtist } from "../state.slint";
import { NavState } from "../state.slint";
import { NavState, TooltipState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
@@ -110,30 +110,48 @@ component ArtistRailRow inherits Rectangle {
}
}
// A small labelled ghost button (toolbar / bulk actions).
component TxtBtn inherits Rectangle {
in property <string> label;
// Bulk-action icon button — homologated with MultiSelectBar's BulkButton
// (icon + hover tooltip, borderless normal, red-bordered danger) so the
// offline-cache track rows share the app-wide bulk-bar style. Named
// distinctly from the existing `IconBtn` (a tinted toolbar glyph button).
component BulkIconBtn inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
in property <bool> danger: false;
callback clicked;
width: lbl.preferred-width + 24px;
height: 32px;
width: 34px;
height: 30px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: ta.has-hover ? Theme.surface-elevated : transparent;
lbl := Text {
x: 12px;
text: root.label;
color: root.danger ? #e0564f : Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
height: 100%;
background: root.danger
? (ta.has-hover ? #ef444433 : transparent)
: (ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.danger ? 1px : 0px;
border-color: #ef4444;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.danger
? #ef4444
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
@@ -154,7 +172,8 @@ export component OfflineManagerView inherits Rectangle {
Text {
text: "Offline Cache";
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
}
}
@@ -314,14 +333,16 @@ export component OfflineManagerView inherits Rectangle {
font-size: Typography.legal;
}
}
TxtBtn {
label: @tr("Re-download");
BulkIconBtn {
icon: @image-url("../assets/icons/refresh-cw.svg");
tooltip: @tr("Re-download");
clicked => {
OfflineManagerActions.bulk-redownload();
}
}
TxtBtn {
label: @tr("Remove");
BulkIconBtn {
icon: @image-url("../assets/icons/trash-2.svg");
tooltip: @tr("Remove");
danger: true;
clicked => {
OfflineManagerActions.bulk-remove();
@@ -329,8 +350,11 @@ export component OfflineManagerView inherits Rectangle {
}
}
// Select all / clear toggle.
TxtBtn {
label: OfflineManagerState.selected-count > 0 ? @tr("Clear") : @tr("Select all");
BulkIconBtn {
icon: OfflineManagerState.selected-count > 0
? @image-url("../assets/icons/x.svg")
: @image-url("../assets/icons/square-check-big.svg");
tooltip: OfflineManagerState.selected-count > 0 ? @tr("Clear") : @tr("Select all");
clicked => {
if (OfflineManagerState.selected-count > 0) {
OfflineManagerActions.clear-selection();
+12 -11
View File
@@ -77,17 +77,17 @@ export component PlaylistView inherits Rectangle {
// Play next / Add to queue / Add to playlist over the selection,
// Remove owner-only — followed playlists keep the queueing actions.
property <[BulkAction]> bulk-actions-owner: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
];
property <[BulkAction]> bulk-actions-follower: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
];
// Label for the active sort field.
@@ -324,7 +324,6 @@ export component PlaylistView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.play-all();
@@ -716,7 +715,9 @@ export component PlaylistView inherits Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: parent.width - self.width - 24px;
// Clear the right-gutter scrollbar (14px + 4px margin) so the FAB
// never sits over it.
x: parent.width - self.width - 40px;
y: parent.height - self.height - 24px;
background: btt-ta.has-hover ? Theme.accent : Theme.surface-elevated;
border-width: 1px;
@@ -0,0 +1,102 @@
// Album header "more" (⋯) context menu — ported 1:1 from the Tauri
// `AlbumMenu` (src/lib/components/AlbumMenu.svelte) as wired by the album
// detail header. Tauri nests Share/Download as submenus; we render them
// FLAT (the same cosmetic-flat convention as TrackContextMenu) with group
// separators matching Tauri's menu grouping.
//
// Item -> media-action("album", <album-id>, <action>) mapping:
// Play next -> "play-next"
// Add to queue -> "queue"
// --
// Add to playlist -> "add-to-playlist"
// Add to Mixtape/Coll. -> "add-to-mixtape"
// --
// Share Qobuz link -> "share-qobuz"
// Share Album.link -> "share-songlink"
// --
// Make available offline -> "cache" (album NOT fully cached)
// Refresh offline copy -> "recache" (album fully cached)
//
// Tauri's onMakeOffline ("download.makeAvailable") item is NOT passed by
// the album detail header, so it is intentionally absent (1:1). The last
// row swaps Make-available-offline <-> Refresh-offline-copy on
// `fully-cached`, the flat form of Tauri's isAlbumFullyDownloaded submenu
// (which only carries Refresh — no Remove at album level).
import { Theme } from "../foundation/semantic-colors.slint";
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
export component AlbumContextMenu inherits PopupWindow {
in property <string> album-id;
// Every track of the album already has a ready offline copy.
in property <bool> fully-cached: false;
callback media-action(string, string, string);
width: 224px;
// 7 rows x 33px + 3 separators x 1px + ContextMenu's 5/5 padding. The
// offline row swaps label/action on `fully-cached` — count is constant.
height: 7 * 33px + 3 * 1px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("album", root.album-id, "queue");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("album", root.album-id, "add-to-playlist");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("album", root.album-id, "add-to-mixtape");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Qobuz link");
clicked => {
root.media-action("album", root.album-id, "share-qobuz");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Album.link");
clicked => {
root.media-action("album", root.album-id, "share-songlink");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
if !root.fully-cached: ContextMenuItem {
icon: @image-url("../assets/icons/cloud-download.svg");
label: @tr("Make available offline");
clicked => {
root.media-action("album", root.album-id, "cache");
}
}
if root.fully-cached: ContextMenuItem {
icon: @image-url("../assets/icons/refresh-cw.svg");
label: @tr("Refresh offline copy");
clicked => {
root.media-action("album", root.album-id, "recache");
}
}
}
}
@@ -17,12 +17,16 @@ export component IconButton inherits Rectangle {
// Footprint width; the right-cluster buttons are narrower (28) than the
// transport (32) so they pack tighter.
in property <length> btn-width: 32px;
// Disabled (e.g. transport with nothing playing) — dimmed + non-clickable,
// 1:1 with Tauri's `.disabled` controls (opacity 0.3).
in property <bool> enabled: true;
callback clicked();
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
opacity: root.enabled ? 1.0 : 0.3;
background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
@@ -37,12 +41,14 @@ export component IconButton inherits Rectangle {
}
ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
if (self.has-hover && root.enabled && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
@@ -64,14 +70,17 @@ export component PlayButton inherits Rectangle {
// While a play is resolving/downloading (no audio yet) the glyph is
// swapped for a spinner so the click reads as "heard, audio is coming".
in property <bool> loading: false;
// Disabled when there is nothing to play (no current track).
in property <bool> enabled: true;
callback clicked();
width: root.circle ? 38px : 34px;
height: root.circle ? 38px : 34px;
border-radius: root.circle ? 19px : Radius.sm;
opacity: root.enabled ? 1.0 : 0.3;
background: root.circle
? (ta.has-hover ? #ffffffd6 : #ffffff)
: (ta.has-hover ? Theme.surface-hover : transparent);
? ((ta.has-hover && root.enabled) ? #ffffffd6 : #ffffff)
: ((ta.has-hover && root.enabled) ? Theme.surface-hover : transparent);
if !root.loading: QbzIcon {
source: root.icon;
@@ -94,12 +103,14 @@ export component PlayButton inherits Rectangle {
}
ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
if (self.has-hover && root.enabled && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
@@ -16,7 +16,12 @@ export component CircleAction inherits Rectangle {
in property <bool> active: false;
// While the album's first track is fetching, the icon swaps for a spinner.
in property <bool> loading: false;
in property <length> diameter: 40px;
// Homologated header sizing — the size hierarchy lives HERE, not in each
// caller: the primary (Play) button is prominent (44px), every secondary
// action is 80% of the old 40px default (32px). Callers no longer set
// `diameter` for header buttons; an explicit override is only for special
// contexts (e.g. a small card-overlay play).
in property <length> diameter: root.primary ? 44px : 32px;
// When false the button dims and the click is gated (e.g. an empty
// collection's hero CTAs). Additive — existing callers default to enabled.
in property <bool> enabled: true;
@@ -38,8 +43,10 @@ export component CircleAction inherits Rectangle {
if !root.loading: QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
// Glyph scales with the tier so the smaller secondary buttons don't
// look icon-heavy (primary keeps the larger 19px glyph).
width: root.primary ? 19px : 15px;
height: root.primary ? 19px : 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
@@ -8,34 +8,38 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { BulkAction } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { BulkAction, TooltipState } from "../state.slint";
// One action button — borderless hover for a normal action, red-bordered
// for a danger action, dimmed + non-interactive when it needs a selection
// and there is none.
// One action button — an ICON with a hover tooltip (the action's label).
// Borderless hover for a normal action, red-bordered for a danger action,
// dimmed + non-interactive when it needs a selection and there is none.
component BulkButton inherits Rectangle {
in property <string> label;
in property <image> icon;
in property <string> tooltip;
in property <bool> danger: false;
in property <bool> enabled-when: true;
callback clicked();
width: lbl.preferred-width + 24px;
width: 34px;
height: 30px;
border-radius: Radius.sm;
opacity: root.enabled-when ? 1.0 : 0.5;
opacity: root.enabled-when ? 1.0 : 0.4;
background: root.danger
? (ta.has-hover ? #ef444433 : transparent)
: (ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.danger ? 1px : 0px;
border-color: #ef4444;
lbl := Text {
text: root.label;
color: root.danger ? #ef4444 : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.danger ? Typography.medium : Typography.regular;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.danger
? #ef4444
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
@@ -45,6 +49,18 @@ component BulkButton inherits Rectangle {
root.clicked();
}
}
// Shared tooltip bubble (same channel as CircleAction), so each
// icon explains itself on hover.
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
@@ -60,7 +76,7 @@ export component MultiSelectBar inherits Rectangle {
HorizontalLayout {
padding-left: 14px;
padding-right: 10px;
spacing: 12px;
spacing: 6px;
Text {
text: root.selected-count + " " + @tr("selected");
color: Theme.text-secondary;
@@ -71,7 +87,8 @@ export component MultiSelectBar inherits Rectangle {
for act in root.actions: VerticalLayout {
alignment: center;
BulkButton {
label: act.label;
icon: act.icon;
tooltip: act.label;
danger: act.danger;
enabled-when: !act.needs-selection || root.selected-count > 0;
clicked => {
+40 -10
View File
@@ -253,7 +253,9 @@ export component TrackRow inherits Rectangle {
// Wide enough for 4-digit positions (big playlists).
width: 32px;
color: Theme.text-muted;
font-size: Typography.body;
// Secondary column — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px).
font-size: Typography.legal;
horizontal-alignment: center;
overflow: elide;
}
@@ -342,10 +344,25 @@ export component TrackRow inherits Rectangle {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
alignment: start;
spacing: 6px;
Text {
// Title line — femtovg measures some scripts' runs at ~0 (Korean
// at Typography.link; Japanese measures fine), so NOTHING about
// this line may be content-measured or the line collapses and the
// glyphs have nowhere to paint. The first failed fix assigned the
// WIDTH (stretch Rectangle) but missed that `alignment: center`
// on this column sizes each line to its preferred HEIGHT — the
// Korean line still collapsed to ~0 tall and its `clip: true`
// erased the glyphs. SongCard parity (the one surface where
// Korean renders): a plain Rectangle direct in the column, NO
// clip, Text width assigned — plus a FIXED height here, so both
// axes are layout-assigned and measurement is never trusted.
title-line := Rectangle {
height: 20px;
title-text := Text {
x: 0;
// Reserve the badge slot (16px + 6px gap) when explicit
// so the elide stops before the badge, never under it.
width: parent.width - (root.track.explicit ? 22px : 0px);
height: 100%;
text: root.track.title;
color: Theme.text-primary;
font-size: Typography.link;
@@ -353,9 +370,20 @@ export component TrackRow inherits Rectangle {
vertical-alignment: center;
overflow: elide;
}
if root.track.explicit: VerticalLayout {
alignment: center;
Rectangle {
// Explicit "E" badge — hugs the END of the title text (Tauri's
// .track-title-row: flex, gap 6px, badge flex-shrink:0), not
// the column's far right. preferred-width is only used to
// POSITION the badge (the title's own width stays assigned,
// so a bogus ~0 measurement can't blank it); when the run
// measures ~0 with a non-empty title (the femtovg Korean
// case), hugging is impossible, so the badge parks in the
// right-edge slot the title width already reserves.
if root.track.explicit: Rectangle {
x: (root.track.title != "" && title-text.preferred-width < 1px)
? title-text.width + 6px
: Math.min(title-text.preferred-width + 6px,
title-text.width + 6px);
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 16px;
height: 16px;
border-radius: 3px;
@@ -370,7 +398,6 @@ export component TrackRow inherits Rectangle {
}
}
}
}
// Artist — a clickable link when an artist-id is present
// (opens the artist view), plain muted text otherwise. The
// TouchArea is sized to the text via the wrapping layout so it
@@ -433,7 +460,10 @@ export component TrackRow inherits Rectangle {
text: root.track.duration;
width: 70px;
color: Theme.text-muted;
font-size: Typography.body;
// Secondary metadata — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px),
// which made the duration larger than the title — illogical.
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -470,8 +470,14 @@ export component SearchResultsView inherits Rectangle {
if SearchState.tab == 0 && SearchState.most-popular-kind != "": HorizontalLayout {
spacing: 24px;
// Most-popular hero column.
// Most-popular hero column. Fixed at 200px (= the widest
// hero, the album card) so the Artists carousel always
// starts at the same x regardless of the hero kind — the
// 160px artist/track cards used to pull the carousel ~40px
// left versus an album hero. The card is centered in the
// slot.
VerticalLayout {
width: 200px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
@@ -492,6 +498,8 @@ export component SearchResultsView inherits Rectangle {
vertical-alignment: center;
}
}
HorizontalLayout {
alignment: center;
if SearchState.most-popular-kind == "artist": ArtistCard {
artist: SearchState.most-popular-artist;
clicked(id) => { root.open-artist(id); }
@@ -508,6 +516,7 @@ export component SearchResultsView inherits Rectangle {
media-action(k, i, a) => { root.media-action(k, i, a); }
}
}
}
// Artists carousel — fills the rest of the row. Uses
// the carousel-only list so the Most-popular hero is
+29 -12
View File
@@ -138,6 +138,11 @@ component LyricsLineRow inherits VerticalLayout {
if (root.dimming-mode == 1) {
return 0.6;
}
// strong (default) — ASYMMETRIC: incoming (future) lines fade much
// harder than already-sung ones, nearly dissolving into the
// background and brightening as they approach the active line, for a
// stronger timing cue. Past lines keep a gentler fall-off.
if (root.is-past) {
if (root.distance == 1) {
return 0.5;
}
@@ -147,13 +152,32 @@ component LyricsLineRow inherits VerticalLayout {
if (root.distance == 3) {
return 0.25;
}
return 0.15;
return 0.18;
}
if (root.distance == 1) {
return 0.4;
}
if (root.distance == 2) {
return 0.2;
}
if (root.distance == 3) {
return 0.08;
}
return 0.03;
}
base := Text {
text: root.display-text;
wrap: word-wrap;
opacity: root.line-opacity();
// 1:1 with Tauri's `transition: opacity 220ms ease-out`
// (LyricsLines.svelte:613-614): upcoming lines fade IN as they brighten
// toward active, passed lines fade OUT to their dim tier — instead of
// the opacity snapping between dimming steps.
animate opacity {
duration: 220ms;
easing: ease-out;
}
// Active base = the DIM (unsung) layer under the karaoke overlay
// (Tauri gradient parity: unsung stays dim, sung sweeps in accent).
color: root.is-active
@@ -324,17 +348,10 @@ export component LyricsLinesView inherits Rectangle {
}
if LyricsState.synced: Rectangle { height: root.spacer-height; }
// Fallback-provider attribution (LRCLIB / lyrics.ovh). Tauri keeps
// these in the About modal (AboutModal.svelte:141,144); Slint has
// no About surface yet, so the line lives at the end of the scroll.
// Qobuz is first-party: provider-label is empty, nothing renders.
if root.show-attribution && LyricsState.provider-label != "": Text {
text: @tr("Lyrics provided by {}", LyricsState.provider-label);
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
}
// Provider attribution moved to a fixed footer in LyricsSidebar
// ("Lyrics source: <provider>"), shown for ALL providers incl.
// Qobuz — so the scrolling attribution that used to live here is
// gone (it only covered the LRCLIB/ovh fallbacks).
}
}
}
+59 -1
View File
@@ -23,7 +23,7 @@ import { IconButton } from "../primitives/BarControls.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { LyricsLinesView } from "LyricsLinesView.slint";
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
import { ShellState, LyricsState } from "../state.slint";
import { ShellState, LyricsState, ArtworkActions } from "../state.slint";
component CloseButton inherits Rectangle {
callback clicked();
@@ -198,6 +198,7 @@ export component LyricsSidebar inherits Rectangle {
|| (LyricsState.status == 2 && LyricsState.lines.length == 0): VerticalLayout {
alignment: center;
padding: Spacing.md;
spacing: 12px;
Text {
text: @tr("No lyrics available");
color: Theme.text-muted;
@@ -205,6 +206,36 @@ export component LyricsSidebar inherits Rectangle {
horizontal-alignment: center;
wrap: word-wrap;
}
// Crowd-source nudge — LRCLIB is community-contributed, so a
// miss is an invitation to add them. Small, with a clickable
// link to the LRCLIB submission tool.
VerticalLayout {
spacing: 2px;
Text {
text: @tr("Missing lyrics? You can contribute them to LRCLIB:");
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
wrap: word-wrap;
}
HorizontalLayout {
alignment: center;
link-ta := TouchArea {
width: link-text.preferred-width;
height: link-text.preferred-height;
mouse-cursor: pointer;
clicked => {
ArtworkActions.open-in-browser("https://lrclibup.boidu.dev/");
}
link-text := Text {
text: "lrclibup.boidu.dev";
color: link-ta.has-hover ? Theme.accent-hover : Theme.accent;
font-size: 10px;
horizontal-alignment: center;
}
}
}
}
}
// Offline with nothing cached — the typed NotAvailableOffline
@@ -265,5 +296,32 @@ export component LyricsSidebar inherits Rectangle {
: Theme.accent;
}
}
// Source attribution — a tiny line pinned at the very bottom, always
// shown once lyrics are present (incl. Qobuz). "Lyrics source:" in
// muted + the provider name in accent. Smallest legible size.
if LyricsState.status == 2 && LyricsState.lines.length > 0: HorizontalLayout {
alignment: start;
padding-left: Spacing.sm;
padding-right: Spacing.sm;
padding-top: 3px;
padding-bottom: 5px;
spacing: 3px;
Text {
text: @tr("Lyrics source") + ":";
color: Theme.text-muted;
font-size: 9px;
vertical-alignment: center;
}
Text {
text: LyricsState.provider == "lrclib" ? "LRCLIB"
: LyricsState.provider == "ovh" ? "lyrics.ovh"
: "Qobuz";
color: Theme.accent;
font-size: 9px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
}
}
@@ -37,6 +37,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
enabled: NowPlayingState.has-track;
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
@@ -51,6 +52,7 @@ export component TransportControls inherits HorizontalLayout {
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.toggle-shuffle();
}
@@ -61,6 +63,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.previous();
}
@@ -75,6 +78,7 @@ export component TransportControls inherits HorizontalLayout {
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.toggle-play();
}
@@ -85,6 +89,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.next();
}
@@ -99,6 +104,7 @@ export component TransportControls inherits HorizontalLayout {
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.cycle-repeat();
}
@@ -115,6 +121,7 @@ export component TransportControls inherits HorizontalLayout {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
enabled: NowPlayingState.has-track;
clicked => {
add-menu.show();
}
@@ -181,6 +188,7 @@ export component TransportControls inherits HorizontalLayout {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to playlist");
enabled: NowPlayingState.has-track;
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
@@ -193,7 +201,8 @@ export component TransportControls inherits HorizontalLayout {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: fav-ta.has-hover ? Theme.surface-hover : transparent;
opacity: NowPlayingState.has-track ? 1.0 : 0.3;
background: (fav-ta.has-hover && NowPlayingState.has-track) ? Theme.surface-hover : transparent;
QbzIcon {
source: QueueState.now-playing-favorite
? @image-url("../assets/icons/heart-filled.svg")
@@ -207,12 +216,14 @@ export component TransportControls inherits HorizontalLayout {
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
fav-ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: NowPlayingState.has-track ? pointer : default;
clicked => {
if (NowPlayingState.has-track) {
QueueState.toggle-now-playing-favorite();
}
}
changed has-hover => {
if (self.has-hover) {
if (self.has-hover && NowPlayingState.has-track) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from library")
: @tr("Add to library");