From 9174b33b1bf4b412ed5050307655e750df793355 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:50:29 -0600 Subject: [PATCH] feat(qt): A-B loop from the player bar's + menu Mark A, mark B, loop between them until cleared; local playback only, never persisted, cleared on track change or a seek outside the section. A 100 ms watcher reads the millisecond position and reissues the local seek; the seekbar shows the section and its two marks. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 12 + .../translations/en/LC_MESSAGES/qbz-ui.po | 12 + .../translations/es/LC_MESSAGES/qbz-ui.po | 12 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 12 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 12 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 12 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 12 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 12 + .../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 22 +- crates/qbz-qt/qml/shell/PlayerBar.qml | 48 +++- crates/qbz-qt/src/ab_loop_qt.rs | 265 ++++++++++++++++++ crates/qbz-qt/src/main.rs | 10 + crates/qbz-qt/src/playback_qt.rs | 15 + crates/qbz-qt/src/player_bridge.rs | 24 ++ 14 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 crates/qbz-qt/src/ab_loop_qt.rs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index f4fa95a35..aeb5e504c 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7511,3 +7511,15 @@ msgstr "10 Sekunden zurück" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "10 Sekunden vor" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Schleifenanfang setzen (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Schleifenende setzen (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Schleife aufheben" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index ed15c5006..c7b4f8397 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3137,3 +3137,15 @@ msgstr "Back 10 seconds" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "Forward 10 seconds" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Set loop start (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Set loop end (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Clear loop" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 5ff8d9504..d3bb22854 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7517,3 +7517,15 @@ msgstr "Retroceder 10 segundos" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "Adelantar 10 segundos" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Fijar inicio del bucle (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Fijar fin del bucle (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Quitar bucle" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index c59b25774..bb634ceee 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7509,3 +7509,15 @@ msgstr "Reculer de 10 secondes" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "Avancer de 10 secondes" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Définir le début de la boucle (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Définir la fin de la boucle (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Supprimer la boucle" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index fd40a2dd7..c89374719 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7492,3 +7492,15 @@ msgstr "10秒戻る" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "10秒進む" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "ループの開始点を設定 (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "ループの終了点を設定 (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "ループを解除" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 8139ee40c..ca60f765d 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7499,3 +7499,15 @@ msgstr "10 seconden terug" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "10 seconden vooruit" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Loopbegin instellen (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Loopeinde instellen (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Loop wissen" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index cceab0643..988a5f785 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7509,3 +7509,15 @@ msgstr "Recuar 10 segundos" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "Avançar 10 segundos" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Definir início do loop (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Definir fim do loop (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Remover loop" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 54a214ee7..c34a9247f 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7530,3 +7530,15 @@ msgstr "Назад на 10 секунд" #: qbz-qt/qml/shell/TransportControls.qml msgid "Forward 10 seconds" msgstr "Вперёд на 10 секунд" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop start (A)" +msgstr "Задать начало петли (A)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Set loop end (B)" +msgstr "Задать конец петли (B)" + +#: qbz-qt/qml/shell/PlayerBar.qml +msgid "Clear loop" +msgstr "Убрать петлю" diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 37f49969f..5ac6f5f70 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -532,6 +532,20 @@ Rectangle { // idiom: a raw JSON.parse in a binding throws on the pre-publish frame and // takes the whole bar down with it. readonly property var settingsDoc: parseSettings() + // A-B loop entries for the "+" menu (see PlayerBar.abEntries): local + // playback only. + function abEntries() { + if (QbzPlayer.npIsRemote || QbzPlayer.npCastActive) + return [] + var m = [{ "sep": true }] + if (QbzPlayer.abState === 0) + m.push({ "label": QbzSession.tr("Set loop start (A)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" }) + else if (QbzPlayer.abState === 1) + m.push({ "label": QbzSession.tr("Set loop end (B)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" }) + if (QbzPlayer.abState !== 0) + m.push({ "label": QbzSession.tr("Clear loop", QbzSession.trRev), "icon": "x", "action": "ab-clear" }) + return m + } function parseSettings() { try { return JSON.parse(QbzBridge.settingsJson) @@ -568,7 +582,7 @@ Rectangle { m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }) m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }) if (root.npEphemeral) - return m + return m.concat(root.abEntries()) // "Add to playlist" is SECOND (TransportControls.slint:143 — the // full bar carries the rationale). Same `npSource` gate as the // mixtape entry: the picker's Qobuz arm takes catalog ids and an @@ -597,7 +611,7 @@ Rectangle { "action": "album-favorite" }) } - return m + return m.concat(root.abEntries()) } onPicked: function (a) { var id = QbzPlayer.npTrackId @@ -612,6 +626,10 @@ Rectangle { } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) + } else if (a === "ab-mark") { + QbzPlayer.abMark() + } else if (a === "ab-clear") { + QbzPlayer.abClear() } else if (a === "mixtape") { // MyQBZ AddItem, built here from the now-playing state: // `npArtworkPath` is a file:// CACHE path, so it is NOT the diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index f4c8c8118..6e2113116 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -126,6 +126,20 @@ Rectangle { // AppearanceState.show-volume-steppers (PlayerBar.slint gates the −/+ // pair on it; Tauri always showed them). readonly property bool showVolumeSteppers: settingsDoc.showVolumeSteppers === true + // A-B loop entries for the "+" menu: one contextual verb + clear. Local + // playback only — the loop cannot follow a cast / Connect renderer. + function abEntries() { + if (QbzPlayer.npIsRemote || QbzPlayer.npCastActive) + return [] + var m = [{ "sep": true }] + if (QbzPlayer.abState === 0) + m.push({ "label": QbzSession.tr("Set loop start (A)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" }) + else if (QbzPlayer.abState === 1) + m.push({ "label": QbzSession.tr("Set loop end (B)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" }) + if (QbzPlayer.abState !== 0) + m.push({ "label": QbzSession.tr("Clear loop", QbzSession.trRev), "icon": "x", "action": "ab-clear" }) + return m + } // Favorite state of the now-playing track (Slint: // QueueState.now-playing-favorite). The queue document carries it on its @@ -426,6 +440,32 @@ Rectangle { x: parent.width * seekRow.visualProgress - width / 2 anchors.verticalCenter: parent.verticalCenter } + // A-B loop: the tinted section + two ticks. Static bindings only + // (no animation), so nothing here touches the repaint pulse. + Rectangle { + visible: QbzPlayer.abState === 2 && QbzPlayer.npDurationSecs > 0 + x: parent.width * (QbzPlayer.abStartSecs / Math.max(QbzPlayer.npDurationSecs, 1)) + width: Math.max(2, parent.width + * ((QbzPlayer.abEndSecs - QbzPlayer.abStartSecs) / Math.max(QbzPlayer.npDurationSecs, 1))) + height: parent.height + radius: 2 + color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.28) + } + Repeater { + model: QbzPlayer.abState === 0 ? [] + : (QbzPlayer.abState === 1 ? [QbzPlayer.abStartSecs] + : [QbzPlayer.abStartSecs, QbzPlayer.abEndSecs]) + Rectangle { + required property int modelData + visible: QbzPlayer.npDurationSecs > 0 + width: 2 + height: 10 + radius: 1 + color: theme.accent + x: seekTrack.width * (modelData / Math.max(QbzPlayer.npDurationSecs, 1)) - 1 + anchors.verticalCenter: parent.verticalCenter + } + } } Text { anchors.right: parent.right @@ -887,7 +927,7 @@ Rectangle { m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }) m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }) if (root.npEphemeral) - return m + return m.concat(root.abEntries()) // "Add to playlist" sits SECOND in TransportControls.slint:143 — // spliced in there rather than appended, because the flyout's // order is part of the parity. It rides the SAME `npSource` gate @@ -919,7 +959,7 @@ Rectangle { "action": "album-favorite" }) } - return m + return m.concat(root.abEntries()) } onPicked: function (a) { var id = QbzPlayer.npTrackId @@ -934,6 +974,10 @@ Rectangle { } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) + } else if (a === "ab-mark") { + QbzPlayer.abMark() + } else if (a === "ab-clear") { + QbzPlayer.abClear() } else if (a === "mixtape") { // MyQBZ AddItem, built here from the now-playing state: // `npArtworkPath` is a file:// CACHE path, so it is NOT the diff --git a/crates/qbz-qt/src/ab_loop_qt.rs b/crates/qbz-qt/src/ab_loop_qt.rs new file mode 100644 index 000000000..06091ba42 --- /dev/null +++ b/crates/qbz-qt/src/ab_loop_qt.rs @@ -0,0 +1,265 @@ +//! A-B loop (player bar "+" menu): repeat a section of the CURRENT track. +//! +//! Process-lifetime, never persisted. Cleared on track change, stop, remote +//! takeover (cast / QConnect) and on a manual seek outside the section. The +//! watcher is a 100 ms task that only exists while a loop is active; it reads +//! the player's millisecond position (a read-only derivation) and issues the +//! same local seek the seekbar does. The audio thread is untouched. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; + +/// B must sit at least this far after A. +const MIN_LOOP_MS: u64 = 2_000; +const WATCH_INTERVAL: Duration = Duration::from_millis(100); +/// Fire this much before B so the jump lands on B, not past it. +const LEAD_MS: u64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Loop { + pub track_id: u64, + /// Whole seconds: `core.seek` is second-granular. + pub a_secs: u64, + /// Milliseconds: the watcher compares against the ms position. + pub b_ms: Option, +} + +static STATE: OnceLock>> = OnceLock::new(); +/// Bumped on every clear / re-arm; a watcher exits when its value differs. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +fn slot() -> &'static Mutex> { + STATE.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn current() -> Option { + *slot().lock().unwrap_or_else(|p| p.into_inner()) +} + +/// 0 = no loop, 1 = A armed (waiting for B), 2 = looping. +fn state_code(l: Option) -> i32 { + match l { + None => 0, + Some(Loop { b_ms: None, .. }) => 1, + Some(_) => 2, + } +} + +/// What one press of the single "mark" verb does. +fn next_after_mark(cur: Option, track_id: u64, pos_ms: u64) -> Option { + match cur { + Some(l) if l.track_id == track_id => match l.b_ms { + None if pos_ms >= l.a_secs * 1000 + MIN_LOOP_MS => Some(Loop { + b_ms: Some(pos_ms), + ..l + }), + None => Some(l), + Some(_) => None, + }, + _ => Some(Loop { + track_id, + a_secs: pos_ms / 1000, + b_ms: None, + }), + } +} + +fn should_jump(l: &Loop, pos_ms: u64) -> bool { + matches!(l.b_ms, Some(b) if pos_ms + LEAD_MS >= b) +} + +/// A manual seek keeps the loop only while it lands inside the section +/// (or, with only A armed, at/after A). +fn survives_manual_seek(l: &Loop, target_secs: u64) -> bool { + if target_secs < l.a_secs { + return false; + } + match l.b_ms { + Some(b) => target_secs * 1000 <= b, + None => true, + } +} + +fn publish(l: Option) { + let state = state_code(l); + let a = l.map(|l| l.a_secs as i32).unwrap_or(0); + let b = l + .and_then(|l| l.b_ms) + .map(|b| (b / 1000) as i32) + .unwrap_or(0); + crate::player_bridge::ui(move |mut bridge| { + bridge.as_mut().set_ab_state(state); + bridge.as_mut().set_ab_start_secs(a); + bridge.as_mut().set_ab_end_secs(b); + }); +} + +fn set(l: Option) { + *slot().lock().unwrap_or_else(|p| p.into_inner()) = l; + GENERATION.fetch_add(1, Ordering::SeqCst); + publish(l); +} + +pub(crate) fn clear() { + if current().is_some() { + log::info!("[qbz-qt] A-B loop cleared"); + set(None); + } +} + +/// The "+" menu verb: arm A, then B, then clear. Local playback only. +pub(crate) async fn mark(runtime: Arc>) { + if crate::now_playing::remote_or_cast_active() { + log::info!("[qbz-qt] A-B loop: ignored while playback is remote"); + return; + } + let event = runtime.core().player().get_playback_event(); + if event.track_id == 0 || event.duration == 0 { + return; + } + let pos_ms = runtime.core().player().state.current_position_ms(); + let next = next_after_mark(current(), event.track_id, pos_ms); + log::info!("[qbz-qt] A-B loop: mark at {pos_ms} ms -> {next:?}"); + set(next); + if matches!(next, Some(Loop { b_ms: Some(_), .. })) { + spawn_watcher(runtime); + } +} + +fn spawn_watcher(runtime: Arc>) { + let my_gen = GENERATION.load(Ordering::SeqCst); + crate::spawn(async move { + let mut ticker = tokio::time::interval(WATCH_INTERVAL); + loop { + ticker.tick().await; + if GENERATION.load(Ordering::SeqCst) != my_gen { + return; + } + let Some(l) = current() else { return }; + if l.b_ms.is_none() { + return; + } + if crate::now_playing::remote_or_cast_active() { + clear(); + return; + } + let event = runtime.core().player().get_playback_event(); + if event.track_id != l.track_id { + clear(); + return; + } + if !event.is_playing { + continue; + } + if should_jump(&l, runtime.core().player().state.current_position_ms()) { + crate::playback_qt::seek_local_secs(&runtime, l.a_secs).await; + } + } + }); +} + +/// Poll-loop track edge (and stop: `track_id == 0`). +pub(crate) fn on_track_changed(track_id: u64) { + if let Some(l) = current() { + if l.track_id != track_id { + clear(); + } + } +} + +/// Every user seek funnels through `playback_qt::seek_frac`; the watcher's +/// own jump uses `seek_local_secs` directly and never reaches here. +pub(crate) fn on_manual_seek(target_secs: u64) { + if let Some(l) = current() { + if !survives_manual_seek(&l, target_secs) { + clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mark_arms_a_then_b_then_clears() { + let armed = next_after_mark(None, 7, 12_400); + assert_eq!( + armed, + Some(Loop { + track_id: 7, + a_secs: 12, + b_ms: None + }) + ); + // B too close to A is ignored (the loop stays armed on A). + assert_eq!(next_after_mark(armed, 7, 13_900), armed); + let active = next_after_mark(armed, 7, 30_250); + assert_eq!( + active, + Some(Loop { + track_id: 7, + a_secs: 12, + b_ms: Some(30_250) + }) + ); + assert_eq!(next_after_mark(active, 7, 40_000), None); + // A different track always starts over. + assert_eq!( + next_after_mark(active, 8, 5_000), + Some(Loop { + track_id: 8, + a_secs: 5, + b_ms: None + }) + ); + } + + #[test] + fn the_watcher_jumps_just_before_b_and_a_manual_seek_outside_the_section_clears() { + let l = Loop { + track_id: 7, + a_secs: 12, + b_ms: Some(30_250), + }; + assert!(!should_jump(&l, 29_000)); + assert!(should_jump(&l, 30_200)); + assert!(should_jump(&l, 31_000)); + assert!(survives_manual_seek(&l, 12)); + assert!(survives_manual_seek(&l, 25)); + assert!(!survives_manual_seek(&l, 11)); + assert!(!survives_manual_seek(&l, 31)); + let armed = Loop { + track_id: 7, + a_secs: 12, + b_ms: None, + }; + assert!(survives_manual_seek(&armed, 20)); + assert!(!survives_manual_seek(&armed, 3)); + } + + #[test] + fn state_codes() { + assert_eq!(state_code(None), 0); + assert_eq!( + state_code(Some(Loop { + track_id: 1, + a_secs: 0, + b_ms: None + })), + 1 + ); + assert_eq!( + state_code(Some(Loop { + track_id: 1, + a_secs: 0, + b_ms: Some(5_000) + })), + 2 + ); + } +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 23cf79016..ebbc99e9c 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -139,6 +139,7 @@ mod kiosk_nav_qt; mod tray_bridge; // The kiosk profile itself (the same contract, §8): env/pref resolution, the // live Kiosk <-> Desktop toggle, and the boot decisions that follow from it. +mod ab_loop_qt; mod artwork_qt; mod atmosphere_qt; mod kiosk_profile_qt; @@ -2582,6 +2583,15 @@ pub(crate) fn transport_seek_by(delta_secs: i32) { hotkeys_bridge::seek_relative(delta_secs); } +pub(crate) fn ab_loop_mark() { + let runtime = app(); + spawn(async move { ab_loop_qt::mark(runtime).await }); +} + +pub(crate) fn ab_loop_clear() { + ab_loop_qt::clear(); +} + pub(crate) fn transport_set_volume(volume: f32) { // Local model first (instant UI), then the engine. now_playing::set_volume(volume); diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 74496b5ef..6369221c4 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -3603,6 +3603,19 @@ fn auto_skip_unavailable<'a>( }) } +/// The LOCAL tail of `seek_frac` (no cast / QConnect routing): the A-B loop's +/// jump, which is only ever armed for local playback. +pub(crate) async fn seek_local_secs(runtime: &Arc>, target: u64) { + let Some(_transport_action) = begin_transport_action() else { + return; + }; + if let Err(e) = runtime.core().seek(target) { + log::warn!("[qbz-qt] seek failed: {e}"); + return; + } + crate::media_controls_qt::push_seeked(target); +} + pub async fn seek_frac(runtime: &Arc>, frac: f32) { let Some(_transport_action) = begin_transport_action() else { return; @@ -3677,6 +3690,7 @@ pub async fn seek_frac(runtime: &Arc>, frac: f32) { return; } let target = (frac.clamp(0.0, 1.0) * event.duration as f32) as u64; + crate::ab_loop_qt::on_manual_seek(target); if let Err(e) = runtime.core().seek(target) { log::warn!("[qbz-qt] seek failed: {e}"); return; @@ -4697,6 +4711,7 @@ pub fn start_poll_loop(runtime: Arc>) { qbz_app::session_persist::capture_and_save(&runtime).await; } last_track_id = track_id; + crate::ab_loop_qt::on_track_changed(track_id); // The engine may have reached this track through a GAPLESS // hand-off, in which case the arming guard still names the // track that just ended. Clear it so the NEW current track can diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index a9506ace4..d77fa7b48 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -161,6 +161,11 @@ pub mod qbz_player { // ui_prefs `show_skip_ten`, mirrored here so TransportControls reads // one property on every bar. #[qproperty(bool, show_skip_ten)] + // A-B loop (ab_loop_qt): 0 none / 1 A armed / 2 looping, plus the two + // marks in whole seconds for the seekbar. + #[qproperty(i32, ab_state)] + #[qproperty(i32, ab_start_secs)] + #[qproperty(i32, ab_end_secs)] type QbzPlayer = super::QbzPlayerRust; /// Registers this object's Qt-thread hop (Main.qml boots EVERY @@ -189,6 +194,11 @@ pub mod qbz_player { /// track and to the buffered edge like the seekbars do. #[qinvokable] fn seek_by(self: Pin<&mut QbzPlayer>, delta_secs: i32); + /// A-B loop verb: arm A, then B, then clear (see ab_loop_qt). + #[qinvokable] + fn ab_mark(self: Pin<&mut QbzPlayer>); + #[qinvokable] + fn ab_clear(self: Pin<&mut QbzPlayer>); #[qinvokable] fn set_volume(self: Pin<&mut QbzPlayer>, volume: f32); /// Persist the SETTLED volume (drag-end only — see QbzSlider.released). @@ -352,6 +362,9 @@ pub struct QbzPlayerRust { np_context_id: QString, show_context_icon: bool, show_skip_ten: bool, + ab_state: i32, + ab_start_secs: i32, + ab_end_secs: i32, } impl Default for QbzPlayerRust { @@ -418,6 +431,9 @@ impl Default for QbzPlayerRust { // re-publishes it on shell entry and on the Settings toggle. show_context_icon: crate::settings_qt::show_context_icon(), show_skip_ten: crate::settings_qt::pref_bool("show_skip_ten", false), + ab_state: 0, + ab_start_secs: 0, + ab_end_secs: 0, } } } @@ -478,6 +494,14 @@ impl qbz_player::QbzPlayer { crate::transport_seek_by(delta_secs); } + pub fn ab_mark(self: Pin<&mut Self>) { + crate::ab_loop_mark(); + } + + pub fn ab_clear(self: Pin<&mut Self>) { + crate::ab_loop_clear(); + } + 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))); }