feat(slint): library/sidebar UX overhaul + theme switching + fixes

Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
This commit is contained in:
vicrodh committed 2026-05-30 15:40:35 -06:00
1 parent 201082d4a1
commit 682719cab0
27 files changed
+1495 -619

No files matched your search

+8 -4
View File
@@ -630,9 +630,10 @@ fn apply_artwork(
}
ArtworkTarget::LocalFolderCard { index } => {
let model = window.global::<crate::LocalLibraryState>().get_folders();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
if let Some(item) = model.row_data(index) {
// Dual-set by id onto the full set + visible + grouped sections.
let id = item.id.to_string();
crate::local_library::set_local_folder_artwork(window, &id, image);
}
}
ArtworkTarget::LocalArtistAlbumCard { index } => {
@@ -660,8 +661,11 @@ fn apply_artwork(
ArtworkTarget::FavoriteArtist { index } => {
let model = window.global::<crate::FavoritesState>().get_artists();
if let Some(mut item) = model.row_data(index) {
item.image = image;
let id = item.id.to_string();
item.image = image.clone();
model.set_row_data(index, item);
// Also reach the rendered (visible + grouped/sidepanel) models.
crate::favorites::set_artist_image(window, &id, image);
}
}
ArtworkTarget::FavoriteLabel { index } => {
+68
View File
@@ -927,6 +927,40 @@ pub fn random_visible_artist(window: &AppWindow) -> Option<String> {
model.row_data(idx).map(|a| a.id.to_string())
}
/// A random playlist id from the currently-visible Playlists set (for the
/// Playlists "random" button — play a random playlist).
pub fn random_visible_playlist(window: &AppWindow) -> Option<String> {
let model = window.global::<FavoritesState>().get_playlists_visible();
let n = model.row_count();
if n == 0 {
return None;
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1);
let idx = (seed % n as u64) as usize;
model.row_data(idx).map(|p| p.id.to_string())
}
/// A random label (id, name) from the currently-visible Labels set (for the
/// Labels "random" button — open a random label's landing).
pub fn random_visible_label(window: &AppWindow) -> Option<(String, String)> {
let model = window.global::<FavoritesState>().get_labels_visible();
let n = model.row_count();
if n == 0 {
return None;
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1);
let idx = (seed % n as u64) as usize;
model
.row_data(idx)
.map(|l| (l.id.to_string(), l.name.to_string()))
}
// ---- Un-favorite in place: fade (set `removing`) then remove -----------
/// Flag the matching track row(s) as removing so they fade out.
@@ -1040,6 +1074,40 @@ pub fn set_album_artwork(window: &AppWindow, id: &str, image: slint::Image) {
}
}
/// Set a freshly-decoded artist photo (by id) on the rendered favorites
/// artist models (flat `artists-visible` + every `artists-grouped` section,
/// which backs both the grouped grid and the sidepanel list). Without this
/// the photo only lands on the source `artists` model and the grouped/
/// sidepanel views show it only after a re-derive (revisit).
pub fn set_artist_image(window: &AppWindow, id: &str, image: slint::Image) {
let st = window.global::<FavoritesState>();
let vis = st.get_artists_visible();
for i in 0..vis.row_count() {
if let Some(mut item) = vis.row_data(i) {
if item.id.as_str() == id {
item.image = image.clone();
vis.set_row_data(i, item);
break;
}
}
}
let grouped = st.get_artists_grouped();
for s in 0..grouped.row_count() {
if let Some(section) = grouped.row_data(s) {
let arts = section.artists;
for i in 0..arts.row_count() {
if let Some(mut item) = arts.row_data(i) {
if item.id.as_str() == id {
item.image = image.clone();
arts.set_row_data(i, item);
break;
}
}
}
}
}
}
/// Set a freshly-decoded collage cover (by id + slot) on the rendered
/// favorites playlists model (`playlists-visible`), which is a clone of the
/// active sub-tab source whenever a search filter is active.
+94 -8
View File
@@ -100,10 +100,16 @@ pub fn albums_gen_current(gen: u64) -> bool {
/// genre per-track, not per-album); the cover PATH rides on `artwork_url`
/// as the artwork-job carrier (the grid renders `artwork`, not the url).
pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard {
let tier = match a.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
// Format-first classification (mirrors Tauri): a lossy format (MP3) gets
// the dedicated MP3 badge tier, never CD.
let tier = if a.format.to_string().eq_ignore_ascii_case("mp3") {
"mp3"
} else {
match a.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
}
};
let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate);
let year = a.year.map(|y| y.to_string()).unwrap_or_default();
@@ -298,6 +304,33 @@ pub fn set_local_album_artwork(window: &AppWindow, id: &str, image: slint::Image
}
}
/// Same, for the Folders tab: full `folders` set + `folders-visible` + each
/// `folders-grouped` section. Without this the cover only lands on the source
/// `folders` model and the rendered (visible/grouped) views miss it on first
/// load (the same bug the Albums/Artists tabs had).
pub fn set_local_folder_artwork(window: &AppWindow, id: &str, image: slint::Image) {
let s = window.global::<LocalLibraryState>();
let set_in = |m: &ModelRc<AlbumCardItem>| {
for i in 0..m.row_count() {
if let Some(mut it) = m.row_data(i) {
if it.id.as_str() == id {
it.artwork = image.clone();
m.set_row_data(i, it);
break;
}
}
}
};
set_in(&s.get_folders());
set_in(&s.get_folders_visible());
let grouped = s.get_folders_grouped();
for gi in 0..grouped.row_count() {
if let Some(sec) = grouped.row_data(gi) {
set_in(&sec.albums);
}
}
}
/// Re-derive the rendered Albums sets (search + quality/format/source filter +
/// sort + group + A-Z) from the full `albums` card set, filtered by id against
/// the raw LocalAlbum cache. Mirrors `derive_folders` plus the filter.
@@ -483,6 +516,54 @@ pub fn reload_albums(
});
}
/// Seed all four tab-badge counts up front (mirrors Favorites' seeded
/// counts) so the nav shows numbers without visiting each tab. Cheap:
/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially
/// huge) tracks table. Album/artist counts match each tab's own loader
/// exactly (same `get_albums_metadata_grouped` set; same `normalize_artist`
/// grouping the rail uses), so badges never jump when a tab is opened.
pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move {
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(|| {
crate::library_db::with_db(|db| {
let albums = db
.get_albums_metadata_grouped(false, true, false)
.map(|v| v.len())
.unwrap_or(0);
let folders = db
.get_folders_with_metadata()
.map(|v| v.len())
.unwrap_or(0);
let tracks = db.count_all_local_tracks().unwrap_or(0) as usize;
// Exact rail count = distinct non-empty normalized names
// (mirrors merge_artists' grouping key).
let artists_raw = db.get_artists().unwrap_or_default();
let mut seen = std::collections::HashSet::new();
for a in &artists_raw {
let n = normalize_artist(&a.name);
if !n.is_empty() {
seen.insert(n);
}
}
Ok((albums, seen.len(), folders, tracks))
})
})
.await
.ok()
.flatten();
if let Some((albums, artists, folders, tracks)) = counts {
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<LocalLibraryState>();
s.set_album_count(albums as i32);
s.set_artist_count(artists as i32);
s.set_folder_count(folders as i32);
s.set_track_count(tracks as i32);
});
}
});
}
/// Load on first visit only (re-entry keeps the set + derived views).
pub fn ensure_albums_loaded(
weak: slint::Weak<AppWindow>,
@@ -530,10 +611,15 @@ fn fmt_duration(secs: u64) -> String {
/// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the
/// artist/album link ids are empty (the row renders them as plain text).
fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
let tier = match t.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
// Format-first classification (mirrors Tauri): MP3/lossy → MP3 tier.
let tier = if t.format.to_string().eq_ignore_ascii_case("mp3") {
"mp3"
} else {
match t.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
}
};
TrackItem {
id: t.id.to_string().into(),
+55 -8
View File
@@ -941,6 +941,9 @@ fn navigate_local_library(
w.global::<LocalLibraryState>().set_active_tab(tab_id.into());
w.global::<NavState>().set_view(ContentView::LocalLibrary);
});
// Seed all four tab-badge counts up front (like Favorites) so the nav
// badges show without visiting each tab.
local_library::seed_counts(weak.clone(), handle.clone());
// Lazy per-tab load on first visit.
match tab {
local_library::LibTab::Albums => {
@@ -2846,14 +2849,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
("playlist-track", track_id) => {
let idx = playlist::index_of(track_id);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let tracks = playlist::current_tracks();
playback::play_tracks(runtime, weak, handle, tracks, idx);
});
// Queue the VISIBLE order starting at the clicked track, so
// the tracks that follow it (not track 1) play next.
if let Some(w) = weak.upgrade() {
let (tracks, idx) = playlist::visible_play_context(&w, track_id);
playback::play_tracks(
runtime.clone(),
weak.clone(),
handle.clone(),
tracks,
idx,
);
}
}
_ => {}
}
@@ -5071,6 +5078,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Populate the collapsed-sidebar folder flyout's playlist list.
let weak = window.as_weak();
window
.global::<SidebarActions>()
.on_load_folder_popup(move |folder_id| {
if let Some(w) = weak.upgrade() {
sidebar::load_folder_popup(&w, folder_id.as_str());
}
});
}
{
let weak = window.as_weak();
window
@@ -5603,6 +5621,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Playlists "random" — play a random visible playlist (reuses the
// playlist-action "play" path).
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_playlists_random(move || {
if let Some(w) = weak.upgrade() {
if let Some(id) = favorites::random_visible_playlist(&w) {
w.global::<FavoritesActions>()
.invoke_playlist_action(id.into(), "play".into());
}
}
});
}
{
// Labels "random" — open a random visible label's landing.
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_labels_random(move || {
if let Some(w) = weak.upgrade() {
if let Some((id, name)) = favorites::random_visible_label(&w) {
w.global::<FavoritesActions>()
.invoke_open_label(id.into(), name.into());
}
}
});
}
{
// Group the favorite artists (off / A-Z) — persisted.
let weak = window.as_weak();
+31
View File
@@ -497,6 +497,37 @@ pub fn index_of(track_id: &str) -> usize {
.unwrap_or(0)
}
/// Build the play queue + start index from the CURRENTLY VISIBLE order
/// (search/sort applied — read straight off `PlaylistState.tracks`), starting
/// at `track_id`. This makes clicking a row begin playback there and queue the
/// tracks that VISUALLY follow it — the general app behaviour. Falls back to
/// the cached order if the visible/cache mapping comes up empty.
pub fn visible_play_context(window: &AppWindow, track_id: &str) -> (Vec<Track>, usize) {
use slint::Model;
let visible_ids: Vec<String> = {
let m = window.global::<PlaylistState>().get_tracks();
(0..m.row_count())
.filter_map(|i| m.row_data(i).map(|it| it.id.to_string()))
.collect()
};
let cur = CURRENT.lock().map(|c| c.clone()).unwrap_or_default();
let by_id: std::collections::HashMap<String, Track> =
cur.iter().map(|t| (t.id.to_string(), t.clone())).collect();
let ordered: Vec<Track> = visible_ids
.iter()
.filter_map(|id| by_id.get(id).cloned())
.collect();
if ordered.is_empty() {
// Cache/visible mismatch — fall back to the cached order.
return (cur, index_of(track_id));
}
let idx = visible_ids
.iter()
.position(|id| id == track_id)
.unwrap_or(0);
(ordered, idx)
}
// ==================== Custom artwork ====================
/// Copy `src` into the artwork cache and store it as this playlist's
+23 -1
View File
@@ -12,7 +12,7 @@ use qbz_core::FrontendAdapter;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::folders::FolderInfo;
use crate::{AppWindow, SidebarEntry, SidebarPlaylistItem, SidebarState};
use crate::{AppWindow, SidebarEntry, SidebarFolderPopupState, SidebarPlaylistItem, SidebarState};
#[derive(Clone)]
pub struct SidebarPlaylist {
@@ -238,6 +238,28 @@ fn playlist_entry(p: &SidebarPlaylist, indent: bool, folder_id: &str) -> Sidebar
}
}
/// Populate the collapsed-sidebar folder flyout with `folder_id`'s playlists,
/// built from the cache so it works even for collapsed folders (whose
/// children are absent from the flattened `entries`).
pub fn load_folder_popup(window: &AppWindow, folder_id: &str) {
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
let sorted = sort_playlists(&data.playlists);
let entries: Vec<SidebarEntry> = sorted
.iter()
.filter(|p| {
data.folder_map
.get(&p.id)
.map(|f| f.as_str() == folder_id)
.unwrap_or(false)
})
.filter(|p| !data.hidden_playlists.contains(&p.id))
.map(|p| playlist_entry(p, true, folder_id))
.collect();
window
.global::<SidebarFolderPopupState>()
.set_playlists(ModelRc::new(VecModel::from(entries)));
}
/// Rebuild the flattened entries (+ the folders list for the
/// move-to-folder menu) from the cache + expand state, applying the
/// active sort and the playlist-name search filter.
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
+136 -200
View File
@@ -35,103 +35,24 @@ import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListView } from "std-widgets.slint";
// One tab in the favorites tab menu.
component FavTab inherits Rectangle {
in property <string> label;
in property <string> id;
in property <int> count;
callback selected(string);
property <bool> active: FavoritesState.active-tab == root.id;
height: 34px;
width: tab-row.preferred-width;
tab-row := HorizontalLayout {
padding-left: 4px;
padding-right: 4px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
}
if root.count > 0: Text {
text: root.count;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
}
// Active underline.
Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: root.active ? Theme.accent : transparent;
border-radius: 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.selected(root.id);
}
}
}
// The 5 favorites tabs as one floating group (row 1, right side). Counts
// stay live (FavoritesState.*-total are seeded up front).
component FavTabMenu inherits HorizontalLayout {
spacing: 18px;
VerticalLayout {
alignment: center;
FavTab {
label: @tr("Tracks");
id: "tracks";
count: FavoritesState.tracks-total;
selected(id) => { FavoritesActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
FavTab {
label: @tr("Albums");
id: "albums";
count: FavoritesState.albums-total;
selected(id) => { FavoritesActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
FavTab {
label: @tr("Artists");
id: "artists";
count: FavoritesState.artists-total;
selected(id) => { FavoritesActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
FavTab {
label: @tr("Playlists");
id: "playlists";
count: FavoritesState.playlists-total;
selected(id) => { FavoritesActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
FavTab {
label: @tr("Labels");
id: "labels";
count: FavoritesState.labels-total;
selected(id) => { FavoritesActions.select-tab(id); }
}
}
// The 5 favorites tabs as one floating group (row 1, right side) — the
// shared Discovery-style SegmentedTabBar with count badges. Counts stay
// live (FavoritesState.*-total are seeded up front).
component FavTabMenu inherits SegmentedTabBar {
active-id: FavoritesState.active-tab;
tabs: [
{ id: "tracks", label: @tr("Tracks"), count: FavoritesState.tracks-total },
{ id: "albums", label: @tr("Albums"), count: FavoritesState.albums-total },
{ id: "artists", label: @tr("Artists"), count: FavoritesState.artists-total },
{ id: "playlists", label: @tr("Playlists"), count: FavoritesState.playlists-total },
{ id: "labels", label: @tr("Labels"), count: FavoritesState.labels-total },
];
selected(id) => { FavoritesActions.select-tab(id); }
}
// One underline sub-tab for the Playlists tab (Library / Following).
@@ -619,10 +540,6 @@ export component FavoritesView inherits Rectangle {
padding-right: 32px;
padding-top: 11px;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
VerticalLayout {
alignment: center;
Text {
@@ -632,11 +549,84 @@ export component FavoritesView inherits Rectangle {
font-weight: Typography.bold;
}
}
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
FavTabMenu { }
// Reserve the action-button height from the start so the header
// doesn't jump when the (conditional) buttons appear after load.
Rectangle { width: 0px; height: 40px; }
// Per-tab action buttons — inline, to the right of the title.
if FavoritesState.active-tab == "tracks"
&& FavoritesState.tracks-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
clicked => { FavoritesActions.play-all-tracks(); }
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => { FavoritesActions.shuffle-tracks(); }
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: FavoritesState.tracks-multi-select;
clicked => { FavoritesActions.toggle-multi-select(); }
}
}
}
if FavoritesState.active-tab == "albums"
&& FavoritesState.albums-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => { FavoritesActions.albums-shuffle(); }
}
}
}
if FavoritesState.active-tab == "artists"
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => { FavoritesActions.artists-shuffle(); }
}
}
}
// Playlists — play a random playlist.
if FavoritesState.active-tab == "playlists"
&& FavoritesState.playlists-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => { FavoritesActions.playlists-random(); }
}
}
}
// Labels — open a random label's landing.
if FavoritesState.active-tab == "labels"
&& FavoritesState.labels-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => { FavoritesActions.labels-random(); }
}
}
}
Rectangle { horizontal-stretch: 1; }
}
Rectangle { height: 12px; }
@@ -654,65 +644,10 @@ export component FavoritesView inherits Rectangle {
padding-right: 32px;
spacing: 8px;
// --- Left: action buttons ---
if FavoritesState.active-tab == "tracks"
&& FavoritesState.tracks-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
clicked => {
FavoritesActions.play-all-tracks();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
FavoritesActions.shuffle-tracks();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: FavoritesState.tracks-multi-select;
clicked => {
FavoritesActions.toggle-multi-select();
}
}
}
}
if FavoritesState.active-tab == "albums"
&& FavoritesState.albums-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
FavoritesActions.albums-shuffle();
}
}
}
}
if FavoritesState.active-tab == "artists"
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
FavoritesActions.artists-shuffle();
}
}
}
// --- Left: the section tab menu, sitting under the title ---
VerticalLayout {
alignment: center;
FavTabMenu { }
}
Rectangle { horizontal-stretch: 1; }
@@ -886,22 +821,8 @@ export component FavoritesView inherits Rectangle {
}
}
}
// Grid <-> sidepanel view toggle.
VerticalLayout {
alignment: center;
ToggleButton {
icon: FavoritesState.artists-view-mode == "sidepanel"
? @image-url("../assets/icons/layout-grid.svg")
: @image-url("../assets/icons/list.svg");
active: FavoritesState.artists-view-mode == "sidepanel";
sm: true;
clicked => {
FavoritesActions.artists-set-view(
FavoritesState.artists-view-mode == "grid" ? "sidepanel" : "grid");
}
}
}
// Group (A-Z) — grid mode only (Tauri hides it in sidepanel).
// Sits between the search field and the view toggle.
if FavoritesState.artists-view-mode == "grid": VerticalLayout {
alignment: center;
QbzSelect {
@@ -917,6 +838,22 @@ export component FavoritesView inherits Rectangle {
}
}
}
// Grid <-> sidepanel view toggle. active:false keeps the two
// icon states visually identical (homologated background).
VerticalLayout {
alignment: center;
ToggleButton {
icon: FavoritesState.artists-view-mode == "sidepanel"
? @image-url("../assets/icons/layout-grid.svg")
: @image-url("../assets/icons/list.svg");
active: false;
sm: true;
clicked => {
FavoritesActions.artists-set-view(
FavoritesState.artists-view-mode == "grid" ? "sidepanel" : "grid");
}
}
}
}
}
@@ -985,7 +922,9 @@ export component FavoritesView inherits Rectangle {
// --- Tracks tab — virtualized self-scrolling list --------------
if !FavoritesState.loading && FavoritesState.load-error == ""
&& FavoritesState.active-tab == "tracks": VerticalLayout {
&& FavoritesState.active-tab == "tracks": Rectangle {
vertical-stretch: 1;
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
@@ -1021,12 +960,9 @@ export component FavoritesView inherits Rectangle {
font-size: Typography.body;
}
Rectangle {
track-list := ListView {
vertical-stretch: 1;
track-list := ListView {
width: 100%;
height: 100%;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
for track[i] in FavoritesState.tracks-visible: VerticalLayout {
spacing: 0px;
// Group header rows when grouping by album / artist.
@@ -1082,25 +1018,25 @@ export component FavoritesView inherits Rectangle {
}
}
}
ListScrollbar {
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;
}
// A-Z jump strip (name grouping) — accurate (uniform rows).
if FavoritesState.tracks-group-mode == "name"
&& FavoritesState.tracks-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 20px;
height: 100%;
jumps: FavoritesState.tracks-alpha;
jump(ord, idx) => {
track-list.viewport-y = -Math.min(
idx * 50px / 1px,
Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px;
}
}
ListScrollbar {
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;
}
// A-Z jump strip (name grouping) — accurate (uniform rows).
if FavoritesState.tracks-group-mode == "name"
&& FavoritesState.tracks-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 20px;
height: 100%;
jumps: FavoritesState.tracks-alpha;
jump(ord, idx) => {
track-list.viewport-y = -Math.min(
idx * 50px / 1px,
Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px;
}
}
}
+3 -2
View File
@@ -1,8 +1,9 @@
// Shell layout metrics.
export global Layout {
out property <length> sidebar-width: 280px;
out property <length> header-height: 48px;
// Max sidebar width (also the default). The drag handle clamps to this.
out property <length> sidebar-width: 260px;
out property <length> header-height: 42px;
out property <length> player-bar-height: 110px;
out property <length> hairline: 1px;
}
@@ -1,28 +1,69 @@
// Semantic color tokens — the contract every component consumes.
//
// Currently bound to the QBZ dark theme. When theme switching lands
// (later in M1), `Theme` becomes the surface that swaps palettes; the
// token names stay stable so components never change.
// `Theme` is the surface that swaps palettes: every token is conditional on
// ThemeState.mode (0 = Dark, 1 = OLED Black, 2 = Tokyo Night, 3 = System).
// The System mode sources colors from the std-widgets Palette, which follows
// the OS light/dark scheme. Token names stay stable so components never
// change. The fixed values mirror src/app.css.
import { Palette } from "palette.slint";
import { Palette as StdPalette } from "std-widgets.slint";
import { ThemeState } from "theme-state.slint";
export global Theme {
// Surfaces
out property <color> surface-main: Palette.black-0;
out property <color> surface-card: Palette.black-1;
out property <color> surface-elevated: Palette.black-2;
// Surfaces (--bg-primary / --bg-secondary / --bg-tertiary)
out property <color> surface-main:
ThemeState.mode == 1 ? #000000
: ThemeState.mode == 2 ? #1a1b26
: ThemeState.mode == 3 ? StdPalette.background
: Palette.black-0;
out property <color> surface-card:
ThemeState.mode == 1 ? #0a0a0a
: ThemeState.mode == 2 ? #16161e
: ThemeState.mode == 3 ? StdPalette.alternate-background
: Palette.black-1;
out property <color> surface-elevated:
ThemeState.mode == 1 ? #1a1a1a
: ThemeState.mode == 2 ? #1c1d29
: ThemeState.mode == 3 ? StdPalette.control-background
: Palette.black-2;
// Text
out property <color> text-primary: Palette.white;
out property <color> text-secondary: Palette.gray-c;
out property <color> text-muted: Palette.gray-8;
out property <color> text-disabled: Palette.gray-5;
// Text (Tokyo Night recolors; OLED keeps the default dark text; System
// derives muted tiers from the OS foreground via alpha)
out property <color> text-primary:
ThemeState.mode == 2 ? #a9b1d6
: ThemeState.mode == 3 ? StdPalette.foreground
: Palette.white;
out property <color> text-secondary:
ThemeState.mode == 2 ? #787c99
: ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.78)
: Palette.gray-c;
out property <color> text-muted:
ThemeState.mode == 2 ? #545c7e
: ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.55)
: Palette.gray-8;
out property <color> text-disabled:
ThemeState.mode == 2 ? #3d425e
: ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.35)
: Palette.gray-5;
// Accent
out property <color> accent: Palette.blue;
out property <color> accent-hover: Palette.blue-hover;
out property <color> accent-pressed: Palette.blue-active;
out property <color> accent-text: Palette.white;
// Accent (--accent-primary / hover / active / btn-primary-text)
out property <color> accent:
ThemeState.mode == 2 ? #7aa2f7
: ThemeState.mode == 3 ? StdPalette.accent-background
: Palette.blue;
out property <color> accent-hover:
ThemeState.mode == 2 ? #7dcfff
: ThemeState.mode == 3 ? StdPalette.accent-background.brighter(0.15)
: Palette.blue-hover;
out property <color> accent-pressed:
ThemeState.mode == 2 ? #bb9af7
: ThemeState.mode == 3 ? StdPalette.accent-background.darker(0.15)
: Palette.blue-active;
out property <color> accent-text:
ThemeState.mode == 2 ? #1a1b26
: ThemeState.mode == 3 ? StdPalette.accent-foreground
: Palette.white;
// Favorite (heart) — the active "loved" state.
out property <color> favorite: Palette.danger;
@@ -0,0 +1,11 @@
// Active theme mode — the single switch that swaps the semantic palette.
// Kept in foundation (not state.slint) so semantic-colors.slint can read it
// without a circular import. Set from Settings > Appearance > Theme.
//
// 0 = Dark (default) 1 = OLED Black 2 = Tokyo Night 3 = System
//
// "System" (3) sources colors from the std-widgets Palette (OS light/dark).
// The Appearance dropdown lists only these ported themes.
export global ThemeState {
in-out property <int> mode: 0;
}
@@ -29,95 +29,23 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint";
import { ListView } from "std-widgets.slint";
// One tab in the Local Library tab menu — underline style (ADR-008: NO
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
component LibTab inherits Rectangle {
in property <string> label;
in property <string> id;
in property <int> count;
callback selected(string);
property <bool> active: LocalLibraryState.active-tab == root.id;
height: 34px;
width: tab-row.preferred-width;
tab-row := HorizontalLayout {
padding-left: 4px;
padding-right: 4px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
}
if root.count > 0: Text {
text: root.count;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
}
// Active underline (NOT a pill background).
Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: root.active ? Theme.accent : transparent;
border-radius: 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.selected(root.id);
}
}
}
// The Local Library tabs as one floating group (row 1, right side). Order
// mirrors the existing HeaderBar "Local Library" menu (Albums / Artists /
// Folders / Tracks). Counts come from LocalLibraryState (0 until seeded).
component LibTabMenu inherits HorizontalLayout {
spacing: 18px;
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Albums");
id: "albums";
count: LocalLibraryState.album-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Artists");
id: "artists";
count: LocalLibraryState.artist-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Folders");
id: "folders";
count: LocalLibraryState.folder-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Tracks");
id: "tracks";
count: LocalLibraryState.track-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
// The Local Library tabs as one floating group (row 1, right side) — now
// the shared Discovery-style SegmentedTabBar, counts rendered as badges.
// Order mirrors the existing HeaderBar "Local Library" menu (Albums /
// Artists / Folders / Tracks). Counts come from LocalLibraryState (seeded
// up front so the badges show without visiting each tab).
component LibTabMenu inherits SegmentedTabBar {
active-id: LocalLibraryState.active-tab;
tabs: [
{ id: "albums", label: @tr("Albums"), count: LocalLibraryState.album-count },
{ id: "artists", label: @tr("Artists"), count: LocalLibraryState.artist-count },
{ id: "folders", label: @tr("Folders"), count: LocalLibraryState.folder-count },
{ id: "tracks", label: @tr("Tracks"), count: LocalLibraryState.track-count },
];
selected(id) => { LocalLibraryActions.select-tab(id); }
}
// Standalone icon-only circle (ADR-008 permits a full circle for an
@@ -445,10 +373,6 @@ export component LocalLibraryView inherits Rectangle {
padding-right: 32px;
padding-top: 11px;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
VerticalLayout {
alignment: center;
Text {
@@ -458,11 +382,7 @@ export component LocalLibraryView inherits Rectangle {
font-weight: Typography.bold;
}
}
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
LibTabMenu { }
}
// Settings gear — inline, to the right of the title.
VerticalLayout {
alignment: center;
CircleIconBtn {
@@ -472,6 +392,7 @@ export component LocalLibraryView inherits Rectangle {
}
}
}
Rectangle { horizontal-stretch: 1; }
}
Rectangle { height: 12px; }
@@ -486,6 +407,12 @@ export component LocalLibraryView inherits Rectangle {
padding-right: 32px;
spacing: 8px;
// The section tab menu sits under the title (left of row 2).
VerticalLayout {
alignment: center;
LibTabMenu { }
}
Rectangle { horizontal-stretch: 1; }
// Albums toolbar — search + sort (both server-side). The
@@ -266,18 +266,25 @@ export component AlbumListRow inherits Rectangle {
overflow: elide;
}
// --- SOURCE (hideable) ----------------------------------------
if root.show-source: Text {
// --- SOURCE (hideable) — discreet per-source glyph, NOT text. ---
// Mirrors TrackRow's source indicator: local -> tinted hard-drive,
// qobuz/offline + plex -> their own mark; small + flat (the bold
// colored badge is reserved for the AlbumCard grid).
if root.show-source: Rectangle {
width: AlbumListCols.source;
text: root.album.source == "qobuz" ? @tr("Qobuz")
: root.album.source == "qobuz_download" ? @tr("Offline")
: root.album.source == "plex" ? @tr("Plex")
: root.album.source == "local" ? @tr("Local")
: "";
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
if root.album.source != "": Image {
source: root.album.source == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: (root.album.source == "qobuz" || root.album.source == "qobuz_download"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: @image-url("../assets/icons/hard-drive.svg"));
colorize: root.album.source == "local" ? Theme.text-muted : transparent;
width: root.album.source == "local" ? 15px : 16px;
height: self.width;
x: 0;
y: (parent.height - self.height) / 2;
image-fit: contain;
}
}
// --- QUALITY (shared QualityBadgeFull) ------------------------
@@ -52,15 +52,18 @@ export component QbzSelect inherits Rectangle {
in property <bool> sm: false;
callback selected(int);
// Toolbar (sm) selects — Group / Sort / filter — were unnecessarily wide;
// trim 20px from the effective width (button + list).
private property <length> eff-width: root.menu-width - (root.sm ? 20px : 0px);
// Resolved width of the open list.
private property <length> list-width:
root.popup-width > root.menu-width ? root.popup-width : root.menu-width;
root.popup-width > root.eff-width ? root.popup-width : root.eff-width;
// Height of one option row and one section-header row.
private property <length> row-height: 32px;
private property <length> header-height: 24px;
width: root.menu-width;
width: root.eff-width;
height: root.sm ? 30px : 34px;
// sm matches the favorites toolbar buttons (6px, no outline); the
// standard size keeps the settings-style bordered look.
@@ -113,7 +116,7 @@ export component QbzSelect inherits Rectangle {
popup := PopupWindow {
// Right-anchored so a list wider than the control grows leftward
// and stays on-screen.
x: root.menu-width - root.list-width;
x: root.eff-width - root.list-width;
y: root.height + 4px;
width: root.list-width;
height: list-layout.preferred-height;
@@ -10,7 +10,7 @@ import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "QbzIcon.slint";
export component QualityBadge inherits Rectangle {
// "hires" | "cd" | "" — empty hides the badge entirely.
// "hires" | "cd" | "mp3" | "" — empty hides the badge entirely.
in property <string> tier;
// Exact quality string for the hover tooltip.
in property <string> label;
@@ -45,6 +45,23 @@ export component QualityBadge inherits Rectangle {
tint: #b0b0b0;
}
}
if root.tier == "mp3": Rectangle {
width: 30px;
height: 30px;
border-radius: 3px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
QbzIcon {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 16px;
height: 16px;
source: @image-url("../assets/mp3.svg");
image-fit: contain;
tint: #b0b0b0;
}
}
// Hover tooltip — floats just above the badge, right-anchored so it
// extends leftward into the card and is never clipped by the next card.
@@ -0,0 +1,95 @@
// Segmented tab navigator — the SAME design Discovery's HomeView uses (a
// small-radius rectangle group, ADR-008-compliant: NOT a capsule pill),
// promoted to a shared, data-driven primitive so Local Library and Library
// (Favorites) read identically. Each tab carries an optional count rendered
// as a compact badge with smaller-but-legible type inside the tab item.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
// One entry in the navigator. `count <= 0` hides the badge.
export struct SegTab {
id: string,
label: string,
count: int,
}
// A single segment: fills with surface-main when active (Discovery's Tab),
// hover surface-hover, transparent otherwise. The count sits in a rounded
// badge to the right of the label.
component Segment inherits Rectangle {
in property <string> label;
in property <int> count;
in property <bool> active;
callback clicked();
border-radius: 4px;
background: root.active ? Theme.surface-main
: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: root.count > 0 ? 8px : 12px;
padding-top: 6px;
padding-bottom: 6px;
spacing: 7px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
// Discovery's tab type: 13px medium.
font-size: Typography.legal;
font-weight: Typography.medium;
vertical-alignment: center;
}
// Count badge — smaller (11px) but legible. min-width keeps single
// digits balanced; the layout grows the chip for 2–3 digit counts.
if root.count > 0: VerticalLayout {
alignment: center;
Rectangle {
min-width: 18px;
height: 16px;
border-radius: 8px;
background: root.active ? #ffffff24 : #ffffff14;
HorizontalLayout {
alignment: center;
padding-left: 5px;
padding-right: 5px;
Text {
text: root.count;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: 11px;
font-weight: Typography.medium;
vertical-alignment: center;
}
}
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
export component SegmentedTabBar inherits Rectangle {
in property <[SegTab]> tabs;
in property <string> active-id;
callback selected(string);
width: row.preferred-width;
height: row.preferred-height;
background: Theme.surface-elevated;
border-radius: 6px;
row := HorizontalLayout {
padding: 3px;
spacing: 4px;
for tab in root.tabs: Segment {
label: tab.label;
count: tab.count;
active: root.active-id == tab.id;
clicked => { root.selected(tab.id); }
}
}
}
@@ -11,9 +11,10 @@
// flags Tauri gates on (System theme, title-bar mode, etc.).
import { Theme } from "../foundation/semantic-colors.slint";
import { ThemeState } from "../foundation/theme-state.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { AppearanceState, SidebarState } from "../state.slint";
import { AppearanceState, SidebarState, ShellState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
@@ -128,7 +129,6 @@ export component AppearanceSettings inherits VerticalLayout {
GroupHeader { text: @tr("THEME"); }
SettingRow {
unsupported: true;
label: @tr("Theme");
HorizontalLayout {
spacing: 8px;
@@ -164,6 +164,10 @@ export component AppearanceSettings inherits VerticalLayout {
current-index: AppearanceState.theme-index;
selected(i) => {
AppearanceState.theme-index = i;
// The themes list is trimmed to the ported set, so the
// dropdown index maps 1:1 to the mode (0 Dark, 1 OLED,
// 2 Tokyo Night, 3 System).
ThemeState.mode = i;
AppearanceState.appearance-select("theme", i);
}
}
@@ -284,6 +288,16 @@ export component AppearanceSettings inherits VerticalLayout {
// ===================================================================
GroupHeader { text: @tr("LIBRARY & VISUALS"); }
SettingRow {
label: @tr("Show navigation in sidebar");
description: @tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar.");
QbzToggle {
checked: ShellState.nav-in-sidebar;
toggled(v) => {
ShellState.nav-in-sidebar = v;
}
}
}
SettingRow {
label: @tr("Show playlist cover collages in sidebar");
description: @tr("Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images.");
@@ -632,6 +646,16 @@ export component AppearanceSettings inherits VerticalLayout {
// ===================================================================
GroupHeader { text: @tr("PLAYER VIEWS"); }
SettingRow {
label: @tr("Show volume +/- buttons");
description: @tr("Add discrete plus and minus buttons next to the volume slider in the player bar.");
QbzToggle {
checked: AppearanceState.show-volume-steppers;
toggled(v) => {
AppearanceState.show-volume-steppers = v;
}
}
}
SettingRow {
unsupported: true;
label: @tr("Mini player default view");
+32 -3
View File
@@ -6,10 +6,12 @@
// content well); no hairline borders.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ShellState } from "../state.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
import { SidebarFolderPopup } from "SidebarFolderPopup.slint";
import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
@@ -72,6 +74,7 @@ export component AppShell inherits Rectangle {
VerticalLayout {
spacing: 0px;
// Header spans the full width at the top; the sidebar starts below it.
HeaderBar {
logout => {
root.logout();
@@ -85,12 +88,33 @@ export component AppShell inherits Rectangle {
vertical-stretch: 1;
spacing: 0px;
Sidebar { }
Sidebar {
navigate(route) => {
root.header-menu-navigate(route);
}
}
// Content region — Home, Album, or Artist.
// Content frame — surface-card chrome around a rounded, inset
// content panel (Slack-style bezel on all four corners).
Rectangle {
horizontal-stretch: 1;
background: Theme.surface-main;
background: Theme.surface-card;
VerticalLayout {
vertical-stretch: 1;
padding-left: 8px;
padding-right: 8px;
// No top padding: the contiguous surface-card gap below the
// header made the items look high (the perceived header
// band was taller than its real height). The panel now
// butts the header; its rounded top corners still bezel.
padding-top: 0px;
padding-bottom: 8px;
Rectangle {
vertical-stretch: 1;
background: Theme.surface-main;
border-radius: Radius.md;
clip: true;
if NavState.view == ContentView.home: HomeView {
album-clicked(id) => {
@@ -240,6 +264,8 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action);
}
}
}
}
}
// Right-side panels — layout siblings of the content area, so
@@ -269,6 +295,9 @@ export component AppShell inherits Rectangle {
}
}
// Collapsed-sidebar folder flyout (mini state). Drawn above the content.
SidebarFolderPopup { }
// Global "Add to playlist" picker overlay, opened from any track
// context menu.
PlaylistPickerModal { }
+227 -91
View File
@@ -16,32 +16,6 @@ import { Radius } from "../foundation/radius.slint";
import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
// Sidebar collapse toggle — bare bright icon, no background. Sits at
// the very left of the header, before the Discover tab.
component SidebarToggle inherits Rectangle {
width: 30px;
height: 30px;
// 14px matches the nav-tab icons; with the 30px box the centering
// math ((30-14)/2 = 8) lands on whole pixels so the glyph stays
// crisp. Tint matches the tab icons (secondary, primary on hover).
QbzIcon {
source: @image-url("../assets/icons/panel-left.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed;
}
}
}
component NavTab inherits Rectangle {
in property <image> icon;
in property <string> label;
@@ -200,6 +174,91 @@ component NavTabWithMenu inherits Rectangle {
}
}
// Compact icon-only nav button shown in the header ONLY while the sidebar is
// fully closed (Cerrado), so the section nav stays reachable. Opens a downward
// dropdown headed by the section name (label + hairline + the clickable rows).
component CompactNavBtn inherits Rectangle {
in property <image> icon;
in property <string> title;
in property <[NavMenuEntry]> items;
in property <int> tab-index;
in property <int> home-item: -1;
width: 30px;
height: 30px;
border-radius: Radius.sm;
property <bool> active: HeaderMenuState.open-index == root.tab-index;
background: root.active ? Theme.surface-elevated
: cn-ta.has-hover ? Theme.surface-hover : transparent;
function open-menu() {
HeaderMenuState.open-index = root.tab-index;
HeaderMenuState.anchor-x = root.absolute-position.x;
HeaderMenuState.from-sidebar = false;
HeaderMenuState.title = root.title;
HeaderMenuState.items = root.items;
HeaderMenuState.home-item = root.home-item;
}
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
cn-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.open-menu(); }
changed has-hover => {
if (self.has-hover) {
HeaderMenuState.trigger-hovered = true;
root.open-menu();
} else if (root.active) {
// Only the trigger owning the open menu clears the flag — this
// avoids a leave/enter race when sliding between triggers.
HeaderMenuState.trigger-hovered = false;
}
}
}
}
// Circular history / collapse button for the header's right cluster —
// same look as the Discover carousel navigators. `enabled` dims + disables.
component HeaderNavBtn inherits Rectangle {
in property <image> icon;
in property <bool> enabled: true;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
opacity: root.enabled ? 1.0 : 0.4;
background: hn-ta.has-hover && root.enabled
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
hn-ta := TouchArea {
enabled: root.enabled;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// Square icon button for the header's right-side controls. `active`
// gives it a highlighted look when its associated panel is open.
component HeaderIconButton inherits Rectangle {
@@ -254,10 +313,12 @@ export component HeaderBar inherits Rectangle {
y: (root.height - self.height) / 2;
height: 30px;
spacing: 2px;
VerticalLayout {
alignment: center;
SidebarToggle { }
}
// Section nav lives in the header by default; when the user moves it
// to the sidebar (ShellState.nav-in-sidebar) the header tabs hide.
// The sidebar-collapse toggle no longer lives here — it moved next to
// the back/forward nav buttons (NavButtons), present in every view.
if !ShellState.nav-in-sidebar: HorizontalLayout {
spacing: 2px;
NavTabWithMenu {
tab-index: 0;
icon: @image-url("../assets/icons/compass.svg");
@@ -308,6 +369,7 @@ export component HeaderBar inherits Rectangle {
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
];
}
}
}
// --- Search (absolutely centered on the window) ------------------------
@@ -320,81 +382,155 @@ export component HeaderBar inherits Rectangle {
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 7px;
// Magnifier — absolutely positioned on the left so it does NOT take
// layout space; the text/placeholder center against the full input
// width as if the icon weren't there.
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
x: 10px;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
// Full-width input — text centered against the whole box.
search-input := TextInput {
width: parent.width;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
single-line: true;
// Enter submits to the full results page; every edit feeds the
// debounced live search (Rust side).
accepted => { SearchActions.submit(self.text); }
edited => { SearchActions.live(self.text); }
}
Text {
visible: search-input.text == "";
text: @tr("Search");
width: parent.width;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
}
}
// --- Left controls: history nav + collapse (anchored left) -------------
// The three navigation buttons (back / forward / sidebar-collapse) float
// at the header's LEFT edge.
left-controls := HorizontalLayout {
x: Spacing.md;
y: (root.height - self.height) / 2;
width: self.preferred-width;
height: 36px;
spacing: 6px;
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/panel-left.svg");
clicked => { ShellState.cycle-sidebar(); }
}
}
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: NavState.can-back;
clicked => { NavState.request-back(); }
}
}
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: NavState.can-forward;
clicked => { NavState.request-forward(); }
}
}
// Compact section nav — only while the sidebar is fully closed
// (Cerrado), so Discover / Library / Local Library / My QBZ stay
// reachable. Icon-only; each opens a titled downward dropdown.
if ShellState.sidebar-state == 2: HorizontalLayout {
spacing: 2px;
// A small gap from the history buttons.
Rectangle { width: 6px; }
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
CompactNavBtn {
tab-index: 0;
title: @tr("Discover");
icon: @image-url("../assets/icons/compass.svg");
home-item: 0;
items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
}
}
// Real text input, with a placeholder shown while empty —
// same pattern as the album track-search field.
Rectangle {
horizontal-stretch: 1;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
// Enter submits to the full results page; every edit
// feeds the debounced live search (Rust side).
accepted => { SearchActions.submit(self.text); }
edited => { SearchActions.live(self.text); }
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 1;
title: @tr("Library");
icon: @image-url("../assets/icons/music-library-2.svg");
items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
Text {
visible: search-input.text == "";
text: @tr("Search");
// Span the input box so vertical-alignment centers the
// placeholder the same way the TextInput centers its
// own text — otherwise it rides at the top.
width: 100%;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 2;
title: @tr("Local Library");
icon: @image-url("../assets/icons/hard-drive.svg");
items: [
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
}
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 3;
title: @tr("My QBZ");
icon: @image-url("../assets/icons/cassette-tape.svg");
items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
];
}
}
}
}
// --- Right controls: queue, lyrics, hamburger (anchored right) ---------
// Explicit width (3 * 36 + 2 * 2 spacing) so the right anchor lands
// correctly — a layout left to its own devices fills the parent.
// --- Right controls: app menu (anchored right) -------------------------
right-controls := HorizontalLayout {
// 2px tighter to the window edge than the standard margin.
x: root.width - self.width - Spacing.md + 2px;
y: (root.height - self.height) / 2;
width: 112px;
width: 36px;
height: 36px;
spacing: 2px;
// Lyrics panel toggle.
HeaderIconButton {
icon: @image-url("../assets/icons/captions.svg");
active: ShellState.right-panel == 2;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
}
}
// Queue panel toggle.
HeaderIconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
HeaderIconButton {
icon: @image-url("../assets/icons/menu.svg");
clicked => {
menu.show();
VerticalLayout {
alignment: center;
HeaderIconButton {
icon: @image-url("../assets/icons/menu.svg");
clicked => {
menu.show();
}
}
}
}
@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { Layout } from "../foundation/layout.slint";
import { HeaderMenuState, NavState, ContentView } from "../state.slint";
import { HeaderMenuState, NavState, ContentView, ShellState } from "../state.slint";
import { NavMenuItem } from "NavMenuItem.slint";
export component HeaderMenuOverlay inherits Rectangle {
@@ -30,38 +30,40 @@ export component HeaderMenuOverlay inherits Rectangle {
// to the shell so Rust can run the navigation + data load.
callback navigate(string);
// (Re)arms the idle countdown — called on any pointer activity over
// the panel or its rows.
function reset-idle() {
idle-timer.restart();
}
// True while the pointer rests on the panel body (set by panel-ta, which
// lives inside the conditional panel and so can't be referenced directly).
property <bool> panel-hovered: false;
// Which row is hovered (-1 = none). Rows don't overlap, so one at a time.
property <int> hovered-row: -1;
// True while the pointer rests anywhere on the menu (panel body OR a row).
// The idle timer is paused while this holds, so resting the cursor on the
// menu — even without moving it — never lets the menu close.
property <bool> menu-hovered:
root.panel-hovered || root.hovered-row >= 0 || HeaderMenuState.trigger-hovered;
// Closes the menu and disarms the timer.
// Closes the menu.
function close-menu() {
HeaderMenuState.open-index = -1;
idle-timer.running = false;
}
// Restart the idle countdown every time a tab (re)publishes the
// open menu — covers both the initial open and hover-switching
// between tabs.
// Clear the panel/row hover trackers whenever the open menu changes, so a
// stale state from the previous menu can't wrongly pause the timer.
// NOTE: trigger-hovered is deliberately NOT reset here — the trigger owns
// it (resetting it on open would clear the flag the instant the hovered
// trigger opens the menu, defeating the whole purpose).
changed open-index => {
if (HeaderMenuState.open-index >= 0) {
idle-timer.running = true;
idle-timer.restart();
} else {
idle-timer.running = false;
}
root.hovered-row = -1;
root.panel-hovered = false;
}
property <int> open-index: HeaderMenuState.open-index;
// Closes the menu after 1s without pointer interaction (project rule:
// no menu stays open more than 1s idle).
// Closes the menu after 1s idle (project rule). The timer runs only while a
// menu is open AND the pointer is NOT over it — so hovering the menu pauses
// the countdown, and leaving it restarts a fresh 1s.
idle-timer := Timer {
interval: 1000ms;
running: false;
running: HeaderMenuState.open-index >= 0 && !root.menu-hovered;
triggered => {
self.running = false;
HeaderMenuState.open-index = -1;
}
}
@@ -72,8 +74,13 @@ export component HeaderMenuOverlay inherits Rectangle {
// events and block hover-switching, exactly like the old
// PopupWindow's pointer grab did.
if HeaderMenuState.open-index >= 0: TouchArea {
y: Layout.header-height;
height: parent.height - Layout.header-height;
// Header dropdowns: scrim starts below the header so the tabs stay
// hoverable. Sidebar flyouts: scrim starts right of the sidebar so the
// section rows stay clickable.
x: HeaderMenuState.from-sidebar ? ShellState.sidebar-rendered-width : 0px;
y: HeaderMenuState.from-sidebar ? 0px : Layout.header-height;
width: parent.width - self.x;
height: parent.height - self.y;
clicked => {
root.close-menu();
}
@@ -83,9 +90,9 @@ export component HeaderMenuOverlay inherits Rectangle {
// surface-main background, 1px border-muted, Radius.sm, drop shadow.
if HeaderMenuState.open-index >= 0: panel := Rectangle {
x: HeaderMenuState.anchor-x;
// Snug under the tabs — the 30px tab is centered in the 48px
// header, so its bottom edge sits at ~39px.
y: 45px;
// Header dropdown: snug under the tabs (~45px). Sidebar flyout: aligned
// to the row's window y, opening to the right.
y: HeaderMenuState.from-sidebar ? HeaderMenuState.anchor-y : 45px;
width: 188px;
height: panel-box.preferred-height;
@@ -98,18 +105,36 @@ export component HeaderMenuOverlay inherits Rectangle {
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
// Keeps the pointer "inside" the menu so the scrim's click
// does not pass through, and resets the idle countdown on
// any movement over the panel body.
// Keeps the pointer "inside" the menu so the scrim's click does
// not pass through, and reports panel-body hover so the idle timer
// pauses while the cursor rests on the menu.
panel-ta := TouchArea {
moved => {
root.reset-idle();
changed has-hover => {
root.panel-hovered = self.has-hover;
}
}
VerticalLayout {
padding-top: 6px;
padding-bottom: 6px;
// Optional section-name header + hairline (used by the compact
// header nav shown when the sidebar is fully closed).
if HeaderMenuState.title != "": HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
Text {
text: HeaderMenuState.title;
color: Theme.text-muted;
font-size: 11px;
font-weight: 600;
height: 26px;
vertical-alignment: center;
}
}
if HeaderMenuState.title != "": Rectangle {
height: 1px;
background: Theme.border-subtle;
}
for entry[idx] in HeaderMenuState.items: NavMenuItem {
label: entry.label;
icon: entry.icon;
@@ -122,10 +147,14 @@ export component HeaderMenuOverlay inherits Rectangle {
}
root.close-menu();
}
// Any pointer interaction with a row resets the
// idle-close countdown.
hovered => {
root.reset-idle();
// Track per-row hover so the idle timer stays paused while
// the cursor rests on a row (not just while it moves).
hover-changed(h) => {
if (h) {
root.hovered-row = idx;
} else if (root.hovered-row == idx) {
root.hovered-row = -1;
}
}
}
}
+6 -68
View File
@@ -1,70 +1,8 @@
// Browser-like back / forward navigation pair.
//
// The [<] [>] history pair uses the same circular elevated style as the
// Discover carousel page navigators. Used inline beside the Discover
// tabs and in place of the per-page Back button. (The sidebar-collapse
// toggle lives in the header now, not here.)
import { Theme } from "../foundation/semantic-colors.slint";
import { NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
// Circular elevated icon button — same look as the Discover carousel
// page navigators (Carousel.slint NavButton).
component HistoryNavBtn inherits Rectangle {
in property <image> icon;
in property <bool> enabled: true;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
opacity: root.enabled ? 1.0 : 0.4;
background: ta.has-hover && root.enabled
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
ta := TouchArea {
enabled: root.enabled;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// The back / forward / collapse buttons moved to the app header (HeaderBar,
// floating right). This component is now an empty zero-size placeholder so
// the many per-view headers that still instantiate it render nothing — the
// real buttons live once, in the header.
export component NavButtons inherits HorizontalLayout {
spacing: 4px;
VerticalLayout {
alignment: center;
HistoryNavBtn {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: NavState.can-back;
clicked => {
NavState.request-back();
}
}
}
VerticalLayout {
alignment: center;
HistoryNavBtn {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: NavState.can-forward;
clicked => {
NavState.request-forward();
}
}
}
width: 0px;
height: 0px;
}
@@ -13,6 +13,9 @@ export component NavMenuItem inherits Rectangle {
// Fires whenever the pointer is over the row — drives the dropdown's
// idle-close timer reset.
callback hovered();
// Fires on hover enter/leave so the host keeps the menu open while the
// pointer rests on a row (the idle timer must not close it then).
callback hover-changed(bool);
height: 32px;
background: ta.has-hover ? Theme.surface-hover : transparent;
@@ -45,6 +48,7 @@ export component NavMenuItem inherits Rectangle {
root.clicked();
}
changed has-hover => {
root.hover-changed(self.has-hover);
if (self.has-hover) {
root.hovered();
}
+22 -3
View File
@@ -16,7 +16,7 @@ import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSlider } from "../primitives/QbzSlider.slint";
import { SeekBar } from "SeekBar.slint";
import { NowPlayingState } from "../state.slint";
import { NowPlayingState, ShellState, AppearanceState } from "../state.slint";
component IconButton inherits Rectangle {
in property <image> icon;
@@ -257,6 +257,24 @@ export component PlayerBar inherits Rectangle {
IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/audio-lines.svg"); icon-size: 16px; }
// Lyrics + queue panel toggles (moved here from the header).
IconButton {
icon: @image-url("../assets/icons/captions.svg");
icon-size: 16px;
active: ShellState.right-panel == 2;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
}
}
IconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon-size: 16px;
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
Rectangle { width: 6px; }
IconButton {
@@ -281,14 +299,15 @@ export component PlayerBar inherits Rectangle {
}
}
}
IconButton {
// Volume +/- steppers — opt-in (Settings > Appearance).
if AppearanceState.show-volume-steppers: IconButton {
icon: @image-url("../assets/icons/minus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
}
}
IconButton {
if AppearanceState.show-volume-steppers: IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
clicked => {
+254 -56
View File
@@ -15,7 +15,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState } from "../state.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState } from "../state.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
// A single context-menu item row.
@@ -119,6 +119,9 @@ component SidebarRow inherits Rectangle {
callback edit-folder(string);
callback hide-playlist(string);
callback hide-folder(string);
// Fires on row hover enter/leave so the sidebar can reveal the scrollbar
// while the pointer is anywhere over the scrollable list.
callback hover-changed(bool);
property <bool> is-folder: root.entry.kind == "folder";
// Live filter for the move-to-folder list (per-row search input).
@@ -161,7 +164,11 @@ component SidebarRow inherits Rectangle {
&& root.show-collage
&& root.entry.cover-count > 0;
height: 32px;
// Collapsed (mini): folders behave as if closed — their nested playlists
// (indented rows) are hidden; contents open via the right-flyout instead.
property <bool> hidden-in-mini: ShellState.sidebar-mini && root.entry.indent;
height: root.hidden-in-mini ? 0px : 32px;
visible: !root.hidden-in-mini;
border-radius: 6px;
background: root.drop-hot
? #22c55e26
@@ -171,34 +178,47 @@ component SidebarRow inherits Rectangle {
border-color: #22c55e66;
HorizontalLayout {
padding-left: root.entry.indent ? 24px : 8px;
padding-right: 6px;
padding-left: ShellState.sidebar-mini ? 0px : (root.entry.indent ? 24px : 8px);
padding-right: ShellState.sidebar-mini ? 0px : 6px;
spacing: 8px;
// mini -> center the lone icon; open -> stretch so the name fills and
// the chevron is pushed to a fixed x (the row's right edge).
alignment: ShellState.sidebar-mini ? center : stretch;
// Leading icon — accent-tinted folder for folders, micro-collage
// (or list-music fallback) for playlists.
VerticalLayout {
alignment: center;
// Fixed-width icon slot so every row's text starts at the same x,
// whether the leading glyph is a 15px folder icon or a 20px collage.
Rectangle {
width: 24px;
if root.is-folder: QbzIcon {
source: root.entry.expanded
// In the collapsed (mini) state folders always read as closed
// (their contents open in the right-flyout, not inline).
source: (root.entry.expanded && !ShellState.sidebar-mini)
? @image-url("../assets/icons/folder-open.svg")
: @image-url("../assets/icons/folder.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
// Folder icon tinted accent; the name keeps text color.
tint: Theme.accent;
}
if !root.is-folder && root.use-collage: SidebarPlaylistCollage {
entry: root.entry;
size: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
if !root.is-folder && !root.use-collage: QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
}
Text {
if !ShellState.sidebar-mini: Text {
text: root.entry.name;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
@@ -208,7 +228,7 @@ component SidebarRow inherits Rectangle {
overflow: elide;
}
// Folder playlist count — only visible on hover.
if root.is-folder && root.entry.count > 0: Text {
if root.is-folder && root.entry.count > 0 && !ShellState.sidebar-mini: Text {
text: root.entry.count;
color: Theme.text-muted;
font-size: 11px;
@@ -216,8 +236,8 @@ component SidebarRow inherits Rectangle {
opacity: ta.has-hover ? 1.0 : 0.0;
}
// Folder open/close chevron — the primary affordance. Reflects
// the open/closed state (down = open, right = closed).
if root.is-folder: VerticalLayout {
// the open/closed state (down = open, right = closed). Hidden in mini.
if root.is-folder && !ShellState.sidebar-mini: VerticalLayout {
alignment: center;
Rectangle {
width: 22px;
@@ -249,9 +269,26 @@ component SidebarRow inherits Rectangle {
}
ta := TouchArea {
mouse-cursor: pointer;
changed has-hover => {
root.hover-changed(self.has-hover);
}
clicked => {
if (root.is-folder) {
root.toggle-folder(root.entry.id);
// Collapsed (mini) sidebar: a folder opens a flyout to the
// right instead of expanding downward (Tauri parity).
if (ShellState.sidebar-mini) {
SidebarFolderPopupState.folder-id = root.entry.id;
SidebarFolderPopupState.folder-name = root.entry.name;
SidebarFolderPopupState.count = root.entry.count;
SidebarFolderPopupState.anchor-x = root.absolute-position.x + root.width + 6px;
SidebarFolderPopupState.anchor-y = root.absolute-position.y;
// Fill the popup's playlists from the cache (collapsed
// folders' children aren't in the flat entries).
SidebarActions.load-folder-popup(root.entry.id);
SidebarFolderPopupState.open = true;
} else {
root.toggle-folder(root.entry.id);
}
} else {
root.open-playlist(root.entry.id);
}
@@ -409,26 +446,143 @@ component SidebarRow inherits Rectangle {
}
}
// One section-nav row in the sidebar. Clicking (or hovering) opens the same
// dropdown the header tab used, but flown out to the RIGHT of the row. In the
// mini state the label is hidden (icon only). Mirrors NavTabWithMenu.
component SidebarNavRow inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <int> tab-index;
in property <[NavMenuEntry]> items;
in property <int> home-item: -1;
property <bool> active: HeaderMenuState.open-index == root.tab-index;
height: 34px;
border-radius: 6px;
background: nav-ta.has-hover ? Theme.surface-hover : transparent;
// Publish this row's menu into the shared overlay, anchored to the right
// of the row at the row's window y.
function open-menu() {
HeaderMenuState.open-index = root.tab-index;
HeaderMenuState.anchor-x = root.absolute-position.x + root.width + 6px;
HeaderMenuState.anchor-y = root.absolute-position.y;
HeaderMenuState.items = root.items;
HeaderMenuState.home-item = root.home-item;
HeaderMenuState.from-sidebar = true;
HeaderMenuState.title = "";
}
HorizontalLayout {
padding-left: ShellState.sidebar-mini ? 0px : 8px;
padding-right: ShellState.sidebar-mini ? 0px : 8px;
spacing: 10px;
alignment: ShellState.sidebar-mini ? center : start;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
tint: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
}
if !ShellState.sidebar-mini: Text {
text: root.label;
color: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
font-weight: 500;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
nav-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.open-menu();
}
changed has-hover => {
if (self.has-hover) {
HeaderMenuState.trigger-hovered = true;
root.open-menu();
} else if (root.active) {
HeaderMenuState.trigger-hovered = false;
}
}
}
}
export component Sidebar inherits Rectangle {
// Rendered width — 0 when collapsed, otherwise the user-set width.
width: ShellState.sidebar-collapsed ? 0px : ShellState.sidebar-width;
// The sidebar sits one surface above the content area, matching the
// QBZ dark theme (--bg-secondary sidebar over --bg-primary content).
// Fired when a sidebar section-nav row is clicked — wired in AppShell to
// the same route handler the header dropdowns use.
callback navigate(string /* route */);
// Three-state rendered width: 0 (closed) / 64 (mini) / 240 (open).
width: ShellState.sidebar-rendered-width;
background: Theme.surface-card;
// Clip so content doesn't spill while the width animates to 0.
// Square edges — the bezel lives on the inset content panel, not here.
// Clip keeps content from spilling while the width animates.
clip: true;
animate width { duration: 160ms; easing: ease-in-out; }
// Captured sidebar width at pointer-down, so drag deltas are
// measured from a stable origin.
property <length> drag-start-width: ShellState.sidebar-width;
VerticalLayout {
padding: Spacing.md;
padding: ShellState.sidebar-mini ? 8px : Spacing.md;
spacing: Spacing.md;
// ---- Header toolbar ----------------------------------------
HorizontalLayout {
// ---- Section nav — each row opens its dropdown as a right flyout,
// exactly like the header tabs did (same item sets). ----------
if ShellState.nav-in-sidebar: VerticalLayout {
spacing: 2px;
SidebarNavRow {
tab-index: 0;
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
home-item: 0;
items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
}
SidebarNavRow {
tab-index: 1;
icon: @image-url("../assets/icons/music-library-2.svg");
label: @tr("Library");
items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
SidebarNavRow {
tab-index: 2;
icon: @image-url("../assets/icons/hard-drive.svg");
label: @tr("Local Library");
items: [
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
SidebarNavRow {
tab-index: 3;
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("My QBZ");
items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
];
}
}
if ShellState.nav-in-sidebar: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// ---- Header toolbar (hidden in the mini state) -------------
if !ShellState.sidebar-mini: HorizontalLayout {
spacing: 4px;
height: 22px;
@@ -603,13 +757,22 @@ export component Sidebar inherits Rectangle {
}
// ---- Playlist list (hidden when the section is collapsed) ----
if !SidebarState.playlists-collapsed: ScrollView {
// Wheel-scrolled Flickable (interactive only while NOT dragging, so a
// playlist drag-and-drop is never hijacked into a scroll) + a thin,
// auto-hiding scrollbar pinned to the sidebar's right edge.
if !SidebarState.playlists-collapsed: Rectangle {
vertical-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 2px;
alignment: start;
for entry in SidebarState.entries: SidebarRow {
pl-flick := Flickable {
width: 100%;
height: 100%;
interactive: !DragState.active;
viewport-width: self.width;
viewport-height: pl-content.preferred-height;
pl-content := VerticalLayout {
width: parent.width;
spacing: 2px;
alignment: start;
for entry in SidebarState.entries: SidebarRow {
entry: entry;
active: entry.kind == "playlist" && entry.id == SidebarState.active-id;
show-collage: SidebarState.playlist-collage;
@@ -637,6 +800,9 @@ export component Sidebar inherits Rectangle {
hide-folder(id) => {
SidebarActions.hide-folder(id);
}
hover-changed(h) => {
pl-sb.hover-rows = h ? pl-sb.hover-rows + 1 : Math.max(0, pl-sb.hover-rows - 1);
}
}
if SidebarState.entries.length == 0 && !SidebarState.loading: Text {
text: @tr("No playlists yet.");
@@ -644,6 +810,61 @@ export component Sidebar inherits Rectangle {
font-size: 12px;
wrap: word-wrap;
}
}
}
// Thin scrollbar pinned to the sidebar's RIGHT EDGE. The +Spacing.md
// offset cancels the list's right padding so the gutter sits flush
// against the sidebar border. It appears whenever the pointer is
// over the scrollable list (hover-rows) or the gutter itself.
pl-sb := Rectangle {
// Offset by the list's real right padding (8px mini / 16px
// open) so the gutter sits flush against the sidebar edge in
// BOTH states. Narrower in the collapsed (mini) state.
x: parent.width - self.width + (ShellState.sidebar-mini ? 8px : Spacing.md);
width: ShellState.sidebar-mini ? 8px : 10px;
height: 100%;
// Count of list rows currently hovered (>0 = pointer over the
// scrollable area). Reveals the scrollbar.
property <int> hover-rows: 0;
property <length> max-scroll: max(0px, pl-flick.viewport-height - pl-flick.height);
property <length> thumb-h: pl-flick.viewport-height > 0px
? max(40px, pl-flick.height / pl-flick.viewport-height * self.height)
: self.height;
property <length> travel: max(0px, self.height - self.thumb-h);
property <bool> show: pl-sb-ta.has-hover || pl-sb-ta.pressed || self.hover-rows > 0;
visible: self.max-scroll > 0px;
thumb := Rectangle {
x: parent.width - self.width;
width: pl-sb-ta.has-hover || pl-sb-ta.pressed
? (ShellState.sidebar-mini ? 4px : 6px)
: (ShellState.sidebar-mini ? 3px : 4px);
height: pl-sb.thumb-h;
y: pl-sb.max-scroll > 0px
? (-pl-flick.viewport-y / pl-sb.max-scroll) * pl-sb.travel
: 0px;
border-radius: 3px;
background: pl-sb-ta.has-hover || pl-sb-ta.pressed ? #ffffff7a : #ffffff4d;
opacity: pl-sb.show ? 1.0 : 0.0;
animate opacity { duration: 160ms; }
animate width { duration: 100ms; }
}
pl-sb-ta := TouchArea {
mouse-cursor: pointer;
moved => {
if (pl-sb-ta.pressed && pl-sb.travel > 0px) {
pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px,
(pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll));
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && pl-sb.travel > 0px) {
pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px,
(pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll));
}
}
}
}
}
// Keep the layout stretching even while collapsed.
@@ -651,30 +872,7 @@ export component Sidebar inherits Rectangle {
vertical-stretch: 1;
}
}
// Drag handle — a thin strip pinned to the right edge. Resizes the
// sidebar; below ~160px it collapses instead of resizing.
drag-handle := TouchArea {
x: parent.width - self.width;
width: 6px;
height: parent.height;
mouse-cursor: ew-resize;
pointer-event(e) => {
if (e.kind == PointerEventKind.down) {
root.drag-start-width = ShellState.sidebar-width;
}
}
moved => {
if (self.pressed) {
if (root.drag-start-width + (self.mouse-x - self.pressed-x) < 160px) {
ShellState.sidebar-collapsed = true;
} else {
ShellState.sidebar-width = Math.min(Math.max(
root.drag-start-width + (self.mouse-x - self.pressed-x),
200px), 420px);
}
}
}
}
// The drag-to-resize handle was removed: the sidebar is now a fixed
// three-state control (open / mini / closed) cycled from the header
// collapse button.
}
@@ -0,0 +1,146 @@
// Collapsed-sidebar folder flyout. When the sidebar is in the Colapsado
// (mini) state, clicking a folder opens this popup to its right, listing the
// folder's playlists (max 4 rows visible; a scrollbar appears beyond four).
// Mirrors Tauri's collapsed folder popover. Rendered at the AppShell level
// because the sidebar clips its own overflow.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { SidebarFolderPopupState, SidebarActions, SidebarEntry } from "../state.slint";
// One playlist row inside the popup.
component FpRow inherits Rectangle {
in property <SidebarEntry> entry;
callback clicked();
height: 32px;
border-radius: 5px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
Text {
text: root.entry.name;
color: Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
export component SidebarFolderPopup inherits Rectangle {
visible: SidebarFolderPopupState.open;
property <length> row-h: 32px;
property <int> visible-rows: Math.min(SidebarFolderPopupState.count, 4);
property <length> list-h: root.visible-rows * root.row-h;
// Scrim — a click anywhere outside the panel closes the popup.
if SidebarFolderPopupState.open: TouchArea {
clicked => {
SidebarFolderPopupState.open = false;
}
}
if SidebarFolderPopupState.open: Rectangle {
x: SidebarFolderPopupState.anchor-x;
y: SidebarFolderPopupState.anchor-y;
width: 230px;
height: 34px + 1px + root.list-h + 8px;
Rectangle {
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 24px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
// Swallow clicks so the scrim doesn't close while interacting.
TouchArea { }
VerticalLayout {
padding-top: 4px;
padding-bottom: 4px;
// Header — folder name.
HorizontalLayout {
height: 30px;
padding-left: 12px;
padding-right: 12px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/folder-open.svg");
width: 14px;
height: 14px;
tint: Theme.accent;
}
}
Text {
text: SidebarFolderPopupState.folder-name;
color: Theme.text-primary;
font-size: 13px;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// Playlist list — capped at four rows; scrolls beyond that.
Rectangle {
height: root.list-h;
fp-flick := Flickable {
width: 100%;
height: 100%;
viewport-width: self.width;
viewport-height: SidebarFolderPopupState.count * root.row-h;
VerticalLayout {
width: parent.width;
alignment: start;
for entry in SidebarFolderPopupState.playlists: FpRow {
entry: entry;
clicked => {
SidebarActions.open-playlist(entry.id);
SidebarFolderPopupState.open = false;
}
}
}
}
if SidebarFolderPopupState.count > 4: ListScrollbar {
x: parent.width - self.width;
width: 10px;
height: 100%;
viewport-height: fp-flick.viewport-height;
visible-height: fp-flick.height;
viewport-y <=> fp-flick.viewport-y;
}
}
}
}
}
}
+54 -9
View File
@@ -804,6 +804,20 @@ export struct SidebarEntry {
cover4: image,
}
// Flyout shown when a folder is clicked while the sidebar is collapsed
// (mini): a popup to the right listing that folder's playlists. The list is
// populated from Rust because collapsed folders' children are NOT present in
// the flat `SidebarState.entries`.
export global SidebarFolderPopupState {
in-out property <bool> open: false;
in-out property <string> folder-id;
in-out property <string> folder-name;
in-out property <int> count: 0; // folder's playlist count (caps the height)
in-out property <length> anchor-x; // window x (sidebar right edge)
in-out property <length> anchor-y; // window y (folder row top)
in property <[SidebarEntry]> playlists: [];
}
// The sidebar's playlists + folders. Populated by crate::sidebar on
// shell entry and after create/delete/move.
export global SidebarState {
@@ -838,6 +852,9 @@ export global SidebarState {
export global SidebarActions {
callback open-playlist(string /* id */);
// Populate SidebarFolderPopupState.playlists for the collapsed-sidebar
// folder flyout (the folder's children aren't in the flat entries).
callback load-folder-popup(string /* folder id */);
callback create-playlist();
callback create-folder();
callback toggle-folder(string /* folder id */);
@@ -1278,14 +1295,10 @@ export global SettingsState {
// so the UI renders standalone even while unwired.
export global AppearanceState {
// --- THEME -------------------------------------------------------------
// Only the themes that are actually ported/wired. Order is load-bearing:
// the index maps 1:1 to ThemeState.mode (0 Dark, 1 OLED, 2 Tokyo, 3 System).
in-out property <[string]> themes: [
"Dark", "OLED Black", "Light", "System",
"Warm", "Nord", "Dracula", "Tokyo Night", "Catppuccin Mocha",
"Breeze Dark", "Adwaita Dark", "Alucard", "Aurora", "Ikari",
"Ayanami", "Iscariot", "Stratego", "Rumi", "Zoey", "Mira",
"Rose Pine Dawn", "Breeze Light", "Adwaita Light", "Duotone Snow",
"Snow Storm", "Frost", "Langley", "Kurosaki",
"WCAG Light", "WCAG Dark", "High Contrast", "Colorblind"
"Dark", "OLED Black", "Tokyo Night", "System"
];
in-out property <int> theme-index: 0;
// Theme list filter cycle: 0 = All, 1 = Dark, 2 = Light. Mirrors the
@@ -1316,6 +1329,8 @@ export global AppearanceState {
// the panel (the only already-functional flag here).
in-out property <bool> sidebar-playlist-collage: true;
in-out property <bool> local-library-track-artwork: false;
// Player volume +/- stepper buttons — opt-in, hidden by default.
in-out property <bool> show-volume-steppers: false;
// --- NOTIFICATIONS -----------------------------------------------------
in-out property <bool> in-app-toasts: true;
@@ -1420,6 +1435,19 @@ export global HeaderMenuState {
// Header x-coordinate of the open tab, so the overlay aligns the
// menu panel under it.
in-out property <length> anchor-x: 0px;
// Window y-coordinate of the anchor — used for sidebar flyouts, which
// open to the RIGHT of the row rather than under the header.
in-out property <length> anchor-y: 0px;
// True when the open menu is a sidebar section flyout (opens right of
// the row) rather than a header dropdown (opens under the tab).
in-out property <bool> from-sidebar: false;
// Optional section-name header shown as the first row (with a hairline
// separator below it). Empty = no header row.
in-out property <string> title: "";
// True while the pointer rests on the TRIGGER (the header tab / compact
// nav icon / sidebar nav row). Pauses the idle-close like hovering the
// menu does, so resting on the trigger never lets it close.
in-out property <bool> trigger-hovered: false;
// The open tab's menu rows.
in-out property <[NavMenuEntry]> items: [];
// Index of the row wired to Home (-1 = none); only Discover uses it.
@@ -1785,10 +1813,25 @@ export global LocationViewActions {
// Globals are imported directly by the shell components, so toggles
// need no callback plumbing.
export global ShellState {
in-out property <bool> sidebar-collapsed: false;
in-out property <length> sidebar-width: 280px;
// Three-state sidebar: 0 = open (240px, labels), 1 = mini (64px, icons
// only, Tauri-style), 2 = closed (0px). The collapse toggle cycles
// open -> mini -> closed -> open.
in-out property <int> sidebar-state: 0;
out property <length> sidebar-rendered-width:
root.sidebar-state == 2 ? 0px
: root.sidebar-state == 1 ? 64px
: 240px;
// True while in the icons-only mini state — gates label/header rendering.
out property <bool> sidebar-mini: root.sidebar-state == 1;
// Advance the sidebar through its three states.
public function cycle-sidebar() {
root.sidebar-state = Math.mod(root.sidebar-state + 1, 3);
}
// 0 = none, 1 = queue, 2 = lyrics
in-out property <int> right-panel: 0;
// Section navigation placement: the sections (Discover/Library/Local
// Library/My QBZ) now live in the sidebar and open right-flyout menus.
in-out property <bool> nav-in-sidebar: true;
}
// Authenticated user, for the header user element.
@@ -2049,6 +2092,8 @@ export global FavoritesActions {
callback albums-search(string /* query */);
callback search-labels(string /* query */);
callback playlists-set-sub-tab(string /* favorites | following */);
callback playlists-random();
callback labels-random();
callback search-playlists(string /* query */);
callback playlists-set-view(string /* grid | list */);
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite */);