feat(hotkeys): Spotify-parity playback actions

Eight Playback actions the table never had, on Spotify's desktop defaults:
volume up/down (Ctrl+Up/Down), mute (M), shuffle (Ctrl+S), repeat (Ctrl+R),
like (Alt+Shift+B), and a surface-independent seek (Ctrl+Shift+Left/Right).

Each reuses an existing transport seam, and the immersive bare-arrow seek
rows are untouched. Volume honours the player bars' volLocked predicate, so
the hotkey cannot move the slider on an ALSA-Direct hw path where the engine
documents the write as a no-op. Like rides a QML seam, as nav.search does,
because the track id is a QbzPlayer property rather than part of the Rust
now-playing model.

Two new tests; labels added to all eight catalogs.
This commit is contained in:
Niklas Herder committed 2026-08-28 07:27:03 +02:00
1 parent b9478a3788
commit 04f9afe83f
12 files changed
+378 -5

No files matched your search

@@ -6036,3 +6036,29 @@ msgstr "Dieses Album enthält keine Titel."
msgid "Reactive Rings"
msgstr "Reaktive Ringe"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Lauter"
msgid "Volume Down"
msgstr "Leiser"
msgid "Mute / Unmute"
msgstr "Stumm / Ton an"
msgid "Toggle Shuffle"
msgstr "Zufallswiedergabe umschalten"
msgid "Cycle Repeat"
msgstr "Wiederholmodus wechseln"
msgid "Like Track"
msgstr "Titel liken"
msgid "Seek Forward"
msgstr "Vorspulen"
msgid "Seek Back"
msgstr "Zurückspulen"
@@ -1641,3 +1641,29 @@ msgstr "This album has no tracks."
msgid "Reactive Rings"
msgstr "Reactive Rings"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Volume Up"
msgid "Volume Down"
msgstr "Volume Down"
msgid "Mute / Unmute"
msgstr "Mute / Unmute"
msgid "Toggle Shuffle"
msgstr "Toggle Shuffle"
msgid "Cycle Repeat"
msgstr "Cycle Repeat"
msgid "Like Track"
msgstr "Like Track"
msgid "Seek Forward"
msgstr "Seek Forward"
msgid "Seek Back"
msgstr "Seek Back"
@@ -6042,3 +6042,29 @@ msgstr "Este álbum no tiene pistas."
msgid "Reactive Rings"
msgstr "Anillos reactivos"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Subir Volumen"
msgid "Volume Down"
msgstr "Bajar Volumen"
msgid "Mute / Unmute"
msgstr "Silenciar / Activar Sonido"
msgid "Toggle Shuffle"
msgstr "Alternar Aleatorio"
msgid "Cycle Repeat"
msgstr "Cambiar Repetición"
msgid "Like Track"
msgstr "Me Gusta"
msgid "Seek Forward"
msgstr "Avanzar"
msgid "Seek Back"
msgstr "Retroceder"
@@ -6034,3 +6034,29 @@ msgstr "Cet album ne contient aucun titre."
msgid "Reactive Rings"
msgstr "Anneaux réactifs"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Augmenter le volume"
msgid "Volume Down"
msgstr "Baisser le volume"
msgid "Mute / Unmute"
msgstr "Couper / Rétablir le son"
msgid "Toggle Shuffle"
msgstr "Basculer la lecture aléatoire"
msgid "Cycle Repeat"
msgstr "Changer le mode de répétition"
msgid "Like Track"
msgstr "Aimer le titre"
msgid "Seek Forward"
msgstr "Avancer"
msgid "Seek Back"
msgstr "Reculer"
@@ -6018,3 +6018,29 @@ msgstr "このアルバムには曲がありません。"
msgid "Reactive Rings"
msgstr "リアクティブリング"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "音量を上げる"
msgid "Volume Down"
msgstr "音量を下げる"
msgid "Mute / Unmute"
msgstr "ミュート / 解除"
msgid "Toggle Shuffle"
msgstr "シャッフル切り替え"
msgid "Cycle Repeat"
msgstr "リピート切り替え"
msgid "Like Track"
msgstr "お気に入りに追加"
msgid "Seek Forward"
msgstr "早送り"
msgid "Seek Back"
msgstr "巻き戻し"
@@ -6024,3 +6024,29 @@ msgstr "Dit album bevat geen nummers."
msgid "Reactive Rings"
msgstr "Reactieve ringen"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Volume omhoog"
msgid "Volume Down"
msgstr "Volume omlaag"
msgid "Mute / Unmute"
msgstr "Dempen / Dempen opheffen"
msgid "Toggle Shuffle"
msgstr "Shuffle aan/uit"
msgid "Cycle Repeat"
msgstr "Herhaling wisselen"
msgid "Like Track"
msgstr "Nummer liken"
msgid "Seek Forward"
msgstr "Vooruit spoelen"
msgid "Seek Back"
msgstr "Terugspoelen"
@@ -6034,3 +6034,29 @@ msgstr "Este álbum não tem faixas."
msgid "Reactive Rings"
msgstr "Anéis reativos"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Aumentar Volume"
msgid "Volume Down"
msgstr "Diminuir Volume"
msgid "Mute / Unmute"
msgstr "Silenciar / Reativar Som"
msgid "Toggle Shuffle"
msgstr "Alternar Aleatório"
msgid "Cycle Repeat"
msgstr "Alternar Repetição"
msgid "Like Track"
msgstr "Curtir Faixa"
msgid "Seek Forward"
msgstr "Avançar"
msgid "Seek Back"
msgstr "Recuar"
@@ -6054,3 +6054,29 @@ msgstr "В этом альбоме нет треков."
msgid "Reactive Rings"
msgstr "Реактивные кольца"
# Hotkeys: the Spotify-parity playback actions
msgid "Volume Up"
msgstr "Громче"
msgid "Volume Down"
msgstr "Тише"
msgid "Mute / Unmute"
msgstr "Звук выкл. / вкл."
msgid "Toggle Shuffle"
msgstr "Перемешивание"
msgid "Cycle Repeat"
msgstr "Режим повтора"
msgid "Like Track"
msgstr "Нравится"
msgid "Seek Forward"
msgstr "Перемотка вперёд"
msgid "Seek Back"
msgstr "Перемотка назад"
+8
View File
@@ -146,6 +146,14 @@ Rectangle {
Connections {
target: QbzHotkeys
function onFocusSearchRequested() { header.focusSearch() }
// playback.favorite — the player bar's own heart call, empty-id guard
// included. The now-playing track id is a QbzPlayer property, so the
// Rust dispatch signals and QML answers rather than resolving the id
// twice (PlayerBar.qml:769-773).
function onFavoriteRequested() {
var id = QbzPlayer.npTrackId
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
}
}
// The §4.6 Ctrl+A + multi-select Escape-exit seam. Selection state is
+38
View File
@@ -133,6 +133,14 @@ pub mod qbz_hotkeys {
/// is stale.
#[qsignal]
fn focus_search_requested(self: Pin<&mut QbzHotkeys>);
/// playback.favorite — the same QML seam as `focus_search_requested`.
/// The now-playing track id lives on QbzPlayer, not in the Rust
/// now-playing model, so AppShell answers this with the call the
/// player bar's own heart makes (`QbzQueue.queueToggleFavorite`),
/// empty-id guard included, instead of a second id resolution here.
#[qsignal]
fn favorite_requested(self: Pin<&mut QbzHotkeys>);
}
impl cxx_qt::Threading for QbzHotkeys {}
@@ -227,6 +235,28 @@ fn seek_relative(delta: i32) {
crate::transport_seek(target as f32 / duration as f32);
}
/// Keyboard volume step — the same 0.05 the player bars' +/- steppers use.
const VOLUME_STEP: f32 = 0.05;
/// The player bars' `volLocked` predicate (PlayerBar.qml:88-94) in Rust: the
/// settings-derived ALSA-Direct hardware lock is LIFTED while a peer renderer
/// owns playback, and the sink-pushed peer lock stands on its own. Without it
/// the hotkey would move the on-screen slider for a change the engine
/// documents as a no-op on that path.
fn volume_locked() -> bool {
let settings_locked =
crate::output_labels::volume_locked(&crate::settings_qt::audio_settings());
(settings_locked && !crate::now_playing::is_remote())
|| crate::now_playing::remote_volume_locked()
}
fn nudge_volume(delta: f32) {
if volume_locked() {
return;
}
crate::transport_set_volume((crate::now_playing::volume() + delta).clamp(0.0, 1.0));
}
impl qbz_hotkeys::QbzHotkeys {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
@@ -351,6 +381,14 @@ impl qbz_hotkeys::QbzHotkeys {
// here: it lives in `QbzMini::enter`, so this hotkey and the view
// menu's row share ONE predicate (contract §4.10.1).
"ui.miniPlayer" => crate::mini_bridge::toggle(),
"playback.volumeUp" => nudge_volume(VOLUME_STEP),
"playback.volumeDown" => nudge_volume(-VOLUME_STEP),
"playback.mute" => crate::transport_toggle_mute(),
"playback.shuffle" => crate::transport_toggle_shuffle(),
"playback.repeat" => crate::transport_cycle_repeat(),
"playback.favorite" => self.as_mut().favorite_requested(),
"playback.seekForward" => seek_relative(5),
"playback.seekBack" => seek_relative(-5),
"focus.seekForward" => seek_relative(5),
"focus.seekBack" => seek_relative(-5),
"focus.seekForwardLong" => seek_relative(10),
+112 -5
View File
@@ -1,8 +1,9 @@
//! Keyboard / hotkeys layer — the PURE core (2026-08-03 hotkeys-port contract
//! `qbz-nix-docs/qt-frontend/2026-08-03-hotkeys-port/00-CONTRACT.md` v2 §3,
//! block B1). Byte-faithful port of the Slint `crates/qbz/src/keybindings.rs`
//! model: the 23-action DEFAULTS table (:88-117 — NB its :3 "26 actions"
//! comment is STALE, do not "fix" the count), the shortcut-string grammar
//! block B1). Port of the Slint `crates/qbz/src/keybindings.rs`
//! model: the DEFAULTS table (:88-117 — 23 actions at the time of the port;
//! NB its :3 "26 actions" comment is STALE, do not "fix" the count), the
//! shortcut-string grammar
//! (:153-249), the bindings store + conflict detection (:256-323), the groups
//! builder + three-column round-robin (:329-380), the capture semantics
//! (:434-467) and the §1.2 Escape priority stack (:585-615).
@@ -12,6 +13,12 @@
//! suggestions_bridge precedent). Do NOT list this file in build.rs
//! rust_files.
//!
//! ONE deliberate addition POST-port, additive to the ported model: eight
//! Playback actions the Slint table never had (volume, mute, shuffle, repeat,
//! like, and a surface-independent seek — Spotify's desktop defaults).
//! Everything below still resolves through one action table and one canonical
//! shortcut string.
//!
//! The one intentional DIFFERENCE from Slint is the token source (contract
//! §3.1 HYBRID rule, round-1 F1): Slint reads winit's LOGICAL key, which is
//! already the shifted glyph AND Ctrl-independent; a QKeyEvent's `text()` is
@@ -156,6 +163,65 @@ pub const ACTIONS: &[ActionDef] = &[
default: "Ctrl+ArrowLeft",
context: Context::None,
},
ActionDef {
id: "playback.volumeUp",
label_en: "Volume Up",
category: Category::Playback,
default: "Ctrl+ArrowUp",
context: Context::None,
},
ActionDef {
id: "playback.volumeDown",
label_en: "Volume Down",
category: Category::Playback,
default: "Ctrl+ArrowDown",
context: Context::None,
},
ActionDef {
id: "playback.mute",
label_en: "Mute / Unmute",
category: Category::Playback,
default: "m",
context: Context::None,
},
ActionDef {
id: "playback.shuffle",
label_en: "Toggle Shuffle",
category: Category::Playback,
default: "Ctrl+s",
context: Context::None,
},
ActionDef {
id: "playback.repeat",
label_en: "Cycle Repeat",
category: Category::Playback,
default: "Ctrl+r",
context: Context::None,
},
ActionDef {
id: "playback.favorite",
label_en: "Like Track",
category: Category::Playback,
default: "Alt+Shift+B",
context: Context::None,
},
// Seek from ANY surface. The `focus.seek*` rows below stay bare arrows and
// stay immersive-only: there the arrows are free, everywhere else they
// belong to whatever the pointer is over.
ActionDef {
id: "playback.seekForward",
label_en: "Seek Forward",
category: Category::Playback,
default: "Ctrl+Shift+ArrowRight",
context: Context::None,
},
ActionDef {
id: "playback.seekBack",
label_en: "Seek Back",
category: Category::Playback,
default: "Ctrl+Shift+ArrowLeft",
context: Context::None,
},
// Navigation
ActionDef {
id: "nav.back",
@@ -996,8 +1062,8 @@ mod tests {
overrides.insert("stale.removed.action".into(), "x".into()); // unknown id
let bindings = active_bindings_with(&overrides);
assert_eq!(bindings.get("ui.queue").unwrap(), "w");
// The known count is exactly the 23 actions; the stale id is absent.
assert_eq!(bindings.len(), 23);
// The known count is exactly the action table; the stale id is absent.
assert_eq!(bindings.len(), ACTIONS.len());
assert!(!bindings.contains_key("stale.removed.action"));
// …but the overrides map itself is untouched (kept in the file).
assert_eq!(overrides.get("stale.removed.action").unwrap(), "x");
@@ -1238,4 +1304,45 @@ mod tests {
assert!(!is_ctrl_a(key_a, 0, "a"));
assert!(!is_ctrl_a(QT_KEY_A + 1, QT_CONTROL, ""));
}
// --- The new Spotify-parity actions ------------------------------------
#[test]
fn the_new_playback_actions_dispatch_on_their_defaults() {
let ov = no_overrides();
for (key, modifiers, text, id) in [
(QT_KEY_UP, QT_CONTROL, "", "playback.volumeUp"),
(QT_KEY_DOWN, QT_CONTROL, "", "playback.volumeDown"),
(QT_KEY_A + 12, 0, "m", "playback.mute"),
(QT_KEY_A + 18, QT_CONTROL, "", "playback.shuffle"),
(QT_KEY_A + 17, QT_CONTROL, "", "playback.repeat"),
(QT_KEY_A + 1, QT_ALT | QT_SHIFT, "", "playback.favorite"),
(QT_KEY_RIGHT, QT_CONTROL | QT_SHIFT, "", "playback.seekForward"),
(QT_KEY_LEFT, QT_CONTROL | QT_SHIFT, "", "playback.seekBack"),
] {
let a = action_for_key(&ov, key, modifiers, text, false)
.unwrap_or_else(|| panic!("{id} did not resolve"));
assert_eq!(a.id, id);
}
}
/// The global seek rows fire anywhere; the immersive ones keep their bare
/// arrows and stay gated on the overlay.
#[test]
fn global_seek_is_context_free_and_immersive_seek_still_is_not() {
let ov = no_overrides();
let global = action_for_key(
&ov,
QT_KEY_RIGHT,
QT_CONTROL | QT_SHIFT,
"",
false,
)
.unwrap();
assert_eq!(global.id, "playback.seekForward");
assert_eq!(global.context, Context::None);
assert!(action_for_key(&ov, QT_KEY_RIGHT, 0, "", false).is_none());
let imm = action_for_key(&ov, QT_KEY_RIGHT, 0, "", true).unwrap();
assert_eq!(imm.id, "focus.seekForward");
}
}
+12
View File
@@ -507,6 +507,18 @@ pub fn is_remote() -> bool {
with_model(|m| m.is_remote).0
}
/// Read-only: the current output volume 0..1. The keyboard volume steps read
/// it to compute the next value (the bars read the published property).
pub(crate) fn volume() -> f32 {
with_model(|m| m.volume).0
}
/// Read-only mirror of `set_remote_volume_locked` — the active peer renderer
/// disallows remote volume control. Half of the hotkeys' `volume_locked`.
pub(crate) fn remote_volume_locked() -> bool {
with_model(|m| m.remote_volume_locked).0
}
/// The REMOTE volume lock (contract §11.3): the active peer Qobuz Connect
/// renderer disallows remote volume control. Written from the qconnect sink's
/// badge refresh alongside `set_remote`, and cleared by the facade's