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
This commit is contained in:
vicrodh committed 2026-09-13 03:50:29 -06:00
1 parent 4824c672fa
commit 9174b33b1b
14 files changed
+476 -4

No files matched your search

+20 -2
View File
@@ -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
+46 -2
View File
@@ -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
+265
View File
@@ -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<u64>,
}
static STATE: OnceLock<Mutex<Option<Loop>>> = 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<Option<Loop>> {
STATE.get_or_init(|| Mutex::new(None))
}
pub(crate) fn current() -> Option<Loop> {
*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<Loop>) -> 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<Loop>, track_id: u64, pos_ms: u64) -> Option<Loop> {
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<Loop>) {
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<Loop>) {
*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<AppRuntime<LoggingAdapter>>) {
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<AppRuntime<LoggingAdapter>>) {
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
);
}
}
+10
View File
@@ -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);
+15
View File
@@ -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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, frac: f32) {
let Some(_transport_action) = begin_transport_action() else {
return;
@@ -3677,6 +3690,7 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>) {
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
+24
View File
@@ -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)));
}