PARITY-DEBT #7, #15, #17, #4 and #5. - **#7** the stream error was drained and only logged, so an ALSA / exclusive failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no explanation. It now toasts, with the sandbox-aware rewrite the reference carries (playback.rs:5093-5115). This is the support issue that fix was written for. - **#15** seek was clamped to the full duration instead of the downloaded fraction, so the head could be dragged into bytes that do not exist. The poll loop publishes `npSeekableMax` and both bars clamp to it, matching SeekBar.slint:93-98. The cache overlay was already drawn and purely decorative. - **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL shuffle mode on and leaving it on for everything played afterwards. The reference shuffles the vector in place and never touches the mode. - **#4** a `plex:<hash>` album-boundary key was passed where a Plex rating key belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`). - **#5** clicking a track in Library played exactly one track and stopped. It now queues the whole VISIBLE list from the clicked row (`order_by_visible`). The Library row play needed one edit outside its author's lane, which the singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was called from QML and implemented in `library_qt`, but never declared on the bridge. That is precisely the failure the xref exists for — QML resolves singleton members lazily, so a missing invokable compiles clean and throws when the user clicks. The QML had shipped a `typeof === "function"` feature-detect with a fallback, so it degraded instead of throwing; the invokable now exists and the fallback is dead code by design. cc
421 lines
17 KiB
Rust
421 lines
17 KiB
Rust
//! QbzPlayer — now-playing/transport domain bridge (phase 23 split of the
|
|
//! QbzBridge God-object; the pattern is documented in main.rs). Props: the
|
|
//! NowPlayingState mirror (np_ prefix) + the playing-row indicator ids +
|
|
//! show-context-icon. Invokables: transport + the play/enqueue entry points
|
|
//! (album / artist / track / playlist) — one-line forwards into the crate
|
|
//! handlers.
|
|
|
|
use std::pin::Pin;
|
|
use std::sync::OnceLock;
|
|
|
|
use cxx_qt::CxxQtThread;
|
|
use cxx_qt::Threading as _;
|
|
use cxx_qt_lib::QString;
|
|
|
|
#[cxx_qt::bridge]
|
|
pub mod qbz_player {
|
|
extern "C++" {
|
|
include!("cxx-qt-lib/qstring.h");
|
|
type QString = cxx_qt_lib::QString;
|
|
}
|
|
|
|
#[auto_cxx_name]
|
|
extern "RustQt" {
|
|
#[qobject]
|
|
#[qml_element]
|
|
#[qml_singleton]
|
|
// --- Now playing (Slint NowPlayingState; np_ prefix) ------------
|
|
// POC: fed by a static NowPlayingModel (src/now_playing.rs) with
|
|
// empty-state defaults; phase 4 swaps the data source for the real
|
|
// player poll.
|
|
#[qproperty(bool, np_has_track)]
|
|
#[qproperty(QString, np_title)]
|
|
#[qproperty(QString, np_artist)]
|
|
#[qproperty(QString, np_artwork_path)]
|
|
#[qproperty(i32, np_elapsed_secs)]
|
|
#[qproperty(i32, np_duration_secs)]
|
|
#[qproperty(f32, np_progress)]
|
|
#[qproperty(f32, np_cache_progress)]
|
|
// SEEK LOCK (PARITY-DEBT #15, Slint `NowPlayingState.seekable-max`,
|
|
// playback.rs:5304). The fraction of the track the user may seek to:
|
|
// while a stream is still downloading it is `buffer_progress` clamped
|
|
// to 0..1, and a fully-available track (local/offline/complete) is
|
|
// 1.0. The seek bars must clamp the drag/click position to it —
|
|
// `np_cache_progress` is the DECORATIVE overlay of the same fill and
|
|
// must not be used as the limit.
|
|
#[qproperty(f32, np_seekable_max)]
|
|
#[qproperty(bool, np_playing)]
|
|
#[qproperty(bool, np_loading)]
|
|
#[qproperty(f32, np_volume)]
|
|
#[qproperty(bool, np_muted)]
|
|
#[qproperty(bool, np_shuffle)]
|
|
// 0 off / 1 all / 2 one.
|
|
#[qproperty(i32, np_repeat_mode)]
|
|
// --- Quality stamp (Slint NowPlayingState quality-* block) -------
|
|
// CATALOG max of the track: "hires" | "mp3" | "lossless" | "cd" | "".
|
|
#[qproperty(QString, np_quality_tier)]
|
|
// Exact detail line of the catalog max, e.g. "24-bit / 96 kHz".
|
|
#[qproperty(QString, np_quality_detail)]
|
|
// Legacy alias of np_quality_detail (the pre-contract Qt name) —
|
|
// published from the same value so existing QML keeps working.
|
|
#[qproperty(QString, np_quality_label)]
|
|
// DELIVERED-vs-catalog state (#590/#638). While `downgraded` is on,
|
|
// the stamp's main line reports the delivered tier/detail and the
|
|
// catalog max moves to the tooltip's "Source" line.
|
|
#[qproperty(bool, np_quality_downgraded)]
|
|
// Delivered detail line while downgraded ("16-bit / 44.1 kHz",
|
|
// "DSD64"); empty when the engine has not reported params yet.
|
|
#[qproperty(QString, np_quality_true_detail)]
|
|
// Delivered tier while downgraded ("hires"|"cd"|"mp3"), else "".
|
|
#[qproperty(QString, np_quality_effective_tier)]
|
|
// WHY the stream is below the catalog max (qbz_models::QualityLimit):
|
|
// 0 none · 1 streaming-quality setting · 2 local output device cap ·
|
|
// 3 cast renderer cap · 4 Qobuz offered nothing higher.
|
|
#[qproperty(i32, np_quality_limit_cause)]
|
|
|
|
// --- Output LEDs (settings.rs `output_labels`) -------------------
|
|
// PIPEWIRE|ALSA|JACK|PULS|SYST|AUTO — *_active lights the LED.
|
|
#[qproperty(QString, np_output_backend_label)]
|
|
#[qproperty(bool, np_output_backend_active)]
|
|
// DACPASS|BITPERF|EXCL|DIRECT|LOCKED|ROUTED|SHARED|DEFAULT.
|
|
#[qproperty(QString, np_output_mode_label)]
|
|
#[qproperty(bool, np_output_mode_active)]
|
|
// The app's software volume is INERT on this route — the ALSA Direct
|
|
// engine's `set_volume` is a no-op unless the DAC's own mixer is
|
|
// driven (`alsa_hardware_volume`). Derived read-only in
|
|
// `output_labels::volume_locked`; the bars render the volume slider
|
|
// disabled with a tooltip instead of pretending it works. No audio
|
|
// behaviour hangs off this flag — it is a UI mirror only.
|
|
#[qproperty(bool, np_volume_locked)]
|
|
|
|
// --- Cast / remote (Qobuz Connect + Chromecast/DLNA) -------------
|
|
// A peer Qobuz Connect renderer owns playback (transport is remote).
|
|
#[qproperty(bool, np_is_remote)]
|
|
// Name of the active renderer / cast target; empty when local.
|
|
#[qproperty(QString, np_cast_target)]
|
|
// A Chromecast/DLNA session is connected — while casting the two
|
|
// output LEDs fuse into a single cast chip.
|
|
#[qproperty(bool, np_cast_active)]
|
|
// "cast" | "dlna".
|
|
#[qproperty(QString, np_cast_protocol)]
|
|
// Now-playing track id (playing-row indicator in track lists).
|
|
#[qproperty(QString, np_track_id)]
|
|
#[qproperty(QString, np_album)]
|
|
#[qproperty(QString, np_album_id)]
|
|
#[qproperty(QString, np_artist_id)]
|
|
// "Playing from" ORIGIN of the current track (NowPlayingState
|
|
// context-kind / context-id): the container the queue was launched
|
|
// from, republished on every track change. kind is "album" | "artist"
|
|
// | "playlist" | "label"; the song-card layers glyph navigates there.
|
|
#[qproperty(QString, np_context_kind)]
|
|
#[qproperty(QString, np_context_id)]
|
|
// "Show track playing context" pref (Playback settings) — feeds the
|
|
// SongCard layers icon.
|
|
#[qproperty(bool, show_context_icon)]
|
|
|
|
type QbzPlayer = super::QbzPlayerRust;
|
|
|
|
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
|
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
|
|
#[qinvokable]
|
|
fn boot(self: Pin<&mut QbzPlayer>);
|
|
|
|
// --- Transport (phase 4 wires the player; POC log-and-noop, except
|
|
// the pure-UI toggles which mutate the NowPlayingModel) -----------
|
|
#[qinvokable]
|
|
fn toggle_play(self: Pin<&mut QbzPlayer>);
|
|
#[qinvokable]
|
|
fn next(self: Pin<&mut QbzPlayer>);
|
|
#[qinvokable]
|
|
fn previous(self: Pin<&mut QbzPlayer>);
|
|
#[qinvokable]
|
|
fn seek(self: Pin<&mut QbzPlayer>, frac: f32);
|
|
#[qinvokable]
|
|
fn set_volume(self: Pin<&mut QbzPlayer>, volume: f32);
|
|
/// Persist the SETTLED volume (drag-end only — see QbzSlider.released).
|
|
/// `set_volume` drives the engine on every drag tick; this writes the
|
|
/// shared ui_prefs.json once, 1:1 with NowPlayingState.persist-volume
|
|
/// (qbz/src/main.rs:14413-14417).
|
|
#[qinvokable]
|
|
fn persist_volume(self: Pin<&mut QbzPlayer>, fraction: f32);
|
|
#[qinvokable]
|
|
fn toggle_mute(self: Pin<&mut QbzPlayer>);
|
|
#[qinvokable]
|
|
fn toggle_shuffle(self: Pin<&mut QbzPlayer>);
|
|
#[qinvokable]
|
|
fn cycle_repeat(self: Pin<&mut QbzPlayer>);
|
|
|
|
/// Album-card click on Home: resolve the album's tracks, enqueue,
|
|
/// and play through the core's resolved path.
|
|
#[qinvokable]
|
|
fn play_album(self: Pin<&mut QbzPlayer>, album_id: QString);
|
|
|
|
/// AlbumView header Shuffle.
|
|
#[qinvokable]
|
|
fn play_album_shuffled(self: Pin<&mut QbzPlayer>, album_id: QString);
|
|
/// AlbumView row play: the album starting AT this track.
|
|
#[qinvokable]
|
|
fn play_album_from(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString);
|
|
/// AlbumView row "Play next" ("next") / "Add to queue" ("later").
|
|
#[qinvokable]
|
|
fn enqueue_album_track(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString, mode: QString);
|
|
/// AlbumCard ⋯ menu: Play next ("next") / Add to queue ("later").
|
|
#[qinvokable]
|
|
fn enqueue_album(self: Pin<&mut QbzPlayer>, album_id: QString, mode: QString);
|
|
/// ArtistView Popular Tracks row play (whole list as the queue).
|
|
#[qinvokable]
|
|
fn play_artist_track(self: Pin<&mut QbzPlayer>, track_id: QString);
|
|
/// ArtistView "Play all" (shuffle=false) / "Shuffle all" (true).
|
|
#[qinvokable]
|
|
fn play_artist_top(self: Pin<&mut QbzPlayer>, shuffle: bool);
|
|
/// ArtistView ⋯ "Add all to queue" — appends the top-tracks queue.
|
|
#[qinvokable]
|
|
fn enqueue_artist_top(self: Pin<&mut QbzPlayer>);
|
|
/// Artist-card overlay play (ArtistGridCard): Popular tracks,
|
|
/// falling back to the studio discography (playback.rs play_artist).
|
|
#[qinvokable]
|
|
fn play_artist_card(self: Pin<&mut QbzPlayer>, artist_id: QString);
|
|
|
|
/// Track-row click (Library): play the track as a 1-element queue.
|
|
#[qinvokable]
|
|
fn play_track(self: Pin<&mut QbzPlayer>, track_id: QString);
|
|
/// Library track context menus: Play next ("next") / Play later
|
|
/// ("later") / Add to queue ("queue") on a single feed track.
|
|
#[qinvokable]
|
|
fn enqueue_track(self: Pin<&mut QbzPlayer>, track_id: QString, mode: QString);
|
|
|
|
/// Card-level playlist actions (LibPlaylistCard overlay + menu).
|
|
#[qinvokable]
|
|
fn play_playlist_by_id(self: Pin<&mut QbzPlayer>, playlist_id: QString);
|
|
#[qinvokable]
|
|
fn enqueue_playlist_by_id(self: Pin<&mut QbzPlayer>, playlist_id: QString, mode: QString);
|
|
}
|
|
|
|
impl cxx_qt::Threading for QbzPlayer {}
|
|
}
|
|
|
|
use qbz_player::QbzPlayer;
|
|
|
|
/// Rust side of the player bridge (plain storage, phase-1 pattern).
|
|
pub struct QbzPlayerRust {
|
|
np_has_track: bool,
|
|
np_title: QString,
|
|
np_artist: QString,
|
|
np_artwork_path: QString,
|
|
np_elapsed_secs: i32,
|
|
np_duration_secs: i32,
|
|
np_progress: f32,
|
|
np_cache_progress: f32,
|
|
np_seekable_max: f32,
|
|
np_playing: bool,
|
|
np_loading: bool,
|
|
np_volume: f32,
|
|
np_muted: bool,
|
|
np_shuffle: bool,
|
|
np_repeat_mode: i32,
|
|
np_quality_tier: QString,
|
|
np_quality_detail: QString,
|
|
np_quality_label: QString,
|
|
np_quality_downgraded: bool,
|
|
np_quality_true_detail: QString,
|
|
np_quality_effective_tier: QString,
|
|
np_quality_limit_cause: i32,
|
|
np_output_backend_label: QString,
|
|
np_output_backend_active: bool,
|
|
np_output_mode_label: QString,
|
|
np_output_mode_active: bool,
|
|
np_volume_locked: bool,
|
|
np_is_remote: bool,
|
|
np_cast_target: QString,
|
|
np_cast_active: bool,
|
|
np_cast_protocol: QString,
|
|
np_track_id: QString,
|
|
np_album: QString,
|
|
np_album_id: QString,
|
|
np_artist_id: QString,
|
|
np_context_kind: QString,
|
|
np_context_id: QString,
|
|
show_context_icon: bool,
|
|
}
|
|
|
|
impl Default for QbzPlayerRust {
|
|
fn default() -> Self {
|
|
Self {
|
|
// A derive would zero these — the model's sane defaults instead.
|
|
np_volume: 1.0,
|
|
np_quality_tier: QString::from("cd"),
|
|
np_has_track: false,
|
|
np_title: QString::default(),
|
|
np_artist: QString::default(),
|
|
np_artwork_path: QString::default(),
|
|
np_elapsed_secs: 0,
|
|
np_duration_secs: 0,
|
|
np_progress: 0.0,
|
|
np_cache_progress: 0.0,
|
|
// Seek freely until a stream says otherwise — the Slint default
|
|
// (`NowPlayingState.seekable-max: 1.0`). Zero here would lock the
|
|
// bar shut before the first track ever opens.
|
|
np_seekable_max: 1.0,
|
|
np_playing: false,
|
|
np_loading: false,
|
|
np_muted: false,
|
|
np_shuffle: false,
|
|
np_repeat_mode: 0,
|
|
np_quality_detail: QString::default(),
|
|
np_quality_label: QString::default(),
|
|
np_quality_downgraded: false,
|
|
np_quality_true_detail: QString::default(),
|
|
np_quality_effective_tier: QString::default(),
|
|
np_quality_limit_cause: 0,
|
|
// The Slint NowPlayingState defaults (state.slint) — an unlit
|
|
// "SYST / DEFAULT" pair until the first settings snapshot.
|
|
np_output_backend_label: QString::from("SYST"),
|
|
np_output_backend_active: false,
|
|
np_output_mode_label: QString::from("DEFAULT"),
|
|
np_output_mode_active: false,
|
|
// Optimistic default: the slider stays live until the first
|
|
// settings read proves the route is an inert ALSA-direct one.
|
|
np_volume_locked: false,
|
|
np_is_remote: false,
|
|
np_cast_target: QString::default(),
|
|
np_cast_active: false,
|
|
np_cast_protocol: QString::default(),
|
|
np_track_id: QString::default(),
|
|
np_album: QString::default(),
|
|
np_album_id: QString::default(),
|
|
np_artist_id: QString::default(),
|
|
np_context_kind: QString::default(),
|
|
np_context_id: QString::default(),
|
|
// Seed only — this runs when the QML engine constructs the
|
|
// singleton, which can be BEFORE the playback-preferences store is
|
|
// open (then it reads false). `now_playing::publish_show_context_icon`
|
|
// re-publishes it on shell entry and on the Settings toggle.
|
|
show_context_icon: crate::settings_qt::show_context_icon(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The UI hop (bridges/mod.rs pattern)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static QT_THREAD: OnceLock<CxxQtThread<QbzPlayer>> = OnceLock::new();
|
|
|
|
/// Queue a player-bridge mutation onto the Qt event loop (no-op before
|
|
/// boot registers the thread).
|
|
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPlayer>) + Send + 'static) {
|
|
if let Some(thread) = QT_THREAD.get() {
|
|
let _ = thread.queue(f);
|
|
}
|
|
}
|
|
|
|
impl qbz_player::QbzPlayer {
|
|
pub fn boot(self: Pin<&mut Self>) {
|
|
if QT_THREAD.set(self.qt_thread()).is_err() {
|
|
log::warn!("[qbz-qt] player Qt thread already registered");
|
|
}
|
|
}
|
|
|
|
pub fn toggle_play(self: Pin<&mut Self>) {
|
|
crate::transport_toggle_play();
|
|
}
|
|
|
|
pub fn next(self: Pin<&mut Self>) {
|
|
crate::transport_next();
|
|
}
|
|
|
|
pub fn previous(self: Pin<&mut Self>) {
|
|
crate::transport_previous();
|
|
}
|
|
|
|
pub fn seek(self: Pin<&mut Self>, frac: f32) {
|
|
crate::transport_seek(frac);
|
|
}
|
|
|
|
pub fn persist_volume(self: Pin<&mut Self>, fraction: f32) {
|
|
crate::settings_qt::save_pref("volume", serde_json::json!(fraction.clamp(0.0, 1.0)));
|
|
}
|
|
|
|
pub fn set_volume(self: Pin<&mut Self>, volume: f32) {
|
|
crate::transport_set_volume(volume);
|
|
}
|
|
|
|
pub fn toggle_mute(self: Pin<&mut Self>) {
|
|
crate::transport_toggle_mute();
|
|
}
|
|
|
|
pub fn toggle_shuffle(self: Pin<&mut Self>) {
|
|
crate::transport_toggle_shuffle();
|
|
}
|
|
|
|
pub fn cycle_repeat(self: Pin<&mut Self>) {
|
|
crate::transport_cycle_repeat();
|
|
}
|
|
|
|
pub fn play_album(self: Pin<&mut Self>, album_id: QString) {
|
|
crate::play_album(album_id.to_string());
|
|
}
|
|
|
|
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
|
|
crate::play_album_shuffled(album_id.to_string());
|
|
}
|
|
|
|
pub fn play_album_from(self: Pin<&mut Self>, album_id: QString, track_id: QString) {
|
|
if let Ok(tid) = track_id.to_string().parse::<u64>() {
|
|
crate::play_album_from_track(album_id.to_string(), tid);
|
|
}
|
|
}
|
|
|
|
pub fn enqueue_album_track(self: Pin<&mut Self>, album_id: QString, track_id: QString, mode: QString) {
|
|
if let Ok(tid) = track_id.to_string().parse::<u64>() {
|
|
crate::enqueue_album_track(album_id.to_string(), tid, mode.to_string());
|
|
}
|
|
}
|
|
|
|
pub fn enqueue_album(self: Pin<&mut Self>, album_id: QString, mode: QString) {
|
|
crate::enqueue_album(album_id.to_string(), mode.to_string());
|
|
}
|
|
|
|
pub fn play_artist_track(self: Pin<&mut Self>, track_id: QString) {
|
|
if let Ok(tid) = track_id.to_string().parse::<u64>() {
|
|
crate::play_artist_track(tid);
|
|
}
|
|
}
|
|
|
|
pub fn play_artist_top(self: Pin<&mut Self>, shuffle: bool) {
|
|
crate::play_artist_top(shuffle);
|
|
}
|
|
|
|
pub fn enqueue_artist_top(self: Pin<&mut Self>) {
|
|
crate::enqueue_artist_top();
|
|
}
|
|
|
|
pub fn play_artist_card(self: Pin<&mut Self>, artist_id: QString) {
|
|
crate::play_artist_card(artist_id.to_string());
|
|
}
|
|
|
|
pub fn play_track(self: Pin<&mut Self>, track_id: QString) {
|
|
if let Ok(id) = track_id.to_string().parse::<u64>() {
|
|
crate::play_track(id);
|
|
}
|
|
}
|
|
|
|
pub fn enqueue_track(self: Pin<&mut Self>, track_id: QString, mode: QString) {
|
|
if let Ok(id) = track_id.to_string().parse::<u64>() {
|
|
crate::enqueue_track(id, mode.to_string());
|
|
}
|
|
}
|
|
|
|
pub fn play_playlist_by_id(self: Pin<&mut Self>, playlist_id: QString) {
|
|
if let Ok(pid) = playlist_id.to_string().parse::<u64>() {
|
|
crate::play_playlist_by_id(pid);
|
|
}
|
|
}
|
|
|
|
pub fn enqueue_playlist_by_id(self: Pin<&mut Self>, playlist_id: QString, mode: QString) {
|
|
if let Ok(pid) = playlist_id.to_string().parse::<u64>() {
|
|
crate::enqueue_playlist_by_id(pid, mode.to_string());
|
|
}
|
|
}
|
|
}
|