fix(player): preserve audible state on source failures
Preflight fallible queue transitions and publish now-playing state only after the requested source is audible, leaving the prior stream intact when a file or server is unavailable. Carry source-owned request headers through remote probes, streaming and fallback downloads without exposing sensitive values. cx
This commit is contained in:
7 files changed
+528
-152
No files matched your search
@@ -129,7 +129,10 @@ pub(crate) async fn play_ticket(runtime: &Runtime, ticket: PlaybackTicket) -> bo
|
||||
|
||||
PlaybackTicket::Bytes { bytes, play_id } => {
|
||||
let len = bytes.len();
|
||||
qbz_audio::register_seek_waveform_key(play_id, qbz_audio::seek_waveform_content_key(&bytes));
|
||||
qbz_audio::register_seek_waveform_key(
|
||||
play_id,
|
||||
qbz_audio::seek_waveform_content_key(&bytes),
|
||||
);
|
||||
if let Err(e) = runtime.core().player().play_data(bytes, play_id) {
|
||||
log::error!("[qbz-qt] audible: play_data {play_id} ({len} B) failed: {e}");
|
||||
return false;
|
||||
@@ -148,7 +151,19 @@ pub(crate) async fn play_ticket(runtime: &Runtime, ticket: PlaybackTicket) -> bo
|
||||
duration_secs,
|
||||
start_secs,
|
||||
log_tag,
|
||||
} => play_stream(runtime, &url, play_id, duration_secs, start_secs, log_tag).await,
|
||||
request_headers,
|
||||
} => {
|
||||
play_stream(
|
||||
runtime,
|
||||
&url,
|
||||
play_id,
|
||||
duration_secs,
|
||||
start_secs,
|
||||
log_tag,
|
||||
&request_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
PlaybackTicket::SeekLoaded { play_id, secs } => {
|
||||
let _ = runtime.core().player().seek(secs as u64);
|
||||
@@ -208,9 +223,14 @@ pub(crate) async fn queue_gapless_successor(
|
||||
| PlaybackTicket::CdTrack { .. }
|
||||
| PlaybackTicket::Catalog { .. } => return Ok(false),
|
||||
PlaybackTicket::Bytes { bytes, .. } => Some(bytes),
|
||||
PlaybackTicket::Stream { url, log_tag, .. } => {
|
||||
PlaybackTicket::Stream {
|
||||
url,
|
||||
log_tag,
|
||||
request_headers,
|
||||
..
|
||||
} => {
|
||||
let started = std::time::Instant::now();
|
||||
let bytes = fetch_body(&url).await?;
|
||||
let bytes = fetch_body(&url, &request_headers).await?;
|
||||
log::info!(
|
||||
"[qbz-qt][GAPLESS] {log_tag} successor {next_id}: {} bytes in {:?}",
|
||||
bytes.len(),
|
||||
@@ -241,9 +261,7 @@ pub(crate) async fn queue_gapless_successor(
|
||||
if player.state.current_track_id() != predecessor_id
|
||||
|| player.state.get_gapless_next_track_id() != 0
|
||||
{
|
||||
log::debug!(
|
||||
"[qbz-qt][GAPLESS] successor {next_id} arrived after its predecessor moved"
|
||||
);
|
||||
log::debug!("[qbz-qt][GAPLESS] successor {next_id} arrived after its predecessor moved");
|
||||
return Ok(false);
|
||||
}
|
||||
qbz_audio::register_seek_waveform_key(next_id, qbz_audio::seek_waveform_content_key(&bytes));
|
||||
@@ -252,12 +270,7 @@ pub(crate) async fn queue_gapless_successor(
|
||||
|
||||
/// Read a file and hand the bytes to the player, with the CUE fast path in
|
||||
/// front of it.
|
||||
async fn play_file(
|
||||
runtime: &Runtime,
|
||||
path: PathBuf,
|
||||
play_id: u64,
|
||||
seek_secs: Option<f64>,
|
||||
) -> bool {
|
||||
async fn play_file(runtime: &Runtime, path: PathBuf, play_id: u64, seek_secs: Option<f64>) -> bool {
|
||||
// CUE fast path — see the module header for why this test changed shape.
|
||||
if let Some(start) = seek_secs.filter(|s| *s > 0.0) {
|
||||
if container_is_loaded(runtime, &path) {
|
||||
@@ -336,6 +349,7 @@ async fn play_stream(
|
||||
duration_secs: u64,
|
||||
start_secs: u64,
|
||||
log_tag: &'static str,
|
||||
request_headers: &[(String, String)],
|
||||
) -> bool {
|
||||
qbz_audio::register_seek_waveform_key(play_id, format!("remote:{log_tag}:{play_id}"));
|
||||
let t0 = std::time::Instant::now();
|
||||
@@ -346,13 +360,14 @@ async fn play_stream(
|
||||
// big file" and "slow because it left the building".
|
||||
let origin = url.split('?').next().unwrap_or("").to_string();
|
||||
|
||||
match qbz_player::remote_stream::stream_remote_track_into_player(
|
||||
match qbz_player::remote_stream::stream_remote_track_into_player_with_headers(
|
||||
&runtime.core().player(),
|
||||
play_id,
|
||||
duration_secs,
|
||||
start_secs,
|
||||
url,
|
||||
log_tag,
|
||||
request_headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -374,7 +389,7 @@ async fn play_stream(
|
||||
),
|
||||
}
|
||||
|
||||
let bytes = match fetch_body(url).await {
|
||||
let bytes = match fetch_body(url, request_headers).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] {log_tag} play {play_id}: download failed ({e}) — {origin}");
|
||||
@@ -406,12 +421,28 @@ async fn play_stream(
|
||||
/// Navidrome 0.63.2, 2026-08-20). Handing that to `play_data` would push a JSON
|
||||
/// blob into the decoder. So the body is also checked for a plausible audio
|
||||
/// length before it is allowed anywhere near the player.
|
||||
async fn fetch_body(url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
async fn fetch_body(url: &str, request_headers: &[(String, String)]) -> Result<Vec<u8>, String> {
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| format!("create download client failed: {e}"))?;
|
||||
let mut request = client.get(url);
|
||||
for (name, value) in request_headers {
|
||||
let name = HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| format!("invalid remote request header name {name:?}"))?;
|
||||
let value = HeaderValue::from_str(value)
|
||||
.map_err(|_| format!("invalid value for remote request header {name:?}"))?;
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?
|
||||
.map_err(|e| format!("request failed: {}", safe_reqwest_error(&e)))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("status error: {e}"))?;
|
||||
.map_err(|e| format!("status error: {}", safe_reqwest_error(&e)))?;
|
||||
let ctype = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
@@ -435,6 +466,21 @@ async fn fetch_body(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Do not let a token-bearing media URL leak through reqwest's Display impl.
|
||||
fn safe_reqwest_error(error: &reqwest::Error) -> String {
|
||||
if error.is_timeout() {
|
||||
"request timed out".into()
|
||||
} else if let Some(status) = error.status() {
|
||||
format!("server returned HTTP {}", status.as_u16())
|
||||
} else if error.is_connect() {
|
||||
"connection failed".into()
|
||||
} else if error.is_body() {
|
||||
"response body failed".into()
|
||||
} else {
|
||||
"request failed".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_models::{PageArtistTrack, Quality, QueueTrack, RepeatMode};
|
||||
|
||||
/// The request tier for every play. Persisted in ui_prefs.json ("streaming_quality")
|
||||
@@ -97,12 +97,18 @@ pub(crate) fn current_quality() -> Quality {
|
||||
/// probes hardware. That is what lets the play funnel own the decision
|
||||
/// instead of taking it as a parameter.
|
||||
pub(crate) fn local_playback_quality() -> (Quality, qbz_models::QualityLimit) {
|
||||
reconcile_device_cap(current_quality(), qbz_app::device_cap::cap().map(|(t, _)| t))
|
||||
reconcile_device_cap(
|
||||
current_quality(),
|
||||
qbz_app::device_cap::cap().map(|(t, _)| t),
|
||||
)
|
||||
}
|
||||
|
||||
/// The decision itself, lifted out of the two lock reads so it is testable.
|
||||
/// Port of the match in `crates/qbz/src/playback.rs local_playback_quality`.
|
||||
fn reconcile_device_cap(pref: Quality, cap: Option<Quality>) -> (Quality, qbz_models::QualityLimit) {
|
||||
fn reconcile_device_cap(
|
||||
pref: Quality,
|
||||
cap: Option<Quality>,
|
||||
) -> (Quality, qbz_models::QualityLimit) {
|
||||
use qbz_models::QualityLimit;
|
||||
match cap {
|
||||
Some(cap) if cap < pref => (cap, QualityLimit::LocalDeviceCap),
|
||||
@@ -345,7 +351,10 @@ impl PlayContext {
|
||||
if kind.is_empty() || id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { kind: kind.to_string(), id: id.to_string() })
|
||||
Some(Self {
|
||||
kind: kind.to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn album(id: &str) -> Option<Self> {
|
||||
@@ -495,7 +504,8 @@ pub(crate) fn queue_track_unavailable(track: &QueueTrack) -> bool {
|
||||
// streamable — which is the owner's test case exactly, since `/album/get`
|
||||
// is honest but plenty of rows only fail at the stream-url step. The cache
|
||||
// exemption applies to both clauses: downloaded bytes play regardless.
|
||||
let dead = !track.streamable || crate::track_replace_qt::session_unavailable::contains(track.id);
|
||||
let dead =
|
||||
!track.streamable || crate::track_replace_qt::session_unavailable::contains(track.id);
|
||||
dead && !crate::offline_qt::is_cached_id(track.id)
|
||||
}
|
||||
|
||||
@@ -646,26 +656,33 @@ pub(crate) struct QueueStart {
|
||||
pub track_id: u64,
|
||||
}
|
||||
|
||||
/// The ONLY `core().set_queue` call in this module — every play path goes
|
||||
/// through it, so the origin can never be dropped on the floor, and neither can
|
||||
/// the blacklist (PARITY-DEBT #1) nor a track Qobuz pulled (contract §5.3 D5).
|
||||
/// A replacement queue after blacklist/availability filtering and context
|
||||
/// stamping, but before it is visible to the core or any UI surface.
|
||||
///
|
||||
/// Returns the surviving anchor; see [`QueueStart`] for why no caller may
|
||||
/// compute it itself any more.
|
||||
pub(crate) async fn set_queue_stamped(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
/// Source-owned playback can fail before the player accepts new audio (dead
|
||||
/// Plex part, unavailable NAS, server outage). Preparing first lets those
|
||||
/// callers prove the anchor is audible without making Queue, NPB and MPRIS
|
||||
/// announce it over the track that is still actually playing.
|
||||
pub(crate) struct PreparedQueue {
|
||||
tracks: Vec<QueueTrack>,
|
||||
start: Option<usize>,
|
||||
anchor: Option<QueueStart>,
|
||||
unavailable_dropped: usize,
|
||||
}
|
||||
|
||||
impl PreparedQueue {
|
||||
pub(crate) fn anchor_track(&self) -> Option<&QueueTrack> {
|
||||
self.start.and_then(|index| self.tracks.get(index))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_queue_stamped(
|
||||
tracks: Vec<QueueTrack>,
|
||||
start: Option<usize>,
|
||||
context: Option<PlayContext>,
|
||||
) -> Option<QueueStart> {
|
||||
) -> Option<PreparedQueue> {
|
||||
let asked = tracks.len();
|
||||
// Filter BEFORE stamping: a dropped row needs no origin, and the start
|
||||
// index has to be remapped against the list the core actually receives.
|
||||
let (mut tracks, start, dropped) = filter_unplayable_queue(tracks, start);
|
||||
// Everything was filtered out of a non-empty build: do NOT publish an empty
|
||||
// queue over whatever is playing (§5.3, last bullet). Say why and leave the
|
||||
// core alone — wiping a working queue because the new one was all-dead is a
|
||||
// second failure stacked on the first.
|
||||
if tracks.is_empty() && asked > 0 {
|
||||
log::info!(
|
||||
"[qbz-qt] set_queue: all {asked} track(s) filtered ({} blacklisted, {} unavailable) \
|
||||
@@ -678,27 +695,54 @@ pub(crate) async fn set_queue_stamped(
|
||||
}
|
||||
stamp_context(&mut tracks, context);
|
||||
warn_dead_context(&tracks, "set_queue");
|
||||
// Read the anchor off the list the CORE receives, not off the caller's —
|
||||
// that difference IS F1. Computed before the move into `set_queue`.
|
||||
let anchor = start.and_then(|index| {
|
||||
tracks.get(index).map(|track| QueueStart {
|
||||
index,
|
||||
track_id: track.id,
|
||||
})
|
||||
});
|
||||
Some(PreparedQueue {
|
||||
tracks,
|
||||
start,
|
||||
anchor,
|
||||
unavailable_dropped: dropped.unavailable,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn commit_prepared_queue(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
prepared: PreparedQueue,
|
||||
) -> Option<QueueStart> {
|
||||
let PreparedQueue {
|
||||
tracks,
|
||||
start,
|
||||
anchor,
|
||||
unavailable_dropped,
|
||||
} = prepared;
|
||||
runtime.core().set_queue(tracks, start).await;
|
||||
toast_unavailable_dropped(dropped.unavailable);
|
||||
// QConnect (contract §6.3, Slint playback.rs:3697-3703): when WE are the
|
||||
// active renderer, push the new queue to the session immediately so a
|
||||
// controller's UI reflects the freshly-built queue — covers infinite-play
|
||||
// refills + album/radio plays. Self-gates to a no-op when not connected
|
||||
// or when a peer owns playback.
|
||||
toast_unavailable_dropped(unavailable_dropped);
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
svc.sync_local_queue_if_changed().await;
|
||||
}
|
||||
anchor
|
||||
}
|
||||
|
||||
/// The ONLY `core().set_queue` call in this module — every play path goes
|
||||
/// through it, so the origin can never be dropped on the floor, and neither can
|
||||
/// the blacklist (PARITY-DEBT #1) nor a track Qobuz pulled (contract §5.3 D5).
|
||||
///
|
||||
/// Returns the surviving anchor; see [`QueueStart`] for why no caller may
|
||||
/// compute it itself any more.
|
||||
pub(crate) async fn set_queue_stamped(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
tracks: Vec<QueueTrack>,
|
||||
start: Option<usize>,
|
||||
context: Option<PlayContext>,
|
||||
) -> Option<QueueStart> {
|
||||
let prepared = prepare_queue_stamped(tracks, start, context)?;
|
||||
commit_prepared_queue(runtime, prepared).await
|
||||
}
|
||||
|
||||
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
|
||||
/// carry their own origin, so the glyph stays right after the queue advances
|
||||
/// into them.
|
||||
@@ -765,7 +809,10 @@ pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool {
|
||||
tracks.iter().all(|qt| {
|
||||
qt.id > 0
|
||||
&& !matches!(
|
||||
qt.source.as_deref().map(|s| s.to_ascii_lowercase()).as_deref(),
|
||||
qt.source
|
||||
.as_deref()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("local") | Some("plex")
|
||||
)
|
||||
})
|
||||
@@ -800,10 +847,8 @@ pub(crate) async fn route_enqueue_to_peer(tracks: &[QueueTrack], mode: &str) ->
|
||||
let Some(svc) = crate::qconnect_qt::service() else {
|
||||
return false;
|
||||
};
|
||||
let routed: Vec<(u64, Option<String>)> = tracks
|
||||
.iter()
|
||||
.map(|qt| (qt.id, qt.source.clone()))
|
||||
.collect();
|
||||
let routed: Vec<(u64, Option<String>)> =
|
||||
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||
match mode {
|
||||
"next" => svc.play_next_batch_on_peer_if_active(&routed).await,
|
||||
"later" => svc.play_later_batch_on_peer_if_active(&routed).await,
|
||||
@@ -818,9 +863,18 @@ pub(crate) async fn route_track_to_peer(track: &QueueTrack, mode: &str) -> bool
|
||||
return false;
|
||||
};
|
||||
match mode {
|
||||
"next" => svc.play_next_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
"later" => svc.play_later_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
_ => svc.add_to_queue_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
"next" => {
|
||||
svc.play_next_on_peer_if_active(track.id, track.source.as_deref())
|
||||
.await
|
||||
}
|
||||
"later" => {
|
||||
svc.play_later_on_peer_if_active(track.id, track.source.as_deref())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
svc.add_to_queue_on_peer_if_active(track.id, track.source.as_deref())
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,9 +950,7 @@ pub(crate) async fn play_resolved_offline_aware(
|
||||
// `is_terminal_unavailable` routes it into the SAME bounded
|
||||
// skip walk a pulled Qobuz track takes — to the user the two
|
||||
// failures are the same one.
|
||||
Ok(false) => Err(format!(
|
||||
"local file no longer available (track {track_id})"
|
||||
)),
|
||||
Ok(false) => Err(format!("local file no longer available (track {track_id})")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
@@ -1010,9 +1062,9 @@ pub(crate) async fn fetch_album_queue(
|
||||
return Ok(tracks);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::debug!(
|
||||
"[qbz-qt] play_album {album_id}: offline fallback unavailable: {e}"
|
||||
),
|
||||
Err(e) => {
|
||||
log::debug!("[qbz-qt] play_album {album_id}: offline fallback unavailable: {e}")
|
||||
}
|
||||
}
|
||||
return Err(format!("get_album {album_id} failed: {catalog_error}"));
|
||||
}
|
||||
@@ -1142,7 +1194,9 @@ async fn offline_album_queue(album_id: &str) -> Result<Vec<QueueTrack>, String>
|
||||
let sample_rate = cached
|
||||
.sample_rate
|
||||
.or_else(|| local.map(|row| row.sample_rate).filter(|rate| *rate > 0.0));
|
||||
let bit_depth = cached.bit_depth.or_else(|| local.and_then(|row| row.bit_depth));
|
||||
let bit_depth = cached
|
||||
.bit_depth
|
||||
.or_else(|| local.and_then(|row| row.bit_depth));
|
||||
QueueTrack {
|
||||
id: cached.track_id,
|
||||
title: cached.title,
|
||||
@@ -1192,7 +1246,9 @@ fn artist_top_queue_tracks(
|
||||
// full variant (best()) — the thumbnail down-tier was reverted
|
||||
// after the 2026-08-15 owner smoke; register the full set so the
|
||||
// immersive large-art feed can re-resolve (D2).
|
||||
if let (Some(id), Some(img)) = (album_id.as_deref(), album.and_then(|a| a.image.as_ref())) {
|
||||
if let (Some(id), Some(img)) =
|
||||
(album_id.as_deref(), album.and_then(|a| a.image.as_ref()))
|
||||
{
|
||||
crate::artwork_qt::note_np_variants(id, img);
|
||||
}
|
||||
QueueTrack {
|
||||
@@ -1241,7 +1297,10 @@ fn artist_top_queue_tracks(
|
||||
/// buckets album/epSingle/ep/single in page order, deduped), concatenating
|
||||
/// each album's tracks and skipping albums that fail (a bulk play must not
|
||||
/// abort on one unavailable album).
|
||||
pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &str) -> Result<(), String> {
|
||||
pub async fn play_artist(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
artist_id: &str,
|
||||
) -> Result<(), String> {
|
||||
const STUDIO_TYPES: &[&str] = &["album", "epSingle", "ep", "single"];
|
||||
let id: u64 = artist_id
|
||||
.parse()
|
||||
@@ -1254,8 +1313,11 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
|
||||
let artist_name = page.name.display.clone();
|
||||
|
||||
// 1) Popular tracks — the primary behavior.
|
||||
let top: Vec<QueueTrack> =
|
||||
artist_top_queue_tracks(page.top_tracks.as_deref().unwrap_or(&[]), artist_id, &artist_name);
|
||||
let top: Vec<QueueTrack> = artist_top_queue_tracks(
|
||||
page.top_tracks.as_deref().unwrap_or(&[]),
|
||||
artist_id,
|
||||
&artist_name,
|
||||
);
|
||||
if !top.is_empty() {
|
||||
// F1: the anchor comes back from the seam, never from `top` — the
|
||||
// filter may have dropped `top[0]`.
|
||||
@@ -1297,7 +1359,9 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
|
||||
}
|
||||
}
|
||||
if album_ids.is_empty() {
|
||||
return Err(format!("artist {artist_id} has no top tracks and no studio releases"));
|
||||
return Err(format!(
|
||||
"artist {artist_id} has no top tracks and no studio releases"
|
||||
));
|
||||
}
|
||||
let mut queue: Vec<QueueTrack> = Vec::new();
|
||||
for aid in &album_ids {
|
||||
@@ -1307,9 +1371,14 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
|
||||
}
|
||||
}
|
||||
if queue.is_empty() {
|
||||
return Err(format!("artist {artist_id} studio discography produced no playable tracks"));
|
||||
return Err(format!(
|
||||
"artist {artist_id} studio discography produced no playable tracks"
|
||||
));
|
||||
}
|
||||
log::info!("[qbz-qt] artist-play {artist_id}: discography fallback, {} tracks", queue.len());
|
||||
log::info!(
|
||||
"[qbz-qt] artist-play {artist_id}: discography fallback, {} tracks",
|
||||
queue.len()
|
||||
);
|
||||
// The discography queue arrives pre-stamped per ALBUM (fetch_album_queue);
|
||||
// the artist origin is what the user launched, so it wins here — the
|
||||
// explicit context overrides the per-album stamp for the whole queue.
|
||||
@@ -1378,7 +1447,10 @@ pub async fn enqueue_album(
|
||||
log::info!("[qbz-qt] enqueue_album {album_id}: every track was filtered");
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
|
||||
log::info!(
|
||||
"[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks",
|
||||
tracks.len()
|
||||
);
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode. The mode is normalized to what
|
||||
@@ -1657,9 +1729,7 @@ async fn try_infinite_refill(
|
||||
Ok(track) => match track.performer.as_ref().map(|p| p.id) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
log::warn!(
|
||||
"[qbz-qt] infinite radio: seed track {seed_track_id} has no performer"
|
||||
);
|
||||
log::warn!("[qbz-qt] infinite radio: seed track {seed_track_id} has no performer");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1670,8 +1740,10 @@ async fn try_infinite_refill(
|
||||
};
|
||||
match runtime.core().create_smart_artist_radio(artist_id).await {
|
||||
Ok(tracks) if !tracks.is_empty() => {
|
||||
let queue: Vec<QueueTrack> =
|
||||
tracks.iter().map(crate::foryou_qt::to_queue_track).collect();
|
||||
let queue: Vec<QueueTrack> = tracks
|
||||
.iter()
|
||||
.map(crate::foryou_qt::to_queue_track)
|
||||
.collect();
|
||||
match play_track_list(runtime, queue, 0, false).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
@@ -1906,8 +1978,15 @@ fn feed_queue_track(track_id: u64) -> Result<QueueTrack, String> {
|
||||
}
|
||||
let duration_secs = {
|
||||
let mut parts = item.duration.split(':');
|
||||
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
|
||||
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
|
||||
parts
|
||||
.next()
|
||||
.and_then(|m| m.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
* 60
|
||||
+ parts
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
Ok(QueueTrack {
|
||||
id: track_id,
|
||||
@@ -2198,13 +2277,9 @@ pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// release the latch. The poll loop's track-change edge clears
|
||||
// it too — whichever runs first wins, and both are idempotent.
|
||||
// Leaving it set on failure is precisely the Tauri scar.
|
||||
PENDING_PLAY_ID.compare_exchange(
|
||||
track.id,
|
||||
0,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.ok();
|
||||
PENDING_PLAY_ID
|
||||
.compare_exchange(track.id, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.ok();
|
||||
}
|
||||
None => {
|
||||
log::info!("[qbz-qt] toggle-play ignored: no loaded audio and an empty queue");
|
||||
@@ -2246,6 +2321,17 @@ pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prove a local file still exists before advancing the cursor. The player
|
||||
// keeps the old stream alive until a replacement starts, so advancing
|
||||
// first would make queue, NPB and MPRIS lie about what is audible.
|
||||
if let Some(candidate) = runtime.core().peek_upcoming(1).await.into_iter().next() {
|
||||
if crate::local_playback::preflight_queue_track(&candidate)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
play_queue_track(runtime, track.id, 0).await;
|
||||
}
|
||||
@@ -2263,12 +2349,26 @@ pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `previous_track` consumes history and publishes QueueUpdated. Validate
|
||||
// a physical local target first so a missing/moved file cannot leave the
|
||||
// cursor, NPB and MPRIS one row behind the audio the user still hears.
|
||||
if let Some(candidate) = runtime.core().peek_previous_track().await {
|
||||
if crate::local_playback::preflight_queue_track(&candidate)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(track) = runtime.core().previous_track().await {
|
||||
play_queue_track(runtime, track.id, 0).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn play_queue_track_public(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
||||
pub(crate) async fn play_queue_track_public(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
track_id: u64,
|
||||
) {
|
||||
play_queue_track(runtime, track_id, 0).await;
|
||||
}
|
||||
|
||||
@@ -2324,10 +2424,7 @@ async fn play_queue_track(
|
||||
}
|
||||
crate::local_playback::LocalPlay::NotLocal => {}
|
||||
}
|
||||
if let Err(e) =
|
||||
play_resolved_offline_aware(runtime, track_id, start_position_secs)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = play_resolved_offline_aware(runtime, track_id, start_position_secs).await {
|
||||
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
|
||||
// The bar must not keep showing the PREVIOUS track while the cursor has
|
||||
// already moved. The old early `return` skipped both refreshes, which is
|
||||
@@ -2439,7 +2536,10 @@ fn auto_skip_unavailable<'a>(
|
||||
}
|
||||
|
||||
pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
||||
match crate::cast_qt::service().seek_fraction_if_cast(frac as f64).await {
|
||||
match crate::cast_qt::service()
|
||||
.seek_fraction_if_cast(frac as f64)
|
||||
.await
|
||||
{
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
@@ -2454,8 +2554,7 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
let fraction = frac.clamp(0.0, 1.0);
|
||||
let duration_secs = runtime.core().get_playback_state().duration;
|
||||
let position_ms =
|
||||
(fraction as f64 * duration_secs as f64 * 1000.0).round() as i64;
|
||||
let position_ms = (fraction as f64 * duration_secs as f64 * 1000.0).round() as i64;
|
||||
match svc.set_position_if_remote(position_ms).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
@@ -2523,7 +2622,10 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
||||
}
|
||||
|
||||
pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32) {
|
||||
if matches!(crate::cast_qt::service().set_volume_if_cast(volume).await, Ok(true)) {
|
||||
if matches!(
|
||||
crate::cast_qt::service().set_volume_if_cast(volume).await,
|
||||
Ok(true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// QConnect CONTROLLER mode (Slint main.rs:14378-14389): set the REMOTE
|
||||
@@ -2543,7 +2645,10 @@ pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32)
|
||||
}
|
||||
}
|
||||
let _ = runtime.core().set_volume(volume.clamp(0.0, 1.0));
|
||||
MUTED.store(volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0, Ordering::Relaxed);
|
||||
MUTED.store(
|
||||
volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
@@ -2684,7 +2789,8 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
|
||||
String::new()
|
||||
};
|
||||
crate::player_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_np_track_id(QString::from(track_id_str.as_str()));
|
||||
b.as_mut()
|
||||
.set_np_track_id(QString::from(track_id_str.as_str()));
|
||||
b.as_mut()
|
||||
.set_np_local_track_id(QString::from(local_track_id_str.as_str()));
|
||||
});
|
||||
@@ -2750,14 +2856,11 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
|
||||
_ => ("album".to_string(), album_id.clone()),
|
||||
};
|
||||
crate::now_playing::set_track(crate::now_playing::TrackMeta {
|
||||
title,
|
||||
title: title.clone(),
|
||||
artist: track.artist.clone(),
|
||||
album: album_display,
|
||||
album: album_display.clone(),
|
||||
album_id,
|
||||
artist_id: track
|
||||
.artist_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
track_id: track.id,
|
||||
source: track.source.clone().unwrap_or_default(),
|
||||
context_kind,
|
||||
@@ -2788,7 +2891,7 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
|
||||
crate::now_playing::set_catalog_quality(track.bit_depth, track.sample_rate, governed);
|
||||
// Artwork through the same cache pipeline as Home (attach + background
|
||||
// download + republish — single url here).
|
||||
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
|
||||
crate::artwork_qt::attach_now_playing(&track, &title, &album_display);
|
||||
// Ambient background triad (phase 14): recompute from the new track's
|
||||
// cover (no-op until the cover lands on disk — the previous palette
|
||||
// stays, like the Slint's default-until-resolved).
|
||||
@@ -2853,8 +2956,7 @@ fn quality_badge_from(
|
||||
let sample_rate = track
|
||||
.sample_rate
|
||||
.or_else(|| source_hint.and_then(|hint| hint.sample_rate_khz));
|
||||
let is_mp3 = source_hint
|
||||
.is_some_and(|hint| hint.format.eq_ignore_ascii_case("mp3"));
|
||||
let is_mp3 = source_hint.is_some_and(|hint| hint.format.eq_ignore_ascii_case("mp3"));
|
||||
if is_mp3 {
|
||||
let label = sample_rate
|
||||
.filter(|rate| *rate > 0.0)
|
||||
@@ -3043,8 +3145,8 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// Extrapolate position while playing; clamp to the track length.
|
||||
let mut position_ms = remote.position_ms;
|
||||
if remote.playing && remote.updated_at_ms > 0 {
|
||||
position_ms = position_ms
|
||||
.saturating_add(now_ms().saturating_sub(remote.updated_at_ms));
|
||||
position_ms =
|
||||
position_ms.saturating_add(now_ms().saturating_sub(remote.updated_at_ms));
|
||||
}
|
||||
if duration_ms > 0 && position_ms > duration_ms {
|
||||
position_ms = duration_ms;
|
||||
@@ -3176,7 +3278,10 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// has downloaded; a fully-available track (None) seeks freely.
|
||||
// `cache` above is the DECORATIVE overlay of the same fill — this
|
||||
// is the value the seek bars must enforce.
|
||||
let seekable_max = event.buffer_progress.map(|p| p.clamp(0.0, 1.0)).unwrap_or(1.0);
|
||||
let seekable_max = event
|
||||
.buffer_progress
|
||||
.map(|p| p.clamp(0.0, 1.0))
|
||||
.unwrap_or(1.0);
|
||||
|
||||
// This observation is cheap and happens each tick; the work does
|
||||
// not. Only a NEW non-zero engine track — including a gapless
|
||||
@@ -3305,8 +3410,7 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let quality = local_playback_quality().0;
|
||||
let streaming_only =
|
||||
crate::settings_qt::audio_settings().streaming_only;
|
||||
let player_cached =
|
||||
runtime.core().player().is_track_cached(next_id);
|
||||
let player_cached = runtime.core().player().is_track_cached(next_id);
|
||||
let offline_cached = crate::offline_qt::is_cached_id(next_id);
|
||||
|
||||
if streaming_only && !player_cached && !offline_cached {
|
||||
@@ -3403,9 +3507,7 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
tokio::spawn(async move {
|
||||
let next_id = next.id;
|
||||
match crate::audible_qt::queue_gapless_successor(
|
||||
&runtime,
|
||||
track_id,
|
||||
&next,
|
||||
&runtime, track_id, &next,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3450,8 +3552,13 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
};
|
||||
let position_ms = (position as i64) * 1000;
|
||||
let duration_ms = (duration as i64) * 1000;
|
||||
svc.report_playback_state(playing_state, position_ms, duration_ms, track_id)
|
||||
.await;
|
||||
svc.report_playback_state(
|
||||
playing_state,
|
||||
position_ms,
|
||||
duration_ms,
|
||||
track_id,
|
||||
)
|
||||
.await;
|
||||
// On a track change, also reconcile the session queue:
|
||||
// if the user started a new album/playlist on QBZ,
|
||||
// push it so the controller follows. Self-gates +
|
||||
@@ -3489,8 +3596,7 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// marker), and this runs AHEAD of repeat/shuffle exactly as
|
||||
// the reference does (playback.rs:5749-5772) — behind them the
|
||||
// marker would lose to a repeat-one and never fire.
|
||||
if ended_track_id != 0
|
||||
&& runtime.core().consume_stop_after_if(ended_track_id).await
|
||||
if ended_track_id != 0 && runtime.core().consume_stop_after_if(ended_track_id).await
|
||||
{
|
||||
if let Err(e) = runtime.core().pause() {
|
||||
log::warn!("[qbz-qt] stop-after: pause failed: {e}");
|
||||
@@ -3845,7 +3951,9 @@ mod tests {
|
||||
// Skip the definition, doc comments and ordinary comments —
|
||||
// this audits CALLS, not prose about them.
|
||||
let t = line.trim_start();
|
||||
if t.starts_with("//") || t.starts_with("fn ") || t.starts_with("pub(crate) fn ")
|
||||
if t.starts_with("//")
|
||||
|| t.starts_with("fn ")
|
||||
|| t.starts_with("pub(crate) fn ")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4005,8 +4113,7 @@ mod tests {
|
||||
/// Everything blocked: an empty queue carries NO start index.
|
||||
#[test]
|
||||
fn everything_blocked_yields_no_start() {
|
||||
let (kept, start, _) =
|
||||
filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false);
|
||||
let (kept, start, _) = filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false);
|
||||
assert!(kept.is_empty());
|
||||
assert_eq!(start, None);
|
||||
}
|
||||
@@ -4061,4 +4168,16 @@ mod tests {
|
||||
assert_eq!(dropped.blacklisted, 1);
|
||||
assert_eq!(dropped.unavailable, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn song_card_near_fit_keeps_the_final_glyphs_instead_of_an_ellipsis() {
|
||||
// TextMetrics.width is a painted bounding box, while the line layout
|
||||
// consumes advanceWidth. Budgeting the former exactly turned
|
||||
// "Metallica" into "Metalli…" in a runway with visible spare space.
|
||||
let qml = include_str!("../qml/shell/SongCard.qml");
|
||||
assert!(qml.contains("artistMetrics.advanceWidth"));
|
||||
assert!(qml.contains("albumMetrics.advanceWidth"));
|
||||
assert!(qml.contains("a - qa <= near"));
|
||||
assert!(qml.contains("b - qb <= near"));
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_models::QueueTrack;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
@@ -418,8 +418,7 @@ static LAST_MINI_QUEUE_JSON: Mutex<String> = Mutex::new(String::new());
|
||||
/// 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 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(
|
||||
@@ -669,7 +668,10 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
.iter()
|
||||
.map(|t| row_from(t, &favorites))
|
||||
.collect();
|
||||
let current = state.current_track.as_ref().map(|t| row_from(t, &favorites));
|
||||
let current = state
|
||||
.current_track
|
||||
.as_ref()
|
||||
.map(|t| row_from(t, &favorites));
|
||||
|
||||
// One core read for the marker (the reference reads it in the same pass,
|
||||
// `queue.rs:396`), so a row can compare its id without a second round trip.
|
||||
@@ -768,6 +770,16 @@ pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index
|
||||
log::warn!("[qbz-qt] queue: play_upcoming {page_index} out of range");
|
||||
return;
|
||||
};
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
let Some(candidate) = state.upcoming.get(upcoming_index) else {
|
||||
return;
|
||||
};
|
||||
if crate::local_playback::preflight_queue_track(candidate)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(track) = runtime.core().play_upcoming_at(upcoming_index).await {
|
||||
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
|
||||
}
|
||||
@@ -779,6 +791,15 @@ pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index
|
||||
/// so it resolves the right track even when the desktop panel was left
|
||||
/// filtered or paged (state.slint:4896-4900 `play-coverflow-upcoming`).
|
||||
pub async fn play_upcoming_flat(runtime: &Arc<AppRuntime<LoggingAdapter>>, upcoming_index: usize) {
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
if let Some(candidate) = state.upcoming.get(upcoming_index) {
|
||||
if crate::local_playback::preflight_queue_track(candidate)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
match runtime.core().play_upcoming_at(upcoming_index).await {
|
||||
Some(track) => crate::playback_qt::play_queue_track_public(runtime, track.id).await,
|
||||
None => log::warn!("[qbz-qt] queue: play_upcoming_flat {upcoming_index} out of range"),
|
||||
@@ -846,14 +867,21 @@ pub async fn play_extended(
|
||||
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);
|
||||
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}");
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1099,7 +1127,10 @@ pub async fn drop_extended(
|
||||
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);
|
||||
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 {
|
||||
@@ -1166,8 +1197,7 @@ pub async fn insert_dragged_track(
|
||||
vec![crate::playback_qt::queue_track_for(runtime, track_id).await?],
|
||||
None,
|
||||
)
|
||||
.pop()
|
||||
else {
|
||||
.pop() else {
|
||||
log::info!("[qbz-qt] queue: dragged track {track_id} was filtered out");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1365,11 +1395,7 @@ pub async fn add_to_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
|
||||
/// documents the same gap), so the mutation is refused here AND the row
|
||||
/// publishes `isLocal` so the panel can drop the control instead of showing
|
||||
/// one that cannot work.
|
||||
pub async fn toggle_favorite(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
) {
|
||||
pub async fn toggle_favorite(runtime: &Arc<AppRuntime<LoggingAdapter>>, kind: &str, id: &str) {
|
||||
if kind == "track" {
|
||||
let local = canonical_u64(id)
|
||||
.map(|tid| LOCAL_ROW_IDS.lock().unwrap().contains(&tid))
|
||||
@@ -1543,8 +1569,7 @@ mod tests {
|
||||
stop_after_track_id: None,
|
||||
manual_next_count: 0,
|
||||
};
|
||||
let (rows, current_index) =
|
||||
extended_rows(&state, &HashSet::new(), "needle");
|
||||
let (rows, current_index) = extended_rows(&state, &HashSet::new(), "needle");
|
||||
|
||||
assert_eq!(current_index, -1);
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
Reference in new issue
Block a user