feat(slint): Playlist Manager + search most-popular hero polish
Playlist Manager: list view renders compact rows with total play time; folder grid cards no longer clip the playlist count (content-driven height); list view shows compact folder chips; the move-to-folder menu gains a folder search and a 4-row scroll cap. Search: the most-popular track hero is resized to the artist-card height with the quality shown as centered text instead of a clipped badge. cc
This commit is contained in:
7 files changed
+331
-65
No files matched your search
@@ -934,6 +934,16 @@ fn wire_playlist_manager(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<PlaylistManagerActions>()
|
||||||
|
.on_search_folders(move |query| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
playlist_manager::search_menu_folders(&w, &query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
window
|
window
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ struct PmPlaylist {
|
|||||||
name: String,
|
name: String,
|
||||||
/// Remote (Qobuz) track count.
|
/// Remote (Qobuz) track count.
|
||||||
tracks_count: u32,
|
tracks_count: u32,
|
||||||
|
/// Total playlist duration in seconds (Qobuz `duration`).
|
||||||
|
duration: u32,
|
||||||
/// Local (non-Qobuz) track count.
|
/// Local (non-Qobuz) track count.
|
||||||
local_count: u32,
|
local_count: u32,
|
||||||
play_count: u32,
|
play_count: u32,
|
||||||
@@ -125,6 +127,7 @@ where
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name.clone(),
|
name: p.name.clone(),
|
||||||
tracks_count: p.tracks_count,
|
tracks_count: p.tracks_count,
|
||||||
|
duration: p.duration,
|
||||||
local_count: local_counts.get(&p.id).copied().unwrap_or(0),
|
local_count: local_counts.get(&p.id).copied().unwrap_or(0),
|
||||||
play_count: play_counts.get(&p.id).copied().unwrap_or(0),
|
play_count: play_counts.get(&p.id).copied().unwrap_or(0),
|
||||||
is_favorite: s.is_favorite,
|
is_favorite: s.is_favorite,
|
||||||
@@ -220,6 +223,21 @@ fn parse_color(s: &str) -> Option<slint::Color> {
|
|||||||
Some(slint::Color::from_rgb_u8(r, g, b))
|
Some(slint::Color::from_rgb_u8(r, g, b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total-playtime label, e.g. "1h 43m" or "12m" (mirrors Tauri's
|
||||||
|
/// `formatDuration`). Empty when the duration is zero.
|
||||||
|
fn format_duration(seconds: u32) -> String {
|
||||||
|
if seconds == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let hours = seconds / 3600;
|
||||||
|
let mins = (seconds % 3600) / 60;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}h {mins}m")
|
||||||
|
} else {
|
||||||
|
format!("{mins}m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn folder_item(f: &FolderFull, count: usize) -> PmFolderItem {
|
fn folder_item(f: &FolderFull, count: usize) -> PmFolderItem {
|
||||||
let color = parse_color(&f.icon_color);
|
let color = parse_color(&f.icon_color);
|
||||||
PmFolderItem {
|
PmFolderItem {
|
||||||
@@ -256,6 +274,7 @@ fn playlist_item(p: &PmPlaylist) -> PmPlaylistItem {
|
|||||||
id: p.id.to_string().into(),
|
id: p.id.to_string().into(),
|
||||||
name: p.name.clone().into(),
|
name: p.name.clone().into(),
|
||||||
tracks_line: format!("{} tracks", p.total_count()).into(),
|
tracks_line: format!("{} tracks", p.total_count()).into(),
|
||||||
|
duration_line: format_duration(p.duration).into(),
|
||||||
local_line: local_line.into(),
|
local_line: local_line.into(),
|
||||||
local_count: p.local_count as i32,
|
local_count: p.local_count as i32,
|
||||||
total_count: p.total_count() as i32,
|
total_count: p.total_count() as i32,
|
||||||
@@ -320,7 +339,16 @@ pub fn rebuild(window: &AppWindow) {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The list-row move-to-folder menu starts unfiltered (= the full set);
|
||||||
|
// its search box narrows it via `search_menu_folders`.
|
||||||
|
let menu_folders: Vec<PmFolderItem> = data
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.map(|f| folder_item(f, folder_counts.get(&f.id).copied().unwrap_or(0)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
state.set_folders(ModelRc::new(VecModel::from(folders)));
|
state.set_folders(ModelRc::new(VecModel::from(folders)));
|
||||||
|
state.set_menu_folders(ModelRc::new(VecModel::from(menu_folders)));
|
||||||
state.set_playlists(ModelRc::new(VecModel::from(playlist_items)));
|
state.set_playlists(ModelRc::new(VecModel::from(playlist_items)));
|
||||||
state.set_tree(ModelRc::new(VecModel::from(tree)));
|
state.set_tree(ModelRc::new(VecModel::from(tree)));
|
||||||
state.set_folder_count(data.folders.len() as i32);
|
state.set_folder_count(data.folders.len() as i32);
|
||||||
@@ -329,6 +357,33 @@ pub fn rebuild(window: &AppWindow) {
|
|||||||
state.set_loading(false);
|
state.set_loading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter the list-row move-to-folder menu's folder list by a
|
||||||
|
/// case-insensitive substring (Slint strings have no `contains`, so this
|
||||||
|
/// lives in Rust). An empty query restores the full list. Counts mirror the
|
||||||
|
/// `rebuild` computation. UI thread only.
|
||||||
|
pub fn search_menu_folders(window: &AppWindow, query: &str) {
|
||||||
|
let q = query.trim().to_lowercase();
|
||||||
|
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut folder_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
for p in &data.playlists {
|
||||||
|
if let Some(fid) = &p.folder_id {
|
||||||
|
*folder_counts.entry(fid.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered: Vec<PmFolderItem> = data
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.filter(|f| q.is_empty() || f.name.to_lowercase().contains(&q))
|
||||||
|
.map(|f| folder_item(f, folder_counts.get(&f.id).copied().unwrap_or(0)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
window
|
||||||
|
.global::<PlaylistManagerState>()
|
||||||
|
.set_menu_folders(ModelRc::new(VecModel::from(filtered)));
|
||||||
|
}
|
||||||
|
|
||||||
/// Flatten folders + their (expanded) playlists + root playlists into the
|
/// Flatten folders + their (expanded) playlists + root playlists into the
|
||||||
/// tree model. Auto-expands all folders the first time the tree opens.
|
/// tree model. Auto-expands all folders the first time the tree opens.
|
||||||
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTreeRow> {
|
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTreeRow> {
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ pub struct TrackRowData {
|
|||||||
pub artist: String,
|
pub artist: String,
|
||||||
pub duration: String,
|
pub duration: String,
|
||||||
pub quality_tier: String,
|
pub quality_tier: String,
|
||||||
|
/// Detailed quality label, e.g. "Hi-Res 24-bit / 192 kHz". Used by the
|
||||||
|
/// most-popular track hero (shown as text instead of an icon badge).
|
||||||
|
pub quality_label: String,
|
||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
pub artwork_url: String,
|
pub artwork_url: String,
|
||||||
}
|
}
|
||||||
@@ -211,6 +214,7 @@ pub fn map_track(track: Track) -> TrackRowData {
|
|||||||
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
||||||
duration: mmss(track.duration),
|
duration: mmss(track.duration),
|
||||||
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
||||||
|
quality_label: quality_label(track.maximum_bit_depth, track.maximum_sampling_rate),
|
||||||
explicit: track.parental_warning,
|
explicit: track.parental_warning,
|
||||||
artwork_url,
|
artwork_url,
|
||||||
}
|
}
|
||||||
@@ -422,6 +426,8 @@ pub fn apply_search(window: &AppWindow, data: SearchData) {
|
|||||||
state.set_artists_total(data.artists_total as i32);
|
state.set_artists_total(data.artists_total as i32);
|
||||||
state.set_playlists_total(data.playlists_total as i32);
|
state.set_playlists_total(data.playlists_total as i32);
|
||||||
|
|
||||||
|
// Default the hero quality label off; only the track branch sets it.
|
||||||
|
state.set_most_popular_quality_label("".into());
|
||||||
match data.most_popular {
|
match data.most_popular {
|
||||||
MostPopularRow::Album(row) => {
|
MostPopularRow::Album(row) => {
|
||||||
state.set_most_popular_kind("album".into());
|
state.set_most_popular_kind("album".into());
|
||||||
@@ -433,6 +439,7 @@ pub fn apply_search(window: &AppWindow, data: SearchData) {
|
|||||||
}
|
}
|
||||||
MostPopularRow::Track(row) => {
|
MostPopularRow::Track(row) => {
|
||||||
state.set_most_popular_kind("track".into());
|
state.set_most_popular_kind("track".into());
|
||||||
|
state.set_most_popular_quality_label(row.quality_label.clone().into());
|
||||||
state.set_most_popular_track(track_item(row));
|
state.set_most_popular_track(track_item(row));
|
||||||
}
|
}
|
||||||
MostPopularRow::None => {
|
MostPopularRow::None => {
|
||||||
@@ -454,6 +461,7 @@ pub fn reset_search(window: &AppWindow) {
|
|||||||
state.set_artists_total(0);
|
state.set_artists_total(0);
|
||||||
state.set_playlists_total(0);
|
state.set_playlists_total(0);
|
||||||
state.set_most_popular_kind("".into());
|
state.set_most_popular_kind("".into());
|
||||||
|
state.set_most_popular_quality_label("".into());
|
||||||
state.set_filter_index(0);
|
state.set_filter_index(0);
|
||||||
state.set_loading(true);
|
state.set_loading(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// ready-to-render models, so this view does no per-row map lookups.
|
// ready-to-render models, so this view does no per-row map lookups.
|
||||||
// Backend is reused via the PlaylistManagerActions callbacks.
|
// Backend is reused via the PlaylistManagerActions callbacks.
|
||||||
|
|
||||||
|
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
@@ -107,8 +108,12 @@ component PmSearch inherits Rectangle {
|
|||||||
component PmMenuItem inherits Rectangle {
|
component PmMenuItem inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> selected: false;
|
in property <bool> selected: false;
|
||||||
|
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
|
||||||
|
// a hidden item collapses to zero height instead (the sidebar pattern).
|
||||||
|
in property <bool> shown: true;
|
||||||
callback activated();
|
callback activated();
|
||||||
height: 30px;
|
height: root.shown ? 30px : 0px;
|
||||||
|
visible: root.shown;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
|
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -272,7 +277,10 @@ component PmFolderCard inherits Rectangle {
|
|||||||
// icon/name/count column. Hover reveals the edit pencil (opacity 0->1)
|
// icon/name/count column. Hover reveals the edit pencil (opacity 0->1)
|
||||||
// and lifts the surface — same affordance as Tauri.
|
// and lifts the surface — same affordance as Tauri.
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 128px;
|
// Tall enough for the 64px icon + name + count + 16px padding without
|
||||||
|
// overflowing (128px squeezed the count to the bottom edge); matches
|
||||||
|
// Tauri's content-driven `.folder-card`. Keep in sync with `folder-h`.
|
||||||
|
height: 150px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
opacity: root.folder.is-hidden ? 0.6 : 1.0;
|
opacity: root.folder.is-hidden ? 0.6 : 1.0;
|
||||||
@@ -345,6 +353,99 @@ component PmFolderCard inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact folder CHIP — the LIST-mode folder representation. Mirrors
|
||||||
|
// Tauri's `.folder-list-item`: a rounded surface row with a small tinted
|
||||||
|
// icon square (36px / 20px glyph) + folder name (13px) + playlist count
|
||||||
|
// (11px, hover-revealed) + a hover-revealed edit pencil (12px). Used in a
|
||||||
|
// wrapping row, so it sizes to its content.
|
||||||
|
component PmFolderChip inherits Rectangle {
|
||||||
|
in property <PmFolderItem> folder;
|
||||||
|
callback open();
|
||||||
|
callback edit();
|
||||||
|
height: 52px;
|
||||||
|
width: chip-row.preferred-width;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
opacity: root.folder.is-hidden ? 0.6 : 1.0;
|
||||||
|
|
||||||
|
chip-row := HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
spacing: 8px;
|
||||||
|
// Tinted icon square.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
PmFolderIcon {
|
||||||
|
folder: root.folder;
|
||||||
|
size: 36px;
|
||||||
|
glyph-size: 20px;
|
||||||
|
radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Folder name (capped width so very long names elide instead of
|
||||||
|
// stretching the chip — matches `.folder-list-name` max-width 120px).
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.folder.name;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Playlist count — revealed on hover (matches `.folder-list-count`).
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.folder.count;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
opacity: ta.has-hover ? 1.0 : 0.0;
|
||||||
|
animate opacity { duration: 150ms; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Edit pencil — revealed on hover (matches `.folder-list-edit`).
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
edit-btn := Rectangle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: (ta.has-hover || edit-ta.has-hover) ? 1.0 : 0.0;
|
||||||
|
animate opacity { duration: 150ms; }
|
||||||
|
background: edit-ta.has-hover ? Theme.surface-card : transparent;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/pen-line.svg");
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||||
|
}
|
||||||
|
edit-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.edit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
// Covers the icon + name region; the edit pencil keeps its own click.
|
||||||
|
width: parent.width - 32px;
|
||||||
|
height: parent.height;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Playlist grid card -------------------------------------------------
|
// --- Playlist grid card -------------------------------------------------
|
||||||
|
|
||||||
component PmGridCard inherits Rectangle {
|
component PmGridCard inherits Rectangle {
|
||||||
@@ -372,6 +473,11 @@ component PmGridCard inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
|
// Pack children at the top so the footer sits right under the name
|
||||||
|
// (matching Tauri's `.grid-item` flex column — no justify-content,
|
||||||
|
// 8px gaps). Without this the layout stretches and pushes the footer
|
||||||
|
// to the bottom edge, leaving a large gap after the name.
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
// Optional reorder header (custom sort only).
|
// Optional reorder header (custom sort only).
|
||||||
if root.can-reorder: HorizontalLayout {
|
if root.can-reorder: HorizontalLayout {
|
||||||
@@ -498,6 +604,8 @@ component PmListRow inherits Rectangle {
|
|||||||
in property <bool> can-reorder: false;
|
in property <bool> can-reorder: false;
|
||||||
// Folder list for the move-to-folder menu ([] = no folders).
|
// Folder list for the move-to-folder menu ([] = no folders).
|
||||||
in property <[PmFolderItem]> folders;
|
in property <[PmFolderItem]> folders;
|
||||||
|
// Local text binding for the menu's folder-search box.
|
||||||
|
property <string> folder-query;
|
||||||
callback open();
|
callback open();
|
||||||
callback favorite();
|
callback favorite();
|
||||||
callback visibility();
|
callback visibility();
|
||||||
@@ -557,7 +665,8 @@ component PmListRow inherits Rectangle {
|
|||||||
font-weight: Typography.medium;
|
font-weight: Typography.medium;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
// Meta line — 12px muted (matches `.list-item .meta`).
|
// Meta line — 12px muted (matches `.list-item .meta`):
|
||||||
|
// "N tracks · 1h 43m (3 local)".
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
@@ -566,6 +675,17 @@ component PmListRow inherits Rectangle {
|
|||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
// Dot + total playtime (Tauri shows this only in the list row).
|
||||||
|
if root.item.duration-line != "": Text {
|
||||||
|
text: "·";
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
if root.item.duration-line != "": Text {
|
||||||
|
text: root.item.duration-line;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
if root.item.local-line != "": Text {
|
if root.item.local-line != "": Text {
|
||||||
text: root.item.local-line;
|
text: root.item.local-line;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
@@ -635,15 +755,20 @@ component PmListRow inherits Rectangle {
|
|||||||
// Move-to-folder "..." (only when folders exist).
|
// Move-to-folder "..." (only when folders exist).
|
||||||
if root.folders.length > 0: more-btn := PmActionButton {
|
if root.folders.length > 0: more-btn := PmActionButton {
|
||||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||||
clicked => { folder-menu.show(); }
|
clicked => {
|
||||||
|
// Reset the filter to the full list each open.
|
||||||
|
root.folder-query = "";
|
||||||
|
PlaylistManagerActions.search-folders("");
|
||||||
|
folder-menu.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
folder-menu := PopupWindow {
|
folder-menu := PopupWindow {
|
||||||
x: root.width - 206px;
|
x: root.width - 226px;
|
||||||
y: 30px;
|
y: 30px;
|
||||||
width: 200px;
|
width: 220px;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface-card;
|
background: Theme.surface-card;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -660,15 +785,41 @@ component PmListRow inherits Rectangle {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
horizontal-alignment: left;
|
horizontal-alignment: left;
|
||||||
}
|
}
|
||||||
|
// Folder search — only with enough folders to warrant it
|
||||||
|
// (matches the sidebar's >=8 threshold). The substring filter
|
||||||
|
// runs in Rust into `menu-folders` (Slint has no `contains`).
|
||||||
|
if root.folders.length >= 8: Rectangle {
|
||||||
|
height: 32px;
|
||||||
|
LineEdit {
|
||||||
|
height: 28px;
|
||||||
|
width: parent.width;
|
||||||
|
placeholder-text: @tr("Search folders");
|
||||||
|
text <=> root.folder-query;
|
||||||
|
edited => {
|
||||||
|
PlaylistManagerActions.search-folders(self.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
PmMenuItem {
|
PmMenuItem {
|
||||||
label: @tr("No folder");
|
label: @tr("No folder");
|
||||||
selected: root.item.folder-id == "";
|
selected: root.item.folder-id == "";
|
||||||
activated => { root.move-to-folder(""); }
|
activated => { root.move-to-folder(""); }
|
||||||
}
|
}
|
||||||
for f in root.folders: PmMenuItem {
|
// Cap the visible folders at 4 rows (30px each), then scroll.
|
||||||
label: f.name;
|
ScrollView {
|
||||||
selected: root.item.folder-id == f.id;
|
height: min(PlaylistManagerState.menu-folders.length * 30px, 120px);
|
||||||
activated => { root.move-to-folder(f.id); }
|
vertical-scrollbar-policy: ScrollBarPolicy.as-needed;
|
||||||
|
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||||
|
VerticalLayout {
|
||||||
|
for f in PlaylistManagerState.menu-folders: PmMenuItem {
|
||||||
|
// Hide the playlist's current folder (the sidebar's
|
||||||
|
// current-folder exclusion).
|
||||||
|
shown: f.id != root.item.folder-id;
|
||||||
|
label: f.name;
|
||||||
|
selected: root.item.folder-id == f.id;
|
||||||
|
activated => { root.move-to-folder(f.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -874,7 +1025,12 @@ component PmSectionHeader inherits Rectangle {
|
|||||||
export component PlaylistManagerView inherits Rectangle {
|
export component PlaylistManagerView inherits Rectangle {
|
||||||
property <length> grid-gap: 16px;
|
property <length> grid-gap: 16px;
|
||||||
property <length> folder-w: 160px;
|
property <length> folder-w: 160px;
|
||||||
property <length> folder-h: 128px;
|
property <length> folder-h: 150px;
|
||||||
|
// Compact folder-chip cell (LIST mode). Wrapping row of uniform-width
|
||||||
|
// chips (Slint has no flex-wrap); 8px gap matches Tauri's `.folders-list`.
|
||||||
|
property <length> chip-w: 184px;
|
||||||
|
property <length> chip-h: 52px;
|
||||||
|
property <length> chip-gap: 8px;
|
||||||
property <length> card-w: 160px;
|
property <length> card-w: 160px;
|
||||||
// Card height depends on whether reorder controls show (matches
|
// Card height depends on whether reorder controls show (matches
|
||||||
// PmGridCard's height).
|
// PmGridCard's height).
|
||||||
@@ -1130,7 +1286,9 @@ export component PlaylistManagerView inherits Rectangle {
|
|||||||
collapsed: PlaylistManagerState.folders-collapsed;
|
collapsed: PlaylistManagerState.folders-collapsed;
|
||||||
toggle => { PlaylistManagerActions.toggle-folders-collapsed(); }
|
toggle => { PlaylistManagerActions.toggle-folders-collapsed(); }
|
||||||
}
|
}
|
||||||
if !PlaylistManagerState.folders-collapsed: folders-grid := Rectangle {
|
// GRID mode: the big folder cards.
|
||||||
|
if !PlaylistManagerState.folders-collapsed
|
||||||
|
&& PlaylistManagerState.view-mode == "grid": folders-grid := Rectangle {
|
||||||
property <int> columns: Math.max(1,
|
property <int> columns: Math.max(1,
|
||||||
Math.floor((self.width + root.grid-gap) / (root.folder-w + root.grid-gap)));
|
Math.floor((self.width + root.grid-gap) / (root.folder-w + root.grid-gap)));
|
||||||
property <int> rows: Math.ceil(PlaylistManagerState.folders.length / self.columns);
|
property <int> rows: Math.ceil(PlaylistManagerState.folders.length / self.columns);
|
||||||
@@ -1145,6 +1303,26 @@ export component PlaylistManagerView inherits Rectangle {
|
|||||||
edit => { PlaylistManagerActions.edit-folder(folder.id); }
|
edit => { PlaylistManagerActions.edit-folder(folder.id); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// LIST mode: a wrapping row of compact folder chips
|
||||||
|
// (Tauri's `.folders-list`). Uniform-width cells since
|
||||||
|
// Slint has no flex-wrap.
|
||||||
|
if !PlaylistManagerState.folders-collapsed
|
||||||
|
&& PlaylistManagerState.view-mode == "list": folders-chips := Rectangle {
|
||||||
|
property <int> columns: Math.max(1,
|
||||||
|
Math.floor((self.width + root.chip-gap) / (root.chip-w + root.chip-gap)));
|
||||||
|
property <int> rows: Math.ceil(PlaylistManagerState.folders.length / self.columns);
|
||||||
|
height: self.rows > 0
|
||||||
|
? self.rows * root.chip-h + Math.max(0, self.rows - 1) * root.chip-gap
|
||||||
|
: 0px;
|
||||||
|
for folder[i] in PlaylistManagerState.folders: PmFolderChip {
|
||||||
|
x: Math.mod(i, folders-chips.columns) * (root.chip-w + root.chip-gap);
|
||||||
|
y: Math.floor(i / folders-chips.columns) * (root.chip-h + root.chip-gap);
|
||||||
|
width: root.chip-w;
|
||||||
|
folder: folder;
|
||||||
|
open => { PlaylistManagerActions.edit-folder(folder.id); }
|
||||||
|
edit => { PlaylistManagerActions.edit-folder(folder.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
Rectangle { height: 24px; }
|
Rectangle { height: 24px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { Radius } from "../foundation/radius.slint";
|
|||||||
import { Spacing } from "../foundation/spacing.slint";
|
import { Spacing } from "../foundation/spacing.slint";
|
||||||
import { TrackItem } from "../state.slint";
|
import { TrackItem } from "../state.slint";
|
||||||
import { QbzIcon } from "QbzIcon.slint";
|
import { QbzIcon } from "QbzIcon.slint";
|
||||||
import { QualityBadge } from "QualityBadge.slint";
|
|
||||||
import { TrackContextMenu } from "TrackContextMenu.slint";
|
import { TrackContextMenu } from "TrackContextMenu.slint";
|
||||||
|
|
||||||
// Circular hover-overlay action button — identical visual to AlbumCard's
|
// Circular hover-overlay action button — identical visual to AlbumCard's
|
||||||
@@ -59,6 +58,10 @@ component OverlayButton inherits Rectangle {
|
|||||||
|
|
||||||
export component SearchTrackHero inherits Rectangle {
|
export component SearchTrackHero inherits Rectangle {
|
||||||
in property <TrackItem> track;
|
in property <TrackItem> track;
|
||||||
|
// Detailed quality string, e.g. "Hi-Res 24-bit / 192 kHz". Rendered as
|
||||||
|
// plain text below the artist (the icon badge was dropped — it clipped
|
||||||
|
// against the card edge). "" hides the line.
|
||||||
|
in property <string> quality-label;
|
||||||
// kind ("track"), track id, action ("play" | "favorite" | menu actions).
|
// kind ("track"), track id, action ("play" | "favorite" | menu actions).
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
@@ -84,9 +87,14 @@ export component SearchTrackHero inherits Rectangle {
|
|||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
// --- Artwork + hover overlay -------------------------------------
|
// --- Artwork + hover overlay -------------------------------------
|
||||||
Rectangle {
|
// Shrunk to 128px (from 142px) so the title / artist / quality-text
|
||||||
width: 142px;
|
// stack fits cleanly below it without growing the 160x220 card.
|
||||||
height: 142px;
|
// Centered horizontally within the inner width.
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: Theme.surface-elevated;
|
background: Theme.surface-elevated;
|
||||||
clip: true;
|
clip: true;
|
||||||
@@ -130,11 +138,11 @@ export component SearchTrackHero inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hover action buttons — favorite / play / more. Centered in
|
// Hover action buttons — favorite / play / more. Centered in
|
||||||
// the 142px artwork.
|
// the 128px artwork.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
x: 0px;
|
x: 0px;
|
||||||
y: (142px - self.height) / 2;
|
y: (128px - self.height) / 2;
|
||||||
width: 142px;
|
width: 128px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
alignment: center;
|
alignment: center;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
@@ -183,57 +191,50 @@ export component SearchTrackHero inherits Rectangle {
|
|||||||
root.media-action(k, i, a);
|
root.media-action(k, i, a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 6px; }
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
// --- Title / artist + quality badge ------------------------------
|
// --- Title / artist / quality text -------------------------------
|
||||||
// 142px wide to align with the artwork. The badge is given a fixed
|
// The icon quality badge was dropped (it clipped against the card
|
||||||
// column so it stays inside the card; the title/artist stack takes
|
// edge). Quality now reads as a plain text line below the artist,
|
||||||
// the remaining width and elides.
|
// matching the album hero's quality string. Full inner width, elided.
|
||||||
HorizontalLayout {
|
VerticalLayout {
|
||||||
width: 142px;
|
alignment: start;
|
||||||
height: 52px;
|
spacing: 1px;
|
||||||
spacing: 6px;
|
horizontal-stretch: 1;
|
||||||
|
|
||||||
VerticalLayout {
|
Text {
|
||||||
alignment: center;
|
text: root.track.title;
|
||||||
spacing: 1px;
|
width: 100%;
|
||||||
horizontal-stretch: 1;
|
horizontal-alignment: center;
|
||||||
|
height: 18px;
|
||||||
Text {
|
color: Theme.text-primary;
|
||||||
text: root.track.title;
|
font-size: 13px;
|
||||||
width: 100%;
|
font-weight: Typography.medium;
|
||||||
height: 18px;
|
vertical-alignment: center;
|
||||||
color: Theme.text-primary;
|
overflow: elide;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: Typography.medium;
|
|
||||||
vertical-alignment: center;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
if root.track.artist != "": Text {
|
|
||||||
text: root.track.artist;
|
|
||||||
width: 100%;
|
|
||||||
height: 16px;
|
|
||||||
color: Theme.text-muted;
|
|
||||||
font-size: 11px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if root.track.artist != "": Text {
|
||||||
// Icon-only quality badge — fixed-width column, kept inside the
|
text: root.track.artist;
|
||||||
// card (the hi-res badge is 42px wide).
|
width: 100%;
|
||||||
VerticalLayout {
|
horizontal-alignment: center;
|
||||||
alignment: center;
|
height: 16px;
|
||||||
width: 44px;
|
color: Theme.text-muted;
|
||||||
HorizontalLayout {
|
font-size: 11px;
|
||||||
alignment: end;
|
vertical-alignment: center;
|
||||||
QualityBadge {
|
overflow: elide;
|
||||||
tier: root.track.quality-tier;
|
}
|
||||||
label: "";
|
if root.quality-label != "": Text {
|
||||||
}
|
text: root.quality-label;
|
||||||
}
|
width: 100%;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
height: 15px;
|
||||||
|
color: Theme.accent;
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ export component SearchResultsView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
if SearchState.most-popular-kind == "track": SearchTrackHero {
|
if SearchState.most-popular-kind == "track": SearchTrackHero {
|
||||||
track: SearchState.most-popular-track;
|
track: SearchState.most-popular-track;
|
||||||
|
quality-label: SearchState.most-popular-quality-label;
|
||||||
media-action(k, i, a) => { root.media-action(k, i, a); }
|
media-action(k, i, a) => { root.media-action(k, i, a); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ export struct PmPlaylistItem {
|
|||||||
name: string,
|
name: string,
|
||||||
// "12 tracks" — total (remote + local).
|
// "12 tracks" — total (remote + local).
|
||||||
tracks-line: string,
|
tracks-line: string,
|
||||||
|
// "1h 43m" — total playtime, "" when zero (list-row meta only).
|
||||||
|
duration-line: string,
|
||||||
// "(3 local)" — local-only suffix, "" when none.
|
// "(3 local)" — local-only suffix, "" when none.
|
||||||
local-line: string,
|
local-line: string,
|
||||||
local-count: int,
|
local-count: int,
|
||||||
@@ -537,6 +539,10 @@ export struct PmTreeRow {
|
|||||||
|
|
||||||
export global PlaylistManagerState {
|
export global PlaylistManagerState {
|
||||||
in property <[PmFolderItem]> folders: [];
|
in property <[PmFolderItem]> folders: [];
|
||||||
|
// Filtered folder list for the list-row move-to-folder menu. Starts as
|
||||||
|
// the full folder set; the menu's search box narrows it via
|
||||||
|
// `search-folders` (Slint strings have no `contains`, so Rust filters).
|
||||||
|
in property <[PmFolderItem]> menu-folders: [];
|
||||||
in property <[PmPlaylistItem]> playlists: []; // the filtered+sorted grid/list set
|
in property <[PmPlaylistItem]> playlists: []; // the filtered+sorted grid/list set
|
||||||
in property <[PmTreeRow]> tree: [];
|
in property <[PmTreeRow]> tree: [];
|
||||||
// Header counters: total folders / total playlists (post-filter for
|
// Header counters: total folders / total playlists (post-filter for
|
||||||
@@ -568,6 +574,9 @@ export global PlaylistManagerActions {
|
|||||||
callback set-filter(string);
|
callback set-filter(string);
|
||||||
callback set-sort(string);
|
callback set-sort(string);
|
||||||
callback set-view-mode(string);
|
callback set-view-mode(string);
|
||||||
|
// Filter the list-row move-to-folder menu's folder list (substring,
|
||||||
|
// case-insensitive). Empty query restores the full list.
|
||||||
|
callback search-folders(string /* query */);
|
||||||
callback toggle-folder-mode();
|
callback toggle-folder-mode();
|
||||||
callback toggle-folders-collapsed();
|
callback toggle-folders-collapsed();
|
||||||
// Tree-view folder expand/collapse.
|
// Tree-view folder expand/collapse.
|
||||||
@@ -1417,6 +1426,10 @@ export global SearchState {
|
|||||||
in property <AlbumCardItem> most-popular-album;
|
in property <AlbumCardItem> most-popular-album;
|
||||||
in property <SlimItem> most-popular-artist;
|
in property <SlimItem> most-popular-artist;
|
||||||
in property <TrackItem> most-popular-track;
|
in property <TrackItem> most-popular-track;
|
||||||
|
// Detailed quality label for the most-popular TRACK hero, e.g.
|
||||||
|
// "Hi-Res 24-bit / 192 kHz". Shown as text (the hero dropped the icon
|
||||||
|
// badge). "" when unknown / not a track.
|
||||||
|
in property <string> most-popular-quality-label;
|
||||||
// Active searchType filter, as a dropdown index (0 = no filter).
|
// Active searchType filter, as a dropdown index (0 = no filter).
|
||||||
// The controller owns the index -> API-value mapping.
|
// The controller owns the index -> API-value mapping.
|
||||||
in property <int> filter-index: 0;
|
in property <int> filter-index: 0;
|
||||||
|
|||||||
Reference in new issue
Block a user