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:
32 files changed
+1440
-334
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user