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:
27 files changed
+1495
-619
No files matched your search
@@ -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 } => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */);
|
||||
|
||||
Reference in new issue
Block a user