feat(slint): TrackRow favorites + artist/album links, PlaylistView, LabelView, scrollbar
CHECKPOINT — this batch is NOT yet acceptable; substantial feedback is pending. Committed so the work is preserved for rework next session, after a max-effort Tauri code review. Areas: TrackRow favorite toggle/state + clickable artist/album (TrackItem gains is-favorite/artist-id/album-id + fav_cache, populated across all builders); PlaylistView header buttons + cover-art hover overlay; LabelView full AlbumCard + grid/list toggle; vertical scrollbar moved to the right gutter across ~12 views. cc
This commit is contained in:
24 files changed
+632
-103
No files matched your search
@@ -49,6 +49,13 @@ pub struct TrackData {
|
||||
pub number: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
/// Performer id for the clickable artist link ("" = plain text).
|
||||
pub artist_id: String,
|
||||
/// Album id for the clickable album link ("" = plain text). Album view
|
||||
/// leaves this empty (its rows belong to the album being viewed, so the
|
||||
/// apply layer stamps the viewed album's id); artist top-tracks set it
|
||||
/// per-track since they span different albums.
|
||||
pub album_id: String,
|
||||
pub duration: String,
|
||||
pub quality_tier: String,
|
||||
pub explicit: bool,
|
||||
@@ -182,11 +189,18 @@ fn map_track(track: Track) -> TrackData {
|
||||
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
|
||||
title = format!("{title} ({version})");
|
||||
}
|
||||
let (artist, artist_id) = track
|
||||
.performer
|
||||
.map(|p| (p.name, p.id.to_string()))
|
||||
.unwrap_or_default();
|
||||
TrackData {
|
||||
id: track.id.to_string(),
|
||||
number: track.track_number.to_string(),
|
||||
title,
|
||||
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
||||
artist,
|
||||
artist_id,
|
||||
// The album view stamps the viewed album's id at the apply layer.
|
||||
album_id: String::new(),
|
||||
duration: mmss(track.duration),
|
||||
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
||||
explicit: track.parental_warning,
|
||||
@@ -220,11 +234,15 @@ fn format_duration(secs: u32) -> String {
|
||||
|
||||
/// Apply album data to the `AlbumState` global. Runs on the Slint event loop.
|
||||
pub fn apply_album(window: &AppWindow, data: AlbumData) {
|
||||
// These rows belong to the album currently being viewed, so the
|
||||
// album link target is this album's own id (the album column is not
|
||||
// shown here, but album-id keeps the row model complete).
|
||||
let album_id: slint::SharedString = data.id.clone().into();
|
||||
let tracks: Vec<TrackItem> = data
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| TrackItem {
|
||||
id: track.id.into(),
|
||||
id: track.id.clone().into(),
|
||||
number: track.number.into(),
|
||||
title: track.title.into(),
|
||||
artist: track.artist.into(),
|
||||
@@ -235,6 +253,9 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
|
||||
selected: false,
|
||||
artwork_url: "".into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: crate::fav_cache::is_favorite(&track.id),
|
||||
artist_id: track.artist_id.into(),
|
||||
album_id: album_id.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -358,11 +358,18 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackData {
|
||||
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
|
||||
title = format!("{title} ({version})");
|
||||
}
|
||||
let (artist, artist_id) = track
|
||||
.artist
|
||||
.map(|a| (a.name.display, a.id.to_string()))
|
||||
.unwrap_or_default();
|
||||
let album_id = track.album.map(|a| a.id).unwrap_or_default();
|
||||
TrackData {
|
||||
id: track.id.to_string(),
|
||||
number: (index + 1).to_string(),
|
||||
title,
|
||||
artist: track.artist.map(|a| a.name.display).unwrap_or_default(),
|
||||
artist,
|
||||
artist_id,
|
||||
album_id,
|
||||
duration: mmss(track.duration.unwrap_or(0)),
|
||||
quality_tier: tier(track.audio_info.and_then(|a| a.maximum_bit_depth)).to_string(),
|
||||
explicit: track.parental_warning.unwrap_or(false),
|
||||
@@ -481,7 +488,7 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
|
||||
.top_tracks
|
||||
.into_iter()
|
||||
.map(|track| TrackItem {
|
||||
id: track.id.into(),
|
||||
id: track.id.clone().into(),
|
||||
number: track.number.into(),
|
||||
title: track.title.into(),
|
||||
artist: track.artist.into(),
|
||||
@@ -492,6 +499,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
|
||||
selected: false,
|
||||
artwork_url: "".into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: crate::fav_cache::is_favorite(&track.id),
|
||||
artist_id: track.artist_id.into(),
|
||||
album_id: track.album_id.into(),
|
||||
})
|
||||
.collect();
|
||||
let release_sections: Vec<DiscoverSection> = data
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Shared favorite-track cache.
|
||||
//!
|
||||
//! A single process-wide set of the user's favorite track IDs, so every
|
||||
//! track-list surface (album, artist, search, playlist, mix, favorites)
|
||||
//! can stamp `is-favorite` on each row without re-fetching, and the row
|
||||
//! heart can toggle optimistically.
|
||||
//!
|
||||
//! The set is loaded once from `core().favorite_track_ids()` (the same
|
||||
//! source the Queue sidebar uses) and kept in sync by the favorite
|
||||
//! media-action handler, which inserts/removes locally the moment the
|
||||
//! user clicks — the network call follows in the background.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
static FAVORITES: LazyLock<RwLock<HashSet<u64>>> =
|
||||
LazyLock::new(|| RwLock::new(HashSet::new()));
|
||||
|
||||
/// Replace the cache with a freshly-fetched set.
|
||||
pub fn set_all(ids: HashSet<u64>) {
|
||||
if let Ok(mut guard) = FAVORITES.write() {
|
||||
*guard = ids;
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the given track id (string form) is in the favorite set.
|
||||
/// Non-numeric ids (local tracks) are never favorites.
|
||||
pub fn is_favorite(track_id: &str) -> bool {
|
||||
let Ok(id) = track_id.parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
FAVORITES.read().map(|g| g.contains(&id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Insert / remove a single id, keeping the cache consistent with an
|
||||
/// optimistic UI toggle. Returns the new favorite state.
|
||||
pub fn set(track_id: u64, favorite: bool) {
|
||||
if let Ok(mut guard) = FAVORITES.write() {
|
||||
if favorite {
|
||||
guard.insert(track_id);
|
||||
} else {
|
||||
guard.remove(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ pub struct TrackCard {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub artist_id: String,
|
||||
pub album_id: String,
|
||||
pub duration: String,
|
||||
pub quality_tier: String,
|
||||
pub explicit: bool,
|
||||
@@ -229,10 +231,17 @@ fn map_track(track: Track) -> TrackCard {
|
||||
.as_ref()
|
||||
.and_then(|a| a.image.best().cloned())
|
||||
.unwrap_or_default();
|
||||
let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default();
|
||||
let (artist, artist_id) = track
|
||||
.performer
|
||||
.map(|p| (p.name, p.id.to_string()))
|
||||
.unwrap_or_default();
|
||||
TrackCard {
|
||||
id: track.id.to_string(),
|
||||
title,
|
||||
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
||||
artist,
|
||||
artist_id,
|
||||
album_id,
|
||||
duration: mmss(track.duration),
|
||||
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
||||
explicit: track.parental_warning,
|
||||
@@ -324,6 +333,11 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
||||
selected: false,
|
||||
artwork_url: t.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
// Everything in the Favorites > Tracks tab is, by
|
||||
// definition, a favorite.
|
||||
is_favorite: true,
|
||||
artist_id: t.artist_id.into(),
|
||||
album_id: t.album_id.into(),
|
||||
})
|
||||
.collect();
|
||||
state.set_tracks(ModelRc::new(VecModel::from(rows)));
|
||||
|
||||
@@ -34,10 +34,15 @@ pub struct AlbumCard {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub artist_id: String,
|
||||
pub genre: String,
|
||||
pub year: String,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub artwork_url: String,
|
||||
// List-row extras (AlbumListRow columns; ignored by the grid card).
|
||||
pub quality_detail: String, // "24-bit / 96 kHz"
|
||||
pub track_count: String, // "12"
|
||||
pub plain_year: String, // "1973"
|
||||
}
|
||||
|
||||
/// Fetch the label page (name + image) and the first album page.
|
||||
@@ -109,15 +114,29 @@ fn map_album(album: Album) -> AlbumCard {
|
||||
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
|
||||
_ => String::new(),
|
||||
};
|
||||
let genre = album.genre.map(|g| g.name).unwrap_or_default();
|
||||
let quality_detail = match (album.maximum_bit_depth, album.maximum_sampling_rate) {
|
||||
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
|
||||
_ => String::new(),
|
||||
};
|
||||
let track_count = album
|
||||
.tracks_count
|
||||
.filter(|n| *n > 0)
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
AlbumCard {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
artist: album.artist.name,
|
||||
artist_id: album.artist.id.to_string(),
|
||||
year,
|
||||
genre,
|
||||
year: year.clone(),
|
||||
quality_tier,
|
||||
quality_label,
|
||||
artwork_url: album.image.best().cloned().unwrap_or_default(),
|
||||
quality_detail,
|
||||
track_count,
|
||||
plain_year: year,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +172,7 @@ fn to_item(card: AlbumCard) -> AlbumCardItem {
|
||||
title: card.title.into(),
|
||||
artist: card.artist.into(),
|
||||
artist_id: card.artist_id.into(),
|
||||
genre: "".into(),
|
||||
genre: card.genre.into(),
|
||||
year: card.year.into(),
|
||||
quality_tier: card.quality_tier.into(),
|
||||
quality_label: card.quality_label.into(),
|
||||
@@ -161,8 +180,12 @@ fn to_item(card: AlbumCard) -> AlbumCardItem {
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: card.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
// Label grid cards render as grid AlbumCards only — list-row
|
||||
// extras stay default.
|
||||
// List-row extras — feed the AlbumListRow columns (QUALITY /
|
||||
// TRACKS / YEAR) for the list view toggle. SOURCE is hidden in
|
||||
// this single-source (Qobuz) context.
|
||||
quality_detail: card.quality_detail.into(),
|
||||
track_count: card.track_count.into(),
|
||||
plain_year: card.plain_year.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod custom_artwork;
|
||||
mod dates;
|
||||
mod discover_browse;
|
||||
mod discovery_dismiss;
|
||||
mod fav_cache;
|
||||
mod favorites;
|
||||
mod foryou;
|
||||
mod genre_filter;
|
||||
@@ -95,6 +96,19 @@ async fn enter_shell(
|
||||
// Load the sidebar playlists list.
|
||||
load_sidebar_playlists(runtime.clone(), weak.clone(), &tokio::runtime::Handle::current());
|
||||
|
||||
// Warm the shared favorite-track cache so track rows can show the
|
||||
// correct heart state from their first paint (album / artist / search
|
||||
// / playlist / mix / favorites all read it). Background-fetched once.
|
||||
{
|
||||
let runtime = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
match runtime.core().favorite_track_ids().await {
|
||||
Ok(ids) => fav_cache::set_all(ids),
|
||||
Err(e) => log::warn!("[qbz-slint] favorite cache load failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load Audio + Playback settings into the Settings page in the
|
||||
// background — store reads and device enumeration are blocking.
|
||||
{
|
||||
@@ -224,6 +238,42 @@ fn update_nav_flags(window: &AppWindow) {
|
||||
state.set_can_forward(nav::can_forward());
|
||||
}
|
||||
|
||||
/// Flip the `is-favorite` flag on every visible row matching `track_id`,
|
||||
/// across all track-list surfaces (album, artist Popular, search,
|
||||
/// playlist, mix, favorites). Used for the optimistic favorite toggle so
|
||||
/// the heart updates the instant the user clicks, regardless of which
|
||||
/// view they are on.
|
||||
fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
|
||||
let flip = |model: &slint::ModelRc<TrackItem>| {
|
||||
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
|
||||
for i in 0..vm.row_count() {
|
||||
if let Some(mut item) = vm.row_data(i) {
|
||||
if item.id == track_id {
|
||||
if item.is_favorite != favorite {
|
||||
item.is_favorite = favorite;
|
||||
vm.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
flip(&window.global::<AlbumState>().get_tracks());
|
||||
flip(&window.global::<ArtistState>().get_top_tracks());
|
||||
flip(&window.global::<SearchState>().get_tracks());
|
||||
flip(&window.global::<PlaylistState>().get_tracks());
|
||||
flip(&window.global::<MixState>().get_tracks());
|
||||
flip(&window.global::<FavoritesState>().get_tracks());
|
||||
|
||||
// Search's most-popular track hero is a standalone TrackItem.
|
||||
let search = window.global::<SearchState>();
|
||||
let mut hero = search.get_most_popular_track();
|
||||
if hero.id == track_id && hero.is_favorite != favorite {
|
||||
hero.is_favorite = favorite;
|
||||
search.set_most_popular_track(hero);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy-load the Discover > For You sections the first time the tab is
|
||||
/// opened. No-op once loaded (the data persists for the session).
|
||||
fn ensure_for_you_loaded(
|
||||
@@ -1998,11 +2048,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
("track", "favorite") => {
|
||||
// Toggle (not just add): read the cached state, flip it
|
||||
// optimistically across every visible track model + the
|
||||
// shared cache, then add/remove on the network.
|
||||
let was_fav = fav_cache::is_favorite(&id);
|
||||
let make_fav = !was_fav;
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
fav_cache::set(track_id, make_fav);
|
||||
}
|
||||
if let Some(w) = weak.upgrade() {
|
||||
set_row_favorite(&w, &id, make_fav);
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let track_id = id.clone();
|
||||
handle.spawn(async move {
|
||||
if let Err(e) = runtime.core().add_favorite("track", &track_id).await {
|
||||
log::error!("[qbz-slint] favorite track failed: {e}");
|
||||
let res = if make_fav {
|
||||
runtime.core().add_favorite("track", &track_id).await
|
||||
} else {
|
||||
runtime.core().remove_favorite("track", &track_id).await
|
||||
};
|
||||
if let Err(e) = res {
|
||||
log::error!("[qbz-slint] toggle track favorite failed: {e}");
|
||||
// Roll the optimistic change back on failure.
|
||||
if let Ok(tid) = track_id.parse::<u64>() {
|
||||
fav_cache::set(tid, was_fav);
|
||||
}
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
set_row_favorite(&w, &track_id, was_fav);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,6 +161,19 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: crate::fav_cache::is_favorite(&track.id.to_string()),
|
||||
artist_id: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.id.to_string())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
album_id: track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|a| a.id.clone())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,19 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: crate::fav_cache::is_favorite(&track.id.to_string()),
|
||||
artist_id: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.id.to_string())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
album_id: track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|a| a.id.clone())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct TrackRowData {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
/// Performer id for the clickable artist link ("" = plain text).
|
||||
pub artist_id: String,
|
||||
/// Album id for the clickable album link ("" = plain text).
|
||||
pub album_id: String,
|
||||
pub duration: String,
|
||||
pub quality_tier: String,
|
||||
/// Detailed quality label, e.g. "Hi-Res 24-bit / 192 kHz". Used by the
|
||||
@@ -208,10 +212,17 @@ pub fn map_track(track: Track) -> TrackRowData {
|
||||
.as_ref()
|
||||
.and_then(|a| a.image.best().cloned())
|
||||
.unwrap_or_default();
|
||||
let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default();
|
||||
let (artist, artist_id) = track
|
||||
.performer
|
||||
.map(|p| (p.name, p.id.to_string()))
|
||||
.unwrap_or_default();
|
||||
TrackRowData {
|
||||
id: track.id.to_string(),
|
||||
title,
|
||||
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
||||
artist,
|
||||
artist_id,
|
||||
album_id,
|
||||
duration: mmss(track.duration),
|
||||
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
||||
quality_label: quality_label(track.maximum_bit_depth, track.maximum_sampling_rate),
|
||||
@@ -344,6 +355,7 @@ fn album_item(row: AlbumRow) -> AlbumCardItem {
|
||||
}
|
||||
|
||||
fn track_item(row: TrackRowData) -> TrackItem {
|
||||
let is_favorite = crate::fav_cache::is_favorite(&row.id);
|
||||
TrackItem {
|
||||
id: row.id.into(),
|
||||
number: "".into(),
|
||||
@@ -356,6 +368,9 @@ fn track_item(row: TrackRowData) -> TrackItem {
|
||||
selected: false,
|
||||
artwork_url: row.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite,
|
||||
artist_id: row.artist_id.into(),
|
||||
album_id: row.album_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
||||
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 { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
component CircleAction inherits Rectangle {
|
||||
@@ -121,7 +122,7 @@ export component AlbumPageView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
// Freeze scroll during a track drag so the ghost doesn't drift.
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
@@ -589,6 +590,16 @@ export component AlbumPageView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
|
||||
// --- Full-description modal --------------------------------------------
|
||||
if root.show-description: Rectangle {
|
||||
background: #000000bf;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
||||
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 { ReleaseGrid } from "ReleaseGrid.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint";
|
||||
@@ -606,6 +607,19 @@ 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
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AlbumCard } from "AlbumCard.slint";
|
||||
import { AlbumListRow, AlbumListHeader } from "../primitives/AlbumListRow.slint";
|
||||
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
|
||||
@@ -271,7 +272,7 @@ export component DiscoverBrowseView inherits Rectangle {
|
||||
}
|
||||
|
||||
// --- Scrolling list (only the listing scrolls) ------------------
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
@@ -377,6 +378,18 @@ export component DiscoverBrowseView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view). Sits
|
||||
// below the fixed 56px header so it tracks only the scrolling list.
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
y: 56px;
|
||||
width: 14px;
|
||||
height: parent.height - 56px;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
|
||||
// --- Genre filter overlay --------------------------------------------
|
||||
// Backdrop closes the popup on outside click; the panel anchors to
|
||||
// the top-right under the Filter-by-genre button (mirrors HomeView).
|
||||
|
||||
@@ -15,6 +15,7 @@ import { HomeSkeleton } from "HomeSkeleton.slint";
|
||||
import { ForYouView } from "ForYouView.slint";
|
||||
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// Header + hint shown for a recently-played module while it has no data
|
||||
@@ -242,8 +243,14 @@ export component HomeView inherits Rectangle {
|
||||
// A Flickable (not a ScrollView): wheel scrolling with no scrollbar
|
||||
// gutters at all. viewport-width is pinned to the Flickable width so
|
||||
// the carousels' clipped content can never scroll horizontally.
|
||||
Flickable {
|
||||
// Wrapped in a non-layout Rectangle so the shared ListScrollbar can
|
||||
// overlay the right gutter without stealing layout space (see the
|
||||
// scrollbar homologation note in ListScrollbar.slint).
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
flick := Flickable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewport-width: self.width;
|
||||
viewport-height: home-content.preferred-height;
|
||||
|
||||
@@ -349,6 +356,16 @@ export component HomeView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AlbumCard } from "../discover/AlbumCard.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// One tab in the favorites tab bar.
|
||||
@@ -248,7 +249,7 @@ export component FavoritesView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
@@ -442,4 +443,14 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,55 @@ import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LabelState, LabelActions, AlbumCardItem } from "../state.slint";
|
||||
import { AlbumCard } from "../discover/AlbumCard.slint";
|
||||
import { AlbumListRow, AlbumListHeader } from "../primitives/AlbumListRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// One half of the grid/list segmented toggle — identical to
|
||||
// DiscoverBrowseView's ToggleButton so the two views read the same.
|
||||
component ToggleButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: root.active
|
||||
? Theme.surface-hover
|
||||
: (ta.has-hover ? Theme.surface-elevated : transparent);
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.active ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component LabelReleasesView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <length> card-width: 220px;
|
||||
property <length> card-height: 266px;
|
||||
property <length> card-gap: 24px;
|
||||
property <length> list-row-gap: 4px;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -71,6 +105,7 @@ export component LabelReleasesView inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 6px;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: @tr("LABEL");
|
||||
color: Theme.text-muted;
|
||||
@@ -90,6 +125,35 @@ export component LabelReleasesView inherits Rectangle {
|
||||
font-size: Typography.body;
|
||||
}
|
||||
}
|
||||
|
||||
// Grid/list segmented toggle — floats right of the header,
|
||||
// same widget + behavior as DiscoverBrowseView.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 68px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
HorizontalLayout {
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/layout-grid.svg");
|
||||
active: LabelState.view-mode == "grid";
|
||||
clicked => {
|
||||
LabelState.view-mode = "grid";
|
||||
}
|
||||
}
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/list.svg");
|
||||
active: LabelState.view-mode == "list";
|
||||
clicked => {
|
||||
LabelState.view-mode = "list";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 32px; }
|
||||
@@ -100,7 +164,8 @@ export component LabelReleasesView inherits Rectangle {
|
||||
}
|
||||
|
||||
// --- Album grid --------------------------------------------
|
||||
if LabelState.albums.length > 0: grid := Rectangle {
|
||||
if LabelState.albums.length > 0
|
||||
&& LabelState.view-mode == "grid": grid := Rectangle {
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.card-gap) / (root.card-width + root.card-gap)));
|
||||
@@ -118,7 +183,37 @@ export component LabelReleasesView inherits Rectangle {
|
||||
root.open-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album list --------------------------------------------
|
||||
// Reusable AlbumListRow; SOURCE column hidden (single-source
|
||||
// Qobuz catalog). Header labels the columns.
|
||||
if LabelState.albums.length > 0
|
||||
&& LabelState.view-mode == "list": VerticalLayout {
|
||||
spacing: root.list-row-gap;
|
||||
AlbumListHeader {
|
||||
show-source: false;
|
||||
}
|
||||
for album[i] in LabelState.albums: AlbumListRow {
|
||||
album: album;
|
||||
index: i;
|
||||
show-source: false;
|
||||
clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,4 +246,14 @@ export component LabelReleasesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Typography } from "../foundation/typography.slint";
|
||||
import { LocationViewState, LocationViewActions, LocationArtistItem } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
component SceneArtistCard inherits Rectangle {
|
||||
@@ -83,7 +84,7 @@ export component ArtistsByLocationView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -198,4 +199,14 @@ export component ArtistsByLocationView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { MixState, DragState } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// Circular header action button (Play / Shuffle / Save / Refresh).
|
||||
@@ -66,7 +67,7 @@ export component MixView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
@@ -262,4 +263,14 @@ export component MixView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
component AppearanceCard inherits Rectangle {
|
||||
@@ -89,7 +90,7 @@ export component MusicianPageView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -266,4 +267,14 @@ export component MusicianPageView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { PmCollage } from "../primitives/PmCollage.slint";
|
||||
import { PmFolderIcon } from "../primitives/PmFolderIcon.slint";
|
||||
@@ -1228,7 +1229,7 @@ export component PlaylistManagerView inherits Rectangle {
|
||||
}
|
||||
|
||||
// --- Scrolling content -----------------------------------------
|
||||
Flickable {
|
||||
pm-flick := Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
@@ -1415,4 +1416,16 @@ export component PlaylistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view). Sits
|
||||
// below the fixed 56px toolbar so it tracks only the scrolling area.
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
y: 56px;
|
||||
width: 14px;
|
||||
height: parent.height - 56px;
|
||||
viewport-height: pm-flick.viewport-height;
|
||||
visible-height: pm-flick.height;
|
||||
viewport-y <=> pm-flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,17 @@ import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
// Header action button — homologated 1:1 with AlbumPageView's
|
||||
// CircleAction: a ghost circle (translucent fill + faint ring, never a
|
||||
// solid accent fill) so the playlist transport row matches the album
|
||||
// one. Adds `active` (accent-tinted glyph for toggled state, e.g.
|
||||
// favorite / select mode) and `enabled` (dimmed + non-interactive),
|
||||
// which the album header doesn't need but the playlist one does.
|
||||
component CircleAction inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> active: false;
|
||||
in property <length> diameter: 38px;
|
||||
in property <length> diameter: 40px;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
|
||||
@@ -27,18 +33,21 @@ component CircleAction inherits Rectangle {
|
||||
height: root.diameter;
|
||||
border-radius: root.diameter / 2;
|
||||
opacity: root.enabled ? 1.0 : 0.4;
|
||||
background: root.primary
|
||||
? (ta.has-hover ? #ffffffd6 : Theme.accent)
|
||||
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
||||
// Ghost circle — translucent fill with a faint ring (matches the
|
||||
// Tauri transport controls + AlbumPageView).
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
border-width: 1px;
|
||||
border-color: ta.has-hover ? Theme.text-disabled : Theme.border-subtle;
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: root.primary ? 18px : 16px;
|
||||
height: root.primary ? 18px : 16px;
|
||||
width: root.primary ? 19px : 17px;
|
||||
height: root.primary ? 19px : 17px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.primary
|
||||
? #ffffff
|
||||
: (root.active ? Theme.accent : Theme.text-secondary);
|
||||
tint: root.active
|
||||
? Theme.accent
|
||||
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: root.enabled ? pointer : default;
|
||||
@@ -152,11 +161,11 @@ export component PlaylistView inherits Rectangle {
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
|
||||
// Hover state — drives the artwork buttons (only
|
||||
// shown on mouse-over). OR'd with the buttons so
|
||||
// moving onto a button keeps them visible.
|
||||
// Hover state — drives the artwork overlay (only
|
||||
// shown on mouse-over). OR'd with the overlay button so
|
||||
// moving onto it keeps the overlay visible.
|
||||
property <bool> hovered:
|
||||
cover-hover.has-hover || set-ta.has-hover || clr-ta.has-hover;
|
||||
cover-hover.has-hover || cover-btn-ta.has-hover;
|
||||
|
||||
// Custom artwork / single server image.
|
||||
property <bool> show-single:
|
||||
@@ -221,60 +230,57 @@ export component PlaylistView inherits Rectangle {
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
|
||||
// Hover detector (below the buttons so they stay
|
||||
// clickable). Owner only.
|
||||
// Hover detector (below the overlay buttons so they
|
||||
// stay clickable). Owner only.
|
||||
cover-hover := TouchArea {
|
||||
mouse-cursor: PlaylistState.is-owner ? pointer : default;
|
||||
enabled: PlaylistState.is-owner;
|
||||
}
|
||||
|
||||
// Set custom artwork (owner only) — bottom-right,
|
||||
// shown on hover.
|
||||
Rectangle {
|
||||
visible: PlaylistState.is-owner && cover-box.hovered;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
x: parent.width - self.width - 8px;
|
||||
y: parent.height - self.height - 8px;
|
||||
background: set-ta.has-hover ? Theme.accent : #000000aa;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/image-plus.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
// Hover overlay — a centered "change cover" affordance
|
||||
// over the whole artwork, mirroring Tauri's
|
||||
// `.artwork-overlay`: a dim scrim that fades in on
|
||||
// hover with a single centered button. ImagePlus when
|
||||
// no custom art is set (sets one); X when one is
|
||||
// (clears it). Owner only. The button stays in the tree
|
||||
// (its TouchArea name must be in scope for `hovered`),
|
||||
// its glyph + action just switch on `has-custom`.
|
||||
cover-overlay := Rectangle {
|
||||
opacity: (PlaylistState.is-owner && cover-box.hovered) ? 1.0 : 0.0;
|
||||
background: #00000066;
|
||||
animate opacity { duration: 150ms; }
|
||||
|
||||
cover-btn := Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: #ffffff;
|
||||
}
|
||||
set-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("playlist", "", "set-artwork");
|
||||
background: cover-btn-ta.has-hover
|
||||
? (PlaylistState.has-custom ? #ef4444 : Theme.accent)
|
||||
: #000000aa;
|
||||
QbzIcon {
|
||||
source: PlaylistState.has-custom
|
||||
? @image-url("../assets/icons/x.svg")
|
||||
: @image-url("../assets/icons/image-plus.svg");
|
||||
width: PlaylistState.has-custom ? 20px : 22px;
|
||||
height: PlaylistState.has-custom ? 20px : 22px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear custom artwork — top-right, shown on hover
|
||||
// when a custom image is set.
|
||||
Rectangle {
|
||||
visible: PlaylistState.is-owner && PlaylistState.has-custom && cover-box.hovered;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
x: parent.width - self.width - 8px;
|
||||
y: 8px;
|
||||
background: clr-ta.has-hover ? #ef4444 : #000000aa;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: #ffffff;
|
||||
}
|
||||
clr-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("playlist", "", "clear-artwork");
|
||||
cover-btn-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
// Only interactive while the overlay is up
|
||||
// (and the user owns the playlist).
|
||||
enabled: PlaylistState.is-owner && cover-box.hovered;
|
||||
clicked => {
|
||||
if (PlaylistState.has-custom) {
|
||||
root.media-action("playlist", "", "clear-artwork");
|
||||
} else {
|
||||
root.media-action("playlist", "", "set-artwork");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,12 +645,14 @@ export component PlaylistView inherits Rectangle {
|
||||
if PlaylistState.multi-select-mode: Rectangle { height: 8px; }
|
||||
|
||||
// Virtualized track list (built-in scrollbar hidden — a
|
||||
// custom one sits in its own gutter to the right).
|
||||
HorizontalLayout {
|
||||
// custom one OVERLAYS its own gutter to the right — homologated
|
||||
// with every other view (the scrollbar sits in the right gutter
|
||||
// without shrinking the list).
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
spacing: 0px;
|
||||
track-list := ListView {
|
||||
horizontal-stretch: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
for track[i] in PlaylistState.tracks: TrackRow {
|
||||
track: track;
|
||||
@@ -664,7 +672,9 @@ export component PlaylistView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
horizontal-stretch: 0;
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: track-list.viewport-height;
|
||||
visible-height: track-list.visible-height;
|
||||
viewport-y <=> track-list.viewport-y;
|
||||
|
||||
@@ -254,22 +254,61 @@ export component TrackRow inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.track.artist != "": Text {
|
||||
text: root.track.artist;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
// 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
|
||||
// doesn't swallow clicks across the whole row width.
|
||||
if root.track.artist != "": HorizontalLayout {
|
||||
alignment: start;
|
||||
Rectangle {
|
||||
width: artist-link.preferred-width;
|
||||
artist-link := Text {
|
||||
text: root.track.artist;
|
||||
color: (root.track.artist-id != "" && artist-ta.has-hover)
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
artist-ta := TouchArea {
|
||||
// Only interactive when there is an artist to open.
|
||||
enabled: root.track.artist-id != "";
|
||||
mouse-cursor: root.track.artist-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
root.media-action("artist", root.track.artist-id, "open");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Album column.
|
||||
if root.show-album: Text {
|
||||
text: root.track.album;
|
||||
// Album column — clickable link when an album-id is present
|
||||
// (opens the album view), plain muted text otherwise.
|
||||
if root.show-album: Rectangle {
|
||||
width: 220px;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
album-link := Text {
|
||||
x: 0;
|
||||
width: 100%;
|
||||
text: root.track.album;
|
||||
color: (root.track.album-id != "" && album-ta.has-hover)
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
album-ta := TouchArea {
|
||||
// Constrain to the rendered text so empty padding isn't clickable.
|
||||
x: 0;
|
||||
width: album-link.preferred-width < parent.width
|
||||
? album-link.preferred-width
|
||||
: parent.width;
|
||||
enabled: root.track.album-id != "";
|
||||
mouse-cursor: root.track.album-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
root.media-action("album", root.track.album-id, "open");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration.
|
||||
@@ -293,7 +332,9 @@ export component TrackRow inherits Rectangle {
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Favorite.
|
||||
// Favorite — filled accent heart when favorited, outline heart
|
||||
// otherwise. Click toggles (handled in Rust, which flips
|
||||
// `is-favorite` optimistically so this updates immediately).
|
||||
if root.show-favorite: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
@@ -302,12 +343,16 @@ export component TrackRow inherits Rectangle {
|
||||
border-radius: Radius.sm;
|
||||
background: fav-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/heart.svg");
|
||||
source: root.track.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
: @image-url("../assets/icons/heart.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
tint: root.track.is-favorite
|
||||
? Theme.accent
|
||||
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-muted);
|
||||
}
|
||||
fav-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PlaylistCard } from "../discover/PlaylistCard.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { SearchTrackHero } from "../primitives/SearchTrackHero.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
|
||||
@@ -430,7 +431,7 @@ export component SearchResultsView inherits Rectangle {
|
||||
}
|
||||
|
||||
// --- Scrollable results body ------------------------------------
|
||||
Flickable {
|
||||
flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
@@ -622,4 +623,18 @@ export component SearchResultsView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view). Sits
|
||||
// below the fixed header (56px nav row + 40px tab strip when shown).
|
||||
property <length> search-header-h:
|
||||
!(SearchState.loading && !root.has-results) ? 96px : 56px;
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
y: root.search-header-h;
|
||||
width: 14px;
|
||||
height: parent.height - root.search-header-h;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,9 @@ export component AppShell inherits Rectangle {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,14 @@ export struct TrackItem {
|
||||
selected: bool,
|
||||
artwork-url: string,
|
||||
artwork: image,
|
||||
// Favorite state — drives the heart fill and the optimistic toggle.
|
||||
// Populated from the user's favorite-track set when each row is built.
|
||||
is-favorite: bool,
|
||||
// Ids for the clickable artist/album links in the row. Empty = the
|
||||
// text is plain (not a link). artist-id -> artist view, album-id ->
|
||||
// album view, both via the media-action bubble channel.
|
||||
artist-id: string,
|
||||
album-id: string,
|
||||
}
|
||||
|
||||
// Album detail screen state.
|
||||
@@ -1127,6 +1135,9 @@ export global LabelState {
|
||||
in property <int> total: 0;
|
||||
in property <bool> loading: false;
|
||||
in property <bool> load-more-loading: false;
|
||||
// "grid" | "list" — the view toggle (mirrors DiscoverBrowseView).
|
||||
// UI-owned, like Discover's; no Rust round-trip needed.
|
||||
in-out property <string> view-mode: "grid";
|
||||
}
|
||||
|
||||
export global LabelActions {
|
||||
|
||||
Reference in new issue
Block a user