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:
vicrodh committed 2026-05-26 16:36:02 -06:00
1 parent a5383ba502
commit b2004e6867
24 files changed
+632 -103

No files matched your search

+23 -2
View File
@@ -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();
+12 -2
View File
@@ -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
+45
View File
@@ -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);
}
}
}
+15 -1
View File
@@ -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)));
+27 -4
View File
@@ -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()
}
}
+76 -2
View File
@@ -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);
});
}
});
}
+13
View File
@@ -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(),
}
}
+13
View File
@@ -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(),
}
}
+16 -1
View File
@@ -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(),
}
}
+12 -1
View File
@@ -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).
+18 -1
View File
@@ -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 -1
View File
@@ -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;
}
}
+74 -64
View File
@@ -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;
+60 -15
View File
@@ -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;
}
}
+3
View File
@@ -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);
}
+11
View File
@@ -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 {