feat(queue): add extended queue view

cx
This commit is contained in:
vicrodh committed 2026-08-22 20:01:47 -06:00
1 parent eed377af1f
commit c7038a6858
11 files changed
+1194 -56

No files matched your search

+404 -32
View File
@@ -39,6 +39,8 @@ pub struct QueueRow {
#[serde(rename = "artistId")]
pub artist_id: String,
pub album: String,
#[serde(rename = "albumId")]
pub album_id: String,
pub duration: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
@@ -54,6 +56,14 @@ pub struct QueueRow {
/// library.db row id, not a Qobuz catalog id.
#[serde(rename = "isLocal")]
pub is_local: bool,
/// Origin word carried by the queue track. TrackRow uses this to keep
/// catalog and local id spaces apart in its shared context-menu logic.
pub source: String,
/// Qobuz Connect accepts catalog-backed ids only. The extended queue keeps
/// incompatible rows visible as history/sequence context, but greys and
/// skips them while Connect is active.
#[serde(rename = "qconnectCompatible")]
pub qconnect_compatible: bool,
/// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
/// "next-up" (only on the unfiltered list).
pub section: String,
@@ -125,6 +135,35 @@ struct ViewState {
page: usize,
}
/// One row in the full queue projection. `phase_index` is deliberately in the
/// core's own coordinate space: history is most-recent-first there even though
/// this view renders it oldest-first, while upcoming is already play order.
#[derive(Clone, Serialize)]
struct ExtendedQueueRow {
#[serde(flatten)]
row: QueueRow,
phase: &'static str,
#[serde(rename = "phaseIndex")]
phase_index: usize,
}
#[derive(Default, Serialize)]
struct ExtendedQueueDoc {
rows: Vec<ExtendedQueueRow>,
#[serde(rename = "currentIndex")]
current_index: i32,
#[serde(rename = "historyCount")]
history_count: usize,
#[serde(rename = "upcomingCount")]
upcoming_count: usize,
#[serde(rename = "stopAfterId")]
stop_after_id: String,
#[serde(rename = "infinitePlay")]
infinite_play: bool,
#[serde(rename = "searchQuery")]
search_query: String,
}
static VIEW: Mutex<ViewState> = Mutex::new(ViewState {
search: String::new(),
page: 0,
@@ -205,6 +244,7 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
artist: track.artist.clone(),
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
album: track.album.clone(),
album_id: track.album_id.clone().unwrap_or_default(),
duration: fmt_duration(track.duration_secs),
quality_tier: tier,
quality_detail: crate::home_qt::quality_detail_from_parts(
@@ -215,6 +255,11 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
art_url: track.artwork_url.clone().unwrap_or_default(),
is_favorite,
is_local: track.is_local,
source: track
.source
.clone()
.unwrap_or_else(|| if track.is_local { "local" } else { "qobuz" }.to_string()),
qconnect_compatible: crate::qconnect_qt::is_qconnect_queue_track(track),
section: String::new(),
// Both halves of the reference's test (`queue.rs:148`): the queue
// track's own source tag, and the id-range check for a row that
@@ -369,6 +414,104 @@ fn publish_coverflow(state: &qbz_models::QueueState) {
/// unconditional, this is the function that needs the cache.
static LAST_MINI_QUEUE_JSON: Mutex<String> = Mutex::new(String::new());
/// The full projection is intentionally built only while its view exists. A
/// restored queue may contain thousands of tracks; the sidebar document stays
/// paged at 40 and should not pay to serialize the full sequence in the normal
/// shell path.
static EXTENDED_OPEN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static LAST_EXTENDED_JSON: Mutex<String> = Mutex::new(String::new());
fn extended_rows(
state: &qbz_models::QueueState,
favorites: &HashSet<u64>,
query: &str,
) -> (Vec<ExtendedQueueRow>, i32) {
let mut rows = Vec::with_capacity(
state.history.len() + usize::from(state.current_track.is_some()) + state.upcoming.len(),
);
let mut current_index = -1;
let mut push = |track: &QueueTrack, phase, phase_index, section: &'static str| {
if query.is_empty() || matches_query(track, query) {
if phase == "current" {
current_index = rows.len() as i32;
}
let mut row = row_from(track, favorites);
row.section = section.to_string();
rows.push(ExtendedQueueRow {
row,
phase,
phase_index,
});
}
};
// Core history is newest-first; the view reads as one chronological
// sequence, so the oldest played track is first.
for (index, track) in state.history.iter().enumerate().rev() {
push(track, "history", index, "");
}
if let Some(track) = state.current_track.as_ref() {
push(track, "current", 0, "");
}
for (index, track) in state.upcoming.iter().enumerate() {
let section = if query.is_empty() && state.manual_next_count > 0 && index == 0 {
"next-in-queue"
} else if query.is_empty() && index == state.manual_next_count {
"next-up"
} else {
""
};
push(track, "upcoming", index, section);
}
(rows, current_index)
}
fn publish_extended(
state: &qbz_models::QueueState,
favorites: &HashSet<u64>,
stop_after_id: &str,
search: &str,
) {
use std::sync::atomic::Ordering;
if !EXTENDED_OPEN.load(Ordering::SeqCst) {
return;
}
let query = search.trim().to_lowercase();
let (rows, current_index) = extended_rows(state, favorites, &query);
let doc = ExtendedQueueDoc {
rows,
current_index,
history_count: state.history.len(),
upcoming_count: state.upcoming.len(),
stop_after_id: stop_after_id.to_string(),
infinite_play: is_infinite_play(),
search_query: search.to_string(),
};
let json = serde_json::to_string(&doc).unwrap_or_else(|_| {
r#"{"rows":[],"currentIndex":-1,"historyCount":0,"upcomingCount":0,"stopAfterId":"","infinitePlay":false,"searchQuery":""}"#.into()
});
let mut last = LAST_EXTENDED_JSON.lock().unwrap();
if *last == json {
return;
}
last.clear();
last.push_str(&json);
crate::queue_bridge::ui(move |mut b| {
b.as_mut()
.set_extended_queue_json(QString::from(json.as_str()));
});
}
pub fn extended_opened() {
EXTENDED_OPEN.store(true, std::sync::atomic::Ordering::SeqCst);
}
pub fn extended_closed() {
EXTENDED_OPEN.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// Build + publish the miniplayer's navigable queue document
/// (`{"currentId":"…","rows":[…]}` — `mini_qt::mini_queue_doc`).
///
@@ -536,6 +679,7 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
.await
.map(|id| id.to_string())
.unwrap_or_default();
publish_extended(&state, &favorites, &stop_after_id, &search);
let doc = QueueDoc {
has_current: current.is_some(),
@@ -641,6 +785,88 @@ pub async fn play_upcoming_flat(runtime: &Arc<AppRuntime<LoggingAdapter>>, upcom
}
}
async fn play_extended_upcoming(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
expected_id: u64,
) {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.upcoming.get(upcoming_index) else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: upcoming changed during extended activation");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(track)
{
log::info!(
"[qbz-qt] queue: skipped QConnect-incompatible upcoming row {}",
track.id
);
return;
}
if let Some(service) = crate::qconnect_qt::service() {
match service.play_remote_renderer_track_if_active(track.id).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
log::warn!("[qbz-qt] queue: extended remote play failed: {error}");
return;
}
}
}
play_upcoming_flat(runtime, upcoming_index).await;
}
/// Activate a row from the chronological projection. Upcoming keeps its queue-
/// wide index. A played row is cloned back into slot zero, then activated, so
/// the remaining upcoming sequence survives instead of being replaced by the
/// History tab's single-track queue.
pub async fn play_extended(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
phase: &str,
phase_index: usize,
expected_id: u64,
) {
match phase {
"upcoming" => {
play_extended_upcoming(runtime, phase_index, expected_id).await;
}
"history" => {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.history.get(phase_index).cloned() else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: history changed during activation");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(&track)
{
log::info!("[qbz-qt] queue: skipped QConnect-incompatible history row {}", track.id);
return;
}
match insert_queue_track_at(runtime, track.clone(), 0).await {
Ok(InsertOutcome::Remote) => {
if let Some(service) = crate::qconnect_qt::service() {
if let Err(error) = service.play_remote_renderer_track_if_active(track.id).await {
log::warn!("[qbz-qt] queue: requeued remote history play failed: {error}");
}
}
}
Ok(InsertOutcome::Local) => {
play_extended_upcoming(runtime, 0, track.id).await;
}
Err(error) => log::warn!("[qbz-qt] queue: requeue history failed: {error}"),
}
}
_ => {}
}
}
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range");
@@ -650,6 +876,18 @@ pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
publish(runtime).await;
}
pub async fn remove_upcoming_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if upcoming_index >= state.upcoming.len() {
return;
}
runtime.core().remove_upcoming_track(upcoming_index).await;
publish(runtime).await;
}
/// Drop every UPCOMING row that cannot play, and report how many went.
///
/// The last half of "an unavailable track is never blocking": it is skipped,
@@ -698,6 +936,18 @@ pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_in
publish(runtime).await;
}
pub async fn remove_all_after_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if upcoming_index >= state.upcoming.len() {
return;
}
runtime.core().remove_upcoming_after(upcoming_index).await;
publish(runtime).await;
}
/// Drag reorder — move the PAGE-LOCAL row `from_page` to the insertion slot
/// `to_slot` (0..=page_len), the contract of `QueueSidebar.slint`'s
/// `QueueState.reorder`. Both ends resolve to QUEUE-WIDE upcoming indices
@@ -755,6 +1005,111 @@ pub async fn move_track(
publish(runtime).await;
}
/// Full-view reorder: both coordinates are queue-wide upcoming positions, so
/// no sidebar page/search state participates.
pub async fn move_track_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
from: usize,
to_slot: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if from >= state.upcoming.len() || to_slot > state.upcoming.len() {
return;
}
if from == to_slot || from + 1 == to_slot {
return;
}
if let Some(service) = crate::qconnect_qt::service() {
match service.reorder_upcoming_if_remote(from, to_slot).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
log::warn!("[qbz-qt] queue: extended reorder handoff failed: {error}");
return;
}
}
}
runtime.core().move_track(from, to_slot).await;
publish(runtime).await;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InsertOutcome {
Local,
Remote,
}
async fn insert_queue_track_at(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track: QueueTrack,
to_slot: usize,
) -> Result<InsertOutcome, String> {
if let Some(service) = crate::qconnect_qt::service() {
if service
.insert_at_slot_on_peer_if_active(track.id, track.source.as_deref(), to_slot)
.await
{
return Ok(InsertOutcome::Remote);
}
}
let added_castable =
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
runtime.core().add_track(track).await;
let upcoming_len = runtime.core().get_queue_state_full().await.upcoming.len();
if upcoming_len > 0 {
let from = upcoming_len - 1;
let slot = to_slot.min(from);
if from != slot {
runtime.core().move_track(from, slot).await;
}
}
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
publish(runtime).await;
Ok(InsertOutcome::Local)
}
/// Commit a drag from the chronological view. Upcoming rows move; history rows
/// are reinserted (not removed from history) at the dropped upcoming slot.
pub async fn drop_extended(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
phase: &str,
phase_index: usize,
expected_id: u64,
to_slot: usize,
) {
match phase {
"upcoming" => {
let state = runtime.core().get_queue_state_full().await;
if state.upcoming.get(phase_index).map(|track| track.id) != Some(expected_id) {
log::warn!("[qbz-qt] queue: upcoming changed during extended drag");
return;
}
move_track_flat(runtime, phase_index, to_slot).await;
}
"history" => {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.history.get(phase_index).cloned() else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: history changed during extended drag");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(&track)
{
log::info!("[qbz-qt] queue: skipped QConnect-incompatible history drag {}", track.id);
return;
}
if let Err(error) = insert_queue_track_at(runtime, track, to_slot).await {
log::warn!("[qbz-qt] queue: extended history drop failed: {error}");
}
}
_ => {}
}
}
/// Ask the panel whether to start playing after an empty-queue drop, or clear
/// the ask. Split from the insert so a MULTI-row drop raises exactly one
/// prompt, after the last row has landed.
@@ -817,21 +1172,6 @@ pub async fn insert_dragged_track(
return Ok(());
};
// QConnect CONTROLLER mode (contract §7): the peer owns the queue, so the
// insert is routed there — AT THE DROPPED POSITION. `CtrlSrvrQueueInsertTracks`
// carries `insert_after`, the same field play-next and play-later steer
// with, so the slot survives the routing instead of collapsing to an
// append. `slot` is already in the visible-upcoming space the peer
// projection uses, so it crosses unchanged.
if let Some(svc) = crate::qconnect_qt::service() {
if svc
.insert_at_slot_on_peer_if_active(track_id, qt.source.as_deref(), to_slot)
.await
{
return Ok(());
}
}
// Resolve the drop slot BEFORE the add, while the page still describes the
// queue the user was looking at. Past the last visible row means "after
// everything on this page".
@@ -844,23 +1184,7 @@ pub async fn insert_dragged_track(
},
};
let added_castable = crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&qt));
runtime.core().add_track(qt).await;
// The appended row is the last of upcoming; re-read rather than assume,
// because `add_track` lands relative to `tracks` and the upcoming window
// depends on `current_index`.
let upcoming_len = runtime.core().get_queue_state_full().await.upcoming.len();
if upcoming_len == 0 {
return Ok(());
}
let from_upcoming = upcoming_len - 1;
if from_upcoming != to_upcoming {
runtime.core().move_track(from_upcoming, to_upcoming).await;
}
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
publish(runtime).await;
let _ = insert_queue_track_at(runtime, qt, to_upcoming).await?;
Ok(())
}
@@ -1180,4 +1504,52 @@ mod tests {
let (flat4, _) = coverflow_flat(&h1, Some(&cur), &up_swap);
assert_ne!(coverflow_seq_hash(&flat4), coverflow_seq_hash(&flat3));
}
#[test]
fn extended_projection_is_chronological_and_keeps_core_indices() {
let state = qbz_models::QueueState {
current_track: Some(track(4, "now")),
current_index: Some(3),
upcoming: vec![track(5, "manual"), track(6, "source")],
history: vec![track(3, "h-new"), track(2, "h-mid"), track(1, "h-old")],
shuffle: false,
repeat: qbz_models::RepeatMode::Off,
total_tracks: 6,
stop_after_track_id: None,
manual_next_count: 1,
};
let (rows, current_index) = extended_rows(&state, &HashSet::new(), "");
let ids: Vec<&str> = rows.iter().map(|row| row.row.id.as_str()).collect();
assert_eq!(ids, ["1", "2", "3", "4", "5", "6"]);
assert_eq!(current_index, 3);
assert_eq!(rows[0].phase, "history");
assert_eq!(rows[0].phase_index, 2);
assert_eq!(rows[2].phase_index, 0);
assert_eq!(rows[4].row.section, "next-in-queue");
assert_eq!(rows[5].row.section, "next-up");
}
#[test]
fn extended_search_filters_all_phases_without_reindexing_actions() {
let state = qbz_models::QueueState {
current_track: Some(track(2, "current miss")),
current_index: Some(1),
upcoming: vec![track(3, "needle upcoming")],
history: vec![track(1, "needle history")],
shuffle: false,
repeat: qbz_models::RepeatMode::Off,
total_tracks: 3,
stop_after_track_id: None,
manual_next_count: 0,
};
let (rows, current_index) =
extended_rows(&state, &HashSet::new(), "needle");
assert_eq!(current_index, -1);
assert_eq!(rows.len(), 2);
assert_eq!((rows[0].phase, rows[0].phase_index), ("history", 0));
assert_eq!((rows[1].phase, rows[1].phase_index), ("upcoming", 0));
assert!(rows.iter().all(|row| row.row.section.is_empty()));
}
}