perf(qt): optimize catalog views and gapless playback
cx
This commit is contained in:
22 files changed
+1614
-346
No files matched your search
@@ -761,6 +761,23 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
self.player.fetch_for_gapless(client, track_id, quality).await
|
||||
}
|
||||
|
||||
/// Queue a cold Qobuz successor from its initial CMAF buffer instead of
|
||||
/// materializing the whole file. Used only by the Streaming-only gapless
|
||||
/// handoff; cached and offline successors keep the byte path above.
|
||||
pub async fn queue_gapless_streaming(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<(), String> {
|
||||
let guard = self.client.read().await;
|
||||
let client = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "No Qobuz client available".to_string())?;
|
||||
self.player
|
||||
.queue_next_streaming(client, track_id, quality)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a fully-materialized audio asset (bytes + MIME + quality) for an
|
||||
/// EXTERNAL renderer (Chromecast / DLNA). Tier order mirrors
|
||||
/// `fetch_for_gapless_resolved`: L1/L2 player cache -> OFFLINE (local CMAF
|
||||
|
||||
@@ -661,6 +661,15 @@ pub struct Album {
|
||||
pub composer: Option<Artist>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// Interpret the catalog's optional availability bit without turning an
|
||||
/// omitted field into a withdrawal. Only an explicit `false` means the
|
||||
/// album is unavailable.
|
||||
pub fn is_streamable(&self) -> bool {
|
||||
self.streamable.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Album artist contributor entry (main artist + featured artists).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumArtist {
|
||||
@@ -1957,6 +1966,23 @@ mod purchase_deserializer_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_album_streamable_absent_reads_available() {
|
||||
let album: Album = serde_json::from_str(r#"{"id":"a","title":"A"}"#).unwrap();
|
||||
assert_eq!(album.streamable, None);
|
||||
assert!(album.is_streamable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_album_streamable_false_is_honoured() {
|
||||
let album: Album = serde_json::from_str(
|
||||
r#"{"id":"a","title":"A","streamable":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(album.streamable, Some(false));
|
||||
assert!(!album.is_streamable());
|
||||
}
|
||||
|
||||
/// `downloadable` drives three list behaviours (the hide-unavailable filter,
|
||||
/// the album click gate, the unavailable marker), so its default is not
|
||||
/// decoration: getting it wrong ships clickable unavailable albums.
|
||||
|
||||
@@ -109,6 +109,19 @@ enum AudioCommand {
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
bit_depth: u32,
|
||||
},
|
||||
/// Append an incrementally-fed successor to the current engine. Used by
|
||||
/// Streaming only: the handoff depends on the initial buffer, not on
|
||||
/// downloading a potentially very long track in full inside the 10 s
|
||||
/// gapless window.
|
||||
PlayNextStreaming {
|
||||
source: Arc<BufferedMediaSource>,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
bit_depth: u32,
|
||||
duration_secs: u64,
|
||||
},
|
||||
/// Play a local DSD file via DoP (DSD over PCM) on ALSA direct (DSD plan
|
||||
/// Phase 2). The audio thread opens the demuxer + an S32 stream at the
|
||||
@@ -124,11 +137,21 @@ enum AudioCommand {
|
||||
}
|
||||
|
||||
/// Pending gapless track data (queued for seamless transition)
|
||||
enum GaplessMedia {
|
||||
Buffered(Vec<u8>),
|
||||
Streaming(Arc<BufferedMediaSource>),
|
||||
Direct,
|
||||
}
|
||||
|
||||
struct GaplessPending {
|
||||
track_id: u64,
|
||||
duration_secs: u64,
|
||||
data: Vec<u8>,
|
||||
media: GaplessMedia,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
bit_depth: u32,
|
||||
normalization_gain: Option<f32>,
|
||||
gain_atomic: Option<Arc<AtomicU32>>,
|
||||
}
|
||||
|
||||
struct CursorMediaSource {
|
||||
@@ -1024,6 +1047,11 @@ pub struct SharedState {
|
||||
/// buffer can be sealed once the new source is in place
|
||||
/// (`seal_stream_feeder`).
|
||||
stream_feeder: Arc<std::sync::Mutex<Option<StreamFeederSlot>>>,
|
||||
/// Feeder for an incrementally-buffered gapless successor. It remains
|
||||
/// separate from `stream_feeder` because both downloads can coexist for
|
||||
/// the current track's final seconds. A normal play/stop cancels both;
|
||||
/// the actual handoff promotes this slot to current ownership.
|
||||
gapless_stream_feeder: Arc<std::sync::Mutex<Option<StreamFeederSlot>>>,
|
||||
}
|
||||
|
||||
/// A registered live segment feeder: which track it feeds, how to cancel
|
||||
@@ -1064,6 +1092,7 @@ impl SharedState {
|
||||
bit_perfect_mode: Arc::new(AtomicU8::new(0)),
|
||||
play_generation: Arc::new(AtomicU64::new(0)),
|
||||
stream_feeder: Arc::new(std::sync::Mutex::new(None)),
|
||||
gapless_stream_feeder: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,16 +1183,18 @@ impl SharedState {
|
||||
/// source, which is exactly the hand-off the pre-cancel code had. The
|
||||
/// buffer is sealed by `seal_stream_feeder` once the new source is in.
|
||||
pub(crate) fn cancel_stream_feeder(&self) {
|
||||
let slot = self.stream_feeder.lock().ok().and_then(|s| {
|
||||
s.as_ref()
|
||||
.map(|slot| (slot.track_id, slot.cancel_tx.clone()))
|
||||
});
|
||||
if let Some((track_id, cancel_tx)) = slot {
|
||||
log::info!(
|
||||
"[CMAF-STREAM] Cancelling feeder for superseded track {}",
|
||||
track_id
|
||||
);
|
||||
let _ = cancel_tx.send(true);
|
||||
for slot in [&self.stream_feeder, &self.gapless_stream_feeder] {
|
||||
let feeder = slot.lock().ok().and_then(|s| {
|
||||
s.as_ref()
|
||||
.map(|slot| (slot.track_id, slot.cancel_tx.clone()))
|
||||
});
|
||||
if let Some((track_id, cancel_tx)) = feeder {
|
||||
log::info!(
|
||||
"[CMAF-STREAM] Cancelling feeder for superseded track {}",
|
||||
track_id
|
||||
);
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,9 +1206,11 @@ impl SharedState {
|
||||
/// working through the buffered remainder this is a no-op — the engine
|
||||
/// has already been swapped by the command just sent.
|
||||
pub(crate) fn seal_stream_feeder(&self) {
|
||||
let slot = self.stream_feeder.lock().ok().and_then(|mut s| s.take());
|
||||
if let Some(slot) = slot {
|
||||
let _ = slot.writer.complete();
|
||||
for feeder in [&self.stream_feeder, &self.gapless_stream_feeder] {
|
||||
let slot = feeder.lock().ok().and_then(|mut s| s.take());
|
||||
if let Some(slot) = slot {
|
||||
let _ = slot.writer.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,6 +1239,71 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_gapless_stream_feeder(
|
||||
&self,
|
||||
track_id: u64,
|
||||
cancel_tx: tokio::sync::watch::Sender<bool>,
|
||||
writer: BufferWriter,
|
||||
gen: u64,
|
||||
) -> bool {
|
||||
let Ok(mut slot) = self.gapless_stream_feeder.lock() else {
|
||||
let _ = cancel_tx.send(true);
|
||||
return false;
|
||||
};
|
||||
if !self.is_current_play(gen) {
|
||||
drop(slot);
|
||||
let _ = cancel_tx.send(true);
|
||||
return false;
|
||||
}
|
||||
if let Some(previous) = slot.replace(StreamFeederSlot {
|
||||
track_id,
|
||||
cancel_tx,
|
||||
writer,
|
||||
}) {
|
||||
let _ = previous.cancel_tx.send(true);
|
||||
let _ = previous.writer.complete();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn cancel_gapless_stream_feeder(&self, track_id: u64) {
|
||||
let slot = self
|
||||
.gapless_stream_feeder
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut slot| {
|
||||
if slot.as_ref().map(|s| s.track_id) == Some(track_id) {
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(slot) = slot {
|
||||
let _ = slot.cancel_tx.send(true);
|
||||
let _ = slot.writer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
fn promote_gapless_stream_feeder(&self, track_id: u64) {
|
||||
let pending = self
|
||||
.gapless_stream_feeder
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut slot| {
|
||||
if slot.as_ref().map(|s| s.track_id) == Some(track_id) {
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let Some(pending) = pending else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut current) = self.stream_feeder.lock() {
|
||||
*current = Some(pending);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) {
|
||||
self.sample_rate.store(sample_rate, Ordering::SeqCst);
|
||||
self.bit_depth.store(bit_depth, Ordering::SeqCst);
|
||||
@@ -3142,8 +3240,12 @@ impl Player {
|
||||
*gapless_pending = Some(GaplessPending {
|
||||
track_id,
|
||||
duration_secs: duration,
|
||||
data: Vec::new(),
|
||||
media: GaplessMedia::Direct,
|
||||
sample_rate: rate,
|
||||
channels: 2,
|
||||
bit_depth: 1,
|
||||
normalization_gain: None,
|
||||
gain_atomic: None,
|
||||
});
|
||||
thread_state.set_gapless_next_track_id(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
@@ -3703,6 +3805,7 @@ impl Player {
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
} => {
|
||||
// Gapless: append next track to existing Rodio Sink
|
||||
let engine = match current_engine.as_mut() {
|
||||
@@ -3731,13 +3834,6 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't queue if already streaming
|
||||
if current_streaming_source.is_some() {
|
||||
log::info!("Gapless: streaming source active, ignoring PlayNext for track {}", track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode the next track's audio
|
||||
let source = match decode_with_fallback(&data) {
|
||||
Ok(s) => s,
|
||||
@@ -3785,6 +3881,7 @@ impl Player {
|
||||
};
|
||||
|
||||
// Wrap source with normalization/visualizer pipeline
|
||||
let pending_gain_atomic = gain_atomic.clone();
|
||||
let source = wrap_source(
|
||||
source,
|
||||
normalization,
|
||||
@@ -3831,8 +3928,12 @@ impl Player {
|
||||
*gapless_pending = Some(GaplessPending {
|
||||
track_id,
|
||||
duration_secs: actual_duration,
|
||||
data,
|
||||
media: GaplessMedia::Buffered(data),
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
normalization_gain: normalization,
|
||||
gain_atomic: pending_gain_atomic,
|
||||
});
|
||||
thread_state.set_gapless_next_track_id(track_id);
|
||||
thread_state.set_gapless_ready(false); // Request fulfilled
|
||||
@@ -3843,6 +3944,140 @@ impl Player {
|
||||
actual_duration
|
||||
);
|
||||
}
|
||||
AudioCommand::PlayNextStreaming {
|
||||
source,
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
duration_secs,
|
||||
} => {
|
||||
let engine = match current_engine.as_mut() {
|
||||
Some(engine) => engine,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Gapless stream: no engine for successor {}",
|
||||
track_id
|
||||
);
|
||||
thread_state.cancel_gapless_stream_feeder(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let (Some(cur_sr), Some(cur_ch)) =
|
||||
(*current_track_sample_rate, *current_track_channels)
|
||||
{
|
||||
if sample_rate != cur_sr || channels != cur_ch {
|
||||
log::info!(
|
||||
"Gapless stream: format mismatch (current {}Hz/{}ch vs next {}Hz/{}ch), ignoring track {}",
|
||||
cur_sr,
|
||||
cur_ch,
|
||||
sample_rate,
|
||||
channels,
|
||||
track_id
|
||||
);
|
||||
thread_state.cancel_gapless_stream_feeder(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !source.has_min_buffer() {
|
||||
log::warn!(
|
||||
"Gapless stream: initial buffer not ready for track {}",
|
||||
track_id
|
||||
);
|
||||
thread_state.cancel_gapless_stream_feeder(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let incremental = match IncrementalStreamingSource::new(source.clone()) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Gapless stream: decoder setup failed for track {}: {}",
|
||||
track_id,
|
||||
error
|
||||
);
|
||||
thread_state.cancel_gapless_stream_feeder(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let norm_settings = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.filter(|settings| settings.normalization_enabled)
|
||||
.map(|settings| settings.normalization_target_lufs);
|
||||
let (normalization, gain_atomic) = if let Some(target_lufs) = norm_settings
|
||||
{
|
||||
let replay_gain = source.get_buffered_data().and_then(|data| {
|
||||
extract_replaygain(&data)
|
||||
.map(|gain| calculate_gain_factor(&gain, target_lufs))
|
||||
});
|
||||
let atomic = Arc::new(AtomicU32::new(
|
||||
replay_gain.unwrap_or(1.0).to_bits(),
|
||||
));
|
||||
if let Some(cached) = loudness_cache.get(track_id) {
|
||||
atomic.store(
|
||||
db_to_linear(cached.gain_db.min(6.0)).to_bits(),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
let _ = analyzer_tx.try_send(AnalyzerMessage::NewTrack {
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
target_lufs,
|
||||
gain_atomic: atomic.clone(),
|
||||
});
|
||||
(replay_gain, Some(atomic))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let pending_gain_atomic = gain_atomic.clone();
|
||||
let source_to_play = wrap_source(
|
||||
Box::new(incremental),
|
||||
normalization,
|
||||
gain_atomic,
|
||||
&analyzer_tx,
|
||||
&analyzer_enabled,
|
||||
);
|
||||
let engine_was_empty = engine.empty();
|
||||
if let Err(error) = engine.append(source_to_play) {
|
||||
log::warn!(
|
||||
"Gapless stream: append failed for track {}: {}",
|
||||
track_id,
|
||||
error
|
||||
);
|
||||
thread_state.cancel_gapless_stream_feeder(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
if engine_was_empty
|
||||
&& !thread_state.is_playing.load(Ordering::SeqCst)
|
||||
{
|
||||
thread_state.is_playing.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
log::info!("[gapless-trace] set (stream) track {track_id}");
|
||||
*gapless_pending = Some(GaplessPending {
|
||||
track_id,
|
||||
duration_secs,
|
||||
media: GaplessMedia::Streaming(source),
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
normalization_gain: normalization,
|
||||
gain_atomic: pending_gain_atomic,
|
||||
});
|
||||
thread_state.set_gapless_next_track_id(track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
log::info!(
|
||||
"Gapless stream: queued track {} after its initial buffer",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3981,7 +4216,21 @@ impl Player {
|
||||
// (clean gapless transition), but never the inconsistent
|
||||
// mid-swap mix.
|
||||
if let Some(ref pending) = gapless_pending {
|
||||
if dur > 0 && pos >= dur {
|
||||
// A streaming reader can only hand off
|
||||
// after its feeder has sealed the source.
|
||||
// The wall-clock timer may reach catalog
|
||||
// duration while a stalled feeder is still
|
||||
// alive; promoting then would drop its last
|
||||
// cancel sender and truncate the outgoing
|
||||
// tail. Cached sources have no such gate.
|
||||
let outgoing_stream_sealed = current_streaming_source
|
||||
.as_ref()
|
||||
.map(|source| {
|
||||
source.is_complete()
|
||||
|| source.download_error().is_some()
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if dur > 0 && pos >= dur && outgoing_stream_sealed {
|
||||
log::info!(
|
||||
"Gapless transition: track {} -> {} (pos {}s >= dur {}s)",
|
||||
thread_state.current_track_id.load(Ordering::SeqCst),
|
||||
@@ -4000,8 +4249,29 @@ impl Player {
|
||||
.duration
|
||||
.store(pending.duration_secs, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
current_audio_data = Some(pending.data.clone());
|
||||
match &pending.media {
|
||||
GaplessMedia::Buffered(data) => {
|
||||
current_audio_data = Some(data.clone());
|
||||
current_streaming_source = None;
|
||||
}
|
||||
GaplessMedia::Streaming(source) => {
|
||||
current_audio_data = None;
|
||||
current_streaming_source = Some(source.clone());
|
||||
thread_state
|
||||
.promote_gapless_stream_feeder(pending.track_id);
|
||||
}
|
||||
GaplessMedia::Direct => {
|
||||
current_audio_data = None;
|
||||
current_streaming_source = None;
|
||||
}
|
||||
}
|
||||
current_track_sample_rate = Some(pending.sample_rate);
|
||||
current_track_channels = Some(pending.channels);
|
||||
thread_state
|
||||
.set_stream_quality(pending.sample_rate, pending.bit_depth);
|
||||
thread_state.set_buffer_progress(0.0);
|
||||
current_normalization_gain = pending.normalization_gain;
|
||||
current_gain_atomic = pending.gain_atomic.clone();
|
||||
thread_state
|
||||
.set_normalization_gain(pending.normalization_gain);
|
||||
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
|
||||
@@ -4036,8 +4306,32 @@ impl Player {
|
||||
.duration
|
||||
.store(pending.duration_secs, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
current_audio_data = Some(pending.data.clone());
|
||||
match &pending.media {
|
||||
GaplessMedia::Buffered(data) => {
|
||||
current_audio_data = Some(data.clone());
|
||||
current_streaming_source = None;
|
||||
}
|
||||
GaplessMedia::Streaming(source) => {
|
||||
current_audio_data = None;
|
||||
current_streaming_source = Some(source.clone());
|
||||
thread_state.promote_gapless_stream_feeder(
|
||||
pending.track_id,
|
||||
);
|
||||
}
|
||||
GaplessMedia::Direct => {
|
||||
current_audio_data = None;
|
||||
current_streaming_source = None;
|
||||
}
|
||||
}
|
||||
current_track_sample_rate = Some(pending.sample_rate);
|
||||
current_track_channels = Some(pending.channels);
|
||||
thread_state.set_stream_quality(
|
||||
pending.sample_rate,
|
||||
pending.bit_depth,
|
||||
);
|
||||
thread_state.set_buffer_progress(0.0);
|
||||
current_normalization_gain = pending.normalization_gain;
|
||||
current_gain_atomic = pending.gain_atomic.clone();
|
||||
thread_state
|
||||
.set_normalization_gain(pending.normalization_gain);
|
||||
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
|
||||
@@ -4084,7 +4378,6 @@ impl Player {
|
||||
&& !gapless_request_armed
|
||||
&& !thread_state.is_gapless_ready()
|
||||
&& thread_state.get_gapless_next_track_id() == 0
|
||||
&& current_streaming_source.is_none()
|
||||
{
|
||||
log::info!("Gapless: approaching end of track ({}s/{}s), requesting next", pos, dur);
|
||||
thread_state.set_gapless_ready(true);
|
||||
@@ -4511,15 +4804,148 @@ impl Player {
|
||||
r
|
||||
}
|
||||
|
||||
/// Queue a cold Qobuz successor as an incremental source. This is the
|
||||
/// Streaming-only counterpart to [`Self::fetch_for_gapless`]: only the
|
||||
/// initial buffer must win the final 10-second race, so track length no
|
||||
/// longer determines whether the transition is seamless.
|
||||
pub async fn queue_next_streaming(
|
||||
&self,
|
||||
client: &QobuzClient,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<(), String> {
|
||||
let gen = self.state.current_play_generation();
|
||||
let cmaf_info = qbz_qobuz::cmaf::setup_streaming(client, track_id, quality).await?;
|
||||
if !self.is_current_play(gen) {
|
||||
return Err("current track changed during gapless stream setup".to_string());
|
||||
}
|
||||
|
||||
let sample_rate = cmaf_info.sampling_rate.unwrap_or(44_100);
|
||||
let channels = 2u16;
|
||||
let bit_depth = cmaf_info.bit_depth.unwrap_or(16);
|
||||
let total_flac_size = cmaf_info.flac_header.len() as u64
|
||||
+ cmaf_info
|
||||
.segment_table
|
||||
.iter()
|
||||
.map(|segment| segment.byte_len as u64)
|
||||
.sum::<u64>();
|
||||
let total_samples: u64 = cmaf_info
|
||||
.segment_table
|
||||
.iter()
|
||||
.map(|segment| segment.sample_count as u64)
|
||||
.sum();
|
||||
let duration_secs = total_samples / sample_rate.max(1) as u64;
|
||||
let speed_mbps = if cmaf_info.init_fetch_ms > 0 {
|
||||
let init_bytes = cmaf_info.flac_header.len() as f64 + 4096.0;
|
||||
(init_bytes / (cmaf_info.init_fetch_ms as f64 / 1000.0)) / (1024.0 * 1024.0)
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
|
||||
let mut config = StreamingConfig::from_speed_mbps(speed_mbps);
|
||||
let user_secs = self
|
||||
.audio_settings
|
||||
.lock()
|
||||
.map(|settings| settings.stream_buffer_seconds)
|
||||
.unwrap_or(2);
|
||||
let bytes_per_second = total_flac_size / duration_secs.max(1);
|
||||
let user_floor = (user_secs as u64).saturating_mul(bytes_per_second) as usize;
|
||||
config.initial_buffer_bytes = config
|
||||
.initial_buffer_bytes
|
||||
.max(user_floor)
|
||||
.clamp(256 * 1024, 8 * 1024 * 1024);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(total_flac_size));
|
||||
let source = Arc::new(source);
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
if !self.state.register_gapless_stream_feeder(
|
||||
track_id,
|
||||
cancel_tx,
|
||||
writer.clone(),
|
||||
gen,
|
||||
) {
|
||||
return Err("gapless stream was superseded before download".to_string());
|
||||
}
|
||||
|
||||
let url_template = cmaf_info.url_template;
|
||||
let content_key = cmaf_info.content_key;
|
||||
let flac_header = cmaf_info.flac_header;
|
||||
let n_segments = cmaf_info.n_segments;
|
||||
let cache = self.audio_cache.clone();
|
||||
tokio::spawn(async move {
|
||||
match Self::cmaf_stream_segments(
|
||||
&url_template,
|
||||
n_segments,
|
||||
content_key,
|
||||
flac_header,
|
||||
writer,
|
||||
track_id,
|
||||
cache,
|
||||
true,
|
||||
total_flac_size,
|
||||
cancel_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => log::info!("[GAPLESS-STREAM] Track {track_id} fully buffered"),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
log::warn!("[GAPLESS-STREAM] Track {track_id} feeder failed: {error}")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let wait_started = Instant::now();
|
||||
const INITIAL_BUFFER_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
while !source.has_min_buffer()
|
||||
&& source.download_error().is_none()
|
||||
&& wait_started.elapsed() < INITIAL_BUFFER_TIMEOUT
|
||||
&& self.is_current_play(gen)
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
if !self.is_current_play(gen) {
|
||||
self.state.cancel_gapless_stream_feeder(track_id);
|
||||
return Err("gapless stream superseded during initial buffering".to_string());
|
||||
}
|
||||
if let Some(error) = source.download_error() {
|
||||
self.state.cancel_gapless_stream_feeder(track_id);
|
||||
return Err(error);
|
||||
}
|
||||
if !source.has_min_buffer() {
|
||||
self.state.cancel_gapless_stream_feeder(track_id);
|
||||
return Err("gapless stream initial buffer timed out".to_string());
|
||||
}
|
||||
|
||||
self.tx
|
||||
.send(AudioCommand::PlayNextStreaming {
|
||||
source,
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
duration_secs,
|
||||
})
|
||||
.map_err(|error| {
|
||||
self.state.cancel_gapless_stream_feeder(track_id);
|
||||
format!("failed to queue gapless stream: {error}")
|
||||
})?;
|
||||
log::info!(
|
||||
"[GAPLESS-STREAM] Track {} initial buffer ready in {}ms",
|
||||
track_id,
|
||||
wait_started.elapsed().as_millis()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a track fully into the L1/L2 cache **without** starting
|
||||
/// playback.
|
||||
///
|
||||
/// Gapless playback requires upcoming tracks to be cache hits so they
|
||||
/// play via `play_data` (fully in-memory) rather than the streaming
|
||||
/// path — the audio engine's `PlayNext` handler ignores gapless
|
||||
/// requests while a streaming source is active. This method is the
|
||||
/// prefetch primitive the controller drives for the next 1-2 queue
|
||||
/// tracks.
|
||||
/// Normal cached gapless playback benefits from upcoming tracks being
|
||||
/// cache hits so they can be appended fully in-memory. Streaming only
|
||||
/// uses [`Self::queue_next_streaming`] instead, which queues a cold
|
||||
/// successor after its initial buffer. This method remains the prefetch
|
||||
/// primitive the controller drives for the next 1-2 queue tracks.
|
||||
///
|
||||
/// Mirrors the Tauri V2 prefetch download: CMAF `download_full` first
|
||||
/// (Akamai CDN), legacy `/track/getFileUrl` full download as fallback.
|
||||
@@ -5117,6 +5543,7 @@ impl Player {
|
||||
track_id,
|
||||
sample_rate: meta.sample_rate,
|
||||
channels: meta.channels,
|
||||
bit_depth: meta.bit_depth.unwrap_or(16),
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send PlayNext to audio thread: {}", e);
|
||||
@@ -5770,7 +6197,7 @@ pub fn external_content_type(mime: &str, format_id: u32) -> String {
|
||||
mod tests {
|
||||
use super::compute_needs_new_stream;
|
||||
use super::external_content_type;
|
||||
use super::{DsdErrorReport, SharedState};
|
||||
use super::{BufferedMediaSource, DsdErrorReport, SharedState, StreamingConfig};
|
||||
|
||||
struct FakeDsdSource {
|
||||
words: std::vec::IntoIter<i32>,
|
||||
@@ -5824,6 +6251,57 @@ mod tests {
|
||||
assert_eq!(state.take_stream_error_message(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseding_play_cancels_current_and_gapless_stream_feeders() {
|
||||
let state = SharedState::new();
|
||||
let (_, current_writer) =
|
||||
BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024));
|
||||
let (_, next_writer) =
|
||||
BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024));
|
||||
let (current_tx, current_rx) = tokio::sync::watch::channel(false);
|
||||
let (next_tx, next_rx) = tokio::sync::watch::channel(false);
|
||||
let generation = state.current_play_generation();
|
||||
state.register_stream_feeder(1, current_tx, current_writer, generation);
|
||||
assert!(state.register_gapless_stream_feeder(
|
||||
2,
|
||||
next_tx,
|
||||
next_writer,
|
||||
generation
|
||||
));
|
||||
|
||||
state.cancel_stream_feeder();
|
||||
|
||||
assert!(*current_rx.borrow());
|
||||
assert!(*next_rx.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gapless_handoff_promotes_the_successor_feeder() {
|
||||
let state = SharedState::new();
|
||||
let (_, writer) = BufferedMediaSource::new(StreamingConfig::fast_start(), Some(1024));
|
||||
let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let generation = state.current_play_generation();
|
||||
assert!(state.register_gapless_stream_feeder(
|
||||
42,
|
||||
cancel_tx,
|
||||
writer,
|
||||
generation
|
||||
));
|
||||
|
||||
state.promote_gapless_stream_feeder(42);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.stream_feeder
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|slot| slot.track_id),
|
||||
Some(42)
|
||||
);
|
||||
assert!(state.gapless_stream_feeder.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stream_always_needs_new() {
|
||||
// Without an existing stream there is nothing to reuse — every
|
||||
|
||||
@@ -55,6 +55,12 @@ Rectangle {
|
||||
property string artworkUrl: ""
|
||||
property bool isFavorite: false
|
||||
property bool isPinned: false
|
||||
// Explicit catalog withdrawal. Absence/false means the producer made no
|
||||
// unavailability claim. A complete offline copy keeps the album live.
|
||||
property bool qobuzUnavailable: false
|
||||
property int cacheStatus: 0
|
||||
readonly property bool pulled: root.qobuzUnavailable
|
||||
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
|
||||
// The row's SOURCE word — "local" | "offline" | "plex" | "jellyfin" |
|
||||
// a Subsonic brand spelling (the Local Library badge set,
|
||||
// src/local_rows.rs `badge_source`) or "qobuz" (the Library
|
||||
@@ -195,9 +201,18 @@ Rectangle {
|
||||
|
||||
/// Build the context popup on first use, then open it.
|
||||
function openAlbumMenu(anchor, x, y) {
|
||||
if (root.menuModel().length === 0)
|
||||
return
|
||||
albumMenuLoader.active = true
|
||||
albumMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
/// A GridView recycling host must close window-overlay children before
|
||||
/// this card is rebound to another album.
|
||||
function releaseForReuse() {
|
||||
if (albumMenuLoader.item)
|
||||
albumMenuLoader.item.close()
|
||||
albumMenuLoader.active = false
|
||||
}
|
||||
|
||||
width: 200
|
||||
// 246 normally; +20 for the "{} plays" line (see `plays`) — the same
|
||||
@@ -254,16 +269,18 @@ Rectangle {
|
||||
function menuModel() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = [
|
||||
{ "label": root.openLabel !== "" ? root.openLabel : t("Open album", r),
|
||||
"icon": "library-big", "action": "open" },
|
||||
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
||||
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
||||
var m = []
|
||||
if (!root.pulledDead) {
|
||||
m.push({ "label": root.openLabel !== "" ? root.openLabel : t("Open album", r),
|
||||
"icon": "library-big", "action": "open" })
|
||||
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
|
||||
// #442 "Play later" — end of the manual block.
|
||||
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
|
||||
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
||||
}
|
||||
if (root.catalogAffordances) {
|
||||
// Keep removal reachable even after Qobuz withdraws the old id.
|
||||
m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
// .slint gates this on a non-local/plex source as well — and the
|
||||
@@ -304,6 +321,8 @@ Rectangle {
|
||||
root.artworkUrl)
|
||||
return
|
||||
}
|
||||
if (root.pulledDead)
|
||||
return
|
||||
if (root.localMode) {
|
||||
if (a === "open") root.openRequested()
|
||||
else if (a === "play") root.playRequested()
|
||||
@@ -355,7 +374,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusSm
|
||||
color: "#000000"
|
||||
opacity: root.overlayOn ? 0.6 : 0.0
|
||||
opacity: root.overlayOn && !root.pulledDead ? 0.6 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
@@ -398,7 +417,7 @@ Rectangle {
|
||||
id: artArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
// Right press opens the SAME menu as the ⋯ button.
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
// Phase 8: the card opens the album view (the overlay play
|
||||
@@ -413,6 +432,8 @@ Rectangle {
|
||||
root.selectToggled()
|
||||
return
|
||||
}
|
||||
if (root.pulledDead)
|
||||
return
|
||||
if (root.localMode)
|
||||
root.openRequested()
|
||||
else
|
||||
@@ -463,7 +484,7 @@ Rectangle {
|
||||
// …and hidden in select mode too (.slint:207 — the checkbox
|
||||
// owns this corner). Pinning writes `album:{albumId}` into the
|
||||
// pinned store, so it needs a catalog id, not a routing mode.
|
||||
visible: root.catalogAffordances && !root.selectMode
|
||||
visible: root.catalogAffordances && !root.selectMode && !root.pulledDead
|
||||
x: parent.width - width - 8
|
||||
y: 8
|
||||
width: 26
|
||||
@@ -498,7 +519,7 @@ Rectangle {
|
||||
visible: !root.selectMode
|
||||
y: 120
|
||||
width: parent.width
|
||||
shown: root.overlayOn
|
||||
shown: root.overlayOn && !root.pulledDead
|
||||
|
||||
CardOverlayButton {
|
||||
id: favBtn
|
||||
@@ -635,6 +656,25 @@ Rectangle {
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// A catalog-withdrawn album is explicit content, not a failed
|
||||
// image load. Keep it above every hover affordance and label it.
|
||||
Rectangle {
|
||||
visible: root.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusSm
|
||||
color: theme.alphaTier(60)
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 20
|
||||
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 6 }
|
||||
|
||||
@@ -643,6 +683,7 @@ Rectangle {
|
||||
width: 200
|
||||
height: 40 + (root.plays > 0 ? 20 : 0)
|
||||
spacing: theme.spacingSm
|
||||
opacity: root.pulledDead ? 0.5 : 1.0
|
||||
Column {
|
||||
width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -651,7 +692,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 20
|
||||
text: root.title
|
||||
color: titleArea.containsMouse ? theme.accent : theme.textPrimary
|
||||
color: titleArea.containsMouse && !root.pulledDead
|
||||
? theme.accent : theme.textPrimary
|
||||
font.pixelSize: theme.fontBody - 2
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -660,7 +702,7 @@ Rectangle {
|
||||
id: titleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
@@ -673,6 +715,8 @@ Rectangle {
|
||||
root.selectToggled()
|
||||
return
|
||||
}
|
||||
if (root.pulledDead)
|
||||
return
|
||||
if (root.localMode)
|
||||
root.openRequested()
|
||||
else
|
||||
|
||||
@@ -118,6 +118,11 @@ Rectangle {
|
||||
agMenuLoader.active = true
|
||||
agMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (agMenuLoader.item)
|
||||
agMenuLoader.item.close()
|
||||
agMenuLoader.active = false
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
|
||||
@@ -136,6 +136,11 @@ Rectangle {
|
||||
plMenuLoader.active = true
|
||||
plMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (plMenuLoader.item)
|
||||
plMenuLoader.item.close()
|
||||
plMenuLoader.active = false
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
|
||||
@@ -35,6 +35,13 @@ Rectangle {
|
||||
|
||||
readonly property bool overlayOn: tcArtArea.containsMouse || favBtn.hovered
|
||||
|| playBtn.hovered || moreBtn.hovered
|
||||
// Same pulled-track contract as rows/TrackRow.qml. A catalog withdrawal
|
||||
// is inert, except when the user already owns a complete offline copy.
|
||||
// `cacheStatus` is a live scalar because `item` is a plain JS snapshot.
|
||||
readonly property bool pulled: root.item.qobuzUnavailable === true
|
||||
property int cacheStatus: root.item.cacheStatus !== undefined
|
||||
? root.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
|
||||
|
||||
implicitWidth: 200
|
||||
implicitHeight: 246
|
||||
@@ -46,9 +53,15 @@ Rectangle {
|
||||
// re-established on every new row object so the document stays
|
||||
// authoritative.
|
||||
property bool favorite: root.item.isFavorite === true
|
||||
onItemChanged: root.favorite = Qt.binding(function () {
|
||||
return root.item.isFavorite === true
|
||||
})
|
||||
onItemChanged: {
|
||||
root.favorite = Qt.binding(function () {
|
||||
return root.item.isFavorite === true
|
||||
})
|
||||
root.cacheStatus = Qt.binding(function () {
|
||||
return root.item.cacheStatus !== undefined
|
||||
? root.item.cacheStatus : 0
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
root.favorite = !root.favorite
|
||||
@@ -56,9 +69,20 @@ Rectangle {
|
||||
}
|
||||
|
||||
function openMenu(anchor, x, y) {
|
||||
if (root.menuModel().length === 0)
|
||||
return
|
||||
trackMenuLoader.active = true
|
||||
trackMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function playNow() {
|
||||
if (!root.pulledDead)
|
||||
QbzPlayer.playTrack(root.item.id)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (trackMenuLoader.item)
|
||||
trackMenuLoader.item.close()
|
||||
trackMenuLoader.active = false
|
||||
}
|
||||
|
||||
// Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin).
|
||||
Connections {
|
||||
@@ -69,6 +93,13 @@ Rectangle {
|
||||
root.favorite = value
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onTrackCacheStatusChanged(trackId, status, progress) {
|
||||
if (trackId === (root.item.id || ""))
|
||||
root.cacheStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: 0
|
||||
@@ -89,7 +120,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusSm
|
||||
color: "#000000"
|
||||
opacity: root.overlayOn ? 0.6 : 0.0
|
||||
opacity: root.overlayOn && !root.pulledDead ? 0.6 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
// Body click PLAYS the track (TrackCard hover); right press opens
|
||||
@@ -98,13 +129,13 @@ Rectangle {
|
||||
id: tcArtArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
root.openMenu(tcArtArea, mouse.x, mouse.y)
|
||||
else
|
||||
QbzPlayer.playTrack(root.item.id)
|
||||
root.playNow()
|
||||
}
|
||||
}
|
||||
// Hover overlay — favorite / play / more (y=120, h=44,
|
||||
@@ -112,7 +143,7 @@ Rectangle {
|
||||
CardOverlayRow {
|
||||
y: 120
|
||||
width: parent.width
|
||||
shown: root.overlayOn
|
||||
shown: root.overlayOn && !root.pulledDead
|
||||
CardOverlayButton {
|
||||
id: favBtn
|
||||
name: root.favorite ? "heart-filled" : "heart"
|
||||
@@ -125,7 +156,7 @@ Rectangle {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.playTrack(root.item.id)
|
||||
onClicked: root.playNow()
|
||||
}
|
||||
CardOverlayButton {
|
||||
id: moreBtn
|
||||
@@ -171,6 +202,25 @@ Rectangle {
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
}
|
||||
}
|
||||
// Honest dead-card state. The scrim sits above the hover controls
|
||||
// (which are not mounted visually in this state) and carries a
|
||||
// translated label instead of relying on a barely perceptible dim.
|
||||
Rectangle {
|
||||
visible: root.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusSm
|
||||
color: theme.alphaTier(60)
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 20
|
||||
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
id: trackMenuLoader
|
||||
active: false
|
||||
@@ -187,6 +237,7 @@ Rectangle {
|
||||
width: 200
|
||||
height: 40
|
||||
spacing: theme.spacingSm
|
||||
opacity: root.pulledDead ? 0.5 : 1.0
|
||||
Column {
|
||||
width: parent.width - (tcQ.visible ? tcQ.width + theme.spacingSm : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -195,7 +246,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 20
|
||||
text: root.item.title || ""
|
||||
color: tcTitleArea.containsMouse ? theme.accent : theme.textPrimary
|
||||
color: tcTitleArea.containsMouse && !root.pulledDead
|
||||
? theme.accent : theme.textPrimary
|
||||
font.pixelSize: theme.fontBody - 2
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -204,13 +256,13 @@ Rectangle {
|
||||
id: tcTitleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
root.openMenu(tcTitleArea, mouse.x, mouse.y)
|
||||
else
|
||||
QbzPlayer.playTrack(root.item.id)
|
||||
root.playNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,19 +297,24 @@ Rectangle {
|
||||
function menuModel() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = [
|
||||
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
||||
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
||||
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
|
||||
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
var m = []
|
||||
if (!root.pulledDead) {
|
||||
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
|
||||
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
||||
}
|
||||
if (root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
|
||||
if (root.item.albumId) m.push({ "label": t("Go to album", r), "icon": "disc", "action": "go-album" })
|
||||
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
if (!root.pulledDead)
|
||||
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
return m
|
||||
}
|
||||
function trackAction(a) {
|
||||
if ((a === "play" || a === "next" || a === "later" || a === "queue")
|
||||
&& root.pulledDead)
|
||||
return
|
||||
if (a === "play") QbzPlayer.playTrack(root.item.id)
|
||||
else if (a === "next") QbzPlayer.enqueueTrack(root.item.id, "next")
|
||||
else if (a === "later") QbzPlayer.enqueueTrack(root.item.id, "later")
|
||||
|
||||
@@ -16,14 +16,17 @@
|
||||
// a trap the Rust seeding avoids anyway, but there is no reason to reproduce
|
||||
// a blank page here.
|
||||
//
|
||||
// WINDOWING (flat grid/list, opt-in via `flick`): only rows within ~one
|
||||
// viewport of the visible band exist. The collection keeps the FULL footprint
|
||||
// WINDOWING (flat grid/list, opt-in via `flick`): only rows within a bounded
|
||||
// runway around the visible band exist. The collection keeps the FULL footprint
|
||||
// as one cheap Item, so its height — and therefore the scroll geometry — never
|
||||
// changes, but there is no longer one Loader shell per off-screen result.
|
||||
// The band is sampled from scroll events rather than bound to `contentY`: a
|
||||
// direct binding re-evaluates every delegate on every frame, while a forever
|
||||
// 80ms Timer spends CPU at rest. Grouped sections remain eager — the same call
|
||||
// the .slint makes (:265-277).
|
||||
// Scroll events only run a constant-time COVERAGE GUARD. It rebuilds the slice
|
||||
// synchronously when the viewport consumes its inner runway; that matters
|
||||
// because a delayed sampler can leave the viewport outside the mounted slice
|
||||
// for one or more completely blank frames during a fast wheel/touchpad jump.
|
||||
// The wider outer runway means the slice still changes roughly once per
|
||||
// viewport, not once per scroll frame. Grouped sections remain eager — the
|
||||
// same call the .slint makes (:265-277).
|
||||
//
|
||||
// TAIL FADE (opt-in via armTailFade(), see the block below): the arrival half
|
||||
// of the Load-more round. OFF unless a host arms it, so the two hosts that do
|
||||
@@ -67,9 +70,12 @@ Column {
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 0
|
||||
|
||||
// --- Windowing band (row indices), sampled ---------------------------
|
||||
// --- Windowing band (row indices) ------------------------------------
|
||||
property int bandFirst: 0
|
||||
property int bandLast: 9999
|
||||
// Start bounded too: the first model publish may precede
|
||||
// Component.onCompleted, and an eager sentinel would briefly instantiate
|
||||
// the entire result set before the first sample replaces it.
|
||||
property int bandLast: 0
|
||||
readonly property bool windowed: flick !== null && !root.isGrouped
|
||||
|
||||
function sampleBand() {
|
||||
@@ -83,40 +89,58 @@ Column {
|
||||
var rowsTop = root.contentOffset + (listMode ? 32 : 0)
|
||||
var top = root.flick.contentY - rowsTop
|
||||
var h = root.flick.height
|
||||
// One viewport of prefetch on each side.
|
||||
root.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
|
||||
root.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
|
||||
// Two viewports on each side. ensureBandCoverage() refreshes when the
|
||||
// viewport enters the inner half, leaving a full viewport mounted even
|
||||
// after the refresh edge and enough lead for image incubation.
|
||||
root.bandFirst = Math.max(0, Math.floor((top - 2 * h) / pitch))
|
||||
root.bandLast = Math.max(0, Math.ceil((top + 3 * h) / pitch))
|
||||
}
|
||||
|
||||
function scheduleBandSample() {
|
||||
if (root.windowed && root.visible && !bandSampler.running)
|
||||
bandSampler.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: bandSampler
|
||||
interval: 40
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
function refreshBand() {
|
||||
if (root.windowed && root.visible) {
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBandCoverage() {
|
||||
if (!root.windowed || !root.visible)
|
||||
return
|
||||
var listMode = root.viewMode === "list"
|
||||
var pitch = listMode ? 64 + root.listRowGap
|
||||
: root.cardHeight + root.cardGap
|
||||
var rowsTop = root.contentOffset + (listMode ? 32 : 0)
|
||||
var top = root.flick.contentY - rowsTop
|
||||
var h = Math.max(1, root.flick.height)
|
||||
var cols = listMode ? 1 : Math.max(1, Math.floor(
|
||||
(root.width + root.cardGap) / (root.cardWidth + root.cardGap)))
|
||||
var totalRows = Math.ceil(root.albums.length / cols)
|
||||
if (totalRows <= 0 || top + h <= 0 || top >= totalRows * pitch)
|
||||
return
|
||||
var visibleFirst = Math.max(0, Math.floor(top / pitch))
|
||||
var visibleLast = Math.max(0, Math.ceil((top + h) / pitch))
|
||||
var innerRunway = Math.max(1, Math.ceil(h / pitch))
|
||||
// A scrollbar seek may jump across the whole slice in one signal;
|
||||
// normal wheel motion reaches an inner edge about once per viewport.
|
||||
if (visibleFirst < root.bandFirst || visibleLast > root.bandLast
|
||||
|| (visibleFirst > innerRunway
|
||||
&& visibleFirst - root.bandFirst < innerRunway)
|
||||
|| (visibleLast < totalRows - innerRunway
|
||||
&& root.bandLast - visibleLast < innerRunway))
|
||||
root.refreshBand()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.flick
|
||||
ignoreUnknownSignals: true
|
||||
function onContentYChanged() { root.scheduleBandSample() }
|
||||
function onHeightChanged() { root.scheduleBandSample() }
|
||||
function onContentYChanged() { root.ensureBandCoverage() }
|
||||
function onHeightChanged() { root.refreshBand() }
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
}
|
||||
onWidthChanged: root.scheduleBandSample()
|
||||
onViewModeChanged: root.scheduleBandSample()
|
||||
onVisibleChanged: root.scheduleBandSample()
|
||||
Component.onCompleted: root.refreshBand()
|
||||
onWidthChanged: root.refreshBand()
|
||||
onViewModeChanged: root.refreshBand()
|
||||
onVisibleChanged: root.refreshBand()
|
||||
|
||||
// --- Windowed artwork -------------------------------------------------
|
||||
//
|
||||
@@ -299,8 +323,7 @@ Column {
|
||||
// A new page appended -> recompute the exact mounted slice before
|
||||
// asking for its covers. ONE handler per signal: declaring a second
|
||||
// `onAlbumsChanged` is a hard qmlcachegen error.
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
root.refreshBand()
|
||||
}
|
||||
onGroupedChanged: root._maybeStartTailFade()
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ Rectangle {
|
||||
/// mode only). Default off, so the two catalog call sites are untouched.
|
||||
property bool selectMode: false
|
||||
property bool checked: false
|
||||
readonly property bool pulled: root.item.qobuzUnavailable === true
|
||||
readonly property int cacheStatus: root.item.cacheStatus !== undefined
|
||||
? root.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
|
||||
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
||||
/// a click into a range (controls/SelectionModel.qml).
|
||||
signal toggleSelect(int modifiers)
|
||||
@@ -61,7 +65,7 @@ Rectangle {
|
||||
width: parent ? parent.width : 0
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: rowHovered ? theme.surfaceHover
|
||||
color: rowHovered && !root.pulledDead ? theme.surfaceHover
|
||||
: (rowIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
|
||||
|
||||
MouseArea {
|
||||
@@ -69,66 +73,78 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead && !root.selectMode
|
||||
? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
// In select mode the row is a selection target, not a menu
|
||||
// host (Tauri/Slint parity: a right-click toggles nothing
|
||||
// there either).
|
||||
if (!root.selectMode)
|
||||
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||
root.openMenu(rowArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
if (root.selectMode) root.toggleSelect(mouse.modifiers)
|
||||
else QbzAlbum.openAlbum(root.item.id || "")
|
||||
else if (!root.pulledDead) QbzAlbum.openAlbum(root.item.id || "")
|
||||
}
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 196
|
||||
// A JS expression, not a literal: the block row is CONDITIONAL. The
|
||||
// function form also re-evaluates on `trRev`, which the literal
|
||||
// already did through each `tr(…, trRev)` call.
|
||||
entries: {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = [
|
||||
{ "label": t("Open album", r), "icon": "library-big", "action": "open" },
|
||||
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
||||
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
||||
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
|
||||
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
// AlbumListRow.slint:434-441 gates the row on a non-local/plex
|
||||
// source. `home_qt::HomeCard` carries NO `source` field, so this
|
||||
// reads "" and the row shows — correct for these two catalog-only
|
||||
// surfaces (Discover Browse, Label Releases). Written defensively
|
||||
// so a producer that starts emitting `source` is honoured without
|
||||
// a second edit here.
|
||||
var src = root.item.source || ""
|
||||
if (src !== "local" && src !== "plex")
|
||||
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
|
||||
return m
|
||||
function menuEntries() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = []
|
||||
if (!root.pulledDead) {
|
||||
m.push({ "label": t("Open album", r), "icon": "library-big", "action": "open" })
|
||||
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
|
||||
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
||||
}
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "open") QbzAlbum.openAlbum(id)
|
||||
else if (a === "play") QbzPlayer.playAlbum(id)
|
||||
// `artUrl`, never `artPath`: the store keeps a denormalized cover
|
||||
// url and a file:// cache path is dead on any other machine.
|
||||
else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "",
|
||||
root.item.artist || "", root.item.artUrl || "")
|
||||
else QbzPlayer.enqueueAlbum(id, a)
|
||||
var src = root.item.source || ""
|
||||
if (!root.pulledDead && src !== "local" && src !== "plex")
|
||||
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
|
||||
return m
|
||||
}
|
||||
function menuAction(a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (root.pulledDead) return
|
||||
if (a === "open") QbzAlbum.openAlbum(id)
|
||||
else if (a === "play") QbzPlayer.playAlbum(id)
|
||||
// `artUrl`, never `artPath`: the store keeps a denormalized cover url
|
||||
// and a file:// cache path is dead on any other machine.
|
||||
else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "",
|
||||
root.item.artist || "", root.item.artUrl || "")
|
||||
else QbzPlayer.enqueueAlbum(id, a)
|
||||
}
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: root.menuEntries()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
}
|
||||
}
|
||||
function openMenu(anchor, x, y) {
|
||||
if (root.menuEntries().length === 0)
|
||||
return
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (rowMenuLoader.item)
|
||||
rowMenuLoader.item.close()
|
||||
rowMenuLoader.active = false
|
||||
}
|
||||
ListView.onPooled: root.releaseForReuse()
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: root.colGap
|
||||
opacity: root.pulledDead ? 0.5 : 1.0
|
||||
|
||||
// Selection checkbox — takes the leading slot in select mode
|
||||
// (MultiSelectBar's companion; the art cell keeps its own width so
|
||||
@@ -162,6 +178,19 @@ Rectangle {
|
||||
source: root.artSource !== "" ? root.artSource : (root.item.artPath || "")
|
||||
radius: 4
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: 4
|
||||
color: theme.alphaTier(60)
|
||||
QbzIcon {
|
||||
name: "circle-alert"
|
||||
width: 18
|
||||
height: 18
|
||||
anchors.centerIn: parent
|
||||
tintName: "favorite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +238,19 @@ Rectangle {
|
||||
width: root.colQuality
|
||||
height: parent.height
|
||||
QualityBadgeFull {
|
||||
visible: !root.pulledDead
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tier: root.item.qualityTier || ""
|
||||
detail: root.item.qualityDetail || ""
|
||||
}
|
||||
Text {
|
||||
visible: root.pulledDead
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
@@ -228,6 +266,7 @@ Rectangle {
|
||||
Item {
|
||||
width: root.colOverflow
|
||||
height: parent.height
|
||||
visible: !root.pulledDead
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
@@ -246,7 +285,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { root.openMenu(moreArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,53 @@ Rectangle {
|
||||
readonly property int atmoReach: 1 + 8 + 26
|
||||
|
||||
// The view's album + url-keyed cover map (artwork pipeline).
|
||||
readonly property var album: JSON.parse(QbzAlbum.albumJson)
|
||||
//
|
||||
// Applied imperatively so a SAME-ALBUM deferred rail publish can preserve
|
||||
// the ListView offset. Binding `model` to a freshly parsed JS array makes
|
||||
// QQuickItemView reset contentY whenever Similar albums / Suggestions / More
|
||||
// from this artist lands — the forced jump the owner caught while reading.
|
||||
property var album: ({})
|
||||
property string documentAlbumId: ""
|
||||
property int albumDocumentEpoch: 0
|
||||
property real albumRestoreY: 0
|
||||
|
||||
function parseAlbumDocument() {
|
||||
try {
|
||||
return JSON.parse(QbzAlbum.albumJson)
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
|
||||
function applyAlbumDocument() {
|
||||
var next = root.parseAlbumDocument()
|
||||
var nextHeader = next.header || ({})
|
||||
var nextId = nextHeader.id !== undefined && nextHeader.id !== null
|
||||
? String(nextHeader.id) : ""
|
||||
var sameAlbum = nextId !== "" && nextId === root.documentAlbumId
|
||||
var savedY = sameAlbum ? pageFlick.contentY : 0
|
||||
root.albumDocumentEpoch += 1
|
||||
var epoch = root.albumDocumentEpoch
|
||||
root.album = next
|
||||
if (nextId !== "")
|
||||
root.documentAlbumId = nextId
|
||||
if (!sameAlbum || savedY <= pageFlick.originY)
|
||||
return
|
||||
root.albumRestoreY = savedY
|
||||
// The model reset and its content-height polish happen after the
|
||||
// document assignment. One deferred restore coalesces a publish burst
|
||||
// and clamps if the replacement document is genuinely shorter.
|
||||
Qt.callLater(function () {
|
||||
if (epoch !== root.albumDocumentEpoch
|
||||
|| nextId !== root.documentAlbumId)
|
||||
return
|
||||
pageFlick.forceLayout()
|
||||
var minY = pageFlick.originY
|
||||
var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height)
|
||||
pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY))
|
||||
})
|
||||
}
|
||||
|
||||
readonly property var albumHeader: album.header || ({})
|
||||
readonly property var tracks: album.tracks || []
|
||||
// `header.awards`, NOT `album.awards`. It is a field of AlbumHeader
|
||||
@@ -252,7 +298,15 @@ Rectangle {
|
||||
root.setToggleState("blocked", value)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: { syncAlbumState(); dispatchCovers() }
|
||||
Component.onCompleted: {
|
||||
root.applyAlbumDocument()
|
||||
syncAlbumState()
|
||||
dispatchCovers()
|
||||
}
|
||||
Connections {
|
||||
target: QbzAlbum
|
||||
function onAlbumJsonChanged() { root.applyAlbumDocument() }
|
||||
}
|
||||
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
|
||||
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
|
||||
// redispatch when the header itself updates.
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
// :36-103 fixed 56px header — artist kicker + bucket title LEFT, the sort
|
||||
// select RIGHT, both vertically centred on y=25
|
||||
// :131-138 body Column padding 32 / 32, top 8, bottom 100, spacing 0
|
||||
// :121-129 infinite scroll: within 600px of the bottom, has-more, and
|
||||
// neither a first page nor a tail page already in flight
|
||||
// :121-129 originally used infinite scroll. Qt uses the shared explicit
|
||||
// Load-more control so a tail publish never changes the Flickable
|
||||
// extent from inside its momentum handler.
|
||||
// :140-203 THREE mutually exclusive body states — loading, error+Retry,
|
||||
// empty — then the grid, then the tail spinner
|
||||
// :205-231 200x266 cards, gap 24, view-mode hard-wired "grid"
|
||||
// :242-256 scrollbar below the header
|
||||
//
|
||||
// WHAT THIS PAGE DELIBERATELY DOES NOT HAVE (checked against the .slint, not
|
||||
// against its cousins): no search field, no group-by, no Hi-Res filter, no
|
||||
// grid/list toggle, and no "Load more" button. LabelReleasesView has all five;
|
||||
// this is a sibling of DiscoverBrowseView, not of that page. One sort select is
|
||||
// the entire toolbar.
|
||||
// against its cousins): no search field, no group-by, no Hi-Res filter and no
|
||||
// grid/list toggle. The explicit Load-more affordance is a Qt performance
|
||||
// policy shared by every paginated View-all; one sort select remains the
|
||||
// entire toolbar.
|
||||
//
|
||||
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts its text
|
||||
// stack 16px after it. In this port back/forward live in the shell header
|
||||
@@ -205,22 +206,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// Infinite scroll, guarded exactly as the .slint is (:123-126).
|
||||
// There is NO "Load more" button on this page.
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& root.doc.loadMoreLoading !== true
|
||||
&& root.doc.loading !== true) {
|
||||
// Snapshot the ids already on screen so ONLY the page
|
||||
// that lands fades in. BEFORE the bridge call, which
|
||||
// may republish synchronously
|
||||
// (LabelReleasesView.qml:327's rule).
|
||||
collection.armTailFade()
|
||||
QbzArtist.releasesLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -328,12 +313,26 @@ Rectangle {
|
||||
contentOffset: 8
|
||||
}
|
||||
|
||||
// --- Tail spinner (.slint:233-237) ---------------------
|
||||
Item { visible: root.doc.loadMoreLoading === true; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: root.doc.loadMoreLoading === true
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: root.albums.length > 0 && root.doc.hasMore === true
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: root.doc.loadMoreLoading === true
|
||||
skeleton: "cards"
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
onClicked: {
|
||||
// Arm before the bridge call: a cached page
|
||||
// may republish synchronously.
|
||||
collection.armTailFade()
|
||||
QbzArtist.releasesLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// "awardalbums".
|
||||
//
|
||||
// A search box over the LOADED set, the award name as the page title, and an
|
||||
// infinite-scroll grid that asks for the next `/award/getAlbums` page as the
|
||||
// tail comes into view. Same four exclusive body branches as the landing
|
||||
// explicitly paginated grid over `/award/getAlbums`. Same four exclusive body
|
||||
// branches as the landing
|
||||
// page, in the reference's order: loading · error+retry · no-results (a search
|
||||
// that matched nothing) · the grid.
|
||||
//
|
||||
@@ -49,18 +49,6 @@ Rectangle {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
// Infinite scroll. Gated on the SAME four conditions the reference
|
||||
// gates on — not loading, not already loading more, there IS more, and
|
||||
// no active query — so a filtered view never pages in behind it.
|
||||
onContentYChanged: {
|
||||
if (contentHeight - (contentY + height) < 600
|
||||
&& root.doc.hasMore === true
|
||||
&& root.doc.loadingMore !== true
|
||||
&& !root.loading
|
||||
&& root.query === "")
|
||||
QbzHome.awardAlbumsLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: page.width - 64
|
||||
@@ -194,14 +182,26 @@ Rectangle {
|
||||
contentOffset: collection.y
|
||||
}
|
||||
|
||||
// Tail spinner for the next page.
|
||||
Item {
|
||||
visible: root.doc.loadingMore === true
|
||||
visible: !root.loading && !root.loadError
|
||||
&& root.albums.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width
|
||||
height: visible ? 72 : 0
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 28
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: root.doc.loadingMore === true
|
||||
skeleton: "cards"
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
onClicked: {
|
||||
collection.armTailFade()
|
||||
QbzHome.awardAlbumsLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
// :44 fixed 56px header — title LEFT, search / genre / grid-list RIGHT
|
||||
// :139 body padding 32 / 32, top 8, bottom 100
|
||||
// :26 200x266 cards, gap 24; :29 64px list rows, gap 4
|
||||
// :126 infinite scroll: within 600px of the bottom, has-more, not already
|
||||
// loading, and NO active search (the filter is client-side, so paging
|
||||
// under one would bring back unfiltered rows)
|
||||
// :126 used to be infinite scroll. Qt deliberately promotes that trigger to
|
||||
// the shared explicit Load-more control: republishing a larger model
|
||||
// from inside `onContentYChanged` changes the Flickable extent while it
|
||||
// still carries momentum, which is the source of the tail rubber-band.
|
||||
// :205 scrollbar below the header; :233 genre popup anchored y=56
|
||||
//
|
||||
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts the
|
||||
@@ -153,16 +154,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// Infinite scroll, guarded exactly as the .slint is.
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& !QbzHome.discoverBrowseLoadingMore
|
||||
&& !QbzHome.discoverBrowseLoading
|
||||
&& root.query === "")
|
||||
QbzHome.discoverBrowseLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -189,8 +180,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
AlbumCollection {
|
||||
id: collection
|
||||
visible: !QbzHome.discoverBrowseLoading
|
||||
width: parent.width - 64
|
||||
collectionKey: root.doc.endpoint || ""
|
||||
albums: root.items
|
||||
viewMode: root.viewMode
|
||||
cardWidth: 200
|
||||
@@ -201,11 +194,32 @@ Rectangle {
|
||||
contentOffset: 8
|
||||
}
|
||||
|
||||
Item { visible: QbzHome.discoverBrowseLoadingMore; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: QbzHome.discoverBrowseLoadingMore
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: !QbzHome.discoverBrowseLoading
|
||||
&& root.items.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: QbzHome.discoverBrowseLoadingMore
|
||||
skeleton: root.viewMode === "list" ? "rows" : "cards"
|
||||
// AlbumCollection pitch: 200x266 + 24px gutter.
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
rowH: 64
|
||||
rowGap: 4
|
||||
rowCount: 2
|
||||
rowArtSize: 44
|
||||
onClicked: {
|
||||
collection.armTailFade()
|
||||
QbzHome.discoverBrowseLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,31 @@ Rectangle {
|
||||
function parseFeed(json) {
|
||||
var t = Date.now()
|
||||
var f = JSON.parse(json)
|
||||
var totals = { "tracks": 0, "albums": 0, "artists": 0,
|
||||
"playlists": 0, "labels": 0 }
|
||||
// Normalize the strings once per Rust publish. Library > All can hold
|
||||
// the complete local library, so repeating toLowerCase() in every
|
||||
// search pass and every sort comparison turns one key press into a
|
||||
// large allocation storm. These private fields stay on the parsed JS
|
||||
// rows; they do not enlarge the Rust JSON transport.
|
||||
for (var i = 0; i < f.length; i++) {
|
||||
var it = f[i]
|
||||
it._titleLc = String(it.title || "").toLowerCase()
|
||||
it._artistLc = String(it.artist || "").toLowerCase()
|
||||
it._albumLc = String(it.album || "").toLowerCase()
|
||||
it._genreLc = String(it.genre || "").toLowerCase()
|
||||
if (it.kind === "track") {
|
||||
if (it.group === "favorites") totals.tracks++
|
||||
} else if (it.kind === "album") {
|
||||
if (it.group === "favorites") totals.albums++
|
||||
} else if (it.kind === "artist") totals.artists++
|
||||
else if (it.kind === "playlist") totals.playlists++
|
||||
else if (it.kind === "label") totals.labels++
|
||||
}
|
||||
// The toolbar emptiness gate used to make a second full pass over the
|
||||
// same feed. Carry the result on the Array itself (outside `length`),
|
||||
// so all consumers keep the exact same row array and model identity.
|
||||
f._tabTotals = totals
|
||||
// Was a bare console.log, i.e. printed on every publish for every user
|
||||
// forever. It belongs to the same investigation as the derive timing
|
||||
// right below it, so it now rides the same category and is silent
|
||||
@@ -99,6 +124,25 @@ Rectangle {
|
||||
|
||||
// Decoded-cover map {artKey: file://path}, fed by the signal.
|
||||
property var artMap: ({})
|
||||
// Artwork completions commonly arrive in a burst. Rebinding artMap for
|
||||
// every file made every mounted card re-evaluate once per neighbour — an
|
||||
// O(window²) cold paint on Library > All. Flush one merged map per frame,
|
||||
// and ignore global library-art signals that are not in this body's keep
|
||||
// band (the sidepanel has its own URL-keyed inbox).
|
||||
property var _artInbox: ({})
|
||||
property var _artKeep: ({})
|
||||
property var _artRequested: ({})
|
||||
Timer {
|
||||
id: artFlush
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (Object.keys(root._artInbox).length === 0)
|
||||
return
|
||||
root.artMap = Object.assign({}, root.artMap, root._artInbox)
|
||||
root._artInbox = ({})
|
||||
}
|
||||
}
|
||||
|
||||
// --- skeleton pulse ---------------------------------------------------
|
||||
// ONE 900ms Timer drives EVERY placeholder in this view (the loading
|
||||
@@ -290,16 +334,8 @@ Rectangle {
|
||||
// following the reference gate wants (counts.playlists is favorites
|
||||
// only, library_qt.rs:479).
|
||||
readonly property var tabTotals: {
|
||||
var c = { "tracks": 0, "albums": 0, "artists": 0, "playlists": 0, "labels": 0 }
|
||||
for (var i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
if (it.kind === "track") { if (it.group === "favorites") c.tracks++ }
|
||||
else if (it.kind === "album") { if (it.group === "favorites") c.albums++ }
|
||||
else if (it.kind === "artist") c.artists++
|
||||
else if (it.kind === "label") c.labels++
|
||||
else if (it.kind === "playlist") c.playlists++
|
||||
}
|
||||
return c
|
||||
return feed._tabTotals || { "tracks": 0, "albums": 0, "artists": 0,
|
||||
"playlists": 0, "labels": 0 }
|
||||
}
|
||||
readonly property bool tabHasItems:
|
||||
activeTab === "all" || (tabTotals[activeTab] || 0) > 0
|
||||
@@ -422,6 +458,13 @@ Rectangle {
|
||||
// otherwise the item's genre must contain one of the selected
|
||||
// names, so genre-less kinds are excluded.
|
||||
var genres = root.genreNames
|
||||
// The dominant path: All really means all, newest first. Rust has
|
||||
// already merged every source in that order, so do not copy and
|
||||
// rescan a potentially 10k-row array merely to return it intact.
|
||||
if (needle === "" && genres.length === 0
|
||||
&& showPurchases && showFavorites && showFollowing && showLocal
|
||||
&& sortBy === "date" && !sortAsc)
|
||||
return feed
|
||||
for (i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
var isLocal = it.source === "local" || it.source === "plex"
|
||||
@@ -435,10 +478,10 @@ Rectangle {
|
||||
if (!ok) continue
|
||||
}
|
||||
if (needle !== ""
|
||||
&& it.title.toLowerCase().indexOf(needle) < 0
|
||||
&& it.artist.toLowerCase().indexOf(needle) < 0) continue
|
||||
&& it._titleLc.indexOf(needle) < 0
|
||||
&& it._artistLc.indexOf(needle) < 0) continue
|
||||
if (genres.length > 0) {
|
||||
var itemGenre = (it.genre || "").toLowerCase()
|
||||
var itemGenre = it._genreLc
|
||||
if (itemGenre === "") continue
|
||||
var genreHit = false
|
||||
for (var gi = 0; gi < genres.length; gi++) {
|
||||
@@ -452,10 +495,10 @@ Rectangle {
|
||||
// other direction; "date" keeps model order (newest-first),
|
||||
// reversed for oldest (library_all.rs derive).
|
||||
if (sortBy === "title") {
|
||||
items.sort(function (a, b) { return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1 })
|
||||
items.sort(function (a, b) { return a._titleLc < b._titleLc ? -1 : 1 })
|
||||
if (!sortAsc) items.reverse()
|
||||
} else if (sortBy === "artist") {
|
||||
items.sort(function (a, b) { return a.artist.toLowerCase() < b.artist.toLowerCase() ? -1 : 1 })
|
||||
items.sort(function (a, b) { return a._artistLc < b._artistLc ? -1 : 1 })
|
||||
if (!sortAsc) items.reverse()
|
||||
} else if (sortAsc) {
|
||||
items = items.slice().reverse()
|
||||
@@ -465,8 +508,8 @@ Rectangle {
|
||||
var tabNeedle = tabSearch.toLowerCase()
|
||||
function hit(it) {
|
||||
return tabNeedle === ""
|
||||
|| it.title.toLowerCase().indexOf(tabNeedle) >= 0
|
||||
|| it.artist.toLowerCase().indexOf(tabNeedle) >= 0
|
||||
|| it._titleLc.indexOf(tabNeedle) >= 0
|
||||
|| it._artistLc.indexOf(tabNeedle) >= 0
|
||||
}
|
||||
var tabGenres = (activeTab === "tracks" || activeTab === "albums")
|
||||
? root.genreNames : []
|
||||
@@ -480,7 +523,7 @@ Rectangle {
|
||||
else if (activeTab === "playlists") keep = x.kind === "playlist"
|
||||
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
|
||||
if (keep && tabGenres.length > 0) {
|
||||
var tg = (x.genre || "").toLowerCase()
|
||||
var tg = x._genreLc
|
||||
keep = false
|
||||
for (var tgi = 0; tgi < tabGenres.length; tgi++) {
|
||||
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
|
||||
@@ -491,8 +534,8 @@ Rectangle {
|
||||
if (activeTab === "albums" && albumsSort !== "default") {
|
||||
var field = albumsSort.indexOf("artist") === 0 ? "artist" : "title"
|
||||
items.sort(function (a, b) {
|
||||
var av = field === "artist" ? a.artist.toLowerCase() : a.title.toLowerCase()
|
||||
var bv = field === "artist" ? b.artist.toLowerCase() : b.title.toLowerCase()
|
||||
var av = field === "artist" ? a._artistLc : a._titleLc
|
||||
var bv = field === "artist" ? b._artistLc : b._titleLc
|
||||
return av < bv ? -1 : 1
|
||||
})
|
||||
if (albumsSort.slice(-4) === "desc") items.reverse()
|
||||
@@ -504,11 +547,16 @@ Rectangle {
|
||||
// room for a full-width header, which is exactly why the reference
|
||||
// scrolls those two PROPORTIONALLY from the A-Z strip instead of
|
||||
// landing on a separator (FavoritesView.slint:1996, :2008).
|
||||
var lc = function (s) { return (s || "").toLowerCase() }
|
||||
var cachedKey = function (item, key) {
|
||||
if (key === "title") return item._titleLc
|
||||
if (key === "artist") return item._artistLc
|
||||
if (key === "album") return item._albumLc
|
||||
return String(item[key] || "").toLowerCase()
|
||||
}
|
||||
var by = function (keys) {
|
||||
items.sort(function (a, b) {
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
var av = lc(a[keys[k]]), bv = lc(b[keys[k]])
|
||||
var av = cachedKey(a, keys[k]), bv = cachedKey(b, keys[k])
|
||||
if (av !== bv) return av < bv ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
@@ -692,11 +740,20 @@ Rectangle {
|
||||
// --------------------- windowed artwork -----------------------------
|
||||
// Report the mounted window as artKeys; prune far-away covers.
|
||||
function reportWindow(visibleArray, first, last) {
|
||||
if (visibleArray.length === 0) return
|
||||
if (visibleArray.length === 0) {
|
||||
root._artKeep = ({})
|
||||
root._artRequested = ({})
|
||||
root._artInbox = ({})
|
||||
root.artPending = 0
|
||||
if (Object.keys(root.artMap).length > 0)
|
||||
root.artMap = ({})
|
||||
return
|
||||
}
|
||||
last = Math.min(last, visibleArray.length - 1)
|
||||
if (first > last) return
|
||||
var keys = []
|
||||
var keep = {}
|
||||
var requested = {}
|
||||
var span = last - first + 1
|
||||
var keepLo = Math.max(0, first - span)
|
||||
var keepHi = Math.min(visibleArray.length - 1, last + span)
|
||||
@@ -705,15 +762,19 @@ Rectangle {
|
||||
for (var i = keepLo; i <= keepHi; i++)
|
||||
if (visibleArray[i].kind !== "group-header") keep[visibleArray[i].artKey] = true
|
||||
var m = artMap
|
||||
var inbox = root._artInbox
|
||||
var pending = 0
|
||||
for (i = first; i <= last; i++) {
|
||||
if (visibleArray[i].kind === "group-header") continue
|
||||
var k = visibleArray[i].artKey
|
||||
if (visibleArray[i].imageUrl !== "") {
|
||||
keys.push(k)
|
||||
if (!m[k]) pending++
|
||||
requested[k] = true
|
||||
if (!m[k] && !inbox[k]) pending++
|
||||
}
|
||||
}
|
||||
root._artKeep = keep
|
||||
root._artRequested = requested
|
||||
// Drives the skeleton Timer + the per-item cap (see the pulse block).
|
||||
artWindowFirst = first
|
||||
artPending = pending
|
||||
@@ -721,6 +782,12 @@ Rectangle {
|
||||
for (var key in m) {
|
||||
if (!keep[key]) { delete m[key]; changed = true }
|
||||
}
|
||||
// A late completion for the previous window may already be queued but
|
||||
// not published. Drop it here so the frame flush cannot resurrect a
|
||||
// cover that this report just evicted.
|
||||
for (key in inbox) {
|
||||
if (!keep[key]) delete inbox[key]
|
||||
}
|
||||
if (changed) artMap = Object.assign({}, m)
|
||||
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
|
||||
}
|
||||
@@ -728,14 +795,20 @@ Rectangle {
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.artMap
|
||||
// One fewer shimmering tile — when the count hits 0 the pulse
|
||||
// Timer stops on its own.
|
||||
if (!m[key] && root.artPending > 0) root.artPending--
|
||||
m[key] = path
|
||||
// Rebind requires a NEW object reference (same-ref assignment
|
||||
// is not a change in QML).
|
||||
root.artMap = Object.assign({}, m)
|
||||
// This is a global signal. The selected-artist pane, Home and
|
||||
// several detail routes publish URL keys through it too; accepting
|
||||
// those here wakes the whole Library grid for an image it cannot
|
||||
// display. Only the current keep band belongs in this map.
|
||||
if (root._artKeep[key] !== true)
|
||||
return
|
||||
var known = root.artMap[key] || root._artInbox[key] || ""
|
||||
if (known === path)
|
||||
return
|
||||
if (known === "" && root._artRequested[key] === true
|
||||
&& root.artPending > 0)
|
||||
root.artPending--
|
||||
root._artInbox[key] = path
|
||||
if (!artFlush.running) artFlush.start()
|
||||
}
|
||||
// Favourite state settled in the store. Patched IN PLACE, and
|
||||
// `feedChanged()` is deliberately NOT called — that is the WHOLE fix
|
||||
@@ -849,23 +922,28 @@ Rectangle {
|
||||
// `favorite` is passed in rather than read off `item`: the row owns the
|
||||
// live state (see FeedListRow.favorite), `item.isFavorite` is only its
|
||||
// seed.
|
||||
function trackMenuModel(item, favorite) {
|
||||
var m = [
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
function trackMenuModel(item, favorite, pulledDead) {
|
||||
var m = []
|
||||
if (!pulledDead) {
|
||||
m.push({ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
|
||||
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
|
||||
}
|
||||
if (item.artistId !== "") m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
if (item.albumId !== "") m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc", "action": "go-album" })
|
||||
m.push({ "label": favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
if (!pulledDead)
|
||||
m.push({ "label": favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
return m
|
||||
}
|
||||
// Takes the ROW, not the item — "favorite" has to go through the row's
|
||||
// own property (a write to `item.isFavorite` notifies nothing).
|
||||
function trackAction(row, a) {
|
||||
var item = row.item
|
||||
if ((a === "play" || a === "next" || a === "later" || a === "queue")
|
||||
&& row.pulledDead)
|
||||
return
|
||||
if (a === "play") root.playTrackInContext(item.id)
|
||||
else if (a === "next") QbzPlayer.enqueueTrack(item.id, "next")
|
||||
else if (a === "later") QbzPlayer.enqueueTrack(item.id, "later")
|
||||
@@ -1073,6 +1151,10 @@ Rectangle {
|
||||
cellWidth: 220
|
||||
cellHeight: 266
|
||||
cacheBuffer: 266 * 2
|
||||
// Mixed cards are expensive to construct. FeedGridCell resets
|
||||
// optimistic heart/pin bindings and closes transient popups at
|
||||
// the reuse edge, so native pooling is safe here.
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
@@ -1128,7 +1210,9 @@ Rectangle {
|
||||
anchors.topMargin: 10 + content.barInset
|
||||
height: list.visible ? parent.height - (10 + content.barInset) : 0
|
||||
visible: content.showList
|
||||
cacheBuffer: 44 * 10
|
||||
spacing: root.activeTab === "all" ? 2 : 0
|
||||
cacheBuffer: 50 * 10
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
@@ -1146,7 +1230,7 @@ Rectangle {
|
||||
width: list.width
|
||||
height: modelData && modelData.kind === "group-header"
|
||||
? 34
|
||||
: (root.activeTab === "tracks" ? 50 : 44)
|
||||
: 50
|
||||
Component {
|
||||
id: listHeaderComp
|
||||
Item {
|
||||
@@ -1209,11 +1293,27 @@ Rectangle {
|
||||
view: root
|
||||
item: modelData
|
||||
rowIndex: Math.max(0, index - root.artWindowFirst)
|
||||
displayIndex: index
|
||||
}
|
||||
}
|
||||
sourceComponent: (modelData && modelData.kind === "group-header")
|
||||
? listHeaderComp
|
||||
: (root.activeTab === "tracks" ? trackRowComp : feedRowComp)
|
||||
function releaseLoadedRow() {
|
||||
if (!item)
|
||||
return
|
||||
if (item.recycleActive !== undefined)
|
||||
item.recycleActive = false
|
||||
if (typeof item.releaseForReuse === "function")
|
||||
item.releaseForReuse()
|
||||
}
|
||||
function activateLoadedRow() {
|
||||
if (item && item.recycleActive !== undefined)
|
||||
item.recycleActive = true
|
||||
}
|
||||
onLoaded: activateLoadedRow()
|
||||
ListView.onPooled: releaseLoadedRow()
|
||||
ListView.onReused: activateLoadedRow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,6 +1342,7 @@ Rectangle {
|
||||
clip: true
|
||||
spacing: 2
|
||||
cacheBuffer: 60 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: content.showPlaylistsList ? root.visibleRows : []
|
||||
|
||||
@@ -1398,8 +1499,17 @@ Rectangle {
|
||||
}
|
||||
function listWindowReport() {
|
||||
if (!list.visible) return
|
||||
var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
|
||||
var last = Math.ceil((list.contentY + list.height) / 44) + 4
|
||||
// Variable-height group headers and the All tab's 2px zebra gutter
|
||||
// make arithmetic row pitches approximate. Ask the native ListView
|
||||
// for the real visible indices and keep a four-row artwork runway.
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0)
|
||||
first = Math.max(0, Math.floor(list.contentY / 50))
|
||||
if (last < 0)
|
||||
last = Math.ceil((list.contentY + list.height) / 50)
|
||||
first = Math.max(0, first - 4)
|
||||
last += 4
|
||||
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Qobuz Playlists "View all" page — QML port of
|
||||
// discover/PlaylistBrowseView.slint: the playlist twin of DiscoverBrowseView
|
||||
// (same fixed 56px header with the shared browse tools, genre overlay,
|
||||
// infinite scroll and scrollbar), plus a single-select category tag bar
|
||||
// explicit pagination and scrollbar), plus a single-select category tag bar
|
||||
// under the header.
|
||||
//
|
||||
// ADR-008: the categories are radio DOTS, never pills (FilterRadio,
|
||||
@@ -54,6 +54,61 @@ Rectangle {
|
||||
readonly property string viewMode: doc.viewMode || "grid"
|
||||
readonly property int headerH: tags.length > 0 ? 92 : 56
|
||||
|
||||
// The playlist collection is custom (mixed own-art / collage cards), so
|
||||
// it owns the same append-only fade AlbumCollection provides to album
|
||||
// pages. The ids present when Load more is clicked remain opaque; only
|
||||
// rows from the page that lands ride `_tailReveal`.
|
||||
property var _tailSeen: null
|
||||
property real _tailReveal: 1.0
|
||||
function clearTailFade() {
|
||||
tailFade.stop()
|
||||
root._tailSeen = null
|
||||
root._tailReveal = 1.0
|
||||
}
|
||||
function armTailFade() {
|
||||
var seen = {}
|
||||
for (var i = 0; i < root.items.length; i++) {
|
||||
var id = root.items[i] ? (root.items[i].id || "") : ""
|
||||
if (id !== "") seen[id] = true
|
||||
}
|
||||
root._tailSeen = seen
|
||||
root._tailReveal = 0.0
|
||||
}
|
||||
function tailOpacity(item) {
|
||||
if (!root._tailSeen || !item)
|
||||
return 1.0
|
||||
var id = item.id || ""
|
||||
return id !== "" && root._tailSeen[id] !== true
|
||||
? root._tailReveal : 1.0
|
||||
}
|
||||
onItemsChanged: {
|
||||
if (!root._tailSeen)
|
||||
return
|
||||
var hasNew = false
|
||||
for (var i = 0; i < root.items.length; i++) {
|
||||
var id = root.items[i] ? (root.items[i].id || "") : ""
|
||||
if (id !== "" && root._tailSeen[id] !== true) {
|
||||
hasNew = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasNew) tailFade.restart()
|
||||
else root.clearTailFade()
|
||||
}
|
||||
onSelectedTagChanged: root.clearTailFade()
|
||||
onQueryChanged: root.clearTailFade()
|
||||
|
||||
NumberAnimation {
|
||||
id: tailFade
|
||||
target: root
|
||||
property: "_tailReveal"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: 220
|
||||
easing.type: Easing.OutCubic
|
||||
onFinished: root.clearTailFade()
|
||||
}
|
||||
|
||||
readonly property string genreSig: {
|
||||
try {
|
||||
var d = JSON.parse(QbzBridge.genreFilterJson)
|
||||
@@ -64,8 +119,10 @@ Rectangle {
|
||||
}
|
||||
property string lastGenreSig: ""
|
||||
onGenreSigChanged: {
|
||||
if (root.lastGenreSig !== "" && root.lastGenreSig !== root.genreSig)
|
||||
if (root.lastGenreSig !== "" && root.lastGenreSig !== root.genreSig) {
|
||||
root.clearTailFade()
|
||||
QbzHome.playlistBrowseGenreChanged()
|
||||
}
|
||||
root.lastGenreSig = root.genreSig
|
||||
}
|
||||
Component.onCompleted: root.lastGenreSig = root.genreSig
|
||||
@@ -240,15 +297,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& !QbzHome.playlistBrowseLoadingMore
|
||||
&& !QbzHome.playlistBrowseLoading
|
||||
&& root.query === "")
|
||||
QbzHome.playlistBrowseLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -293,16 +341,19 @@ Rectangle {
|
||||
// solved this for the album pages and this one never
|
||||
// got it.
|
||||
//
|
||||
// The band is sampled through a one-shot timer fed by
|
||||
// scroll events rather than bound to `contentY`: this
|
||||
// avoids O(n) work per frame and wakes nothing at rest.
|
||||
// Scroll events run a constant-time coverage guard.
|
||||
// The slice itself changes only after roughly one
|
||||
// viewport of travel, but it changes synchronously:
|
||||
// a fast wheel/scrollbar jump can never outrun the old
|
||||
// slice and paint a completely blank viewport while a
|
||||
// delayed timer catches up.
|
||||
//
|
||||
// The FULL footprint stays on this one Item, while the
|
||||
// Repeater model is only the sampled slice. This avoids
|
||||
// both the old card cost and one Loader shell per
|
||||
// off-screen result. The list arm uses the same band.
|
||||
property int bandFirst: 0
|
||||
property int bandLast: 9999
|
||||
property int bandLast: 0
|
||||
function sampleBand() {
|
||||
if (plGrid.columns <= 0 || !plGrid.visible)
|
||||
return
|
||||
@@ -311,13 +362,8 @@ Rectangle {
|
||||
: plGrid.cardH + plGrid.gap
|
||||
var top = flick.contentY - plGrid.y
|
||||
var h = flick.height
|
||||
plGrid.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
|
||||
plGrid.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
|
||||
}
|
||||
|
||||
function scheduleBandSample() {
|
||||
if (plGrid.visible && !plBandSampler.running)
|
||||
plBandSampler.start()
|
||||
plGrid.bandFirst = Math.max(0, Math.floor((top - 2 * h) / pitch))
|
||||
plGrid.bandLast = Math.max(0, Math.ceil((top + 3 * h) / pitch))
|
||||
}
|
||||
|
||||
function refreshBand() {
|
||||
@@ -325,6 +371,31 @@ Rectangle {
|
||||
plGrid.reportArtWindow()
|
||||
}
|
||||
|
||||
function ensureBandCoverage() {
|
||||
if (!plGrid.visible || plGrid.columns <= 0)
|
||||
return
|
||||
var listMode = root.viewMode === "list"
|
||||
var pitch = listMode ? plGrid.listH + plGrid.listGap
|
||||
: plGrid.cardH + plGrid.gap
|
||||
var top = flick.contentY - plGrid.y
|
||||
var h = Math.max(1, flick.height)
|
||||
var totalRows = listMode ? root.items.length
|
||||
: Math.ceil(root.items.length / plGrid.columns)
|
||||
if (totalRows <= 0 || top + h <= 0
|
||||
|| top >= totalRows * pitch)
|
||||
return
|
||||
var visibleFirst = Math.max(0, Math.floor(top / pitch))
|
||||
var visibleLast = Math.max(0, Math.ceil((top + h) / pitch))
|
||||
var innerRunway = Math.max(1, Math.ceil(h / pitch))
|
||||
if (visibleFirst < plGrid.bandFirst
|
||||
|| visibleLast > plGrid.bandLast
|
||||
|| (visibleFirst > innerRunway
|
||||
&& visibleFirst - plGrid.bandFirst < innerRunway)
|
||||
|| (visibleLast < totalRows - innerRunway
|
||||
&& plGrid.bandLast - visibleLast < innerRunway))
|
||||
plGrid.refreshBand()
|
||||
}
|
||||
|
||||
// Covers, one key at a time and only near the viewport
|
||||
// — the Library > All shape. Rust used to download
|
||||
// every missing cover of the page in one batch and then
|
||||
@@ -364,16 +435,10 @@ Rectangle {
|
||||
if (pending.length > 0)
|
||||
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
|
||||
}
|
||||
Timer {
|
||||
id: plBandSampler
|
||||
interval: 40
|
||||
repeat: false
|
||||
onTriggered: plGrid.refreshBand()
|
||||
}
|
||||
Connections {
|
||||
target: flick
|
||||
function onContentYChanged() { plGrid.scheduleBandSample() }
|
||||
function onHeightChanged() { plGrid.scheduleBandSample() }
|
||||
function onContentYChanged() { plGrid.ensureBandCoverage() }
|
||||
function onHeightChanged() { plGrid.refreshBand() }
|
||||
}
|
||||
Connections {
|
||||
target: root
|
||||
@@ -431,6 +496,7 @@ Rectangle {
|
||||
y: Math.floor(globalIndex / plGrid.columns) * (plGrid.cardH + plGrid.gap)
|
||||
width: plGrid.cardW
|
||||
height: plGrid.cardH
|
||||
opacity: root.tailOpacity(plCell.cardData)
|
||||
|
||||
onCardDataChanged: {
|
||||
if (!browseCard)
|
||||
@@ -473,15 +539,37 @@ Rectangle {
|
||||
item: rowData
|
||||
artSource: plGrid.artOf(rowData)
|
||||
rowIndex: globalIndex
|
||||
opacity: root.tailOpacity(rowData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { visible: QbzHome.playlistBrowseLoadingMore; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: QbzHome.playlistBrowseLoadingMore
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: !QbzHome.playlistBrowseLoading
|
||||
&& root.items.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: QbzHome.playlistBrowseLoadingMore
|
||||
skeleton: root.viewMode === "list" ? "rows" : "cards"
|
||||
// Playlist grid pitch: 200x246 + 24px gutter.
|
||||
cellW: 224
|
||||
cellH: 270
|
||||
rowH: 60
|
||||
rowGap: 2
|
||||
rowCount: 2
|
||||
rowArtSize: 44
|
||||
onClicked: {
|
||||
root.armTailFade()
|
||||
QbzHome.playlistBrowseLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,28 +58,41 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||
root.openMenu(rowArea, mouse.x, mouse.y)
|
||||
else
|
||||
QbzBridge.openPlaylist(root.item.id || "")
|
||||
}
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 196
|
||||
entries: [
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "play") QbzPlayer.playPlaylistById(id)
|
||||
else QbzPlayer.enqueuePlaylistById(id, a)
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: [
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "play") QbzPlayer.playPlaylistById(id)
|
||||
else QbzPlayer.enqueuePlaylistById(id, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
function openMenu(anchor, x, y) {
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (rowMenuLoader.item)
|
||||
rowMenuLoader.item.close()
|
||||
rowMenuLoader.active = false
|
||||
}
|
||||
ListView.onPooled: root.releaseForReuse()
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -27,6 +27,47 @@ Item {
|
||||
/// animation cap counts from the viewport, never from a 10k-row model.
|
||||
property int cellIndex: 0
|
||||
|
||||
readonly property string identity: (cell.item.kind || "") + ":" + (cell.item.id || "")
|
||||
function releaseLoadedCard() {
|
||||
if (cardLoader.item
|
||||
&& typeof cardLoader.item.releaseForReuse === "function")
|
||||
cardLoader.item.releaseForReuse()
|
||||
}
|
||||
function restoreMutableBindings() {
|
||||
var loaded = cardLoader.item
|
||||
if (!loaded)
|
||||
return
|
||||
if (cell.item.kind === "album") {
|
||||
loaded.isFavorite = Qt.binding(function () {
|
||||
return cell.item.isFavorite === true
|
||||
})
|
||||
loaded.isPinned = Qt.binding(function () {
|
||||
return cell.item.isPinned === true
|
||||
})
|
||||
} else if (cell.item.kind === "artist"
|
||||
|| cell.item.kind === "playlist") {
|
||||
loaded.isPinned = Qt.binding(function () {
|
||||
return cell.item.isPinned === true
|
||||
})
|
||||
}
|
||||
}
|
||||
function scheduleMutableRestore() {
|
||||
var expected = cell.identity
|
||||
Qt.callLater(function () {
|
||||
if (cell.identity === expected)
|
||||
cell.restoreMutableBindings()
|
||||
})
|
||||
}
|
||||
onItemChanged: {
|
||||
// A popup is a window-overlay child; close it while the old loaded
|
||||
// card still exists, then restore optimistic scalar bindings after
|
||||
// Loader has selected the new kind.
|
||||
cell.releaseLoadedCard()
|
||||
cell.scheduleMutableRestore()
|
||||
}
|
||||
GridView.onPooled: cell.releaseLoadedCard()
|
||||
GridView.onReused: cell.scheduleMutableRestore()
|
||||
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
@@ -43,6 +84,9 @@ Item {
|
||||
artSource: cell.view.artMap[cell.item.artKey] || ""
|
||||
isFavorite: cell.item.isFavorite
|
||||
isPinned: cell.item.isPinned
|
||||
qobuzUnavailable: cell.item.qobuzUnavailable === true
|
||||
cacheStatus: cell.item.cacheStatus !== undefined
|
||||
? cell.item.cacheStatus : 0
|
||||
// The pin payload's display snapshot: the REMOTE url, never
|
||||
// `artMap` (a local file:// cache path that means nothing after a
|
||||
// cache wipe). Without it every album pinned from the Library grid
|
||||
@@ -122,6 +166,7 @@ Item {
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
id: cardLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: cell.item.kind === "group-header" ? groupHeaderComp
|
||||
: cell.item.kind === "album" ? albumCardComp
|
||||
@@ -129,6 +174,7 @@ Item {
|
||||
: cell.item.kind === "artist" ? artistCardComp
|
||||
: cell.item.kind === "playlist" ? playlistCardComp
|
||||
: labelCardComp
|
||||
onLoaded: cell.restoreMutableBindings()
|
||||
}
|
||||
// THE fix for "many grey squares that fill in unevenly": each pending
|
||||
// cover shimmers on its own and stops the moment ITS file:// path lands in
|
||||
@@ -146,6 +192,10 @@ Item {
|
||||
|| cell.item.kind === "playlist")
|
||||
&& cell.item.imageUrl !== ""
|
||||
&& (cell.view.artMap[cell.item.artKey] || "") === ""
|
||||
// TrackCard's unavailable scrim is semantic content. A pending
|
||||
// artwork skeleton must never paint over it and turn the honest
|
||||
// state back into an ambiguous grey tile.
|
||||
&& !(cardLoader.item && cardLoader.item.pulledDead === true)
|
||||
phase: cell.view.skelPhase
|
||||
cellIndex: cell.cellIndex
|
||||
}
|
||||
|
||||
@@ -23,12 +23,26 @@ Rectangle {
|
||||
property var item: ({})
|
||||
/// Viewport-relative position, for the skeleton's animated cap.
|
||||
property int rowIndex: 0
|
||||
/// Absolute model position, for the stable zebra independent of pooling.
|
||||
property int displayIndex: 0
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 44
|
||||
height: 50
|
||||
radius: 6
|
||||
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
color: rowArea.containsMouse && !feedRow.pulledDead
|
||||
? theme.surfaceHover
|
||||
: (feedRow.displayIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
|
||||
|
||||
// Catalog-withdrawal contract shared with TrackRow/TrackCard. A complete
|
||||
// offline copy keeps the row live; the bridge signal updates that answer
|
||||
// without waiting for a full Library document republish.
|
||||
readonly property bool pulled: (feedRow.item.kind === "track"
|
||||
|| feedRow.item.kind === "album")
|
||||
&& feedRow.item.qobuzUnavailable === true
|
||||
property int cacheStatus: feedRow.item.cacheStatus !== undefined
|
||||
? feedRow.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: feedRow.pulled && feedRow.cacheStatus !== 3
|
||||
|
||||
// Live heart, as a real property — `item.isFavorite = !…` notified
|
||||
// nothing (plain JS object; see rows/TrackRow.qml for the measurement)
|
||||
@@ -36,13 +50,41 @@ Rectangle {
|
||||
// binding is re-established on every new row object, so a republished
|
||||
// feed still wins.
|
||||
property bool favorite: feedRow.item.isFavorite === true
|
||||
onItemChanged: feedRow.favorite = Qt.binding(function () {
|
||||
return feedRow.item.isFavorite === true
|
||||
})
|
||||
onItemChanged: {
|
||||
feedRow.favorite = Qt.binding(function () {
|
||||
return feedRow.item.isFavorite === true
|
||||
})
|
||||
feedRow.cacheStatus = Qt.binding(function () {
|
||||
return feedRow.item.cacheStatus !== undefined
|
||||
? feedRow.item.cacheStatus : 0
|
||||
})
|
||||
feedRow.releaseForReuse()
|
||||
}
|
||||
function toggleFavorite() {
|
||||
feedRow.favorite = !feedRow.favorite
|
||||
QbzLibrary.libraryToggleFavorite(feedRow.item.kind, feedRow.item.id)
|
||||
}
|
||||
function albumMenuModel() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = []
|
||||
if (!feedRow.pulledDead) {
|
||||
m.push({ "label": t("Open album", r), "icon": "library-big", "action": "open" })
|
||||
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
||||
}
|
||||
// The old id may be dead, but the user must still be able to remove
|
||||
// its favorite entry instead of being left with a permanent tombstone.
|
||||
m.push({ "label": feedRow.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
return m
|
||||
}
|
||||
function albumAction(action) {
|
||||
if ((action === "open" || action === "play") && feedRow.pulledDead)
|
||||
return
|
||||
if (action === "open") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
else if (action === "play") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
else if (action === "favorite") feedRow.toggleFavorite()
|
||||
}
|
||||
// Settle + rollback + cross-surface walk. `artKey` IS
|
||||
// `library_qt::feed_key(kind, id)`, the very key the signal carries —
|
||||
// which is also why LibraryView's own handler can patch the backing row by
|
||||
@@ -54,6 +96,16 @@ Rectangle {
|
||||
feedRow.favorite = value
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onTrackCacheStatusChanged(trackId, status, progress) {
|
||||
// Numeric Qobuz ids overlap across entity types. This signal is
|
||||
// track-scoped and must not revive an album with the same number.
|
||||
if (feedRow.item.kind === "track"
|
||||
&& trackId === (feedRow.item.id || ""))
|
||||
feedRow.cacheStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
// Row body — click plays/opens by kind. Declared BEFORE the cells so
|
||||
// the ⋯ button and art-play win their clicks.
|
||||
@@ -61,10 +113,16 @@ Rectangle {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
if (feedRow.item.kind === "track") {
|
||||
if (!feedRow.pulledDead)
|
||||
feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
}
|
||||
else if (feedRow.item.kind === "album") {
|
||||
if (!feedRow.pulledDead)
|
||||
QbzAlbum.openAlbum(feedRow.item.id)
|
||||
}
|
||||
else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
@@ -76,19 +134,20 @@ Rectangle {
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
opacity: feedRow.pulledDead ? 0.5 : 1.0
|
||||
// Col — artwork (round for artists) + hover play.
|
||||
Rectangle {
|
||||
width: 44
|
||||
height: 44
|
||||
width: 36
|
||||
height: 36
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: feedRow.item.kind === "artist" ? 22 : 6
|
||||
radius: feedRow.item.kind === "artist" ? 18 : 5
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
visible: !lrCollage.visible
|
||||
source: feedRow.view.artMap[feedRow.item.artKey] || ""
|
||||
radius: feedRow.item.kind === "artist" ? 22 : 6
|
||||
radius: feedRow.item.kind === "artist" ? 18 : 5
|
||||
// A label logo is contain-fitted (cropping cuts the
|
||||
// wordmark) on a FLAT surface — those are transparent
|
||||
// PNGs whose edges carry no colour to derive from.
|
||||
@@ -124,14 +183,16 @@ Rectangle {
|
||||
visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album")
|
||||
&& feedRow.item.imageUrl !== ""
|
||||
&& (feedRow.view.artMap[feedRow.item.artKey] || "") === ""
|
||||
&& !feedRow.pulledDead
|
||||
phase: feedRow.view.skelPhase
|
||||
cellIndex: feedRow.rowIndex
|
||||
}
|
||||
Rectangle {
|
||||
visible: feedRow.item.kind === "track" || feedRow.item.kind === "album"
|
||||
|| feedRow.item.kind === "playlist"
|
||||
visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album"
|
||||
|| feedRow.item.kind === "playlist")
|
||||
&& !feedRow.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: feedRow.item.kind === "artist" ? 22 : 6
|
||||
radius: feedRow.item.kind === "artist" ? 18 : 5
|
||||
color: "#a6000000"
|
||||
opacity: lrPlayArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
@@ -143,8 +204,14 @@ Rectangle {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
if (feedRow.item.kind === "track") {
|
||||
if (!feedRow.pulledDead)
|
||||
feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
}
|
||||
else if (feedRow.item.kind === "album") {
|
||||
if (!feedRow.pulledDead)
|
||||
QbzPlayer.playAlbum(feedRow.item.id)
|
||||
}
|
||||
// `playPlaylistById`, NOT `playPlaylist` — the latter
|
||||
// plays the OPEN playlist page and takes no id
|
||||
// (player_bridge.rs:189 is the by-id entry).
|
||||
@@ -152,10 +219,21 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// On a 36px thumbnail a text badge would be illegible. The alert
|
||||
// glyph is the same semantic mark TrackRow uses in its number
|
||||
// cell; the explicit translated label lives in the quality slot.
|
||||
QbzIcon {
|
||||
visible: feedRow.pulledDead
|
||||
name: "circle-alert"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: "favorite"
|
||||
}
|
||||
}
|
||||
// Col — title + subtitle (artist link for track/album).
|
||||
Column {
|
||||
width: parent.width - 44 - 116 - (srcCol.visible ? 44 : 0)
|
||||
width: parent.width - 36 - 116 - (srcCol.visible ? 44 : 0)
|
||||
- 150 - 18 - 30 - 6 * 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
@@ -163,7 +241,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 18
|
||||
text: feedRow.item.title
|
||||
color: lrTitleArea.containsMouse ? theme.accent : theme.textPrimary
|
||||
color: lrTitleArea.containsMouse && !feedRow.pulledDead
|
||||
? theme.accent : theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -172,10 +251,16 @@ Rectangle {
|
||||
id: lrTitleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
if (feedRow.item.kind === "track") {
|
||||
if (!feedRow.pulledDead)
|
||||
feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
}
|
||||
else if (feedRow.item.kind === "album") {
|
||||
if (!feedRow.pulledDead)
|
||||
QbzAlbum.openAlbum(feedRow.item.id)
|
||||
}
|
||||
else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
@@ -278,10 +363,23 @@ Rectangle {
|
||||
color: "transparent"
|
||||
QualityMini {
|
||||
visible: (feedRow.item.kind === "album" || feedRow.item.kind === "track")
|
||||
&& feedRow.item.qualityTier !== ""
|
||||
&& feedRow.item.qualityTier !== "" && !feedRow.pulledDead
|
||||
tier: feedRow.item.qualityTier
|
||||
// The CD/MP3 chip is 30px wide while the Hi-Res mark is 42px.
|
||||
// Left-aligning both shifts Hi-Res' visual centre 6px right.
|
||||
// Keep one 30px mark axis without centring the whole 150px
|
||||
// quality column (whose content is intentionally leading).
|
||||
x: Math.round((30 - width) / 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
visible: feedRow.pulledDead
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
// Col — favorite / follow indicator.
|
||||
QbzIcon {
|
||||
@@ -321,13 +419,9 @@ Rectangle {
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: feedRow.item.kind === "track"
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite)
|
||||
: feedRow.item.kind === "album" ? [
|
||||
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite,
|
||||
feedRow.pulledDead)
|
||||
: feedRow.item.kind === "album" ? feedRow.albumMenuModel()
|
||||
: feedRow.item.kind === "artist" ? [
|
||||
{ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" },
|
||||
]
|
||||
@@ -338,12 +432,10 @@ Rectangle {
|
||||
]
|
||||
onPicked: function (a) {
|
||||
if (feedRow.item.kind === "track") { feedRow.view.trackAction(feedRow, a); return }
|
||||
if (feedRow.item.kind === "album") { feedRow.albumAction(a); return }
|
||||
if (a === "open") {
|
||||
if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
} else if (a === "play") {
|
||||
if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
} else if (a === "go-artist") {
|
||||
QbzArtist.openArtist(feedRow.item.id)
|
||||
} else if (a === "favorite") {
|
||||
@@ -354,7 +446,18 @@ Rectangle {
|
||||
}
|
||||
/// Build the popup on first use, then open it.
|
||||
function openLrMenu(anchor, x, y) {
|
||||
var entries = feedRow.item.kind === "track"
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite,
|
||||
feedRow.pulledDead)
|
||||
: feedRow.item.kind === "album" ? feedRow.albumMenuModel() : [1]
|
||||
if (entries.length === 0)
|
||||
return
|
||||
lrMenuLoader.active = true
|
||||
lrMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (lrMenuLoader.item)
|
||||
lrMenuLoader.item.close()
|
||||
lrMenuLoader.active = false
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ Item {
|
||||
clip: true
|
||||
spacing: root.rowGap
|
||||
cacheBuffer: 64 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.rows
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ Item {
|
||||
anchors.rightMargin: 22
|
||||
clip: true
|
||||
cacheBuffer: 56 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
|
||||
|
||||
@@ -87,9 +87,9 @@ pub struct FeedItem {
|
||||
pub sample_rate: Option<f64>,
|
||||
#[serde(rename = "isFavorite")]
|
||||
pub is_favorite: bool,
|
||||
/// Qobuz PULLED this track (`streamable: false` — contract §5.1). Track
|
||||
/// rows only; meaningless on album / artist / playlist rows and skipped
|
||||
/// when false, so the ~10k-row feed JSON does not grow a key per row.
|
||||
/// Qobuz PULLED this track or album (`streamable: false` — contract §5.1).
|
||||
/// Meaningless on artist / playlist rows and skipped when false, so the
|
||||
/// ~10k-row feed JSON does not grow a key per row.
|
||||
///
|
||||
/// BOTH feed -> queue builders read it (`feed_track_to_queue` below and
|
||||
/// `playback_qt::feed_queue_track`), for the same reason they both read
|
||||
@@ -100,6 +100,15 @@ pub struct FeedItem {
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
pub not_streamable: bool,
|
||||
/// Liveable exception to `not_streamable`: a complete offline copy still
|
||||
/// plays after Qobuz withdraws the catalog row. Zero is omitted so the
|
||||
/// high-cardinality feed only pays for tracks that actually carry state.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "cacheStatus",
|
||||
skip_serializing_if = "is_zero_i32"
|
||||
)]
|
||||
pub cache_status: i32,
|
||||
pub genre: String,
|
||||
pub year: String,
|
||||
pub duration: String,
|
||||
@@ -136,6 +145,10 @@ fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
fn is_zero_i32(value: &i32) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
impl FeedItem {
|
||||
fn keyed(mut self) -> Self {
|
||||
self.art_key = feed_key(&self.kind, &self.id);
|
||||
@@ -346,6 +359,12 @@ fn map_track(track: Track) -> FeedItem {
|
||||
// is one of the most likely places a user meets this — they hearted it
|
||||
// while it was still there.
|
||||
let not_streamable = !track.is_streamable();
|
||||
let track_id = track.id.to_string();
|
||||
let cache_status = if crate::offline_qt::is_cached(&track_id) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut title = track.title;
|
||||
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
|
||||
title = format!("{title} ({version})");
|
||||
@@ -376,7 +395,7 @@ fn map_track(track: Track) -> FeedItem {
|
||||
kind: "track".into(),
|
||||
group: "favorites".into(),
|
||||
source: "qobuz".into(),
|
||||
id: track.id.to_string(),
|
||||
id: track_id,
|
||||
title,
|
||||
subtitle: artist.clone(),
|
||||
artist,
|
||||
@@ -398,12 +417,19 @@ fn map_track(track: Track) -> FeedItem {
|
||||
image_url: artwork_url,
|
||||
is_favorite: true,
|
||||
not_streamable,
|
||||
cache_status,
|
||||
..Default::default()
|
||||
}
|
||||
.keyed()
|
||||
}
|
||||
|
||||
fn map_album(album: Album) -> FeedItem {
|
||||
fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem {
|
||||
// Same absence contract as Track: only an explicit false is a catalog
|
||||
// withdrawal. A COMPLETE offline copy remains live because playAlbum has
|
||||
// an index-backed fallback when /album/get no longer resolves this id.
|
||||
let not_streamable = !album.is_streamable();
|
||||
let track_count = album.tracks_count.or(album.track_count).unwrap_or(0) as usize;
|
||||
let fully_cached = track_count > 0 && ready_offline_tracks >= track_count;
|
||||
let bit_depth = album
|
||||
.audio_info
|
||||
.as_ref()
|
||||
@@ -447,6 +473,8 @@ fn map_album(album: Album) -> FeedItem {
|
||||
// reverted after the 2026-08-15 owner smoke (contract 04 §3).
|
||||
image_url: album.image.best().cloned().unwrap_or_default(),
|
||||
is_favorite: true,
|
||||
not_streamable,
|
||||
cache_status: if fully_cached { 3 } else { 0 },
|
||||
..Default::default()
|
||||
}
|
||||
.keyed()
|
||||
@@ -587,6 +615,36 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
||||
.keyed()
|
||||
}
|
||||
|
||||
/// Ready offline rows grouped by Qobuz album id.
|
||||
///
|
||||
/// Library can contain hundreds of favorite albums, so snapshot the index in
|
||||
/// one pass rather than issuing a query per card. The count is compared with
|
||||
/// the catalog track count in `map_album`; a partial download must not make a
|
||||
/// withdrawn album look wholly playable.
|
||||
async fn ready_offline_album_track_counts() -> std::collections::HashMap<String, usize> {
|
||||
use qbz_offline_cache::OfflineCacheStatus;
|
||||
|
||||
let Some(offline) = crate::offline_qt::get().await else {
|
||||
return std::collections::HashMap::new();
|
||||
};
|
||||
let tracks = {
|
||||
let db = offline.db.lock().await;
|
||||
db.as_ref()
|
||||
.and_then(|db| db.get_all_tracks().ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for track in tracks {
|
||||
if track.status != OfflineCacheStatus::Ready {
|
||||
continue;
|
||||
}
|
||||
if let Some(album_id) = track.album_id.filter(|id| !id.is_empty()) {
|
||||
*counts.entry(album_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// The whole library load: fan out, normalize, merge (library_all.rs
|
||||
/// ordering semantics), compute the tab counts.
|
||||
pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<usize, String> {
|
||||
@@ -679,9 +737,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
||||
feed.push(item);
|
||||
}
|
||||
let albums: Vec<Album> = parse_items(raw_albums, "album");
|
||||
let ready_album_tracks = ready_offline_album_track_counts().await;
|
||||
let n = albums.len();
|
||||
for (i, item) in albums.into_iter().map(map_album).enumerate() {
|
||||
let mut item = item;
|
||||
for (i, album) in albums.into_iter().enumerate() {
|
||||
let ready = ready_album_tracks.get(&album.id).copied().unwrap_or(0);
|
||||
let mut item = map_album(album, ready);
|
||||
item.added_rank = rank(i, n);
|
||||
feed.push(item);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,19 @@ fn prefetch_quality_tag(quality: Quality) -> qbz_audio::network_throttle::Playba
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming-only gapless policy. Cached/offline successors stay on the
|
||||
/// established byte path; only a cold network successor needs the bounded
|
||||
/// initial-buffer handoff. A zero adaptive cap protects a live stream that is
|
||||
/// already struggling instead of competing with it for the link.
|
||||
fn should_stream_gapless_successor(
|
||||
streaming_only: bool,
|
||||
player_cached: bool,
|
||||
offline_cached: bool,
|
||||
throttle_cap: usize,
|
||||
) -> bool {
|
||||
streaming_only && !player_cached && !offline_cached && throttle_cap > 0
|
||||
}
|
||||
|
||||
/// Warm the next two queue rows in the player's shared L1/L2 cache. This only
|
||||
/// performs cheap policy/queue work inline; every full download is spawned in
|
||||
/// the background and goes through `Player::prefetch_into_cache`, which owns
|
||||
@@ -3287,6 +3300,57 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let runtime = runtime.clone();
|
||||
let next_id = next.id;
|
||||
tokio::spawn(async move {
|
||||
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 offline_cached = crate::offline_qt::is_cached_id(next_id);
|
||||
|
||||
if streaming_only && !player_cached && !offline_cached {
|
||||
// A long successor cannot reliably finish a
|
||||
// whole-file download inside the fixed 10 s
|
||||
// handoff window. In Streaming only, prepare
|
||||
// just its initial CMAF buffer and append that
|
||||
// incremental source behind the current one.
|
||||
// The same adaptive throttle that protects
|
||||
// immediate prefetch keeps a struggling live
|
||||
// stream from starting this second transfer.
|
||||
let throttle_cap = qbz_audio::network_throttle::state()
|
||||
.current_prefetch_cap(
|
||||
qbz_audio::network_throttle::playback_mbps_for_quality(
|
||||
prefetch_quality_tag(quality),
|
||||
),
|
||||
1,
|
||||
);
|
||||
if !should_stream_gapless_successor(
|
||||
streaming_only,
|
||||
player_cached,
|
||||
offline_cached,
|
||||
throttle_cap,
|
||||
) {
|
||||
log::info!(
|
||||
"[qbz-qt] [GAPLESS] streaming successor {next_id} skipped: network throttle cap 0"
|
||||
);
|
||||
return;
|
||||
}
|
||||
match runtime
|
||||
.core()
|
||||
.queue_gapless_streaming(next_id, quality)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[qbz-qt] [GAPLESS] queued streaming track {next_id}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error) => log::warn!(
|
||||
"[qbz-qt] [GAPLESS] streaming setup for {next_id} failed: {error}; trying byte fallback"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Shared tier-walk: L1/L2 (player cache) -> OFFLINE
|
||||
// -> network, then hand the bytes to play_next. The
|
||||
// offline handle and the row sink are the same two
|
||||
@@ -3308,7 +3372,7 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// higher tier than the funnel would request
|
||||
// is exactly how a quality-blind cache
|
||||
// leaks an uncapped stream to the DAC.
|
||||
local_playback_quality().0,
|
||||
quality,
|
||||
off.as_deref(),
|
||||
sink.as_ref(),
|
||||
)
|
||||
@@ -3521,7 +3585,8 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
mod tests {
|
||||
use super::{
|
||||
filter_queue_with, prefetch_track_ids, quality_badge_from, reconcile_device_cap,
|
||||
stream_error_text, xorshift_shuffle_seeded, PrefetchCandidate, PrefetchTrackEdge,
|
||||
should_stream_gapless_successor, stream_error_text, xorshift_shuffle_seeded,
|
||||
PrefetchCandidate, PrefetchTrackEdge,
|
||||
};
|
||||
use qbz_models::{Quality, QualityLimit, QueueTrack};
|
||||
use qbz_source::QualityHint;
|
||||
@@ -3658,6 +3723,22 @@ mod tests {
|
||||
assert!(prefetch_track_ids(false, false, 0, &[remote(2), remote(3)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_only_cold_successor_uses_incremental_gapless() {
|
||||
assert!(should_stream_gapless_successor(true, false, false, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_or_offline_successor_keeps_the_byte_handoff() {
|
||||
assert!(!should_stream_gapless_successor(true, true, false, 1));
|
||||
assert!(!should_stream_gapless_successor(true, false, true, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_cap_zero_blocks_incremental_gapless_download() {
|
||||
assert!(!should_stream_gapless_successor(true, false, false, 0));
|
||||
}
|
||||
|
||||
// --- #638 fix 3: the request tier reconciles preference against the
|
||||
// detected local device cap. -------------------------------------------
|
||||
|
||||
|
||||
Reference in new issue
Block a user