perf(qt): optimize catalog views and gapless playback

cx
This commit is contained in:
vicrodh committed 2026-08-22 18:50:21 -06:00
1 parent e0e9566f81
commit eed377af1f
22 files changed
+1614 -346

No files matched your search

+17
View File
@@ -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
+26
View File
@@ -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.
+512 -34
View File
@@ -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
+58 -14
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+76 -19
View File
@@ -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")
+55 -32
View File
@@ -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()
+80 -41
View File
@@ -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 -2
View File
@@ -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.
+27 -28
View File
@@ -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()
}
}
}
}
}
+20 -20
View File
@@ -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()
}
}
}
}
+32 -18
View File
@@ -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()
}
}
}
}
}
+155 -45
View File
@@ -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))
}
+123 -35
View File
@@ -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()
}
}
}
}
}
+28 -15
View File
@@ -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
}
+137 -34
View File
@@ -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
+67 -7
View File
@@ -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);
}
+83 -2
View File
@@ -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. -------------------------------------------