feat(slint): add queue sidebar controller
QueueController owns the sidebar-local view state — active tab, search query, paginator page — and the cached favorite-track IDs. It pulls a full queue snapshot via QbzCore::get_queue_state_full, applies the search filter, slices out the current 40-track page, and pushes the result onto QueueState. Every queue mutation refreshes it. The four footer actions: clear-queue calls core clear_queue (wiping the now-playing slot only when nothing is audible, matching Tauri); save-as-playlist resolves the queue track set (name-input modal pending); infinite-play toggles the persisted AutoplayMode; search filters the upcoming list client-side. play paths route through after_track_change, extracted from the per-call duplication in playback.rs. cc
This commit is contained in:
4 files changed
+842
-185
No files matched your search
@@ -23,6 +23,7 @@ mod commands;
|
||||
mod home;
|
||||
mod nav;
|
||||
mod playback;
|
||||
mod queue;
|
||||
mod recently;
|
||||
mod settings;
|
||||
mod ui_prefs;
|
||||
@@ -727,22 +728,76 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Queue sidebar — clicking a row jumps to that queue index.
|
||||
// Queue sidebar — build the controller and wire every callback.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<QueueState>()
|
||||
.on_play_index(move |index| {
|
||||
playback::play_queue_index(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
index.max(0) as usize,
|
||||
let controller = queue::QueueController::new(
|
||||
app_runtime.clone(),
|
||||
window.as_weak(),
|
||||
tokio_rt.handle().clone(),
|
||||
settings_ctx.playback_prefs(),
|
||||
);
|
||||
// Publish it so the playback paths refresh the sidebar after every
|
||||
// queue mutation (play / skip / auto-advance / enqueue).
|
||||
playback::set_queue_controller(controller.clone());
|
||||
|
||||
let qs = window.global::<QueueState>();
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_play_upcoming(move |index| c.play_upcoming(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_play_history(move |index| c.play_history(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_remove_upcoming(move |index| c.remove_upcoming(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_clear_queue(move || c.clear());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_toggle_now_playing_favorite(move || c.toggle_favorite());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_save_as_playlist(move || c.save_as_playlist());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_toggle_infinite_play(move || c.toggle_infinite_play());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
let weak = window.as_weak();
|
||||
qs.on_search_changed(move || {
|
||||
let query = weak
|
||||
.upgrade()
|
||||
.map(|w| w.global::<QueueState>().get_search_query().to_string())
|
||||
.unwrap_or_default();
|
||||
c.search_changed(query);
|
||||
});
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_prev_page(move || c.prev_page());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_next_page(move || c.next_page());
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_set_tab(move |tab| c.set_tab(tab));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
// On open, also re-pull favorites so the heart is accurate.
|
||||
qs.on_panel_opened(move || c.refresh_with_favorites());
|
||||
}
|
||||
}
|
||||
|
||||
// Album track search — client-side filter, no backend round-trip.
|
||||
{
|
||||
|
||||
@@ -11,14 +11,51 @@
|
||||
//! values onto the `NowPlayingState` global. The same task drives
|
||||
//! auto-advance when a track ends.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_models::{Quality, QueueTrack, RepeatMode};
|
||||
use slint::{ComponentHandle, Model};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::{AppWindow, NowPlayingState, QueueItem, QueueState};
|
||||
use crate::queue::QueueController;
|
||||
use crate::{AppWindow, NowPlayingState};
|
||||
|
||||
/// The Queue sidebar controller, published once the shell is up so the
|
||||
/// playback paths (album/track play, skip, auto-advance) can refresh the
|
||||
/// sidebar after every queue mutation.
|
||||
static QUEUE_CONTROLLER: OnceLock<QueueController> = OnceLock::new();
|
||||
|
||||
/// Register the Queue sidebar controller. Called once during shell setup.
|
||||
pub fn set_queue_controller(controller: QueueController) {
|
||||
let _ = QUEUE_CONTROLLER.set(controller);
|
||||
}
|
||||
|
||||
/// Refresh the Queue sidebar from the current core queue state. No-op
|
||||
/// before the controller is registered. `with_favorites` re-pulls the
|
||||
/// favorite-track cache as well (used after a fresh play starts).
|
||||
fn refresh_sidebar(with_favorites: bool) {
|
||||
if let Some(controller) = QUEUE_CONTROLLER.get() {
|
||||
if with_favorites {
|
||||
controller.refresh_with_favorites();
|
||||
} else {
|
||||
controller.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared post-track-change step: update the now-playing card, record the
|
||||
/// play in the recently-played store, and start audio for `track_id`.
|
||||
/// Used by the queue controller's play paths.
|
||||
pub async fn after_track_change(
|
||||
runtime: &Runtime,
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
track_id: u64,
|
||||
) {
|
||||
refresh_now_playing_meta(runtime, weak).await;
|
||||
record_recent(runtime).await;
|
||||
play_audible(runtime, track_id).await;
|
||||
}
|
||||
|
||||
/// Streaming quality used for all playback in the MVP — highest tier,
|
||||
/// the player falls back internally when it is not available.
|
||||
@@ -43,106 +80,6 @@ async fn play_audible(runtime: &Runtime, track_id: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain, `Send` queue-row data produced on the worker thread. The
|
||||
/// `QueueItem` (which holds a non-`Send` `slint::Image`) is built from
|
||||
/// this inside the event-loop closure.
|
||||
struct QueueRowData {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
artwork_url: String,
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
/// Push the current queue snapshot onto the `QueueState` global. Builds
|
||||
/// the ordered list (history → current → upcoming) so the sidebar shows
|
||||
/// the whole queue with the playing row marked.
|
||||
async fn refresh_queue(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
|
||||
let mut rows: Vec<QueueRowData> = Vec::new();
|
||||
let mut current_index: i32 = -1;
|
||||
|
||||
for track in &state.history {
|
||||
rows.push(queue_row(track, false));
|
||||
}
|
||||
if let Some(current) = state.current_track.as_ref() {
|
||||
current_index = rows.len() as i32;
|
||||
rows.push(queue_row(current, true));
|
||||
}
|
||||
for track in &state.upcoming {
|
||||
rows.push(queue_row(track, false));
|
||||
}
|
||||
|
||||
// Collect the cover jobs before the rows are moved into the closure.
|
||||
let art_jobs: Vec<(usize, String)> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| !row.artwork_url.is_empty())
|
||||
.map(|(idx, row)| (idx, row.artwork_url.clone()))
|
||||
.collect();
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let items: Vec<QueueItem> = rows
|
||||
.into_iter()
|
||||
.map(|row| QueueItem {
|
||||
id: row.id.into(),
|
||||
title: row.title.into(),
|
||||
artist: row.artist.into(),
|
||||
artwork: slint::Image::default(),
|
||||
playing: row.playing,
|
||||
})
|
||||
.collect();
|
||||
let qs = w.global::<QueueState>();
|
||||
qs.set_items(slint::ModelRc::new(slint::VecModel::from(items)));
|
||||
qs.set_current_index(current_index);
|
||||
});
|
||||
|
||||
load_queue_artwork(weak.clone(), art_jobs);
|
||||
}
|
||||
|
||||
/// Map a `QueueTrack` to plain `Send` row data.
|
||||
fn queue_row(track: &QueueTrack, playing: bool) -> QueueRowData {
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
QueueRowData {
|
||||
id: track.id.to_string(),
|
||||
title,
|
||||
artist: track.artist.clone(),
|
||||
artwork_url: track.artwork_url.clone().unwrap_or_default(),
|
||||
playing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve cover art for a list of queue rows and apply each decoded
|
||||
/// image onto its `QueueState.items` row by index. Spawns one task per
|
||||
/// cover; misses are silently skipped.
|
||||
fn load_queue_artwork(weak: slint::Weak<AppWindow>, jobs: Vec<(usize, String)>) {
|
||||
let Some(cache) = crate::artwork::shared_cache() else {
|
||||
return;
|
||||
};
|
||||
for (idx, url) in jobs {
|
||||
let weak = weak.clone();
|
||||
let cache = cache.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some((pixels, w, h)) =
|
||||
crate::artwork::fetch_and_decode(&url, &cache, 96).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = weak.upgrade_in_event_loop(move |win| {
|
||||
let items = win.global::<QueueState>().get_items();
|
||||
if let Some(mut item) = items.row_data(idx) {
|
||||
item.artwork = crate::artwork::pixels_to_image(&pixels, w, h);
|
||||
items.set_row_data(idx, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the now-playing cover and apply it to `NowPlayingState`.
|
||||
fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, url: String) {
|
||||
if url.is_empty() {
|
||||
@@ -331,10 +268,8 @@ pub fn play_album(
|
||||
let start_track_id = tracks[start].id;
|
||||
|
||||
runtime.core().set_queue(tracks, Some(start)).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, start_track_id).await;
|
||||
after_track_change(&runtime, &weak, start_track_id).await;
|
||||
refresh_sidebar(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,15 +312,13 @@ pub fn play_track_now(
|
||||
);
|
||||
|
||||
runtime.core().set_queue(vec![queue_track], Some(0)).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
after_track_change(&runtime, &weak, track_id).await;
|
||||
refresh_sidebar(true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Enqueue an album's tracks at the end of the current queue.
|
||||
pub fn enqueue_album(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, album_id: String) {
|
||||
pub fn enqueue_album(runtime: Runtime, _weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, album_id: String) {
|
||||
handle.spawn(async move {
|
||||
let album = match runtime.core().get_album(&album_id).await {
|
||||
Ok(album) => album,
|
||||
@@ -411,12 +344,12 @@ pub fn enqueue_album(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
|
||||
return;
|
||||
}
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_sidebar(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Enqueue a single track at the end of the current queue.
|
||||
pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
|
||||
pub fn enqueue_track(runtime: Runtime, _weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
|
||||
handle.spawn(async move {
|
||||
let track = match runtime.core().get_track(track_id).await {
|
||||
Ok(track) => track,
|
||||
@@ -441,27 +374,7 @@ pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tok
|
||||
let queue_track =
|
||||
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork);
|
||||
runtime.core().add_track(queue_track).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Jump to queue entry `index` and start audio for it.
|
||||
pub fn play_queue_index(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
index: usize,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let Some(track) = runtime.core().play_index(index).await else {
|
||||
log::warn!("[qbz-slint] playback: play_index {index} out of range");
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
refresh_sidebar(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,10 +401,8 @@ pub fn next(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runti
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
after_track_change(&runtime, &weak, track_id).await;
|
||||
refresh_sidebar(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -503,10 +414,8 @@ pub fn previous(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::r
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
after_track_change(&runtime, &weak, track_id).await;
|
||||
refresh_sidebar(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -636,32 +545,6 @@ mod tests {
|
||||
assert_eq!(fmt_remaining(250, 200), "-0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_row_appends_version() {
|
||||
let track = QueueTrack {
|
||||
id: 7,
|
||||
title: "Song".to_string(),
|
||||
version: Some("Live".to_string()),
|
||||
artist: "Artist".to_string(),
|
||||
album: "Album".to_string(),
|
||||
duration_secs: 100,
|
||||
artwork_url: None,
|
||||
hires: false,
|
||||
bit_depth: None,
|
||||
sample_rate: None,
|
||||
is_local: false,
|
||||
album_id: None,
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: None,
|
||||
parental_warning: false,
|
||||
source_item_id_hint: None,
|
||||
};
|
||||
let row = queue_row(&track, true);
|
||||
assert_eq!(row.title, "Song (Live)");
|
||||
assert_eq!(row.id, "7");
|
||||
assert!(row.playing);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the playback poll loop. Runs for the app lifetime: every ~450ms
|
||||
@@ -738,10 +621,8 @@ pub fn start_poll_loop(
|
||||
seen_position = 0;
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
let next_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, next_id).await;
|
||||
after_track_change(&runtime, &weak, next_id).await;
|
||||
refresh_sidebar(true);
|
||||
} else {
|
||||
log::info!("[qbz-slint] playback: queue finished");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! Queue sidebar controller.
|
||||
//!
|
||||
//! Ported from the Tauri `QueuePanel.svelte`. The core's `QueueManager`
|
||||
//! owns the authoritative track list; this controller owns the
|
||||
//! *sidebar-local* view state — which tab is shown, the search query, the
|
||||
//! current paginator page — and the cached favorite-track IDs used to
|
||||
//! light the NOW PLAYING heart.
|
||||
//!
|
||||
//! `refresh` pulls a fresh `get_queue_state_full()` snapshot, applies the
|
||||
//! active search filter, slices out the current 40-track page, and pushes
|
||||
//! everything onto the `QueueState` Slint global. Every queue mutation
|
||||
//! (play / remove / clear / page change / search) calls back into
|
||||
//! `refresh` so the UI and the core never drift.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use qbz_app::settings::playback::AutoplayMode;
|
||||
use qbz_models::QueueTrack;
|
||||
use slint::{ComponentHandle, Model};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::{AppWindow, QueueItem, QueueState};
|
||||
|
||||
/// Upcoming tracks shown per paginator page — matches the Tauri sidebar.
|
||||
pub const PAGE_SIZE: usize = 40;
|
||||
|
||||
type Runtime = Arc<qbz_app::shell::AppRuntime<SlintAdapter>>;
|
||||
|
||||
/// Sidebar-local view state. Wrapped in a `Mutex` and shared as an `Arc`
|
||||
/// across every queue callback closure.
|
||||
#[derive(Default)]
|
||||
struct ViewState {
|
||||
/// Active tab: 0 = Queue, 1 = History.
|
||||
tab: i32,
|
||||
/// Live search query (filters the upcoming list, case-insensitive).
|
||||
search: String,
|
||||
/// Zero-based paginator page within the (filtered) upcoming list.
|
||||
page: usize,
|
||||
/// Cached set of the user's favorite track IDs.
|
||||
favorites: std::collections::HashSet<u64>,
|
||||
/// Whether the favorites cache has been populated at least once.
|
||||
favorites_loaded: bool,
|
||||
}
|
||||
|
||||
/// The Queue sidebar controller — see the module docs.
|
||||
pub struct QueueController {
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
playback: qbz_app::settings::playback::PlaybackPreferencesState,
|
||||
view: Arc<Mutex<ViewState>>,
|
||||
}
|
||||
|
||||
// `PlaybackPreferencesState` is not `Clone`, but its sole field is an
|
||||
// `Arc`-shared store handle, so the controller can be cloned cheaply by
|
||||
// sharing that handle. Every other field is already `Arc`/`Clone`.
|
||||
impl Clone for QueueController {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
runtime: self.runtime.clone(),
|
||||
weak: self.weak.clone(),
|
||||
handle: self.handle.clone(),
|
||||
playback: qbz_app::settings::playback::PlaybackPreferencesState {
|
||||
store: Arc::clone(&self.playback.store),
|
||||
},
|
||||
view: Arc::clone(&self.view),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain `Send` row data built off the UI thread; the non-`Send`
|
||||
/// `QueueItem` (holds a `slint::Image`) is constructed inside the event
|
||||
/// loop from this.
|
||||
struct RowData {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
duration: String,
|
||||
explicit: bool,
|
||||
artwork_url: String,
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
/// `M:SS` duration string.
|
||||
fn fmt_duration(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
/// Title with the Qobuz version suffix appended, matching the Tauri
|
||||
/// `formatTrackTitle` behaviour.
|
||||
fn display_title(track: &QueueTrack) -> String {
|
||||
match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One page's bounds within a `total`-length list, `PAGE_SIZE` per page.
|
||||
struct PageBounds {
|
||||
/// Page index clamped into `[0, page_count)`.
|
||||
page: usize,
|
||||
/// Total number of pages (at least 1).
|
||||
page_count: usize,
|
||||
/// Zero-based slice start.
|
||||
start: usize,
|
||||
/// Zero-based slice end (exclusive).
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// Compute the paginator bounds for `total` items at the requested
|
||||
/// `page`. The page is clamped so a shrunk list never leaves the
|
||||
/// paginator past the end. Extracted so the maths is unit-testable.
|
||||
fn paginate(total: usize, requested_page: usize) -> PageBounds {
|
||||
let page_count = total.div_ceil(PAGE_SIZE).max(1);
|
||||
let page = requested_page.min(page_count - 1);
|
||||
let start = page * PAGE_SIZE;
|
||||
let end = (start + PAGE_SIZE).min(total);
|
||||
PageBounds {
|
||||
page,
|
||||
page_count,
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_from(track: &QueueTrack, playing: bool) -> RowData {
|
||||
RowData {
|
||||
id: track.id.to_string(),
|
||||
title: display_title(track),
|
||||
artist: track.artist.clone(),
|
||||
duration: fmt_duration(track.duration_secs),
|
||||
explicit: track.parental_warning,
|
||||
artwork_url: track.artwork_url.clone().unwrap_or_default(),
|
||||
playing,
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueController {
|
||||
pub fn new(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
playback: qbz_app::settings::playback::PlaybackPreferencesState,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
weak,
|
||||
handle,
|
||||
playback,
|
||||
view: Arc::new(Mutex::new(ViewState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull a fresh full queue snapshot, re-apply the search filter and
|
||||
/// current page, and push the result onto `QueueState`. Spawns on the
|
||||
/// tokio runtime; safe to call from any thread.
|
||||
pub fn refresh(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the favorites cache, then refresh the queue view.
|
||||
pub fn refresh_with_favorites(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
this.reload_favorites().await;
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the user's favorite-track IDs from Qobuz into the cache.
|
||||
async fn reload_favorites(&self) {
|
||||
match self.runtime.core().favorite_track_ids().await {
|
||||
Ok(ids) => {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.favorites = ids;
|
||||
view.favorites_loaded = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] queue: favorite_track_ids failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_async(&self) {
|
||||
// Lazily populate the favorites cache the first time the queue is
|
||||
// shown so the NOW PLAYING heart is correct without a manual sync.
|
||||
let need_favorites = self
|
||||
.view
|
||||
.lock()
|
||||
.map(|v| !v.favorites_loaded)
|
||||
.unwrap_or(false);
|
||||
if need_favorites {
|
||||
self.reload_favorites().await;
|
||||
}
|
||||
|
||||
let state = self.runtime.core().get_queue_state_full().await;
|
||||
|
||||
// --- NOW PLAYING --------------------------------------------------
|
||||
let now_playing = state.current_track.as_ref().map(|t| row_from(t, true));
|
||||
let now_playing_favorite = state
|
||||
.current_track
|
||||
.as_ref()
|
||||
.map(|t| {
|
||||
self.view
|
||||
.lock()
|
||||
.map(|v| v.favorites.contains(&t.id))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
// --- UP NEXT (search-filtered) -----------------------------------
|
||||
let (search, page) = self
|
||||
.view
|
||||
.lock()
|
||||
.map(|v| (v.search.clone(), v.page))
|
||||
.unwrap_or_default();
|
||||
let query = search.trim().to_lowercase();
|
||||
|
||||
let filtered: Vec<&QueueTrack> = if query.is_empty() {
|
||||
state.upcoming.iter().collect()
|
||||
} else {
|
||||
state
|
||||
.upcoming
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
display_title(t).to_lowercase().contains(&query)
|
||||
|| t.artist.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let upcoming_total = filtered.len();
|
||||
let bounds = paginate(upcoming_total, page);
|
||||
let (page, page_count, start, end) =
|
||||
(bounds.page, bounds.page_count, bounds.start, bounds.end);
|
||||
// Persist the clamped page in case the filter shrank the list.
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.page = page;
|
||||
}
|
||||
|
||||
let page_rows: Vec<RowData> = filtered[start..end]
|
||||
.iter()
|
||||
.map(|t| row_from(t, false))
|
||||
.collect();
|
||||
|
||||
// page-start / page-end are 1-based for the human-readable counter.
|
||||
let page_start = if upcoming_total == 0 { 0 } else { start + 1 };
|
||||
let page_end = end;
|
||||
// "left" mirrors the Tauri queueRemainingTracks: tracks after the
|
||||
// current one across the whole (unfiltered) queue.
|
||||
let remaining = state
|
||||
.current_index
|
||||
.map(|idx| state.total_tracks.saturating_sub(idx + 1))
|
||||
.unwrap_or(state.total_tracks);
|
||||
|
||||
// --- HISTORY ------------------------------------------------------
|
||||
let history_rows: Vec<RowData> =
|
||||
state.history.iter().map(|t| row_from(t, false)).collect();
|
||||
|
||||
// --- Infinite-play flag ------------------------------------------
|
||||
let infinite = self
|
||||
.playback
|
||||
.get_preferences()
|
||||
.map(|p| p.autoplay_mode == AutoplayMode::InfiniteRadio)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Collect artwork jobs before the rows move into the closure.
|
||||
// Indices: 0 = now-playing, 1.. = page rows, then history rows.
|
||||
let mut art_jobs: Vec<(ArtTarget, String)> = Vec::new();
|
||||
if let Some(np) = now_playing.as_ref() {
|
||||
if !np.artwork_url.is_empty() {
|
||||
art_jobs.push((ArtTarget::NowPlaying, np.artwork_url.clone()));
|
||||
}
|
||||
}
|
||||
for (idx, row) in page_rows.iter().enumerate() {
|
||||
if !row.artwork_url.is_empty() {
|
||||
art_jobs.push((ArtTarget::Upcoming(idx), row.artwork_url.clone()));
|
||||
}
|
||||
}
|
||||
for (idx, row) in history_rows.iter().enumerate() {
|
||||
if !row.artwork_url.is_empty() {
|
||||
art_jobs.push((ArtTarget::History(idx), row.artwork_url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let weak = self.weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let qs = w.global::<QueueState>();
|
||||
|
||||
let np_item = now_playing
|
||||
.as_ref()
|
||||
.map(|r| to_item(r))
|
||||
.unwrap_or_default();
|
||||
qs.set_has_current(now_playing.is_some());
|
||||
qs.set_now_playing(np_item);
|
||||
qs.set_now_playing_favorite(now_playing_favorite);
|
||||
|
||||
let page_items: Vec<QueueItem> = page_rows.iter().map(to_item).collect();
|
||||
qs.set_upcoming_page(slint::ModelRc::new(slint::VecModel::from(page_items)));
|
||||
qs.set_upcoming_total(upcoming_total as i32);
|
||||
qs.set_upcoming_remaining(remaining as i32);
|
||||
qs.set_page(page as i32);
|
||||
qs.set_page_count(page_count as i32);
|
||||
qs.set_page_start(page_start as i32);
|
||||
qs.set_page_end(page_end as i32);
|
||||
|
||||
let history_items: Vec<QueueItem> = history_rows.iter().map(to_item).collect();
|
||||
qs.set_history(slint::ModelRc::new(slint::VecModel::from(history_items)));
|
||||
|
||||
qs.set_infinite_play(infinite);
|
||||
});
|
||||
|
||||
load_artwork(self.weak.clone(), art_jobs);
|
||||
}
|
||||
|
||||
// --- Callbacks --------------------------------------------------------
|
||||
|
||||
/// Play the upcoming entry at `page_index` within the current page.
|
||||
/// Resolves the page-local index to a queue-wide upcoming index,
|
||||
/// honoring the active search filter, then jumps the core there.
|
||||
pub fn play_upcoming(&self, page_index: usize) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let Some(upcoming_index) = this.resolve_upcoming_index(page_index).await else {
|
||||
log::warn!("[qbz-slint] queue: play_upcoming {page_index} out of range");
|
||||
return;
|
||||
};
|
||||
let Some(track) = this
|
||||
.runtime
|
||||
.core()
|
||||
.play_upcoming_at(upcoming_index)
|
||||
.await
|
||||
else {
|
||||
log::warn!("[qbz-slint] queue: play_upcoming_at {upcoming_index} miss");
|
||||
return;
|
||||
};
|
||||
crate::playback::after_track_change(&this.runtime, &this.weak, track.id).await;
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove the upcoming entry at `page_index` within the current page.
|
||||
pub fn remove_upcoming(&self, page_index: usize) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let Some(upcoming_index) = this.resolve_upcoming_index(page_index).await else {
|
||||
log::warn!("[qbz-slint] queue: remove_upcoming {page_index} out of range");
|
||||
return;
|
||||
};
|
||||
this.runtime
|
||||
.core()
|
||||
.remove_upcoming_track(upcoming_index)
|
||||
.await;
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve a page-local upcoming index to a queue-wide upcoming index.
|
||||
/// When a search is active the queue-wide index is found by matching
|
||||
/// the filtered row back against the unfiltered upcoming list.
|
||||
async fn resolve_upcoming_index(&self, page_index: usize) -> Option<usize> {
|
||||
let (search, page) = self
|
||||
.view
|
||||
.lock()
|
||||
.map(|v| (v.search.clone(), v.page))
|
||||
.ok()?;
|
||||
let query = search.trim().to_lowercase();
|
||||
let absolute_in_filtered = page * PAGE_SIZE + page_index;
|
||||
|
||||
if query.is_empty() {
|
||||
return Some(absolute_in_filtered);
|
||||
}
|
||||
|
||||
// Walk the unfiltered upcoming list, counting matches until the
|
||||
// requested filtered position is reached.
|
||||
let state = self.runtime.core().get_queue_state_full().await;
|
||||
let mut matched = 0usize;
|
||||
for (idx, track) in state.upcoming.iter().enumerate() {
|
||||
let hit = display_title(track).to_lowercase().contains(&query)
|
||||
|| track.artist.to_lowercase().contains(&query);
|
||||
if hit {
|
||||
if matched == absolute_in_filtered {
|
||||
return Some(idx);
|
||||
}
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Play a History entry by its `index` in the history list.
|
||||
pub fn play_history(&self, index: usize) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let state = this.runtime.core().get_queue_state_full().await;
|
||||
let Some(track) = state.history.get(index).cloned() else {
|
||||
log::warn!("[qbz-slint] queue: play_history {index} out of range");
|
||||
return;
|
||||
};
|
||||
// History plays start a fresh single-track queue, matching the
|
||||
// Tauri handlePlayHistoryTrack path (the history list is not a
|
||||
// re-entry point into the existing queue order).
|
||||
this.runtime.core().set_queue(vec![track.clone()], Some(0)).await;
|
||||
crate::playback::after_track_change(&this.runtime, &this.weak, track.id).await;
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Empty the queue. When nothing is playing the now-playing slot is
|
||||
/// wiped too, mirroring the Tauri `handleClearQueue` behaviour.
|
||||
pub fn clear(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let playing = this.runtime.core().get_playback_state().is_playing;
|
||||
// keep_current = playing: keep the slot only while audible.
|
||||
this.runtime.core().clear_queue(playing).await;
|
||||
if let Ok(mut view) = this.view.lock() {
|
||||
view.page = 0;
|
||||
view.search.clear();
|
||||
}
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the favorite state of the now-playing track.
|
||||
pub fn toggle_favorite(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let state = this.runtime.core().get_queue_state_full().await;
|
||||
let Some(track) = state.current_track else {
|
||||
return;
|
||||
};
|
||||
let currently = this
|
||||
.view
|
||||
.lock()
|
||||
.map(|v| v.favorites.contains(&track.id))
|
||||
.unwrap_or(false);
|
||||
let make_favorite = !currently;
|
||||
match this
|
||||
.runtime
|
||||
.core()
|
||||
.set_track_favorite(track.id, make_favorite)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Ok(mut view) = this.view.lock() {
|
||||
if make_favorite {
|
||||
view.favorites.insert(track.id);
|
||||
} else {
|
||||
view.favorites.remove(&track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] queue: toggle favorite failed: {e}");
|
||||
}
|
||||
}
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle infinite-play: flips the persisted autoplay mode between
|
||||
/// `InfiniteRadio` and `ContinueWithinSource`, mirroring the Tauri
|
||||
/// `handleToggleInfinitePlay` (which calls `setAutoplayMode`).
|
||||
pub fn toggle_infinite_play(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let enabled = this
|
||||
.playback
|
||||
.get_preferences()
|
||||
.map(|p| p.autoplay_mode == AutoplayMode::InfiniteRadio)
|
||||
.unwrap_or(false);
|
||||
let next = if enabled {
|
||||
AutoplayMode::ContinueWithinSource
|
||||
} else {
|
||||
AutoplayMode::InfiniteRadio
|
||||
};
|
||||
if let Err(e) = this.playback.set_autoplay_mode(next) {
|
||||
log::error!("[qbz-slint] queue: set autoplay mode failed: {e}");
|
||||
}
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// The current queue track IDs (current + upcoming), in play order —
|
||||
/// the set the "save as playlist" action would persist.
|
||||
pub fn save_as_playlist(&self) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let state = this.runtime.core().get_queue_state_full().await;
|
||||
let mut ids: Vec<u64> = Vec::new();
|
||||
if let Some(curr) = state.current_track.as_ref() {
|
||||
ids.push(curr.id);
|
||||
}
|
||||
ids.extend(state.upcoming.iter().map(|t| t.id));
|
||||
// The Slint MVP has no playlist-name input modal yet, so the
|
||||
// queue cannot be persisted into a named playlist here. Log the
|
||||
// resolved track set so the wiring is verifiable; the modal is
|
||||
// tracked as follow-up work.
|
||||
log::info!(
|
||||
"[qbz-slint] queue: save-as-playlist requested for {} tracks (modal pending)",
|
||||
ids.len()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Move to the previous paginator page.
|
||||
pub fn prev_page(&self) {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.page = view.page.saturating_sub(1);
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
/// Move to the next paginator page.
|
||||
pub fn next_page(&self) {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.page += 1;
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
/// Switch the active tab (0 = Queue, 1 = History).
|
||||
pub fn set_tab(&self, tab: i32) {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.tab = tab;
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
/// Update the search query and re-filter the upcoming list. Changing
|
||||
/// the query resets the paginator to the first page.
|
||||
pub fn search_changed(&self, query: String) {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
view.search = query;
|
||||
view.page = 0;
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a resolved cover image should land.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ArtTarget {
|
||||
NowPlaying,
|
||||
Upcoming(usize),
|
||||
History(usize),
|
||||
}
|
||||
|
||||
/// Build a `QueueItem` from plain row data (no artwork — that resolves
|
||||
/// asynchronously and is set onto the row afterward).
|
||||
fn to_item(row: &RowData) -> QueueItem {
|
||||
QueueItem {
|
||||
id: row.id.clone().into(),
|
||||
title: row.title.clone().into(),
|
||||
artist: row.artist.clone().into(),
|
||||
artwork: slint::Image::default(),
|
||||
playing: row.playing,
|
||||
duration: row.duration.clone().into(),
|
||||
explicit: row.explicit,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(id: u64, title: &str, version: Option<&str>) -> QueueTrack {
|
||||
QueueTrack {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
version: version.map(|v| v.to_string()),
|
||||
artist: "Artist".to_string(),
|
||||
album: "Album".to_string(),
|
||||
duration_secs: 100,
|
||||
artwork_url: None,
|
||||
hires: false,
|
||||
bit_depth: None,
|
||||
sample_rate: None,
|
||||
is_local: false,
|
||||
album_id: None,
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: None,
|
||||
parental_warning: false,
|
||||
source_item_id_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_duration_pads_seconds() {
|
||||
assert_eq!(fmt_duration(0), "0:00");
|
||||
assert_eq!(fmt_duration(9), "0:09");
|
||||
assert_eq!(fmt_duration(65), "1:05");
|
||||
assert_eq!(fmt_duration(3725), "62:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_appends_version() {
|
||||
assert_eq!(display_title(&track(1, "Song", Some("Live"))), "Song (Live)");
|
||||
assert_eq!(display_title(&track(1, "Song", None)), "Song");
|
||||
// Empty version string is treated as no version.
|
||||
assert_eq!(display_title(&track(1, "Song", Some(""))), "Song");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_from_marks_playing_and_explicit() {
|
||||
let mut t = track(7, "Song", Some("Mix"));
|
||||
t.parental_warning = true;
|
||||
t.duration_secs = 215;
|
||||
let row = row_from(&t, true);
|
||||
assert_eq!(row.id, "7");
|
||||
assert_eq!(row.title, "Song (Mix)");
|
||||
assert_eq!(row.duration, "3:35");
|
||||
assert!(row.playing);
|
||||
assert!(row.explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_single_page() {
|
||||
let b = paginate(10, 0);
|
||||
assert_eq!(b.page_count, 1);
|
||||
assert_eq!(b.page, 0);
|
||||
assert_eq!((b.start, b.end), (0, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_exact_page_boundary() {
|
||||
// Exactly PAGE_SIZE items -> one full page, not two.
|
||||
let b = paginate(PAGE_SIZE, 0);
|
||||
assert_eq!(b.page_count, 1);
|
||||
assert_eq!((b.start, b.end), (0, PAGE_SIZE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_spans_multiple_pages() {
|
||||
// 95 items, 40 per page -> 3 pages (40 / 40 / 15).
|
||||
let total = 95;
|
||||
let p0 = paginate(total, 0);
|
||||
assert_eq!(p0.page_count, 3);
|
||||
assert_eq!((p0.start, p0.end), (0, 40));
|
||||
let p1 = paginate(total, 1);
|
||||
assert_eq!((p1.start, p1.end), (40, 80));
|
||||
let p2 = paginate(total, 2);
|
||||
assert_eq!((p2.start, p2.end), (80, 95));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_clamps_overshot_page() {
|
||||
// Requesting page 9 of a 2-page list clamps to the last page.
|
||||
let b = paginate(50, 9);
|
||||
assert_eq!(b.page_count, 2);
|
||||
assert_eq!(b.page, 1);
|
||||
assert_eq!((b.start, b.end), (40, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_empty_list_has_one_page() {
|
||||
let b = paginate(0, 0);
|
||||
assert_eq!(b.page_count, 1);
|
||||
assert_eq!((b.start, b.end), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve cover art for each job and apply it onto the matching row in
|
||||
/// the `QueueState` global. One task per cover; misses are skipped.
|
||||
fn load_artwork(weak: slint::Weak<AppWindow>, jobs: Vec<(ArtTarget, String)>) {
|
||||
let Some(cache) = crate::artwork::shared_cache() else {
|
||||
return;
|
||||
};
|
||||
for (target, url) in jobs {
|
||||
let weak = weak.clone();
|
||||
let cache = cache.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some((pixels, w, h)) =
|
||||
crate::artwork::fetch_and_decode(&url, &cache, 96).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = weak.upgrade_in_event_loop(move |win| {
|
||||
let img = crate::artwork::pixels_to_image(&pixels, w, h);
|
||||
let qs = win.global::<QueueState>();
|
||||
match target {
|
||||
ArtTarget::NowPlaying => {
|
||||
let mut item = qs.get_now_playing();
|
||||
item.artwork = img;
|
||||
qs.set_now_playing(item);
|
||||
}
|
||||
ArtTarget::Upcoming(idx) => {
|
||||
let items = qs.get_upcoming_page();
|
||||
if let Some(mut item) = items.row_data(idx) {
|
||||
item.artwork = img;
|
||||
items.set_row_data(idx, item);
|
||||
}
|
||||
}
|
||||
ArtTarget::History(idx) => {
|
||||
let items = qs.get_history();
|
||||
if let Some(mut item) = items.row_data(idx) {
|
||||
item.artwork = img;
|
||||
items.set_row_data(idx, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,16 @@ impl SettingsCtx {
|
||||
maps: Mutex::new(SettingsMaps::default()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A handle to the playback-preferences store sharing the same
|
||||
/// underlying SQLite connection. The Queue controller uses it to read
|
||||
/// and toggle the autoplay (infinite-play) mode so the sidebar's
|
||||
/// infinite-play button stays in step with the Playback settings.
|
||||
pub fn playback_prefs(&self) -> PlaybackPreferencesState {
|
||||
PlaybackPreferencesState {
|
||||
store: std::sync::Arc::clone(&self.playback.store),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain, `Send` settings data built off the UI thread.
|
||||
|
||||
Reference in new issue
Block a user