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