feat(slint): LocalLibrary ephemeral folders + Folders tree 1:1 port

Ephemeral folders: open / browse / play a folder without indexing it —
album-grouped pane, CUE seek fast-path (no full re-decode per virtual track),
folder-path persistence + rehydrate, "already playing" add-vs-replace choice
dialog (ephemeral-only), and now-playing / queue / menu gating so an ephemeral
track exposes no persistence actions.

Folders tree brought 1:1 with Tauri:
- Rail header: select-mode, collapse-all, open-ephemeral, tree search.
- Resizable rail (default ~20% narrower; drag handle, min 50% default /
  max 50% of the content area).
- Subfolder cover grid + grid/list toggle, recursive track count, name search;
  compact icon bulk bar (folder-check / play-next / queue / playlist / clear).
- Tree is the default mode; circular checkboxes homologated with TrackRow;
  compact rows (smaller type + icon, no ambiguous trailing count).
- Flat/Tree icon toggle (disc-album / folder-tree), floated right.

Per-source TrackRow context menu (logic, not a blanket trim): Qobuz/offline =
full; local = play/next/queue + add-to-playlist; plex/ephemeral = play/next/
queue only.

App-wide standard: Math.round pixel-snap on icon centering across the UI to
kill sub-pixel blur (CircleAction, header back/forward/collapse, and the rest).
NowPlayingBar context icon -> turntable (frees disc-album for the Folders
toggle). New assets: folder-check, folder-tree, turntable.

cc
This commit is contained in:
vicrodh committed 2026-06-07 10:12:59 -06:00
1 parent ee9a3c1c89
commit 82129cff35
74 files changed
+2666 -522

No files matched your search

+12
View File
@@ -85,6 +85,9 @@ pub enum ArtworkTarget {
LocalAlbumCard { index: usize, gen: u64 },
/// A card in LocalLibraryState.folders[index] (Folders-flat grid).
LocalFolderCard { index: usize },
/// A subfolder cover card in LocalLibraryState.folder-detail-subfolders[index]
/// (Folders-tree detail pane).
LocalFolderDetailCard { index: usize },
/// A card in LocalLibraryState.artists-selected-albums[index] (the
/// Artists tab right pane — the selected artist's albums).
LocalArtistAlbumCard { index: usize },
@@ -636,6 +639,15 @@ fn apply_artwork(
crate::local_library::set_local_folder_artwork(window, &id, image);
}
}
ArtworkTarget::LocalFolderDetailCard { index } => {
let model = window
.global::<crate::LocalLibraryState>()
.get_folder_detail_subfolders();
if let Some(item) = model.row_data(index) {
let path = item.path.to_string();
crate::local_library::set_folder_detail_subfolder_artwork(window, &path, image);
}
}
ArtworkTarget::LocalArtistAlbumCard { index } => {
let model = window
.global::<crate::LocalLibraryState>()
+76
View File
@@ -0,0 +1,76 @@
//! Process-global wrapper around the shared `qbz_library::ephemeral` state.
//!
//! Ephemeral folders let the user open a folder OUTSIDE their indexed library,
//! browse it, and play it without anything being written to `library.db`. The
//! scan/metadata/CUE logic is frontend-agnostic (`qbz-library`, ADR-006); this
//! module just holds the single in-process session and exposes thin helpers the
//! UI controller (`local_library.rs`) and the playback resolver (`playback.rs`)
//! call. The session is in-memory only — it vanishes on app exit; what survives
//! a restart is the folder PATH, persisted in `locallibrary_prefs` and
//! re-scanned on startup.
use std::path::Path;
use std::sync::LazyLock;
use qbz_library::ephemeral::{
EphemeralError, EphemeralFolderResult, EphemeralLibraryState, EPHEMERAL_ID_FLOOR,
};
use qbz_library::LocalTrack;
// Every method on `EphemeralLibraryState` takes `&self` and guards its own
// inner `Mutex`, so a bare `LazyLock` (no outer lock) is enough.
static STATE: LazyLock<EphemeralLibraryState> = LazyLock::new(EphemeralLibraryState::new);
/// `true` if `id` is a synthetic ephemeral track id (>= 2^48). DB row ids never
/// reach this floor, so the check unambiguously routes playback to the in-memory
/// store instead of `library.db`.
pub fn is_ephemeral_id(id: i64) -> bool {
id >= EPHEMERAL_ID_FLOOR
}
/// Scan a folder and replace the current ephemeral session. Blocking (fs I/O) —
/// call from `spawn_blocking`.
pub fn open(path: &Path) -> Result<EphemeralFolderResult, EphemeralError> {
STATE.open_folder(path)
}
/// Drop the current ephemeral session.
pub fn clear() {
STATE.clear();
}
/// Resolve a synthetic id to its cached `LocalTrack` (None if the session was
/// cleared or the id is stale).
pub fn get_track(id: i64) -> Option<LocalTrack> {
STATE.get_track(id)
}
/// Every track in the current session, in scan order.
pub fn tracks_snapshot() -> Vec<LocalTrack> {
STATE.tracks_snapshot()
}
/// The tracks of one album group (matched on `album_group_key`, with the same
/// `album|||album_artist` fallback the scanner uses), in scan order.
pub fn album_tracks(group_key: &str) -> Vec<LocalTrack> {
STATE
.tracks_snapshot()
.into_iter()
.filter(|t| ephemeral_album_key(t) == group_key)
.collect()
}
/// The album grouping key for an ephemeral track — `album_group_key` when set,
/// else `album|||album_artist` (mirrors the scanner's fallback so the UI groups
/// and the play-album lookup agree).
pub fn ephemeral_album_key(t: &LocalTrack) -> String {
if !t.album_group_key.is_empty() {
t.album_group_key.clone()
} else {
format!(
"{}|||{}",
t.album,
t.album_artist.as_deref().unwrap_or(&t.artist)
)
}
}
+572 -11
View File
@@ -20,8 +20,8 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::{
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FolderNode, LocalArtistItem,
LocalArtistSection, LocalLibraryState, TrackItem,
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, EphemeralAlbum, FolderNode,
FolderSubcardItem, LocalArtistItem, LocalArtistSection, LocalLibraryState, TrackItem,
};
/// The four browse tabs. Order mirrors Tauri's default tab order
@@ -652,10 +652,12 @@ fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
removing: false,
cache_status: 0,
cache_progress: 0.0,
// Source indicator: offline copies read as Qobuz, user files as local.
// Source indicator: offline copies read as Qobuz, user files as local,
// ephemeral tracks tagged so the UI can gate persistence actions.
source: match t.source.as_deref() {
Some("qobuz_download") => "qobuz",
Some("plex") => "plex",
Some("ephemeral") => "ephemeral",
_ => "local",
}
.into(),
@@ -1359,6 +1361,8 @@ fn entry_to_node(entry: &qbz_library::FolderTreeEntry, depth: i32) -> FolderNode
expanded: false,
can_expand: *track_count_under > 0,
track_count: *track_count_under as i32,
selected: false,
select_state: 0,
},
qbz_library::FolderTreeEntry::Track { path, segment } => FolderNode {
path: path.clone().into(),
@@ -1368,6 +1372,8 @@ fn entry_to_node(entry: &qbz_library::FolderTreeEntry, depth: i32) -> FolderNode
expanded: false,
can_expand: false,
track_count: 0,
selected: false,
select_state: 0,
},
}
}
@@ -1409,10 +1415,12 @@ pub fn ensure_folder_tree_loaded(weak: slint::Weak<AppWindow>, handle: tokio::ru
expanded: false,
can_expand: cnt > 0,
track_count: cnt as i32,
selected: false,
select_state: 0,
})
.collect();
let s = w.global::<LocalLibraryState>();
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
apply_tree(&s, nodes);
s.set_folder_tree_loading(false);
});
});
@@ -1445,7 +1453,7 @@ pub fn toggle_folder_node(
end += 1;
}
nodes.drain(pos + 1..end);
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
apply_tree(&s, nodes);
}
});
return;
@@ -1472,28 +1480,271 @@ pub fn toggle_folder_node(
for (i, cn) in child_nodes.into_iter().enumerate() {
nodes.insert(pos + 1 + i, cn);
}
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
apply_tree(&s, nodes);
}
});
});
}
// ---- Tree rail: search filter + multi-select ----
use std::collections::HashMap;
// Selected tracks in the tree (path -> record), for the bulk bar.
static TREE_SELECTED: LazyLock<Mutex<HashMap<String, qbz_library::LocalTrack>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn tree_selected() -> std::sync::MutexGuard<'static, HashMap<String, qbz_library::LocalTrack>> {
TREE_SELECTED.lock().unwrap_or_else(|e| e.into_inner())
}
/// Apply the current selection to `nodes` (track `selected`, folder tri-state),
/// commit the tree model, refresh the selected count, and derive the visible
/// (search-filtered) set. The single sink for every tree mutation.
fn apply_tree(s: &LocalLibraryState, mut nodes: Vec<FolderNode>) {
{
let sel = tree_selected();
for n in nodes.iter_mut() {
if n.is_folder {
let prefix = format!("{}/", n.path);
let under = sel.keys().filter(|p| p.starts_with(&prefix)).count();
n.select_state = if under == 0 {
0
} else if n.track_count > 0 && under as i32 >= n.track_count {
2
} else {
1
};
n.selected = false;
} else {
n.selected = sel.contains_key(n.path.as_str());
n.select_state = 0;
}
}
s.set_tree_selected_count(sel.len() as i32);
}
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
derive_folder_tree_visible(s);
}
/// Derive `folder-tree-visible` from `folder-tree`, filtered by the rail search
/// (keeps matching nodes AND their ancestors so the tree stays navigable).
fn derive_folder_tree_visible(s: &LocalLibraryState) {
let full = s.get_folder_tree();
let nodes: Vec<FolderNode> = (0..full.row_count()).filter_map(|i| full.row_data(i)).collect();
let q = s.get_folders_tree_search().as_str().trim().to_lowercase();
if q.is_empty() {
s.set_folder_tree_visible(ModelRc::new(VecModel::from(nodes)));
return;
}
let matches: Vec<String> = nodes
.iter()
.filter(|n| n.segment.as_str().to_lowercase().contains(&q))
.map(|n| n.path.to_string())
.collect();
let kept: Vec<FolderNode> = nodes
.into_iter()
.filter(|n| {
let p = n.path.as_str();
n.segment.as_str().to_lowercase().contains(&q)
|| matches.iter().any(|m| m.starts_with(&format!("{p}/")))
})
.collect();
s.set_folder_tree_visible(ModelRc::new(VecModel::from(kept)));
}
/// Re-run the visible filter after a search-text change.
pub fn folders_tree_search(window: &AppWindow, query: &str) {
let s = window.global::<LocalLibraryState>();
s.set_folders_tree_search(query.into());
derive_folder_tree_visible(&s);
}
/// Collapse every expanded folder — keep only the depth-0 roots.
pub fn collapse_all_tree(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
let mut nodes = collect_tree(&s);
nodes.retain(|n| n.depth == 0);
for n in nodes.iter_mut() {
n.expanded = false;
}
apply_tree(&s, nodes);
}
/// Toggle multi-select mode; leaving it clears the selection.
pub fn toggle_tree_select_mode(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
let on = !s.get_tree_select_mode();
s.set_tree_select_mode(on);
if !on {
tree_selected().clear();
let nodes = collect_tree(&s);
apply_tree(&s, nodes);
}
}
/// Toggle every track under a folder (recursive). 'all' → deselect; else select.
pub fn toggle_tree_folder_select(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
path: String,
) {
handle.spawn(async move {
let p = path.clone();
let tracks = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.list_folder_tracks_recursive(&p, false))
.unwrap_or_default()
})
.await
.unwrap_or_default();
if tracks.is_empty() {
return;
}
let all_selected = {
let sel = tree_selected();
tracks.iter().all(|t| sel.contains_key(&t.file_path))
};
{
let mut sel = tree_selected();
if all_selected {
for t in &tracks {
sel.remove(&t.file_path);
}
} else {
for t in tracks {
sel.insert(t.file_path.clone(), t);
}
}
}
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<LocalLibraryState>();
let nodes = collect_tree(&s);
apply_tree(&s, nodes);
});
});
}
/// Toggle a single track row by path.
pub fn toggle_tree_track_select(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
path: String,
) {
handle.spawn(async move {
let was_selected = tree_selected().contains_key(&path);
if was_selected {
tree_selected().remove(&path);
} else {
// Resolve the track record from its parent folder listing.
let parent = std::path::Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let p = parent.clone();
let tracks = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.list_folder_tracks(&p, false)).unwrap_or_default()
})
.await
.unwrap_or_default();
if let Some(t) = tracks.into_iter().find(|t| t.file_path == path) {
tree_selected().insert(path.clone(), t);
}
}
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<LocalLibraryState>();
let nodes = collect_tree(&s);
apply_tree(&s, nodes);
});
});
}
/// Snapshot the currently-selected tree tracks (scan order by path).
pub fn tree_selected_snapshot() -> Vec<qbz_library::LocalTrack> {
let sel = tree_selected();
let mut v: Vec<qbz_library::LocalTrack> = sel.values().cloned().collect();
v.sort_by(|a, b| a.file_path.cmp(&b.file_path));
v
}
/// Toggle "select all": if every track under the roots is already selected,
/// clear the selection; otherwise select them all. Two-way (the bulk button
/// flips select-all / un-select-all).
pub fn tree_select_all(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move {
// Roots = the registered library folders.
let paths = tokio::task::spawn_blocking(|| {
crate::library_db::with_db(|db| db.get_folders()).unwrap_or_default()
})
.await
.unwrap_or_default();
let all = tokio::task::spawn_blocking(move || {
let mut acc: Vec<qbz_library::LocalTrack> = Vec::new();
for p in paths {
let mut t =
crate::library_db::with_db(|db| db.list_folder_tracks_recursive(&p, false))
.unwrap_or_default();
acc.append(&mut t);
}
acc
})
.await
.unwrap_or_default();
{
let mut sel = tree_selected();
let all_selected =
!all.is_empty() && all.iter().all(|t| sel.contains_key(&t.file_path));
if all_selected {
// Un-select everything under the roots.
for t in &all {
sel.remove(&t.file_path);
}
} else {
for t in all {
sel.insert(t.file_path.clone(), t);
}
}
}
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<LocalLibraryState>();
let nodes = collect_tree(&s);
apply_tree(&s, nodes);
});
});
}
/// Clear the tree selection.
pub fn tree_clear_selection(window: &AppWindow) {
tree_selected().clear();
let s = window.global::<LocalLibraryState>();
let nodes = collect_tree(&s);
apply_tree(&s, nodes);
}
/// Select a folder in the tree: load its detail pane — direct child tracks
/// plus immediate subfolders (for in-pane drill-down).
pub fn select_folder(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
path: String,
segment: String,
) {
let _ = weak.upgrade_in_event_loop({
let path = path.clone();
let segment = segment.clone();
// Drill-in from a subfolder card passes an empty segment — derive the
// display name from the path so the detail header is never blank.
let segment = if segment.is_empty() {
path_basename(&path)
} else {
segment.clone()
};
move |w| {
let s = w.global::<LocalLibraryState>();
s.set_folders_selected_path(path.clone().into());
s.set_folders_selected_name(segment.clone().into());
s.set_folder_detail_loading(true);
// Reset the per-folder subfolder filter on navigation.
s.set_folder_detail_search("".into());
}
});
let path_for_fetch = path.clone();
@@ -1513,19 +1764,96 @@ pub fn select_folder(
.unwrap_or_default();
let _ = weak.upgrade_in_event_loop(move |w| {
let track_items: Vec<TrackItem> = tracks.into_iter().map(map_local_track).collect();
let sub_nodes: Vec<FolderNode> = subfolders
// Subfolders become cover cards (1:1 with Tauri). The cover comes from
// FolderTreeEntry::Folder.artwork (resolved async below).
let cards: Vec<FolderSubcardItem> = subfolders
.iter()
.filter(|e| matches!(e, qbz_library::FolderTreeEntry::Folder { .. }))
.map(|e| entry_to_node(e, 0))
.filter_map(|e| match e {
qbz_library::FolderTreeEntry::Folder {
path,
segment,
track_count_under,
artwork,
} => Some(FolderSubcardItem {
path: path.clone().into(),
name: segment.clone().into(),
track_count: *track_count_under as i32,
artwork: slint::Image::default(),
artwork_url: artwork.clone().unwrap_or_default().into(),
}),
_ => None,
})
.collect();
// Recursive count = sum of subfolder counts + this folder's direct tracks.
let recursive: i32 =
cards.iter().map(|c| c.track_count).sum::<i32>() + track_items.len() as i32;
let s = w.global::<LocalLibraryState>();
s.set_folder_detail_tracks(ModelRc::new(VecModel::from(track_items)));
s.set_folder_detail_subfolders(ModelRc::new(VecModel::from(sub_nodes)));
s.set_folder_detail_track_count(recursive);
s.set_folder_detail_subfolders(ModelRc::new(VecModel::from(cards)));
s.set_folder_detail_loading(false);
derive_folder_detail(&w);
// Spawn cover artwork jobs over the full subfolder set.
let full = s.get_folder_detail_subfolders();
let mut jobs: Vec<ArtworkJob> = Vec::new();
for i in 0..full.row_count() {
if let Some(it) = full.row_data(i) {
let url = it.artwork_url.to_string();
if !url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::LocalFolderDetailCard { index: i },
url,
});
}
}
}
if !jobs.is_empty() {
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache.clone());
}
});
});
}
/// Re-derive the rendered subfolder set (`-visible`) from the full set, filtered
/// by the subfolder name search. Mirrors `derive_folders`.
fn derive_folder_detail(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
let full = s.get_folder_detail_subfolders();
let q = s.get_folder_detail_search().as_str().trim().to_lowercase();
let rows: Vec<FolderSubcardItem> = (0..full.row_count())
.filter_map(|i| full.row_data(i))
.filter(|it| q.is_empty() || it.name.as_str().to_lowercase().contains(&q))
.collect();
s.set_folder_detail_subfolders_visible(ModelRc::new(VecModel::from(rows)));
}
/// Set the search filter for the subfolder cards and re-derive.
pub fn folder_detail_search(window: &AppWindow, query: &str) {
window
.global::<LocalLibraryState>()
.set_folder_detail_search(query.into());
derive_folder_detail(window);
}
/// Dual-set a resolved cover onto the full + visible subfolder sets by path.
pub fn set_folder_detail_subfolder_artwork(window: &AppWindow, path: &str, image: slint::Image) {
let s = window.global::<LocalLibraryState>();
let set_in = |m: &ModelRc<FolderSubcardItem>| {
for i in 0..m.row_count() {
if let Some(mut it) = m.row_data(i) {
if it.path.as_str() == path {
it.artwork = image.clone();
m.set_row_data(i, it);
break;
}
}
}
};
set_in(&s.get_folder_detail_subfolders());
set_in(&s.get_folder_detail_subfolders_visible());
}
// ============================== Artists tab ===============================
//
// Two-column master/detail, 1:1 with Tauri's Artists tab (NOT a card grid —
@@ -2123,6 +2451,239 @@ pub fn fetch_album_tracks_blocking(group_key: &str) -> Vec<qbz_library::LocalTra
.unwrap_or_default()
}
// ======================= Ephemeral folder =========================
// Open a folder OUTSIDE the indexed library, browse + play it without writing
// to library.db. The scan/metadata logic is shared (`qbz_library::ephemeral`);
// here we drive the picker, build the album-grouped pane, and persist the path.
/// Last path segment (folder name) for the header.
fn folder_display_name(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string())
}
/// Load a cover from a cached artwork path (strips an optional `file://`).
fn load_cover(path: &Option<String>) -> slint::Image {
let Some(p) = path.as_deref().filter(|s| !s.is_empty()) else {
return slint::Image::default();
};
let p = p.strip_prefix("file://").unwrap_or(p);
slint::Image::load_from_path(std::path::Path::new(p)).unwrap_or_default()
}
/// Group ephemeral tracks into album blocks (sorted by title), each with its
/// cover + tracks. Returns the blocks and whether the session spans >1 album.
/// MUST run on the UI thread — it loads `slint::Image`s (not Send).
fn build_ephemeral_albums(tracks: &[qbz_library::LocalTrack]) -> (Vec<EphemeralAlbum>, bool) {
use std::collections::BTreeMap;
// Preserve scan order within a group; key order is stabilized by title sort.
let mut groups: BTreeMap<String, Vec<qbz_library::LocalTrack>> = BTreeMap::new();
for t in tracks {
groups
.entry(crate::ephemeral::ephemeral_album_key(t))
.or_default()
.push(t.clone());
}
let multi = groups.len() > 1;
let mut albums: Vec<EphemeralAlbum> = groups
.into_iter()
.map(|(key, group)| {
let first = &group[0];
let title = if first.album_group_title.is_empty() {
first.album.clone()
} else {
first.album_group_title.clone()
};
let artist = first
.album_artist
.clone()
.unwrap_or_else(|| first.artist.clone());
let count = group.len();
let meta = match first.year {
Some(y) if y > 0 => format!("{y} · {count} tracks"),
_ => format!("{count} tracks"),
};
let tier = if first.format.to_string().eq_ignore_ascii_case("mp3") {
"mp3"
} else {
match first.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
}
};
let is_cue = first.cue_file_path.is_some() || first.cue_start_secs.is_some();
let artwork = load_cover(&first.artwork_path);
let items: Vec<TrackItem> = group.into_iter().map(map_local_track).collect();
EphemeralAlbum {
group_key: key.into(),
title: title.into(),
artist: artist.into(),
meta: meta.into(),
quality_tier: tier.into(),
is_cue,
artwork,
tracks: ModelRc::new(VecModel::from(items)),
}
})
.collect();
albums.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
(albums, multi)
}
/// Push a scanned ephemeral result onto the UI (UI thread). `focus` switches to
/// the Folders tab (true for an explicit open; false on startup rehydrate so we
/// don't hijack the landing view).
fn apply_ephemeral(
window: &AppWindow,
name: &str,
path: &str,
tracks: &[qbz_library::LocalTrack],
focus: bool,
) {
let (albums, multi) = build_ephemeral_albums(tracks);
let s = window.global::<LocalLibraryState>();
s.set_ephemeral_active(true);
s.set_ephemeral_loading(false);
s.set_ephemeral_name(name.into());
s.set_ephemeral_path(path.into());
s.set_ephemeral_track_count(tracks.len() as i32);
s.set_ephemeral_multi_album(multi);
s.set_ephemeral_albums(ModelRc::new(VecModel::from(albums)));
if focus {
s.set_active_tab("folders".into());
}
}
type EphRuntime = Arc<AppRuntime<SlintAdapter>>;
/// Open the native folder picker, then scan + show the ephemeral pane.
pub fn open_ephemeral(
runtime: EphRuntime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
handle.spawn(async move {
let Some(dir) = rfd::AsyncFileDialog::new()
.set_title("Choose a folder to play")
.pick_folder()
.await
else {
return;
};
let path = dir.path().to_string_lossy().to_string();
scan_ephemeral(Some(runtime), weak, path, true).await;
});
}
/// Re-open a previously-persisted ephemeral path on startup (no picker). Skips
/// silently if the path is gone, clearing the stale pref.
pub fn rehydrate_ephemeral(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
let Some(path) = crate::locallibrary_prefs::ephemeral_path() else {
return;
};
handle.spawn(async move {
scan_ephemeral(None, weak, path, false).await;
});
}
/// Shared scan path used by both the picker and rehydrate. When a `runtime` is
/// given (explicit open), any ephemeral track currently playing is wiped first
/// so it can't bleed into the freshly-loaded session (its synthetic id would be
/// reused). Rehydrate passes `None` (startup — nothing is playing).
async fn scan_ephemeral(
runtime: Option<EphRuntime>,
weak: slint::Weak<AppWindow>,
path: String,
from_picker: bool,
) {
if let Some(rt) = &runtime {
crate::playback::wipe_ephemeral_if_playing(rt, &weak).await;
}
let name = folder_display_name(&path);
{
let nm = name.clone();
let p = path.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<LocalLibraryState>();
s.set_ephemeral_active(true);
s.set_ephemeral_loading(true);
s.set_ephemeral_name(nm.into());
s.set_ephemeral_path(p.into());
s.set_ephemeral_albums(ModelRc::new(VecModel::from(Vec::<EphemeralAlbum>::new())));
if from_picker {
s.set_active_tab("folders".into());
}
});
}
let scan_path = path.clone();
let result = tokio::task::spawn_blocking(move || {
crate::ephemeral::open(std::path::Path::new(&scan_path))
})
.await;
match result {
Ok(Ok(res)) => {
let tracks = res.tracks;
let skipped = res.skipped_files;
let nm = name.clone();
let p = path.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
apply_ephemeral(&w, &nm, &p, &tracks, from_picker);
});
crate::locallibrary_prefs::save_ephemeral_path(Some(&path));
if from_picker {
if skipped > 0 {
crate::toast::success_weak(
&weak,
format!("Opened folder ({skipped} files skipped)"),
);
} else {
crate::toast::success_weak(&weak, "Folder opened");
}
}
}
Ok(Err(e)) => {
log::warn!("[qbz-slint] ephemeral open failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
reset_ephemeral_state(&w);
});
crate::ephemeral::clear();
crate::locallibrary_prefs::save_ephemeral_path(None);
if from_picker {
crate::toast::error_weak(&weak, "Couldn't open that folder");
}
}
Err(_) => {
let _ = weak.upgrade_in_event_loop(|w| {
reset_ephemeral_state(&w);
});
}
}
}
/// Reset the ephemeral UI state to its closed defaults.
fn reset_ephemeral_state(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
s.set_ephemeral_active(false);
s.set_ephemeral_loading(false);
s.set_ephemeral_name("".into());
s.set_ephemeral_path("".into());
s.set_ephemeral_track_count(0);
s.set_ephemeral_multi_album(false);
s.set_ephemeral_albums(ModelRc::new(VecModel::from(Vec::<EphemeralAlbum>::new())));
}
/// Clear the ephemeral session: drop the pane, the in-memory store, and the
/// persisted path.
pub fn clear_ephemeral(window: &AppWindow) {
reset_ephemeral_state(window);
crate::ephemeral::clear();
crate::locallibrary_prefs::save_ephemeral_path(None);
}
#[cfg(test)]
mod tests {
use super::*;
+30 -9
View File
@@ -13,12 +13,17 @@ use crate::{AppWindow, LocalLibraryState};
struct Prefs {
#[serde(default = "d_off")]
tracks_group: String,
// Path of the last-opened ephemeral folder, re-scanned on startup. None when
// no ephemeral session is active.
#[serde(default)]
ephemeral_folder: Option<String>,
}
impl Default for Prefs {
fn default() -> Self {
Self {
tracks_group: d_off(),
ephemeral_folder: None,
}
}
}
@@ -49,21 +54,37 @@ pub fn load(window: &AppWindow) {
.set_tracks_group_mode(p.tracks_group.into());
}
/// Persist the current toolbar choices read from LocalLibraryState.
pub fn save(window: &AppWindow) {
fn write(p: &Prefs) {
let Some(path) = store_path() else {
return;
};
let p = Prefs {
tracks_group: window
.global::<LocalLibraryState>()
.get_tracks_group_mode()
.into(),
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_vec_pretty(&p) {
if let Ok(json) = serde_json::to_vec_pretty(p) {
let _ = std::fs::write(&path, json);
}
}
/// Persist the current toolbar choices read from LocalLibraryState. Preserves
/// the ephemeral-folder path (read-modify-write).
pub fn save(window: &AppWindow) {
let mut p = read();
p.tracks_group = window
.global::<LocalLibraryState>()
.get_tracks_group_mode()
.into();
write(&p);
}
/// The persisted ephemeral-folder path, if any (rehydrated on startup).
pub fn ephemeral_path() -> Option<String> {
read().ephemeral_folder.filter(|s| !s.is_empty())
}
/// Persist (or clear) the ephemeral-folder path (read-modify-write).
pub fn save_ephemeral_path(path: Option<&str>) {
let mut p = read();
p.ephemeral_folder = path.map(|s| s.to_string());
write(&p);
}
+253 -1
View File
@@ -45,6 +45,7 @@ mod qconnect_service;
mod qconnect_transport;
mod queue;
mod drag;
mod ephemeral;
mod folders;
mod library_db;
mod local_library;
@@ -997,7 +998,10 @@ fn navigate_local_library(
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
}
local_library::LibTab::Folders => {
local_library::ensure_folders_loaded(weak, handle.clone(), image_cache);
// Tree is the default mode → load the tree roots too (the flat set
// stays loaded so toggling to flat is instant).
local_library::ensure_folders_loaded(weak.clone(), handle.clone(), image_cache);
local_library::ensure_folder_tree_loaded(weak, handle.clone());
}
local_library::LibTab::Tracks => {
local_library::ensure_tracks_loaded(weak, handle.clone());
@@ -4936,6 +4940,104 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// ---- Folders tree rail: search / collapse / multi-select ----
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_folders_tree_search(move |query| {
if let Some(w) = weak.upgrade() {
local_library::folders_tree_search(&w, query.as_str());
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_folders_collapse_all(move || {
if let Some(w) = weak.upgrade() {
local_library::collapse_all_tree(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_folders_toggle_select_mode(move || {
if let Some(w) = weak.upgrade() {
local_library::toggle_tree_select_mode(&w);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_folders_toggle_folder_select(move |path| {
local_library::toggle_tree_folder_select(weak.clone(), handle.clone(), path.to_string());
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_folders_toggle_track_select(move |path| {
local_library::toggle_tree_track_select(weak.clone(), handle.clone(), path.to_string());
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_folders_bulk_action(move |action| {
let Some(w) = weak.upgrade() else {
return;
};
match action.as_str() {
"select-all" => {
local_library::tree_select_all(weak.clone(), handle.clone());
}
"clear" => local_library::tree_clear_selection(&w),
"queue" => {
let rows = local_library::tree_selected_snapshot();
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, false);
local_library::tree_clear_selection(&w);
}
"play-next" => {
let rows = local_library::tree_selected_snapshot();
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true);
local_library::tree_clear_selection(&w);
}
"add-to-playlist" => {
let rows = local_library::tree_selected_snapshot();
let ids: Vec<String> = rows
.iter()
.filter(|t| t.source.as_deref() != Some("plex"))
.map(|t| t.id.to_string())
.collect();
if !ids.is_empty() {
playlist_picker::open_multi(&w, &ids, true);
let runtime = runtime.clone();
let weak2 = weak.clone();
handle.spawn(async move {
let playlists = playlist_picker::load(&runtime).await;
let _ = weak2.upgrade_in_event_loop(move |w| {
playlist_picker::apply(&w, playlists);
});
});
}
}
_ => {}
}
});
}
// ---- Folders tab actions ----
{
let weak = window.as_weak();
@@ -5003,17 +5105,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocalLibraryActions>()
.on_folders_select(move |path, segment| {
local_library::select_folder(
weak.clone(),
handle.clone(),
image_cache.clone(),
path.to_string(),
segment.to_string(),
);
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_folder_detail_search(move |query| {
if let Some(w) = weak.upgrade() {
local_library::folder_detail_search(&w, query.as_str());
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
@@ -5058,6 +5172,144 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// ---- Ephemeral folder actions ----
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_ephemeral_open(move || {
local_library::open_ephemeral(runtime.clone(), weak.clone(), handle.clone());
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_ephemeral_play_all(move || {
playback::ephemeral_play_or_prompt(
runtime.clone(),
weak.clone(),
handle.clone(),
"all".to_string(),
String::new(),
);
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_ephemeral_play_track(move |id| {
playback::ephemeral_play_or_prompt(
runtime.clone(),
weak.clone(),
handle.clone(),
"track".to_string(),
id.to_string(),
);
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_ephemeral_play_album(move |key| {
playback::ephemeral_play_or_prompt(
runtime.clone(),
weak.clone(),
handle.clone(),
"album".to_string(),
key.to_string(),
);
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LocalLibraryActions>()
.on_ephemeral_clear(move || {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
// Stop a playing ephemeral track before dropping the session
// so its (about-to-be-reused) id can't false-highlight rows.
playback::wipe_ephemeral_if_playing(&runtime, &weak).await;
let _ = weak.upgrade_in_event_loop(|w| {
local_library::clear_ephemeral(&w);
});
});
});
}
// Ephemeral "already playing" choice modal — clear-and-play vs add-to-queue.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<EphemeralPlayChoiceActions>()
.on_replace(move || {
if let Some(w) = weak.upgrade() {
let s = w.global::<EphemeralPlayChoiceState>();
let kind = s.get_intent_kind().to_string();
let arg = s.get_intent_arg().to_string();
s.set_open(false);
playback::ephemeral_play(
runtime.clone(),
weak.clone(),
handle.clone(),
kind,
arg,
);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<EphemeralPlayChoiceActions>()
.on_enqueue(move || {
if let Some(w) = weak.upgrade() {
let s = w.global::<EphemeralPlayChoiceState>();
let kind = s.get_intent_kind().to_string();
let arg = s.get_intent_arg().to_string();
s.set_open(false);
playback::ephemeral_enqueue(
runtime.clone(),
weak.clone(),
handle.clone(),
kind,
arg,
);
}
});
}
{
let weak = window.as_weak();
window
.global::<EphemeralPlayChoiceActions>()
.on_close(move || {
if let Some(w) = weak.upgrade() {
w.global::<EphemeralPlayChoiceState>().set_open(false);
}
});
}
// Restore a previously-open ephemeral folder (re-scans the path; does NOT
// switch the landing view). Runs once at startup.
local_library::rehydrate_ephemeral(window.as_weak(), tokio_rt.handle().clone());
// ---- Artists tab actions ----
{
let weak = window.as_weak();
+213 -9
View File
@@ -151,7 +151,9 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
// current track and `track_id` momentarily disagree. Auto-advance, skip and
// play-all all flow through here, so they become source-aware for free.
if let Some(qt) = runtime.core().current_track().await {
if qt.id == track_id && qt.source.as_deref() == Some("local") {
if qt.id == track_id
&& matches!(qt.source.as_deref(), Some("local") | Some("ephemeral"))
{
play_local_file_audible(runtime, track_id).await;
return;
}
@@ -176,14 +178,45 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
/// file, so seek to the track start. `row_id` is the library row id. Called
/// by `play_audible` when the current queue track's source is `"local"`.
async fn play_local_file_audible(runtime: &Runtime, row_id: u64) {
let info = tokio::task::spawn_blocking(move || {
// Ephemeral tracks (synthetic id >= 2^48) resolve from the in-memory
// session, never the DB. Everything downstream (read bytes, play_data, CUE
// seek) is identical to a real local file.
//
// FAST PATH for CUE virtual tracks: all the tracks of a CUE album share ONE
// big audio file. If that file is already loaded in the player (the loaded
// track is ephemeral and points at the same path), DON'T re-read + re-decode
// the whole FLAC — just seek to the new track's start. Re-reading a multi-
// hundred-MB single-file album on every track click was "infierno de lento".
// The seekbar then reports absolute file time (accepted limitation, as in
// the Tauri build); the now-playing title/artist still update from the queue
// cursor.
if crate::ephemeral::is_ephemeral_id(row_id as i64) {
if let Some(target) = crate::ephemeral::get_track(row_id as i64) {
let loaded_id = runtime.core().player().state.current_track_id();
if runtime.core().player().has_loaded_audio()
&& crate::ephemeral::is_ephemeral_id(loaded_id as i64)
&& crate::ephemeral::get_track(loaded_id as i64)
.map(|l| l.file_path == target.file_path)
.unwrap_or(false)
{
let pos = target.cue_start_secs.unwrap_or(0.0).max(0.0);
let _ = runtime.core().player().seek(pos as u64);
return;
}
}
}
let info = if crate::ephemeral::is_ephemeral_id(row_id as i64) {
crate::ephemeral::get_track(row_id as i64).map(|t| (t.file_path, t.cue_start_secs))
} else {
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.get_track(row_id as i64))
})
.await
.ok()
.flatten()
.flatten()
.map(|t| (t.file_path, t.cue_start_secs));
.map(|t| (t.file_path, t.cue_start_secs))
};
let Some((path, cue)) = info else {
log::error!("[qbz-slint] local play: track {row_id} not found");
return;
@@ -262,6 +295,168 @@ pub fn play_local_album(
});
}
/// If the track currently playing is from an ephemeral folder, stop it and
/// clear the queue + now-playing chrome. Mirrors Tauri's
/// `wipeEphemeralPlaybackArtifacts`: called when the ephemeral session is
/// cleared or replaced, so a stale ephemeral track (whose synthetic id will be
/// reused by the next session) can't linger in the bar or false-highlight a row
/// in the newly-loaded folder.
pub async fn wipe_ephemeral_if_playing(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let is_eph = runtime
.core()
.current_track()
.await
.map(|t| crate::ephemeral::is_ephemeral_id(t.id as i64))
.unwrap_or(false);
if !is_eph {
return;
}
let _ = runtime.core().stop();
runtime.core().clear_queue(false).await;
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NowPlayingState>().set_has_track(false);
});
refresh_sidebar(true);
}
/// Play the whole ephemeral folder (every album, scan order). The in-memory
/// snapshot becomes the queue; playback routes through the shared local-file
/// seam (the synthetic ids resolve via `crate::ephemeral`).
pub fn play_ephemeral_all(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
handle.spawn(async move {
let tracks = crate::ephemeral::tracks_snapshot();
play_local_tracks_now(&runtime, &weak, tracks, 0).await;
});
}
/// Play one ephemeral album (its tracks become the queue, in scan order).
pub fn play_ephemeral_album(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
group_key: String,
) {
handle.spawn(async move {
let tracks = crate::ephemeral::album_tracks(&group_key);
play_local_tracks_now(&runtime, &weak, tracks, 0).await;
});
}
/// Play one ephemeral track — its album group becomes the queue, starting at
/// the clicked track (mirrors Tauri's `playEphemeralTrack`).
pub fn play_ephemeral_track(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
track_id: i64,
) {
handle.spawn(async move {
let Some(track) = crate::ephemeral::get_track(track_id) else {
return;
};
let key = crate::ephemeral::ephemeral_album_key(&track);
let tracks = crate::ephemeral::album_tracks(&key);
let start = tracks
.iter()
.position(|t| t.id == track_id)
.unwrap_or(0);
play_local_tracks_now(&runtime, &weak, tracks, start).await;
});
}
/// Replace the queue with an ephemeral selection identified by intent.
pub fn ephemeral_play(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
kind: String,
arg: String,
) {
match kind.as_str() {
"all" => play_ephemeral_all(runtime, weak, handle),
"album" => play_ephemeral_album(runtime, weak, handle, arg),
"track" => {
if let Ok(id) = arg.parse::<i64>() {
play_ephemeral_track(runtime, weak, handle, id);
}
}
_ => {}
}
}
/// Append an ephemeral selection to the CURRENT queue (no replace).
pub fn ephemeral_enqueue(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
kind: String,
arg: String,
) {
handle.spawn(async move {
let tracks = match kind.as_str() {
"all" => crate::ephemeral::tracks_snapshot(),
"album" => crate::ephemeral::album_tracks(&arg),
"track" => arg
.parse::<i64>()
.ok()
.and_then(crate::ephemeral::get_track)
.into_iter()
.collect(),
_ => Vec::new(),
};
if tracks.is_empty() {
return;
}
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
runtime.core().add_tracks(queue).await;
refresh_sidebar(true);
crate::toast::success_weak(&weak, "Added to queue");
});
}
/// Either play the ephemeral selection now, or — if a queue is already active —
/// prompt add-to-queue vs clear-and-play. Only the ephemeral pane uses this
/// (user decision 2026-06-06: ephemeral-only, dialog-on-play).
pub fn ephemeral_play_or_prompt(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
kind: String,
arg: String,
) {
let rt = runtime.clone();
let wk = weak.clone();
let hd = handle.clone();
handle.spawn(async move {
let active = rt.core().current_track().await.is_some();
if active {
// "Add to queue" only when the existing queue is itself all-ephemeral
// (no mixing ephemeral with persistent tracks).
let (queue, _) = rt.core().get_all_queue_tracks().await;
let enqueue_allowed = !queue.is_empty()
&& queue.iter().all(|t| {
crate::ephemeral::is_ephemeral_id(t.id as i64)
|| t.source.as_deref() == Some("ephemeral")
});
let k = kind.clone();
let a = arg.clone();
let _ = wk.upgrade_in_event_loop(move |w| {
let s = w.global::<crate::EphemeralPlayChoiceState>();
s.set_intent_kind(k.into());
s.set_intent_arg(a.into());
s.set_enqueue_allowed(enqueue_allowed);
s.set_open(true);
});
} else {
ephemeral_play(rt, wk, hd, kind, arg);
}
});
}
/// Play an explicit list of local tracks (already resolved — e.g. one album
/// VERSION), starting at `start`. `shuffle` enables shuffle mode after the
/// queue is set. Used by the dedicated Local album view so it plays the SHOWN
@@ -398,7 +593,16 @@ fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
} else {
track.sample_rate
};
let is_offline = track.source.as_deref() == Some("qobuz_download");
// Source-aware: offline copies read as Qobuz downloads (carry the Qobuz id
// so the shared resolver finds them); ephemeral tracks keep their synthetic
// high id + an "ephemeral" tag so playback routes to the in-memory store;
// everything else is a real local user file.
let src = match track.source.as_deref() {
Some("qobuz_download") => "qobuz_download",
Some("ephemeral") => "ephemeral",
_ => "local",
};
let is_offline = src == "qobuz_download";
QueueTrack {
id: if is_offline {
track.qobuz_track_id.unwrap_or(track.id) as u64
@@ -418,11 +622,7 @@ fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
album_id: Some(track.album_group_key.clone()),
artist_id: None,
streamable: true,
source: Some(if is_offline {
"qobuz_download".to_string()
} else {
"local".to_string()
}),
source: Some(src.to_string()),
parental_warning: false,
source_item_id_hint: None,
}
@@ -548,6 +748,9 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
let album_id = track.album_id.clone().unwrap_or_default();
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
let track_id = track.id.to_string();
// Ephemeral tracks have no DB row → metadata-bound actions (favorite,
// add-to-playlist, track-info) are gated off in the UI via this flag.
let is_ephemeral = crate::ephemeral::is_ephemeral_id(track.id as i64);
let duration = track.duration_secs;
let artwork = track.artwork_ref();
// Quality badge: tier from bit depth (24-bit+ = Hi-Res), exact detail line
@@ -618,6 +821,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
np.set_album_id(album_id.into());
np.set_artist_id(artist_id.into());
np.set_track_id(track_id.into());
np.set_is_ephemeral(is_ephemeral);
np.set_quality_tier(quality_tier.into());
np.set_quality_detail(quality_detail.into());
np.set_duration_secs(duration as i32);
+4
View File
@@ -79,6 +79,7 @@ struct RowData {
explicit: bool,
artwork_url: String,
playing: bool,
is_ephemeral: bool,
}
/// `M:SS` duration string.
@@ -132,6 +133,8 @@ fn row_from(track: &QueueTrack, playing: bool) -> RowData {
explicit: track.parental_warning,
artwork_url: track.artwork_url.clone().unwrap_or_default(),
playing,
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|| crate::ephemeral::is_ephemeral_id(track.id as i64),
}
}
@@ -646,6 +649,7 @@ fn to_item(row: &RowData) -> QueueItem {
playing: row.playing,
duration: row.duration.clone().into(),
explicit: row.explicit,
is_ephemeral: row.is_ephemeral,
}
}
+12 -12
View File
@@ -40,8 +40,8 @@ component CircleAction inherits Rectangle {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
@@ -79,8 +79,8 @@ component SidebarCard inherits Rectangle {
source: root.icon;
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
@@ -564,8 +564,8 @@ export component AlbumPageView inherits Rectangle {
source: @image-url("../assets/icons/heart.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -575,8 +575,8 @@ export component AlbumPageView inherits Rectangle {
source: @image-url("../assets/icons/cloud-download.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -662,8 +662,8 @@ export component AlbumPageView inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -698,8 +698,8 @@ export component AlbumPageView inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -47,7 +47,7 @@ component VersionPicker inherits Rectangle {
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
pick-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
spacing: 7px;
VerticalLayout {
alignment: center;
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, 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, TextUtil, QconnectDevState, QconnectDevice, AppearanceState } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, 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, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions } 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`.
+12 -12
View File
@@ -473,8 +473,8 @@ export component ArtistPageView inherits Rectangle {
width: 28px;
height: 28px;
check-all-box := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 22px;
height: 22px;
border-radius: 11px;
@@ -537,8 +537,8 @@ export component ArtistPageView inherits Rectangle {
? Theme.text-primary
: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
@@ -710,8 +710,8 @@ export component ArtistPageView inherits Rectangle {
source: @image-url("../assets/icons/panel-right-close.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -1101,8 +1101,8 @@ export component ArtistPageView inherits Rectangle {
"../assets/icons/thumbs-down.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: dismiss-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -1142,8 +1142,8 @@ export component ArtistPageView inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -1177,8 +1177,8 @@ export component ArtistPageView inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-bio.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
<path d="m9 13 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 14 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" />
<path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H12a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" />
<path d="M3 5a2 2 0 0 0 2 2h3" />
<path d="M3 3v13a2 2 0 0 0 2 2h3" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

+4 -4
View File
@@ -44,8 +44,8 @@ component OverlayButton inherits Rectangle {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (root.primary ? #000000 : #ffffff);
@@ -266,8 +266,8 @@ export component AlbumCard inherits Rectangle {
width: root.album.source == "plex" ? 16px
: (root.album.source == "qobuz_download" ? 22px : 14px);
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
}
@@ -57,8 +57,8 @@ export component ArtistCard inherits Rectangle {
source: @image-url("../assets/icons/user.svg");
width: 38px;
height: 38px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
Image {
@@ -25,8 +25,8 @@ component NavButton inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
@@ -50,8 +50,8 @@ component ViewAllLink inherits Rectangle {
color: Theme.accent;
font-size: 14px;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
+4 -4
View File
@@ -28,8 +28,8 @@ component NavButton inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
@@ -55,8 +55,8 @@ component ViewAllLink inherits Rectangle {
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 14px;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
@@ -81,8 +81,8 @@ component BrowseSearch inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
@@ -66,8 +66,8 @@ component GenreTreeRowItem inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
@@ -138,8 +138,8 @@ component GenreCard inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent;
}
}
@@ -205,8 +205,8 @@ export component GenreFilterPopup inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
close-ta := TouchArea {
@@ -365,8 +365,8 @@ export component GenreFilterPopup inherits Rectangle {
text: @tr("Clear filter");
color: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
clear-ta := TouchArea {
mouse-cursor: GenreFilterState.selected-count > 0 ? pointer : default;
+2 -2
View File
@@ -128,8 +128,8 @@ component GearButton inherits Rectangle {
source: @image-url("../assets/icons/home-gear.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
@@ -33,8 +33,8 @@ component OverlayButton inherits Rectangle {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.primary ? #000000 : #ffffff;
}
@@ -25,8 +25,8 @@ component NavButton inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
@@ -50,8 +50,8 @@ component ViewAllLink inherits Rectangle {
color: Theme.accent;
font-size: 14px;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
@@ -24,8 +24,8 @@ export component PlaylistCollage inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: root.size * 0.32;
height: root.size * 0.32;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
@@ -37,8 +37,8 @@ component MixTile inherits Rectangle {
}
Text {
text: root.name;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
color: #ffffff;
font-size: 22px;
font-weight: Typography.bold;
+7 -7
View File
@@ -39,8 +39,8 @@ export component RadioCard inherits Rectangle {
Rectangle {
width: 126px;
height: 126px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2 - 8px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px - 8px;
border-radius: 4px;
background: Theme.surface-card;
clip: true;
@@ -59,7 +59,7 @@ export component RadioCard inherits Rectangle {
font-size: 15px;
font-weight: Typography.bold;
letter-spacing: 2px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: parent.height - self.height - 16px;
}
@@ -76,16 +76,16 @@ export component RadioCard inherits Rectangle {
height: 44px;
border-radius: 22px;
background: #ffffff;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
opacity: ta.has-hover || play-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #000000;
}
play-ta := TouchArea {
@@ -29,8 +29,8 @@ component NavButton inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
+4 -4
View File
@@ -25,8 +25,8 @@ component CircleButton inherits Rectangle {
source: root.icon;
width: root.primary ? 18px : 16px;
height: root.primary ? 18px : 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.primary ? #ffffff : Theme.text-secondary;
}
ta := TouchArea {
@@ -92,8 +92,8 @@ export component Spotlight inherits VerticalLayout {
source: @image-url("../assets/icons/user.svg");
width: 40px;
height: 40px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
portrait-ta := TouchArea {
@@ -143,8 +143,8 @@ component PlaylistListRow inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -179,8 +179,8 @@ component PlaylistListRow inherits Rectangle {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.row-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -198,8 +198,8 @@ component PlaylistListRow inherits Rectangle {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
row-menu := PopupWindow {
@@ -259,7 +259,7 @@ component FavLabelCard inherits Rectangle {
Rectangle {
width: 140px;
height: 140px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
border-radius: 70px;
background: root.item.image-url == ""
? @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)
@@ -275,8 +275,8 @@ component FavLabelCard inherits Rectangle {
source: @image-url("../assets/icons/disc-3.svg");
width: 44px;
height: 44px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
ta := TouchArea {
@@ -317,7 +317,7 @@ component FavArtistCard inherits Rectangle {
Rectangle {
width: 120px;
height: 120px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
border-radius: 60px;
background: Theme.surface-elevated;
clip: true;
@@ -331,8 +331,8 @@ component FavArtistCard inherits Rectangle {
source: @image-url("../assets/icons/user.svg");
width: 44px;
height: 44px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
ta := TouchArea {
@@ -388,8 +388,8 @@ component SidepanelArtistRow inherits Rectangle {
source: @image-url("../assets/icons/user.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -907,8 +907,8 @@ export component FavoritesView inherits Rectangle {
text: @tr("Retry");
color: Theme.text-primary;
font-size: Typography.legal;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
retry-ta := TouchArea {
mouse-cursor: pointer;
+10 -10
View File
@@ -89,8 +89,8 @@ export component LabelPageView inherits Rectangle {
source: @image-url("../assets/icons/disc-3.svg");
width: 60px;
height: 60px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
Image {
@@ -243,8 +243,8 @@ export component LabelPageView inherits Rectangle {
width: 28px;
height: 28px;
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 22px;
height: 22px;
border-radius: 11px;
@@ -300,8 +300,8 @@ export component LabelPageView inherits Rectangle {
: @tr("View less");
color: reveal-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
reveal-ta := TouchArea {
mouse-cursor: pointer;
@@ -438,8 +438,8 @@ export component LabelPageView inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -470,8 +470,8 @@ export component LabelPageView inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-desc.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -66,8 +66,8 @@ export component LabelReleasesView inherits Rectangle {
source: @image-url("../assets/icons/disc-3.svg");
width: 60px;
height: 60px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
Image {
@@ -282,8 +282,8 @@ export component LabelReleasesView inherits Rectangle {
? Theme.text-primary
: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
File diff suppressed because it is too large. Load diff
@@ -27,7 +27,7 @@ component SceneArtistCard inherits Rectangle {
Rectangle {
width: 140px;
height: 140px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
border-radius: 70px;
background: Theme.surface-elevated;
drop-shadow-blur: 16px;
@@ -44,8 +44,8 @@ component SceneArtistCard inherits Rectangle {
source: @image-url("../assets/icons/user.svg");
width: 48px;
height: 48px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
ta := TouchArea {
@@ -185,8 +185,8 @@ export component ArtistsByLocationView inherits Rectangle {
? Theme.text-primary
: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
+4 -4
View File
@@ -34,8 +34,8 @@ component CircleAction inherits Rectangle {
source: root.icon;
width: root.primary ? 18px : 16px;
height: root.primary ? 18px : 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.primary ? #ffffff : Theme.text-secondary;
}
ta := TouchArea {
@@ -100,8 +100,8 @@ export component MixView inherits Rectangle {
background: root.mix-gradient;
Text {
text: MixState.title;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
color: #ffffff;
font-size: 34px;
font-weight: Typography.bold;
@@ -123,8 +123,8 @@ export component MusicianPageView inherits Rectangle {
source: @image-url("../assets/icons/user.svg");
width: 28px;
height: 28px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -253,8 +253,8 @@ export component MusicianPageView inherits Rectangle {
? Theme.text-primary
: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
@@ -31,8 +31,8 @@ component IconBtn inherits Rectangle {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ta.has-hover ? Theme.text-primary : root.tint;
}
ta := TouchArea {
@@ -51,8 +51,8 @@ component StatusIcon inherits Rectangle {
if root.status == 2: Image {
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
source: @image-url("../assets/icons/loader-circle.svg");
colorize: Theme.accent;
transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg;
@@ -60,8 +60,8 @@ component StatusIcon inherits Rectangle {
if root.status != 2: QbzIcon {
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
source: root.status == 3
? @image-url("../assets/icons/circle-check-big.svg")
: (root.status == 4
@@ -419,8 +419,8 @@ export component OfflineManagerView inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 10px;
height: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
visible: row.selected;
}
@@ -89,8 +89,8 @@ component PmSearch inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
@@ -208,8 +208,8 @@ component PmIconButton inherits Rectangle {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? #ffffff : Theme.text-secondary;
}
ta := TouchArea {
@@ -236,8 +236,8 @@ component PmActionButton inherits Rectangle {
source: root.icon;
width: root.glyph;
height: root.glyph;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.filled ? Theme.accent : (ta.has-hover ? Theme.text-primary : Theme.text-muted);
}
ta := TouchArea {
@@ -262,8 +262,8 @@ component PmLocalBadge inherits Rectangle {
: @image-url("../assets/icons/cloud-download.svg");
width: 13px;
height: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.status == "all_local" ? #22c55e : #f59e0b;
}
}
@@ -335,8 +335,8 @@ component PmFolderCard inherits Rectangle {
source: @image-url("../assets/icons/pen-line.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
edit-ta := TouchArea {
@@ -423,8 +423,8 @@ component PmFolderChip inherits Rectangle {
source: @image-url("../assets/icons/pen-line.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
edit-ta := TouchArea {
@@ -1005,8 +1005,8 @@ component PmSectionHeader inherits Rectangle {
: @image-url("../assets/icons/chevron-down.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
chev-ta := TouchArea {
+14 -14
View File
@@ -44,8 +44,8 @@ component CircleAction inherits Rectangle {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
@@ -226,8 +226,8 @@ export component PlaylistView inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: 56px;
height: 56px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
@@ -255,8 +255,8 @@ export component PlaylistView inherits Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: cover-btn-ta.has-hover
? (PlaylistState.has-custom ? #ef4444 : Theme.accent)
: #000000aa;
@@ -266,8 +266,8 @@ export component PlaylistView inherits Rectangle {
: @image-url("../assets/icons/image-plus.svg");
width: PlaylistState.has-custom ? 20px : 22px;
height: PlaylistState.has-custom ? 20px : 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
cover-btn-ta := TouchArea {
@@ -674,8 +674,8 @@ export component PlaylistView inherits Rectangle {
source: @image-url("../assets/icons/chevron-up.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: btt-ta.has-hover ? #ffffff : Theme.text-secondary;
}
btt-ta := TouchArea {
@@ -698,8 +698,8 @@ export component PlaylistView inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -730,8 +730,8 @@ export component PlaylistView inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: desc-close.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -209,8 +209,8 @@ export component AlbumListRow inherits Rectangle {
source: @image-url("../assets/icons/play-fill.svg");
width: 22px;
height: 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
art-ta := TouchArea {
@@ -282,7 +282,7 @@ export component AlbumListRow inherits Rectangle {
width: root.album.source == "local" ? 15px : 16px;
height: self.width;
x: 0;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
}
@@ -331,8 +331,8 @@ export component AlbumListRow inherits Rectangle {
source: @image-url("../assets/icons/ellipsis.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
more-ta := TouchArea {
@@ -1,10 +1,12 @@
// Ghost circular action button — translucent fill with a faint ring,
// icon tinted on hover/active (never a solid accent fill, mirroring the
// Tauri transport controls). Shared by the artist + label page headers
// and their Popular Tracks clusters (Play all / Select / More).
// Circular header/action button — matches the album-card hover OVERLAY
// buttons: a semi-transparent white fill (stable hit surface) with a bright
// 1.5px ring, white icon; `primary` is a solid white circle with a dark icon
// (the play affordance). Shared by the album/artist/label page headers, their
// Popular Tracks clusters, and the ephemeral pane.
import { Theme } from "../foundation/semantic-colors.slint";
import { QbzIcon } from "QbzIcon.slint";
import { TooltipState } from "../state.slint";
export component CircleAction inherits Rectangle {
in property <image> icon;
@@ -12,24 +14,30 @@ export component CircleAction inherits Rectangle {
// Stays "pressed" — same surface as hover, icon tinted accent.
in property <bool> active: false;
in property <length> diameter: 40px;
// Optional hover tooltip (empty = none), using the global TooltipOverlay.
in property <string> tooltip;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent;
border-width: 1px;
border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle;
// Overlay-matched contrast: solid white for the primary play, semi-
// transparent white fill + bright 1.5px ring otherwise.
background: root.primary
? (ta.has-hover ? #ffffffd6 : #ffffff)
: ((ta.has-hover || root.active) ? #ffffff3d : #ffffff24);
border-width: root.primary ? 0px : 1.5px;
border-color: #ffffffcc;
QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
: (root.primary ? #000000 : #ffffff);
}
ta := TouchArea {
@@ -37,5 +45,15 @@ export component CircleAction inherits Rectangle {
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
@@ -22,8 +22,8 @@ export component CreateFolderModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 400px);
height: panel.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -56,8 +56,8 @@ export component CreateFolderModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -84,8 +84,8 @@ export component CreateFolderModal inherits Rectangle {
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
create-ta := TouchArea {
mouse-cursor: (CreateFolderState.name != "" && !CreateFolderState.creating)
@@ -27,8 +27,8 @@ export component CreatePlaylistModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 420px);
height: panel.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -61,8 +61,8 @@ export component CreatePlaylistModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -136,7 +136,7 @@ export component CreatePlaylistModal inherits Rectangle {
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 4px;
background: CreatePlaylistState.is-public ? Theme.accent : transparent;
border-width: CreatePlaylistState.is-public ? 0px : 1.5px;
@@ -145,8 +145,8 @@ export component CreatePlaylistModal inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
visible: CreatePlaylistState.is-public;
}
@@ -175,8 +175,8 @@ export component CreatePlaylistModal inherits Rectangle {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: pointer;
@@ -197,8 +197,8 @@ export component CreatePlaylistModal inherits Rectangle {
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
create-ta := TouchArea {
mouse-cursor: (CreatePlaylistState.name != "" && !CreatePlaylistState.creating)
@@ -22,8 +22,8 @@ export component EditPlaylistModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 420px);
height: panel.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -56,8 +56,8 @@ export component EditPlaylistModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -97,8 +97,8 @@ export component EditPlaylistModal inherits Rectangle {
color: #ef4444;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
del-ta := TouchArea {
mouse-cursor: EditPlaylistState.busy ? default : pointer;
@@ -123,8 +123,8 @@ export component EditPlaylistModal inherits Rectangle {
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
save-ta := TouchArea {
mouse-cursor: (EditPlaylistState.name != "" && !EditPlaylistState.busy)
@@ -0,0 +1,127 @@
// "Already playing" choice modal — shown when an ephemeral play action is
// triggered while a queue is already playing. Two outcomes: add the selection
// to the queue, or clear the queue and start playing it.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { EphemeralPlayChoiceState, EphemeralPlayChoiceActions } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
export component EphemeralPlayChoiceModal inherits Rectangle {
visible: EphemeralPlayChoiceState.open;
if EphemeralPlayChoiceState.open: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
EphemeralPlayChoiceActions.close();
}
}
Rectangle {
width: Math.min(root.width - 80px, 420px);
height: panel.preferred-height;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
panel := VerticalLayout {
padding: 20px;
spacing: 16px;
HorizontalLayout {
Text {
text: @tr("Something is already playing");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
EphemeralPlayChoiceActions.close();
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
Text {
text: @tr("Add this to the current queue, or clear the queue and start playing it?");
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
HorizontalLayout {
alignment: end;
spacing: 10px;
// Add to queue (secondary, ghost) — only when the current
// queue is itself all-ephemeral (no mixing).
if EphemeralPlayChoiceState.enqueue-allowed: Rectangle {
width: enqueue-label.preferred-width + 32px;
height: 36px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: enqueue-ta.has-hover ? Theme.surface-hover : transparent;
enqueue-label := Text {
text: @tr("Add to queue");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
enqueue-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
EphemeralPlayChoiceActions.enqueue();
}
}
}
// Clear queue and play (accent).
Rectangle {
width: replace-label.preferred-width + 32px;
height: 36px;
border-radius: Radius.sm;
background: replace-ta.has-hover ? Theme.accent-hover : Theme.accent;
replace-label := Text {
text: @tr("Clear queue and play");
color: Theme.accent-text;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
replace-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
EphemeralPlayChoiceActions.replace();
}
}
}
}
}
}
}
}
@@ -56,8 +56,8 @@ export component ExpandableSearch inherits Rectangle {
source: @image-url("../assets/icons/search.svg");
width: root.sm ? 14px : 16px;
height: root.sm ? 14px : 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: open-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
open-ta := TouchArea {
@@ -137,8 +137,8 @@ export component ExpandableSearch inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
@@ -26,8 +26,8 @@ export component FolderEditModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 480px);
height: panel.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -59,8 +59,8 @@ export component FolderEditModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -110,8 +110,8 @@ export component FolderEditModal inherits Rectangle {
: @image-url("../assets/icons/folder.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: parent.active ? #ffffff : Theme.text-secondary;
}
preset-ta := TouchArea {
@@ -138,8 +138,8 @@ export component FolderEditModal inherits Rectangle {
source: @image-url("../assets/icons/image-plus.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
img-ta := TouchArea {
@@ -180,8 +180,8 @@ export component FolderEditModal inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
sw-ta := TouchArea {
@@ -229,8 +229,8 @@ export component FolderEditModal inherits Rectangle {
color: #ef4444;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
del-ta := TouchArea {
mouse-cursor: FolderEditState.busy ? default : pointer;
@@ -252,8 +252,8 @@ export component FolderEditModal inherits Rectangle {
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
save-ta := TouchArea {
mouse-cursor: (FolderEditState.name != "" && !FolderEditState.busy)
@@ -224,8 +224,8 @@ export component JumpNavBar inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -262,8 +262,8 @@ export component JumpNavBar inherits Rectangle {
source: @image-url("../assets/icons/search.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: search-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -26,7 +26,7 @@ export component ListScrollbar inherits Rectangle {
// Track.
Rectangle {
width: 6px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
height: 100%;
background: #ffffff12;
border-radius: 3px;
@@ -35,7 +35,7 @@ export component ListScrollbar inherits Rectangle {
// Thumb.
thumb := Rectangle {
width: bar-ta.has-hover || bar-ta.pressed ? 10px : 8px;
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
height: root.thumb-h;
y: root.max-scroll > 0px
? (-root.viewport-y / root.max-scroll) * root.travel
@@ -34,8 +34,8 @@ component BulkButton inherits Rectangle {
color: root.danger ? #ef4444 : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.danger ? Typography.medium : Typography.regular;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
ta := TouchArea {
@@ -33,8 +33,8 @@ component PickRow inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
@@ -79,8 +79,8 @@ export component PlaylistPickerModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 460px);
height: Math.min(root.height - 120px, 520px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -114,8 +114,8 @@ export component PlaylistPickerModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -31,8 +31,8 @@ export component PmCollage inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: root.size * 0.32;
height: root.size * 0.32;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
@@ -41,8 +41,8 @@ export component PmFolderIcon inherits Rectangle {
: @image-url("../assets/icons/folder.svg");
width: root.glyph-size;
height: root.glyph-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
@@ -19,8 +19,8 @@ export component QbzCheckbox inherits Rectangle {
visible: root.checked;
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent-text;
}
@@ -25,7 +25,7 @@ export component QbzSlider inherits Rectangle {
// Track.
Rectangle {
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
height: 4px;
border-radius: 2px;
background: Theme.surface-elevated;
@@ -34,7 +34,7 @@ export component QbzSlider inherits Rectangle {
// Accent fill.
Rectangle {
x: 0;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: root.thumb-size / 2 + root.fraction * root.travel;
height: 4px;
border-radius: 2px;
@@ -44,7 +44,7 @@ export component QbzSlider inherits Rectangle {
// Thumb.
Rectangle {
x: root.fraction * root.travel;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: root.thumb-size;
height: root.thumb-size;
border-radius: root.thumb-size / 2;
@@ -23,8 +23,8 @@ export component QualityBadge inherits Rectangle {
source: @image-url("../assets/hi-res.svg");
width: 42px;
height: 28px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
if root.tier == "cd": Rectangle {
@@ -35,8 +35,8 @@ export component QualityBadge inherits Rectangle {
border-width: 1px;
border-color: Theme.border-subtle;
QbzIcon {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 16px;
height: 16px;
source: @image-url("../assets/cd.svg");
@@ -53,8 +53,8 @@ export component QualityBadge inherits Rectangle {
border-width: 1px;
border-color: Theme.border-subtle;
QbzIcon {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 16px;
height: 16px;
source: @image-url("../assets/mp3.svg");
@@ -43,8 +43,8 @@ component OverlayButton inherits Rectangle {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.primary ? #000000 : #ffffff;
}
@@ -112,8 +112,8 @@ export component SearchTrackHero inherits Rectangle {
source: @image-url("../assets/icons/music.svg");
width: 30px;
height: 30px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
@@ -44,8 +44,8 @@ export component TagEditorModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 820px);
height: Math.min(root.height - 80px, 720px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -81,8 +81,8 @@ export component TagEditorModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -177,7 +177,7 @@ export component TagEditorModal inherits Rectangle {
border-color: Theme.border-subtle;
background: search-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
search-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
spacing: 6px;
if TagEditorState.remote_searching: VerticalLayout {
alignment: center;
@@ -296,8 +296,8 @@ export component TagEditorModal inherits Rectangle {
source: @image-url("../assets/icons/external-link.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
ext-ta := TouchArea {
@@ -320,8 +320,8 @@ export component TagEditorModal inherits Rectangle {
color: Theme.accent-text;
font-size: Typography.legal;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
apply-ta := TouchArea {
mouse-cursor: TagEditorState.remote_loading ? default : pointer;
@@ -448,8 +448,8 @@ export component TagEditorModal inherits Rectangle {
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: TagEditorState.saving ? default : pointer;
@@ -478,8 +478,8 @@ export component TagEditorModal inherits Rectangle {
color: Theme.accent-text;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
save-ta := TouchArea {
mouse-cursor: TagEditorState.saving ? default : pointer;
+2 -2
View File
@@ -95,8 +95,8 @@ export component Toast inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
close-ta := TouchArea {
@@ -8,10 +8,20 @@ import { ContextMenuItem } from "ContextMenuItem.slint";
export component TrackContextMenu inherits PopupWindow {
in property <string> track-id;
// Per-source applicability (the surrounding row sets these from the track's
// source). `qobuz-actions` = the Qobuz-backed block (favorite, radio,
// mixtape, share, make-offline, go-to album/artist, track info) — on for
// Qobuz + offline, off for local / Plex / ephemeral. `playlist-action` =
// Add to playlist — on for Qobuz + offline + local (local playlists), off
// for Plex + ephemeral. The transient actions (play / next / queue) always
// show.
in property <bool> qobuz-actions: true;
in property <bool> playlist-action: true;
callback media-action(string, string, string);
width: 224px;
height: 392px;
// 3 transient + optional add-to-playlist + the 9-item Qobuz block.
height: (3 + (root.playlist-action ? 1 : 0) + (root.qobuz-actions ? 9 : 0)) * 30px;
ContextMenu {
ContextMenuItem {
@@ -35,70 +45,70 @@ export component TrackContextMenu inherits PopupWindow {
root.media-action("track", root.track-id, "queue");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/radio.svg");
label: @tr("Create radio");
clicked => {
root.media-action("track", root.track-id, "create-radio");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to library");
clicked => {
root.media-action("track", root.track-id, "favorite");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("track", root.track-id, "add-to-mixtape");
}
}
ContextMenuItem {
if root.playlist-action: ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("track", root.track-id, "add-to-playlist");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Qobuz link");
clicked => {
root.media-action("track", root.track-id, "share-qobuz");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Song.link");
clicked => {
root.media-action("track", root.track-id, "share-songlink");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/cloud-download.svg");
label: @tr("Make available offline");
clicked => {
root.media-action("track", root.track-id, "download");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Go to album");
clicked => {
root.media-action("track", root.track-id, "go-to-album");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Go to artist");
clicked => {
root.media-action("track", root.track-id, "go-to-artist");
}
}
ContextMenuItem {
if root.qobuz-actions: ContextMenuItem {
icon: @image-url("../assets/icons/info.svg");
label: @tr("Track info");
clicked => {
@@ -97,8 +97,8 @@ export component TrackPlayCell inherits Rectangle {
source: @image-url("../assets/icons/music.svg");
width: root.art-size * 0.45;
height: root.art-size * 0.45;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
Image {
@@ -115,8 +115,8 @@ export component TrackPlayCell inherits Rectangle {
animate opacity { duration: 150ms; }
}
if root.show-bars: EqualizerBars {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.show-overlay && !root.show-bars: QbzIcon {
source: (root.is-playing && root.row-hovered)
@@ -124,8 +124,8 @@ export component TrackPlayCell inherits Rectangle {
: @image-url("../assets/icons/play-fill.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
@@ -139,12 +139,12 @@ export component TrackPlayCell inherits Rectangle {
text: root.number;
color: Theme.text-muted;
font-size: Typography.body;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.show-bars: EqualizerBars {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.show-overlay && !root.show-bars: QbzIcon {
source: (root.is-playing && root.row-hovered)
@@ -152,8 +152,8 @@ export component TrackPlayCell inherits Rectangle {
: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
+40 -22
View File
@@ -41,6 +41,9 @@ export component TrackRow inherits Rectangle {
// shown INSTEAD of the favorite + offline columns.
in property <bool> show-source: false;
in property <bool> multi-select-mode: false;
// Trailing "..." context-menu column. Off for rows that have no menu
// actions (e.g. ephemeral-folder rows — play-only, no persistence).
in property <bool> show-menu: true;
// Custom-order reorder controls (up/down chevrons) at the leading
// edge — used by the playlist custom sort.
in property <bool> show-reorder: false;
@@ -51,8 +54,11 @@ export component TrackRow inherits Rectangle {
// Explicit track number, else the 1-based list position.
property <string> number-text:
root.track.number != "" ? root.track.number : "\{root.index + 1}";
// Set from the (conditional) context-menu button so the row highlight
// still reacts to hovering it without referencing a possibly-absent id.
property <bool> menu-hovered: false;
property <bool> row-hovered:
ta.has-hover || play-cell.hovered || more-ta.has-hover;
ta.has-hover || play-cell.hovered || root.menu-hovered;
height: 50px;
border-radius: 8px;
@@ -159,8 +165,8 @@ export component TrackRow inherits Rectangle {
source: @image-url("../assets/icons/chevron-up.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: up-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
up-ta := TouchArea {
@@ -177,8 +183,8 @@ export component TrackRow inherits Rectangle {
source: @image-url("../assets/icons/chevron-down.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: down-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
down-ta := TouchArea {
@@ -227,8 +233,8 @@ export component TrackRow inherits Rectangle {
}
}
if root.multi-select-mode: Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 13px;
height: 13px;
border-radius: 6.5px;
@@ -240,8 +246,8 @@ export component TrackRow inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 8px;
height: 8px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
visible: root.track.selected;
}
@@ -266,8 +272,8 @@ export component TrackRow inherits Rectangle {
source: @image-url("../assets/icons/lock.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent;
opacity: lock-overlay.pulse ? 1.0 : 0.4;
animate opacity { duration: 450ms; }
@@ -415,8 +421,8 @@ export component TrackRow inherits Rectangle {
: @image-url("../assets/icons/heart.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.track.is-favorite
? Theme.accent
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-muted);
@@ -451,8 +457,8 @@ export component TrackRow inherits Rectangle {
if root.track.cache-status == 1 || root.track.cache-status == 2: Image {
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
source: @image-url("../assets/icons/loader-circle.svg");
colorize: Theme.accent;
transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg;
@@ -466,8 +472,8 @@ export component TrackRow inherits Rectangle {
: @image-url("../assets/icons/cloud-download.svg"));
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.track.cache-status == 3
? Theme.accent
: (root.track.cache-status == 4
@@ -502,15 +508,15 @@ export component TrackRow inherits Rectangle {
colorize: root.track.source == "local" ? Theme.text-muted : transparent;
width: root.track.source == "local" ? 15px : 16px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
}
}
// Context menu.
VerticalLayout {
if root.show-menu: VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
@@ -521,8 +527,8 @@ export component TrackRow inherits Rectangle {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
more-ta := TouchArea {
@@ -530,6 +536,9 @@ export component TrackRow inherits Rectangle {
clicked => {
track-menu.show();
}
changed has-hover => {
root.menu-hovered = self.has-hover;
}
}
}
}
@@ -539,6 +548,15 @@ export component TrackRow inherits Rectangle {
x: parent.width - self.width - 12px;
y: 48px;
track-id: root.track.id;
// Per-source menu logic: Qobuz-backed actions (favorite, radio, share,
// mixtape, offline, go-to, track-info) only for Qobuz/offline rows;
// Add-to-playlist for Qobuz/offline/local (local playlists), not Plex/
// ephemeral; transient play/queue always.
qobuz-actions: root.track.source != "local"
&& root.track.source != "plex"
&& root.track.source != "ephemeral";
playlist-action: root.track.source != "plex"
&& root.track.source != "ephemeral";
media-action(k, i, a) => {
root.media-action(k, i, a);
}
@@ -81,8 +81,8 @@ component FilterRadio inherits Rectangle {
height: 8px;
border-radius: 4px;
background: root.selected ? Theme.accent : transparent;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
}
@@ -131,8 +131,8 @@ component SectionHeader inherits Rectangle {
color: Theme.accent;
font-size: 14px;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
more-ta := TouchArea {
mouse-cursor: pointer;
@@ -165,8 +165,8 @@ component LoadMoreButton inherits Rectangle {
text: @tr("Load more") + " (\{root.loaded} / \{root.total})";
color: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
btn-ta := TouchArea {
mouse-cursor: pointer;
@@ -375,8 +375,8 @@ export component SearchResultsView inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 13px;
height: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
@@ -56,8 +56,8 @@ export component LibFolderEditModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 520px);
height: Math.min(root.height - 80px, panel.preferred-height);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -90,8 +90,8 @@ export component LibFolderEditModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -132,7 +132,7 @@ export component LibFolderEditModal inherits Rectangle {
border-color: Theme.border-subtle;
background: change-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
change-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
spacing: 6px;
VerticalLayout {
alignment: center;
@@ -309,7 +309,7 @@ export component LibFolderEditModal inherits Rectangle {
border-color: Theme.border-subtle;
background: scan-ta.has-hover && self.can-scan ? Theme.surface-hover : Theme.surface-elevated;
scan-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
spacing: 7px;
VerticalLayout {
alignment: center;
@@ -353,8 +353,8 @@ export component LibFolderEditModal inherits Rectangle {
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: pointer;
@@ -372,8 +372,8 @@ export component LibFolderEditModal inherits Rectangle {
color: Theme.accent-text;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
save-ta := TouchArea {
mouse-cursor: pointer;
@@ -70,8 +70,8 @@ component IconBtn inherits Rectangle {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
ta := TouchArea {
@@ -142,8 +142,8 @@ component FolderRow inherits Rectangle {
border-width: 1px;
border-color: !root.enabled-folder ? Theme.border-subtle : #e0564f;
badge-text := Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
text: !root.enabled-folder ? @tr("Disabled") : @tr("Unavailable");
color: !root.enabled-folder ? Theme.text-muted : #e0564f;
font-size: Typography.legal;
@@ -263,7 +263,7 @@ export component LocalLibrarySettings inherits VerticalLayout {
border-color: #e0564f;
background: stop-ta.has-hover ? #e0564f22 : transparent;
stop-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
spacing: 6px;
VerticalLayout {
alignment: center;
+4
View File
@@ -20,6 +20,7 @@ import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
import { TagEditorModal } from "../primitives/TagEditorModal.slint";
@@ -370,6 +371,9 @@ export component AppShell inherits Rectangle {
// Global "Create folder" modal.
CreateFolderModal { }
// Ephemeral "already playing" add-vs-replace choice.
EphemeralPlayChoiceModal { }
// Global "Folder editor" modal (Playlist Manager create/edit folder).
FolderEditModal { }
+8 -8
View File
@@ -211,8 +211,8 @@ component CompactNavBtn inherits Rectangle {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
cn-ta := TouchArea {
@@ -247,8 +247,8 @@ component CompactPlaylistsBtn inherits Rectangle {
source: @image-url("../assets/icons/list-music.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
cp-ta := TouchArea {
@@ -280,8 +280,8 @@ component HeaderNavBtn inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
@@ -368,7 +368,7 @@ export component HeaderBar inherits Rectangle {
width: 14px;
height: 14px;
x: 10px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
@@ -565,7 +565,7 @@ export component HeaderBar inherits Rectangle {
Rectangle {
width: 1px;
height: 18px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: Theme.border-subtle;
}
VerticalLayout {
@@ -23,8 +23,8 @@ component CloseButton inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
+8 -2
View File
@@ -203,7 +203,7 @@ export component PlayerBar inherits Rectangle {
// and floated above the album art. Mirror classic: fixed
// height + y-centre in the column.
height: Layout.player-bar-height - 38px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
art-size: Layout.player-bar-height - 38px;
// Homologate the stamp with classic: full-size badge + LEDs,
// not the compact variant.
@@ -220,9 +220,12 @@ export component PlayerBar inherits Rectangle {
root.media-action(kind, id, "open");
}
track-info() => {
// Ephemeral tracks have no DB row → no track-info page.
if (!NowPlayingState.is-ephemeral) {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
}
// Classic: transport cluster, left-aligned, plain play glyph.
if ShellState.npb-mode == 1: VerticalLayout {
alignment: center;
@@ -285,7 +288,7 @@ export component PlayerBar inherits Rectangle {
// +4px taller (2px top/bottom) so the title + audio stamp are
// not glued to the card edge; art + stamp grow in step.
height: 64px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
art-size: 60px;
// Full-size 1:1-Tauri stamp at every width.
compact-stamp: false;
@@ -300,10 +303,13 @@ export component PlayerBar inherits Rectangle {
root.media-action(kind, id, "open");
}
track-info() => {
// Ephemeral tracks have no DB row → no track-info page.
if (!NowPlayingState.is-ephemeral) {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
}
}
// RIGHT column (responsive 39/30/25%, symmetric with the song
// card) — secondary actions + volume, clustered at the right wall.
@@ -27,8 +27,8 @@ export component QconnectDevModal inherits Rectangle {
Rectangle {
width: Math.min(root.width - 80px, 600px);
height: Math.min(root.height - 80px, 640px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
@@ -63,8 +63,8 @@ export component QconnectDevModal inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
@@ -109,8 +109,8 @@ export component QconnectDevModal inherits Rectangle {
text: "Clear";
color: Theme.text-secondary;
font-size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
+18 -15
View File
@@ -16,7 +16,7 @@ import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { ShellState, QueueState, QueueItem } from "../state.slint";
import { ShellState, QueueState, QueueItem, NowPlayingState } from "../state.slint";
// Close button — mirrors the X control used by LyricsSidebar.
component CloseButton inherits Rectangle {
@@ -30,8 +30,8 @@ component CloseButton inherits Rectangle {
source: @image-url("../assets/icons/x.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
@@ -219,7 +219,7 @@ component QueueRow inherits Rectangle {
border-radius: 4px;
background: Theme.surface-elevated;
clip: true;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
if root.item.artwork.width > 0: Image {
source: root.item.artwork;
width: 100%;
@@ -279,8 +279,8 @@ component QueueRow inherits Rectangle {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: mta.has-hover ? Theme.text-primary : Theme.text-muted;
}
mta := TouchArea {
@@ -299,7 +299,8 @@ component QueueRow inherits Rectangle {
x: root.width - 200px;
y: 44px;
width: 196px;
height: 175px;
// Ephemeral rows drop the persistence items (Add to playlist / Track info).
height: root.item.is-ephemeral ? 105px : 175px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/trash-2.svg");
@@ -316,11 +317,11 @@ component QueueRow inherits Rectangle {
icon: @image-url("../assets/icons/list-x.svg");
label: @tr("Remove all after");
}
ContextMenuItem {
if !root.item.is-ephemeral: ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Add to playlist");
}
ContextMenuItem {
if !root.item.is-ephemeral: ContextMenuItem {
icon: @image-url("../assets/icons/info.svg");
label: @tr("Track info");
}
@@ -518,8 +519,9 @@ export component QueueSidebar inherits Rectangle {
overflow: elide;
}
}
// Favorite (heart) toggle.
VerticalLayout {
// Favorite (heart) toggle — hidden for ephemeral
// tracks (no DB row to favorite).
if !NowPlayingState.is-ephemeral: VerticalLayout {
alignment: center;
fav := Rectangle {
width: 28px;
@@ -534,8 +536,8 @@ export component QueueSidebar inherits Rectangle {
: @image-url("../assets/icons/heart.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: QueueState.now-playing-favorite
? Theme.favorite
: (fav-ta.has-hover
@@ -750,8 +752,9 @@ export component QueueSidebar inherits Rectangle {
}
}
}
// Action 2 — Save the queue as a playlist.
VerticalLayout {
// Action 2 — Save the queue as a playlist. Hidden while an ephemeral
// track is playing (an ephemeral queue can't be persisted).
if !NowPlayingState.is-ephemeral: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/add-to-list.svg");
+3 -3
View File
@@ -69,7 +69,7 @@ export component SeekBar inherits HorizontalLayout {
height: 12px;
border-radius: 6px;
background: Theme.text-primary;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
x: parent.width * root.clamp01(root.progress) - self.width / 2;
opacity: ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 100ms; }
@@ -79,7 +79,7 @@ export component SeekBar inherits HorizontalLayout {
ta := TouchArea {
width: 100%;
height: 18px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
// No-drop cursor over the not-yet-downloaded region.
mouse-cursor: (root.clamp01(self.mouse-x / self.width) > root.seekable-max)
? MouseCursor.not-allowed
@@ -125,7 +125,7 @@ export component SeekBar inherits HorizontalLayout {
height: 6px;
fill: Theme.surface-elevated;
commands: "M 0 0 L 5 6 L 10 0 Z";
x: (parent.width - self.width) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: parent.height - 1px;
}
}
+11 -11
View File
@@ -84,8 +84,8 @@ component HeaderIconButton inherits Rectangle {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: (btn-ta.has-hover || root.active) ? Theme.text-primary : Theme.text-muted;
}
btn-ta := TouchArea {
@@ -226,23 +226,23 @@ component SidebarRow inherits Rectangle {
: @image-url("../assets/icons/folder.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
// 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;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
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;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
}
@@ -278,8 +278,8 @@ component SidebarRow inherits Rectangle {
: @image-url("../assets/icons/chevron-right.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: chevron-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
chevron-ta := TouchArea {
@@ -409,7 +409,7 @@ component SidebarRow inherits Rectangle {
height: 7px;
Rectangle {
height: 1px;
y: (parent.height - self.height) / 2;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: Theme.border-subtle;
}
}
+12 -9
View File
@@ -190,7 +190,7 @@ export component SongCard inherits Rectangle {
width: root.art-size * 0.5;
height: root.art-size * 0.5;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: (parent.height - self.height) / 2 + root.art-size * 0.06;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px + root.art-size * 0.06;
tint: Theme.text-muted;
}
if !NowPlayingState.has-track: Text {
@@ -244,9 +244,11 @@ export component SongCard inherits Rectangle {
overflow: elide;
}
ttta := TouchArea {
mouse-cursor: NowPlayingState.has-track ? pointer : default;
mouse-cursor: (NowPlayingState.has-track && !NowPlayingState.is-ephemeral)
? pointer : default;
clicked => {
if (NowPlayingState.has-track) {
// Ephemeral tracks have no track-info page.
if (NowPlayingState.has-track && !NowPlayingState.is-ephemeral) {
root.track-info();
}
}
@@ -273,10 +275,11 @@ export component SongCard inherits Rectangle {
border-radius: 3px;
background: ctxta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
// disc-album, pinned flush-LEFT (x:0): centering the glyph in
// its box left a ~2px indent so the artist·album line started
// further right than the title above it and read as misaligned.
source: @image-url("../assets/icons/disc-album.svg");
// turntable (the "playing from" context), pinned flush-LEFT
// (x:0): centering the glyph in its box left a ~2px indent so
// the artist·album line read as misaligned vs the title.
// (disc-album now means "flat folders" in the Folders toggle.)
source: @image-url("../assets/icons/turntable.svg");
width: 13px;
height: 13px;
x: 0;
@@ -309,7 +312,7 @@ export component SongCard inherits Rectangle {
label: NowPlayingState.artist;
stretch: 2.0;
activated => {
if (NowPlayingState.artist-id != "") {
if (NowPlayingState.artist-id != "" && !NowPlayingState.is-ephemeral) {
root.open-artist(NowPlayingState.artist-id);
}
}
@@ -331,7 +334,7 @@ export component SongCard inherits Rectangle {
label: NowPlayingState.album;
stretch: 1.0;
activated => {
if (NowPlayingState.album-id != "") {
if (NowPlayingState.album-id != "" && !NowPlayingState.is-ephemeral) {
root.open-album(NowPlayingState.album-id);
}
}
+112 -3
View File
@@ -385,6 +385,36 @@ export struct FolderNode {
expanded: bool, // folder open?
can-expand: bool, // has children (track_count_under > 0)
track-count: int, // recursive track count under a folder
// Select-mode: track rows carry `selected`; folder rows carry a tri-state
// `select-state` (0 none / 1 partial / 2 all) over their descendant tracks.
selected: bool,
select-state: int,
}
// One subfolder cover card in the tree-mode folder-detail pane (1:1 with
// Tauri's `.subfolder-card`). `artwork` is loaded async from the folder cover.
export struct FolderSubcardItem {
path: string, // absolute path (the node id)
name: string, // last path component (display)
track-count: int, // recursive track count under the folder
artwork: image,
artwork-url: string, // cover file path (artwork-pipeline source)
}
// One album block in the ephemeral-folder pane (an ad-hoc folder can hold
// several albums). `artwork` is loaded from the cached cover file on the UI
// thread; `tracks` are the album's tracks in scan order.
export struct EphemeralAlbum {
group-key: string,
title: string,
artist: string,
// "year · N tracks" (already formatted).
meta: string,
quality-tier: string,
// This album is a single-file CUE rip (one audio file, virtual tracks).
is-cue: bool,
artwork: image,
tracks: [TrackItem],
}
export global LocalLibraryState {
@@ -444,7 +474,8 @@ export global LocalLibraryState {
// --- Folders tab ---------------------------------------------------
// flat = album grid grouped by directory; tree = two-pane lazy filesystem.
in-out property <string> folders-view-mode: "flat"; // "flat" | "tree"
// Tree is the Slint default (user 2026-06-06).
in-out property <string> folders-view-mode: "tree"; // "flat" | "tree"
// Flat mode: `folders` is the full set (artwork target); `folders-visible`
// / `folders-grouped` are the search/sort/group-derived render sets.
in property <[AlbumCardItem]> folders: [];
@@ -457,14 +488,44 @@ export global LocalLibraryState {
in-out property <string> folders-group: "off"; // off | alpha | artist
in-out property <string> folders-grid-view: "grid"; // grid | list
// Tree mode: a flattened visible-node list + the selected folder's detail
// (its direct tracks; subfolders shown as a grid).
// (its direct tracks; subfolders shown as a grid). `folder-tree` is the full
// flattened set; `folder-tree-visible` is the search-filtered render set.
in property <[FolderNode]> folder-tree: [];
in property <[FolderNode]> folder-tree-visible: [];
in property <bool> folder-tree-loading: false;
// Tree rail width (resizable). Default ~20% narrower than the old 340px;
// clamped in the UI to [50% of default, 50% of the content area].
in-out property <length> tree-sidebar-width: 272px;
// Tree rail header: search box + multi-select mode (1:1 with Tauri).
in-out property <string> folders-tree-search: "";
in-out property <bool> tree-select-mode: false;
in property <int> tree-selected-count: 0;
in-out property <string> folders-selected-path: "";
in property <string> folders-selected-name: "";
in property <[FolderNode]> folder-detail-subfolders: [];
// Subfolder cover cards (1:1 with Tauri): `folder-detail-subfolders` is the
// full set (artwork target); `-visible` is the search-filtered render set.
in property <[FolderSubcardItem]> folder-detail-subfolders: [];
in property <[FolderSubcardItem]> folder-detail-subfolders-visible: [];
in property <[TrackItem]> folder-detail-tracks: [];
in property <bool> folder-detail-loading: false;
// Recursive track count under the selected folder (the header "N tracks").
in property <int> folder-detail-track-count: 0;
// Per-pane grid/list toggle + subfolder name filter.
in-out property <string> folder-detail-view: "grid"; // "grid" | "list"
in-out property <string> folder-detail-search: "";
// --- Ephemeral folder (ad-hoc play, NOT indexed into library.db) ----
// When `ephemeral-active`, the Folders tab shows the ephemeral pane
// (album-grouped) instead of the normal flat/tree browse. The session is
// in-memory; the folder path is persisted so it rehydrates on restart.
in property <bool> ephemeral-active: false;
in property <bool> ephemeral-loading: false;
in property <string> ephemeral-path: "";
in property <string> ephemeral-name: "";
in property <int> ephemeral-track-count: 0;
// True when the session spans more than one album → show per-album play.
in property <bool> ephemeral-multi-album: false;
in property <[EphemeralAlbum]> ephemeral-albums: [];
// --- Artists tab — two-column master/detail (1:1 with Tauri) --------
// Left rail: the merged/deduped artist master list. `artists` is the full
@@ -547,6 +608,29 @@ export global LocalLibraryActions {
// Tree: play one track from the selected folder's direct tracks (the
// folder's direct list becomes the queue, starting at this track).
callback folders-play-track(string /* track id */);
// Tree detail pane: filter the subfolder cards by name.
callback folder-detail-search(string /* query */);
// Tree rail header: filter the tree by name; collapse every folder.
callback folders-tree-search(string /* query */);
callback folders-collapse-all();
// Multi-select mode (tree rail): toggle the mode; toggle a folder's
// descendant tracks / a single track; run a bulk action on the selection.
callback folders-toggle-select-mode();
callback folders-toggle-folder-select(string /* folder path */);
callback folders-toggle-track-select(string /* track path */);
callback folders-bulk-action(string /* select-all|clear|queue|play-next|add-to-playlist */);
// --- Ephemeral folder (ad-hoc play) ---
// Open the native folder picker → scan → show the ephemeral pane.
callback ephemeral-open();
// Play the whole ephemeral folder (all albums, scan order).
callback ephemeral-play-all();
// Play one ephemeral track — its album group becomes the queue.
callback ephemeral-play-track(string /* track id */);
// Play one ephemeral album (its tracks become the queue).
callback ephemeral-play-album(string /* album group key */);
// Clear the ephemeral session (drop the pane + forget the path).
callback ephemeral-clear();
// --- Artists tab ---
// Filter the master list (two-way bound to artists-search; re-derive).
@@ -951,6 +1035,26 @@ export global CreateFolderActions {
callback close();
}
// Ephemeral "play with active queue" choice — shown when the user triggers an
// ephemeral play action while a queue is already playing. Two outcomes:
// add to the queue, or clear the queue and start playing.
export global EphemeralPlayChoiceState {
in-out property <bool> open: false;
// "all" | "album" | "track" — what the user pressed play on.
in-out property <string> intent-kind: "";
// group key (album) or track id (track); empty for "all".
in-out property <string> intent-arg: "";
// Whether "Add to queue" is offered — only when the current queue is itself
// entirely ephemeral (queue ops may not mix ephemeral with persistent tracks).
in-out property <bool> enqueue-allowed: false;
}
export global EphemeralPlayChoiceActions {
callback enqueue(); // add the selection to the current queue
callback replace(); // clear the queue and start playing the selection
callback close();
}
// Playlist detail screen state. Rows reuse the shared TrackRow
// (show-artwork + show-album); populated by crate::playlist.
export global PlaylistState {
@@ -1914,6 +2018,8 @@ export struct QueueItem {
playing: bool,
duration: string, // "M:SS"
explicit: bool,
// From an ephemeral folder — gates persistence actions in the row menu.
is-ephemeral: bool,
}
// Now-playing / transport state. The Rust playback poll loop pushes the
@@ -1927,6 +2033,9 @@ export global NowPlayingState {
in property <string> artist-id;
in property <image> artwork;
in property <string> track-id;
// True when the playing track is from an ephemeral folder (synthetic id):
// it has no DB row, so favorite / add-to-playlist / track-info are gated off.
in property <bool> is-ephemeral: false;
in property <bool> playing: false;
in property <int> position-secs: 0;
in property <int> duration-secs: 0;