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:
vicrodh committed 2026-05-19 01:49:44 -06:00
1 parent 747ae6082c
commit 291deff7fb
4 files changed
+842 -185

No files matched your search

+67 -12
View File
@@ -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.
{
+54 -173
View File
@@ -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| {
+711
View File
@@ -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);
}
}
}
});
});
}
}
+10
View File
@@ -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.