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:
14 files changed
+476
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user