feat(qbz-qt): phase 4 — live playback, queue, and transport via QbzCore
Album-card click plays the album: get_album -> QueueTrack vec -> set_queue -> play_track_resolved (tier walk stays in the core). 1 Hz poll publishes position/meta/quality into the NowPlaying model; end-of- track edge auto-advances with meta refresh (traced live: 24-bit/48kHz CMAF stream, position advancing, track 2/3 transitions). Transport invokables rewired to real core calls; queue panel shows NOW PLAYING + numbered UP NEXT from get_queue_state. Public QbzCore API proved sufficient — protected audio/player crates untouched. Named gaps: no mute API, no playback event stream, auto-advance/prefetch semantics remain frontend glue (~150 lines every UI must port). km
This commit is contained in:
7 files changed
+890
-85
No files matched your search
@@ -162,7 +162,9 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: the album page is out of scope — inert.
|
||||
// Phase 4: a card click plays the album (the album page
|
||||
// itself is out of scope — POC-NOTE).
|
||||
onClicked: QbzBridge.playAlbum(card.id)
|
||||
}
|
||||
// Hover action buttons — favorite / play / more (INERT;
|
||||
// POC-NOTE: playback + favorites land in later phases).
|
||||
@@ -208,7 +210,13 @@ Rectangle {
|
||||
anchors.centerIn: parent
|
||||
tintName: "black"
|
||||
}
|
||||
MouseArea { id: playOvArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor }
|
||||
MouseArea {
|
||||
id: playOvArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.playAlbum(card.id)
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 36
|
||||
@@ -437,7 +445,8 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: album page out of scope — inert.
|
||||
// Phase 4: slim rows are albums — click plays (page out of scope).
|
||||
onClicked: QbzBridge.playAlbum(card.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// Right-side Queue panel — QML port of
|
||||
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
|
||||
//
|
||||
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card
|
||||
// area, the UP NEXT list region, and the EMPTY states (the queue is empty
|
||||
// until phase 4 — QbzBridge.queueModel is an empty QVariantList).
|
||||
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card,
|
||||
// the UP NEXT list, and the empty states. Phase 4: the Queue tab renders
|
||||
// the REAL queue (QbzBridge.queueModel — one JSON-encoded row per QVariant,
|
||||
// see the nesting POC-NOTE in playback_qt.rs `publish_queue`).
|
||||
//
|
||||
// POC-NOTE: the footer (count line + clear / save-as-playlist / infinite /
|
||||
// sleep-timer actions + inline queue search) is omitted — in the Slint
|
||||
// panel it only mounts when the queue is non-empty, which is never until
|
||||
// phase 4. Drag-reorder, pagination, the now-playing favorite toggle and
|
||||
// the sleep-timer popover are likewise phase-4 scope.
|
||||
// POC-NOTEs:
|
||||
// - The footer (count line + clear / save-as-playlist / infinite /
|
||||
// sleep-timer actions + inline queue search) is omitted — phase 4 brings
|
||||
// no queue mutations beyond play/next/previous.
|
||||
// - The History tab stays empty (no local play-history store in the POC).
|
||||
// - Row context menus, drag-reorder, pagination and the now-playing
|
||||
// favorite toggle are out of scope.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -21,7 +24,17 @@ Rectangle {
|
||||
|
||||
// Queue (0) / History (1) — Slint QueueState.tab.
|
||||
property int tab: 0
|
||||
readonly property bool queueEmpty: QbzBridge.queueModel.length === 0
|
||||
// Parsed queue rows: [{id, title, artist, duration, artPath, current}].
|
||||
readonly property var rows: {
|
||||
var out = []
|
||||
for (var i = 0; i < QbzBridge.queueModel.length; i++) {
|
||||
try { out.push(JSON.parse(QbzBridge.queueModel[i])) } catch (e) {}
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property var currentRow: rows.length > 0 && rows[0].current ? rows[0] : null
|
||||
readonly property var upcomingRows: currentRow ? rows.slice(1) : rows
|
||||
readonly property bool queueEmpty: rows.length === 0
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -47,6 +60,87 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// One queue row (QueueRow.slint): index or thumbnail, title, artist,
|
||||
// duration.
|
||||
component QueueRow: Rectangle {
|
||||
property var row: ({})
|
||||
property int rowIndex: 0
|
||||
property bool showNumber: true
|
||||
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: qrArea.containsMouse ? theme.surfaceHover
|
||||
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: theme.spacingSm
|
||||
anchors.rightMargin: theme.spacingXs
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
visible: showNumber
|
||||
width: 22
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: rowIndex + 1
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Rectangle {
|
||||
visible: !showNumber
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: row.artPath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - (showNumber ? 22 : 34) - 9 * 2 - durationText.implicitWidth
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.artist
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: durationText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.duration
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: qrArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: click-to-play / context menu out of scope.
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
@@ -121,58 +215,128 @@ Rectangle {
|
||||
height: parent.height - 45
|
||||
|
||||
// ===== Queue tab =====
|
||||
Column {
|
||||
Flickable {
|
||||
visible: root.tab === 0
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 12
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: queueBody.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// NOW PLAYING section — mounts only with a current track.
|
||||
Text {
|
||||
visible: !root.queueEmpty
|
||||
text: QbzBridge.tr("NOW PLAYING")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
// UP NEXT section — mounts only with upcoming tracks.
|
||||
Text {
|
||||
visible: !root.queueEmpty
|
||||
text: QbzBridge.tr("UP NEXT")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Repeater {
|
||||
model: QbzBridge.queueModel
|
||||
// Phase 4 renders real rows (44px QueueRow); unreachable
|
||||
// while the model is empty.
|
||||
delegate: Item {}
|
||||
}
|
||||
|
||||
// Empty state — no current track and no upcoming.
|
||||
Column {
|
||||
visible: root.queueEmpty
|
||||
id: queueBody
|
||||
width: parent.width
|
||||
topPadding: 48 - 10
|
||||
spacing: 6
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("Your queue is empty")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
padding: 10
|
||||
spacing: 12
|
||||
|
||||
// NOW PLAYING section.
|
||||
Column {
|
||||
visible: root.currentRow !== null
|
||||
width: parent.width - 20
|
||||
spacing: 10
|
||||
Text {
|
||||
text: QbzBridge.tr("NOW PLAYING")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
// Highlighted current-track card.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 9
|
||||
Rectangle {
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceCard
|
||||
clip: true
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.currentRow ? root.currentRow.artPath : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 34 - 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.currentRow ? root.currentRow.title : ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.currentRow ? root.currentRow.artist : ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("Play an album or track to get started")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
// UP NEXT section.
|
||||
Column {
|
||||
visible: root.upcomingRows.length > 0
|
||||
width: parent.width - 20
|
||||
spacing: 8
|
||||
Text {
|
||||
text: QbzBridge.tr("UP NEXT")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Repeater {
|
||||
model: root.upcomingRows
|
||||
delegate: QueueRow {
|
||||
width: parent ? parent.width : 0
|
||||
row: modelData
|
||||
rowIndex: index
|
||||
showNumber: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state — no current track and no upcoming.
|
||||
Column {
|
||||
visible: root.queueEmpty
|
||||
width: parent.width - 20
|
||||
topPadding: 48 - 10
|
||||
spacing: 6
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("Your queue is empty")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("Play an album or track to get started")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,7 +355,8 @@ Rectangle {
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
// POC-NOTE: history is always empty until phase 4.
|
||||
// POC-NOTE: history is always empty until the local
|
||||
// play-history store is wired.
|
||||
Text {
|
||||
width: parent.width
|
||||
topPadding: 48 - 10
|
||||
|
||||
@@ -109,3 +109,42 @@ pub async fn download_missing(urls: Vec<String>) {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Now-playing artwork (phase 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `file://<cached path>` when the url is already on disk ("" otherwise) —
|
||||
/// synchronous, no download. Used for queue rows.
|
||||
pub fn cached_path(url: &str) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
cache()
|
||||
.and_then(|c| c.lock().unwrap().get(url))
|
||||
.map(|p| format!("file://{}", p.display()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Attach the current track's artwork to the NowPlayingModel: disk hit
|
||||
/// applies immediately; a miss downloads in the background and then
|
||||
/// republishes (mirrors `attach_cached` + `download_missing` for one url).
|
||||
pub fn attach_now_playing(url: &str) {
|
||||
if url.is_empty() {
|
||||
crate::now_playing::set_artwork_path(String::new());
|
||||
return;
|
||||
}
|
||||
let hit = cached_path(url);
|
||||
if !hit.is_empty() {
|
||||
crate::now_playing::set_artwork_path(hit);
|
||||
return;
|
||||
}
|
||||
let url = url.to_string();
|
||||
crate::spawn(async move {
|
||||
download_missing(vec![url.clone()]).await;
|
||||
let path = cached_path(&url);
|
||||
if !path.is_empty() {
|
||||
crate::now_playing::set_artwork_path(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -178,6 +178,11 @@ pub mod qbz_bridge {
|
||||
/// index + rails and republish `homeSectionsJson`.
|
||||
#[qinvokable]
|
||||
fn reload_home(self: Pin<&mut QbzBridge>);
|
||||
|
||||
/// 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 QbzBridge>, album_id: QString);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzBridge {}
|
||||
@@ -340,38 +345,42 @@ impl qbz_bridge::QbzBridge {
|
||||
}
|
||||
|
||||
pub fn toggle_play(self: Pin<&mut Self>) {
|
||||
crate::now_playing::toggle_play();
|
||||
crate::transport_toggle_play();
|
||||
}
|
||||
|
||||
pub fn next(self: Pin<&mut Self>) {
|
||||
crate::now_playing::next();
|
||||
crate::transport_next();
|
||||
}
|
||||
|
||||
pub fn previous(self: Pin<&mut Self>) {
|
||||
crate::now_playing::previous();
|
||||
crate::transport_previous();
|
||||
}
|
||||
|
||||
pub fn seek(self: Pin<&mut Self>, frac: f32) {
|
||||
crate::now_playing::seek(frac);
|
||||
crate::transport_seek(frac);
|
||||
}
|
||||
|
||||
pub fn set_volume(self: Pin<&mut Self>, volume: f32) {
|
||||
crate::now_playing::set_volume(volume);
|
||||
crate::transport_set_volume(volume);
|
||||
}
|
||||
|
||||
pub fn toggle_mute(self: Pin<&mut Self>) {
|
||||
crate::now_playing::toggle_mute();
|
||||
crate::transport_toggle_mute();
|
||||
}
|
||||
|
||||
pub fn toggle_shuffle(self: Pin<&mut Self>) {
|
||||
crate::now_playing::toggle_shuffle();
|
||||
crate::transport_toggle_shuffle();
|
||||
}
|
||||
|
||||
pub fn cycle_repeat(self: Pin<&mut Self>) {
|
||||
crate::now_playing::cycle_repeat();
|
||||
crate::transport_cycle_repeat();
|
||||
}
|
||||
|
||||
pub fn reload_home(self: Pin<&mut Self>) {
|
||||
crate::reload_home();
|
||||
}
|
||||
|
||||
pub fn play_album(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::play_album(album_id.to_string());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod home_qt;
|
||||
mod nav_qt;
|
||||
mod now_playing;
|
||||
mod offline_fwd;
|
||||
mod playback_qt;
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -118,6 +119,8 @@ fn enter_shell(session: auth_qt::SessionInfo) {
|
||||
// engine gates Qobuz calls anyway, and the view shows the offline
|
||||
// placeholder instead).
|
||||
load_home_once();
|
||||
// Phase 4: the 1 Hz playback state pump (idempotent).
|
||||
playback_qt::start_poll_loop(app());
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
|
||||
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
|
||||
@@ -246,6 +249,60 @@ fn load_home_once() {
|
||||
reload_home();
|
||||
}
|
||||
|
||||
// ============================ Playback (phase 4) ===========================
|
||||
|
||||
/// Album-card click on Home: resolve + enqueue + play through the core.
|
||||
pub(crate) fn play_album(album_id: String) {
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
if let Err(e) = playback_qt::play_album(&runtime, &album_id).await {
|
||||
log::error!("[qbz-qt] play_album failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn transport_toggle_play() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::toggle_play(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_next() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::next(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_previous() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::previous(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_seek(frac: f32) {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::seek_frac(&runtime, frac).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_set_volume(volume: f32) {
|
||||
// Local model first (instant UI), then the engine.
|
||||
now_playing::set_volume(volume);
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::set_volume(&runtime, volume).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_toggle_mute() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::toggle_mute(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_toggle_shuffle() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::toggle_shuffle(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn transport_cycle_repeat() {
|
||||
let runtime = app();
|
||||
spawn(async move { playback_qt::cycle_repeat(&runtime).await });
|
||||
}
|
||||
|
||||
/// `reloadHome()` invokable / auto-load worker: fetch + publish + artwork.
|
||||
pub(crate) fn reload_home() {
|
||||
if offline_fwd::engine().status().is_offline() {
|
||||
@@ -264,6 +321,7 @@ pub(crate) fn reload_home() {
|
||||
// artwork_qt.rs — per-row model updates are the follow-up).
|
||||
let missing = artwork_qt::attach_cached(&mut sections);
|
||||
let count: usize = sections.iter().map(|s| s.items.len()).sum();
|
||||
|
||||
publish_home_sections(§ions);
|
||||
log::info!(
|
||||
"[qbz-qt] home published: {} sections, {} cards, {} artwork misses",
|
||||
@@ -280,6 +338,7 @@ pub(crate) fn reload_home() {
|
||||
log::info!("[qbz-qt] home republished after artwork downloads");
|
||||
});
|
||||
}
|
||||
|
||||
ui(|mut b| b.as_mut().set_home_loading(false));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -300,6 +359,21 @@ fn publish_home_sections(sections: &[home_qt::HomeSection]) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the bridge's `queueModel` QVariantList from per-row JSON strings
|
||||
/// (each QVariant carries one QString — see the nesting POC-NOTE in
|
||||
/// playback_qt.rs `publish_queue`).
|
||||
pub(crate) fn json_rows_to_qvariant_list(
|
||||
rows: Vec<String>,
|
||||
) -> cxx_qt_lib::QList<cxx_qt_lib::QVariant> {
|
||||
let mut list = cxx_qt_lib::QList::<cxx_qt_lib::QVariant>::default();
|
||||
for row in rows {
|
||||
list.append(<QString as cxx_qt_lib::QVariantValue>::construct(&QString::from(
|
||||
row.as_str(),
|
||||
)));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn main() {
|
||||
qbz_log::install("info");
|
||||
qbz_i18n::set_language(qbz_i18n::resolve_auto());
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NowPlayingModel {
|
||||
pub duration_secs: i32,
|
||||
pub playing: bool,
|
||||
pub loading: bool,
|
||||
/// Streaming buffer fill 0..1 (seekbar cache overlay); 0 = not streaming.
|
||||
pub cache: f32,
|
||||
pub volume: f32,
|
||||
pub muted: bool,
|
||||
pub shuffle: bool,
|
||||
@@ -43,6 +45,7 @@ impl Default for NowPlayingModel {
|
||||
duration_secs: 0,
|
||||
playing: false,
|
||||
loading: false,
|
||||
cache: 0.0,
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
shuffle: false,
|
||||
@@ -62,6 +65,7 @@ static MODEL: Mutex<NowPlayingModel> = Mutex::new(NowPlayingModel {
|
||||
duration_secs: 0,
|
||||
playing: false,
|
||||
loading: false,
|
||||
cache: 0.0,
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
shuffle: false,
|
||||
@@ -127,34 +131,132 @@ pub fn set_volume(volume: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_mute() {
|
||||
mutate(|m| m.muted = !m.muted);
|
||||
pub fn set_muted(muted: bool) {
|
||||
mutate(|m| m.muted = muted);
|
||||
}
|
||||
|
||||
pub fn toggle_shuffle() {
|
||||
mutate(|m| m.shuffle = !m.shuffle);
|
||||
pub fn set_shuffle(shuffle: bool) {
|
||||
mutate(|m| m.shuffle = shuffle);
|
||||
}
|
||||
|
||||
pub fn cycle_repeat() {
|
||||
mutate(|m| m.repeat_mode = (m.repeat_mode + 1) % 3);
|
||||
pub fn set_repeat_mode(mode: i32) {
|
||||
mutate(|m| m.repeat_mode = mode);
|
||||
}
|
||||
|
||||
// --- Transport (log-and-noop until phase 4) ------------------------------
|
||||
|
||||
pub fn toggle_play() {
|
||||
// POC-NOTE: no player wired — phase 4 replaces this with the core
|
||||
// playback command through AppRuntime.
|
||||
log::info!("[qbz-qt] transport toggle-play (no-op until phase 4)");
|
||||
pub fn repeat_mode() -> i32 {
|
||||
MODEL.lock().unwrap().repeat_mode
|
||||
}
|
||||
|
||||
pub fn next() {
|
||||
log::info!("[qbz-qt] transport next (no-op until phase 4)");
|
||||
pub fn set_playing(playing: bool) {
|
||||
mutate(|m| m.playing = playing);
|
||||
}
|
||||
|
||||
pub fn previous() {
|
||||
log::info!("[qbz-qt] transport previous (no-op until phase 4)");
|
||||
// --- Track meta + position (phase 4: fed by the poll pump) ---------------
|
||||
|
||||
/// Current-track metadata as published by the playback controller.
|
||||
pub struct TrackMeta {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub duration_secs: i32,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub artwork_url: String,
|
||||
pub shuffle: bool,
|
||||
pub repeat_mode: i32,
|
||||
}
|
||||
|
||||
pub fn seek(frac: f32) {
|
||||
log::info!("[qbz-qt] transport seek({frac:.3}) (no-op until phase 4)");
|
||||
/// A new current track: full meta swap (art path attaches separately via
|
||||
/// the artwork pipeline — see `artwork_qt::attach_now_playing`).
|
||||
pub fn set_track(meta: TrackMeta) {
|
||||
mutate(|m| {
|
||||
m.has_track = true;
|
||||
m.title = meta.title;
|
||||
m.artist = meta.artist;
|
||||
m.duration_secs = meta.duration_secs;
|
||||
m.elapsed_secs = 0;
|
||||
m.quality_tier = meta.quality_tier;
|
||||
m.quality_label = meta.quality_label;
|
||||
m.artwork_path = String::new();
|
||||
m.loading = true;
|
||||
m.playing = true;
|
||||
m.shuffle = meta.shuffle;
|
||||
m.repeat_mode = meta.repeat_mode;
|
||||
});
|
||||
// Stash the url out-of-band so the artwork pipeline can resolve it
|
||||
// without a second full publish.
|
||||
*ARTWORK_URL.lock().unwrap() = meta.artwork_url;
|
||||
}
|
||||
|
||||
/// No current track (queue cleared / finished): back to the idle bar.
|
||||
pub fn clear_track() {
|
||||
mutate(|m| {
|
||||
*m = NowPlayingModel {
|
||||
volume: m.volume,
|
||||
muted: m.muted,
|
||||
..NowPlayingModel::default()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// The artwork url of the current track, stashed for the artwork pipeline.
|
||||
static ARTWORK_URL: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// Attach a resolved artwork path to the current track (artwork pipeline).
|
||||
pub fn set_artwork_path(path: String) {
|
||||
mutate(|m| m.artwork_path = path);
|
||||
}
|
||||
|
||||
/// 1 Hz position push from the poll pump. `has_audio` = the engine
|
||||
/// surfaced a track id (clears the loading spinner).
|
||||
pub fn set_position(elapsed_secs: i32, duration_secs: i32, playing: bool, cache: f32, has_audio: bool) {
|
||||
let mut guard = MODEL.lock().unwrap();
|
||||
{
|
||||
let m = &mut *guard;
|
||||
m.elapsed_secs = elapsed_secs;
|
||||
if duration_secs > 0 {
|
||||
m.duration_secs = duration_secs;
|
||||
}
|
||||
m.playing = playing;
|
||||
if has_audio {
|
||||
m.loading = false;
|
||||
}
|
||||
// progress is derived in publish() from elapsed/duration; cache is
|
||||
// stored raw for the seekbar's buffer overlay.
|
||||
m.cache = cache;
|
||||
}
|
||||
let m = guard.clone();
|
||||
drop(guard);
|
||||
publish_with_cache(&m);
|
||||
}
|
||||
|
||||
/// Same as `publish` but honors the poll-fed cache value instead of
|
||||
/// mirroring progress.
|
||||
fn publish_with_cache(m: &NowPlayingModel) {
|
||||
let m = m.clone();
|
||||
crate::ui(move |mut b| {
|
||||
let progress = if m.duration_secs > 0 {
|
||||
(m.elapsed_secs as f32 / m.duration_secs as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
b.as_mut().set_np_has_track(m.has_track);
|
||||
b.as_mut().set_np_title(QString::from(m.title.as_str()));
|
||||
b.as_mut().set_np_artist(QString::from(m.artist.as_str()));
|
||||
b.as_mut()
|
||||
.set_np_artwork_path(QString::from(m.artwork_path.as_str()));
|
||||
b.as_mut().set_np_elapsed_secs(m.elapsed_secs);
|
||||
b.as_mut().set_np_duration_secs(m.duration_secs);
|
||||
b.as_mut().set_np_progress(progress);
|
||||
b.as_mut().set_np_cache_progress(m.cache);
|
||||
b.as_mut().set_np_playing(m.playing);
|
||||
b.as_mut().set_np_loading(m.loading);
|
||||
b.as_mut().set_np_volume(m.volume);
|
||||
b.as_mut().set_np_muted(m.muted);
|
||||
b.as_mut().set_np_shuffle(m.shuffle);
|
||||
b.as_mut().set_np_repeat_mode(m.repeat_mode);
|
||||
b.as_mut()
|
||||
.set_np_quality_tier(QString::from(m.quality_tier.as_str()));
|
||||
b.as_mut()
|
||||
.set_np_quality_label(QString::from(m.quality_label.as_str()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Minimal playback controller — drives `QbzCore`'s EXISTING public API
|
||||
//! only (qbz-audio / qbz-player are protected; no audio logic is
|
||||
//! reimplemented here). Ports the *shape* of `crates/qbz/src/playback.rs`:
|
||||
//! play_album (fetch -> QueueTrack vec -> set_queue -> play_track_resolved)
|
||||
//! and the poll loop (PlaybackEvent -> UI state, track-change meta refresh,
|
||||
//! end-of-track advance).
|
||||
//!
|
||||
//! POC-NOTEs (deliberate cuts vs playback.rs, named for the effort report):
|
||||
//! - Streaming quality: the ui_prefs DEFAULT ("hires_plus" ->
|
||||
//! `Quality::UltraHiRes`). The ui_prefs store and the #638 device-cap
|
||||
//! clamp live in the Slint glue; there is no prefs UI in the POC.
|
||||
//! - Blacklist filtering of album tracks: skipped (store not open).
|
||||
//! - Offline-cache tier (offline bytes), gapless prefetch (`play_next`),
|
||||
//! prefetch warming, stop-after, infinite refill, session persist,
|
||||
//! QConnect/cast branches, recently-played recording: all out of scope.
|
||||
//! Auto-advance therefore plays each successor at fetch time (audible
|
||||
//! gap between tracks) — the engine's gapless path is NOT wired.
|
||||
//! - `advance_to_playable` (skip-unavailable walk): the POC advance takes
|
||||
//! the core queue's next directly; a failed play logs and stops.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_models::{Quality, QueueTrack, RepeatMode};
|
||||
|
||||
/// The request tier for every play: the ui_prefs default ("hires_plus").
|
||||
/// See module docs (no prefs UI in the POC).
|
||||
const POC_QUALITY: Quality = Quality::UltraHiRes;
|
||||
|
||||
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
|
||||
// dedicated mute API on the core — mute is volume 0 with a stash).
|
||||
static MUTED: AtomicBool = AtomicBool::new(false);
|
||||
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Play an album (album-card click on Home)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch the album, build the queue (playback.rs `make_queue_track` port),
|
||||
/// set it starting at track 1, and play through the core's resolved path.
|
||||
pub async fn play_album(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
album_id: &str,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[qbz-qt] play_album: resolving {album_id}");
|
||||
let album = runtime
|
||||
.core()
|
||||
.get_album(album_id)
|
||||
.await
|
||||
.map_err(|e| format!("get_album {album_id} failed: {e}"))?;
|
||||
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
let raw_tracks = album
|
||||
.tracks
|
||||
.as_ref()
|
||||
.map(|container| container.items.as_slice())
|
||||
.unwrap_or_default();
|
||||
if raw_tracks.is_empty() {
|
||||
return Err(format!("album {album_id} has no playable tracks"));
|
||||
}
|
||||
let tracks: Vec<QueueTrack> = raw_tracks
|
||||
.iter()
|
||||
.map(|track| QueueTrack {
|
||||
id: track.id,
|
||||
title: track.title.clone(),
|
||||
version: track.version.clone(),
|
||||
artist: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| album_artist.clone()),
|
||||
album: album_title.clone(),
|
||||
album_version: album
|
||||
.version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string),
|
||||
duration_secs: track.duration as u64,
|
||||
artwork_url: if album_artwork.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(album_artwork.clone())
|
||||
},
|
||||
hires: track.hires,
|
||||
bit_depth: track.maximum_bit_depth,
|
||||
sample_rate: track.maximum_sampling_rate,
|
||||
is_local: false,
|
||||
album_id: Some(album.id.clone()),
|
||||
artist_id: track.performer.as_ref().map(|p| p.id),
|
||||
streamable: track.streamable,
|
||||
source: Some("qobuz".to_string()),
|
||||
parental_warning: track.parental_warning,
|
||||
source_item_id_hint: Some(album.id.clone()),
|
||||
context_kind: Some("album".to_string()),
|
||||
context_id: Some(album.id.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let first_id = tracks[0].id;
|
||||
let count = tracks.len();
|
||||
runtime.core().set_queue(tracks, Some(0)).await;
|
||||
publish_queue(runtime).await;
|
||||
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
|
||||
.await
|
||||
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
|
||||
log::info!("[qbz-qt] play_album: play_track_resolved started for {first_id}");
|
||||
refresh_now_playing(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
let result = if event.is_playing {
|
||||
runtime.core().pause()
|
||||
} else {
|
||||
runtime.core().resume()
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::warn!("[qbz-qt] toggle-play failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
play_queue_track(runtime, track.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if let Some(track) = runtime.core().previous_track().await {
|
||||
play_queue_track(runtime, track.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.play_track_resolved(track_id, POC_QUALITY, None, None, 0)
|
||||
.await
|
||||
{
|
||||
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
refresh_now_playing(runtime).await;
|
||||
publish_queue(runtime).await;
|
||||
}
|
||||
|
||||
pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
if event.duration == 0 {
|
||||
return;
|
||||
}
|
||||
let target = (frac.clamp(0.0, 1.0) * event.duration as f32) as u64;
|
||||
if let Err(e) = runtime.core().seek(target) {
|
||||
log::warn!("[qbz-qt] seek failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32) {
|
||||
let _ = runtime.core().set_volume(volume.clamp(0.0, 1.0));
|
||||
MUTED.store(volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if MUTED.swap(false, Ordering::Relaxed) {
|
||||
// Unmute — restore the stored level.
|
||||
let restored = f32::from_bits(PREMUTE_VOLUME.load(Ordering::Relaxed) as u32);
|
||||
let restored = if restored > 0.0 { restored } else { 0.7 };
|
||||
let _ = runtime.core().set_volume(restored);
|
||||
crate::now_playing::set_muted(false);
|
||||
} else {
|
||||
// Mute — stash the current level, then drop to zero.
|
||||
let current = runtime.core().player().get_playback_event().volume;
|
||||
let current = if current > 0.0 { current } else { 0.7 };
|
||||
PREMUTE_VOLUME.store(current.to_bits() as u64, Ordering::Relaxed);
|
||||
MUTED.store(true, Ordering::Relaxed);
|
||||
let _ = runtime.core().set_volume(0.0);
|
||||
crate::now_playing::set_muted(true);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn toggle_shuffle(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let enabled = runtime.core().toggle_shuffle().await;
|
||||
crate::now_playing::set_shuffle(enabled);
|
||||
publish_queue(runtime).await;
|
||||
}
|
||||
|
||||
pub async fn cycle_repeat(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let next_mode = match crate::now_playing::repeat_mode() {
|
||||
0 => RepeatMode::All,
|
||||
1 => RepeatMode::One,
|
||||
_ => RepeatMode::Off,
|
||||
};
|
||||
runtime.core().set_repeat_mode(next_mode).await;
|
||||
let value = match next_mode {
|
||||
RepeatMode::Off => 0,
|
||||
RepeatMode::All => 1,
|
||||
RepeatMode::One => 2,
|
||||
};
|
||||
crate::now_playing::set_repeat_mode(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta + queue publishing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Push the queue's current-track meta into the NowPlayingModel (title /
|
||||
/// artist / quality / artwork via the phase-3 pipeline).
|
||||
pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
let Some(track) = state.current_track else {
|
||||
crate::now_playing::clear_track();
|
||||
return;
|
||||
};
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
log::info!(
|
||||
"[qbz-qt] now playing: '{title}' — {} ({}s)",
|
||||
track.artist,
|
||||
track.duration_secs,
|
||||
);
|
||||
let (tier, label) = quality_badge(&track);
|
||||
crate::now_playing::set_track(crate::now_playing::TrackMeta {
|
||||
title,
|
||||
artist: track.artist.clone(),
|
||||
duration_secs: track.duration_secs as i32,
|
||||
quality_tier: tier,
|
||||
quality_label: label,
|
||||
artwork_url: track.artwork_url.clone().unwrap_or_default(),
|
||||
shuffle: state.shuffle,
|
||||
repeat_mode: match state.repeat {
|
||||
RepeatMode::Off => 0,
|
||||
RepeatMode::All => 1,
|
||||
RepeatMode::One => 2,
|
||||
},
|
||||
});
|
||||
// Artwork through the same cache pipeline as Home (attach + background
|
||||
// download + republish — single url here).
|
||||
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Quality tier + exact label from a queue track's bit depth / sample rate
|
||||
/// (kHz float, catalog), matching the discover card badge format.
|
||||
fn quality_badge(track: &QueueTrack) -> (String, String) {
|
||||
let tier = match track.bit_depth {
|
||||
Some(d) if d >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if track.hires => "hires",
|
||||
None => "",
|
||||
}
|
||||
.to_string();
|
||||
let label = match (track.bit_depth, track.sample_rate) {
|
||||
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, crate::home_qt::format_rate(sr)),
|
||||
_ => String::new(),
|
||||
};
|
||||
(tier, label)
|
||||
}
|
||||
|
||||
/// Publish the real queue into `queueModel` (now-playing row first, then
|
||||
/// up-next rows) as one QVariant per row.
|
||||
///
|
||||
/// POC-NOTE: rows are JSON-encoded strings inside the QVariants — the same
|
||||
/// cxx-qt-lib nesting limitation as homeSectionsJson (a QVariant cannot
|
||||
/// hold a QVariantMap in 0.7.3); QML parses each row.
|
||||
pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
#[derive(serde::Serialize)]
|
||||
struct QueueRow {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
duration: String,
|
||||
#[serde(rename = "artPath")]
|
||||
art_path: String,
|
||||
current: bool,
|
||||
}
|
||||
let mut rows: Vec<QueueRow> = Vec::new();
|
||||
let mut push = |track: &QueueTrack, current: bool| {
|
||||
rows.push(QueueRow {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
artist: track.artist.clone(),
|
||||
duration: fmt_mmss(track.duration_secs),
|
||||
art_path: crate::artwork_qt::cached_path(
|
||||
&track.artwork_url.clone().unwrap_or_default(),
|
||||
),
|
||||
current,
|
||||
});
|
||||
};
|
||||
if let Some(current) = state.current_track.as_ref() {
|
||||
push(current, true);
|
||||
}
|
||||
for track in &state.upcoming {
|
||||
push(track, false);
|
||||
}
|
||||
let json_rows: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| serde_json::to_string(r).unwrap_or_default())
|
||||
.collect();
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_queue_model(crate::json_rows_to_qvariant_list(json_rows));
|
||||
});
|
||||
}
|
||||
|
||||
fn fmt_mmss(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1 Hz state pump (playback.rs `start_poll_loop`, minimized)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POLL_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Start the 1 Hz pump on shell entry (idempotent). Publishes position /
|
||||
/// playing / cache into the NowPlayingModel, detects track changes (meta +
|
||||
/// queue refresh), and advances the queue at end-of-track through the core.
|
||||
pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
if POLL_STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let mut last_track_id: u64 = 0;
|
||||
let mut was_playing = false;
|
||||
let mut seen_position: u64 = 0;
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
// Surface audio-stream failures once (playback.rs drains the same
|
||||
// slot for its toast).
|
||||
if let Some(msg) = runtime.core().player().state.take_stream_error_message() {
|
||||
log::error!("[qbz-qt] audio output error: {msg}");
|
||||
}
|
||||
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
let track_id = event.track_id;
|
||||
let position = event.position;
|
||||
let duration = event.duration;
|
||||
let is_playing = event.is_playing;
|
||||
let cache = event.buffer_progress.unwrap_or(0.0);
|
||||
|
||||
// --- Track-change edge (new current track surfaced) ----------
|
||||
if track_id != 0 && track_id != last_track_id {
|
||||
// Reconcile the queue pointer (a real advance moves it; a
|
||||
// manual play already did) and refresh meta + queue.
|
||||
let _ = runtime.core().sync_current_to_id(track_id).await;
|
||||
refresh_now_playing(&runtime).await;
|
||||
publish_queue(&runtime).await;
|
||||
last_track_id = track_id;
|
||||
}
|
||||
|
||||
if track_id != 0 {
|
||||
log::debug!("[qbz-qt] poll: id={track_id} pos={position}/{duration} playing={is_playing} cache={cache:.2}");
|
||||
}
|
||||
|
||||
// --- Position / state push ------------------------------------
|
||||
crate::now_playing::set_position(
|
||||
position as i32,
|
||||
duration as i32,
|
||||
is_playing,
|
||||
cache,
|
||||
track_id != 0,
|
||||
);
|
||||
|
||||
// --- End-of-track edge (playback.rs condition, verbatim) ------
|
||||
let track_ended = was_playing
|
||||
&& !is_playing
|
||||
&& last_track_id != 0
|
||||
&& (track_id == 0 || track_id == last_track_id)
|
||||
&& duration > 0
|
||||
&& seen_position + 2 >= duration;
|
||||
if track_ended {
|
||||
last_track_id = 0;
|
||||
// POC-NOTE: no stop-after / infinite-refill / skip-unavailable
|
||||
// walk — the core queue's own next (repeat/shuffle aware).
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
let next_id = track.id;
|
||||
log::info!("[qbz-qt] poll: advancing to {next_id}");
|
||||
play_queue_track(&runtime, next_id).await;
|
||||
} else {
|
||||
log::info!("[qbz-qt] poll: queue finished");
|
||||
crate::now_playing::set_playing(false);
|
||||
}
|
||||
}
|
||||
|
||||
seen_position = position;
|
||||
was_playing = is_playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user