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:
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 "Перемотка назад"
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user