feat(steam-deck): game mode support (plan PR1-PR5)
- PR1: gamescope detection at boot — X11 backend, scale 1, kiosk fullscreen; tray + miniplayer suppressed (click-offset fix). - PR2: full keyboard navigation for the kiosk (3 zones, focus ring, scroll-into-view; typing never navigates). - PR3: native gamepad via gilrs — D-pad/stick/A/B to synthetic Slint keys with hysteresis, repeat and debounce; B suppresses Escape while a text field is focused (closes the Steam OSK). - PR4: Steam OSK via the steam://open/keyboard deeplink on text focus. - PR5: scripts/qbz-add-to-steam (stdlib-only, 19/19 unit tests) with artwork assets + pure-gamepad controller layout. Spec: qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md km
This commit is contained in:
24 files changed
+2758
-51
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,481 @@
|
||||
// QBZ — Steam Deck (neptune) controller layout, seeded by qbz-add-to-steam.
|
||||
//
|
||||
// Based on the stock Steam "Gamepad" template (controller_neptune_gamepad_joystick.vdf,
|
||||
// Steam client 2026-06) — PURE GAMEPAD: only virtual X360 pad output, NO keyboard
|
||||
// or mouse emulation, so QBZ's own gilrs gamepad thread is the single input source
|
||||
// (see qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md, PR 3/PR 5).
|
||||
//
|
||||
// The one deviation from the template: Y is bound to Steam's "Show Keyboard"
|
||||
// system action (controller_action SHOW_KEYBOARD — token verified in
|
||||
// steamclient.so) as a manual OSK fallback next to the steam://open/keyboard
|
||||
// deeplink. Y therefore does NOT emit an XInput button.
|
||||
//
|
||||
// TODO(verify-on-hardware): this file is authored from the known exported
|
||||
// config format, not yet exported/validated by the Deck UI. Do the one-time
|
||||
// validation from the plan: load it on a real Deck in Game Mode, confirm the
|
||||
// layout applies on first launch (configset_<serial>.vdf path) and that Y
|
||||
// opens the OSK, then re-export and replace this file if the format drifted.
|
||||
"controller_mappings"
|
||||
{
|
||||
"version" "3"
|
||||
"title" "QBZ Kiosk (gamepad + Y=keyboard)"
|
||||
"description" "Pure gamepad layout for the QBZ kiosk: D-pad/stick navigate, A activates, B goes back, Y shows the Steam keyboard. No keyboard/mouse emulation."
|
||||
"controller_type" "controller_neptune"
|
||||
"major_revision" "0"
|
||||
"minor_revision" "0"
|
||||
"group"
|
||||
{
|
||||
"id" "0"
|
||||
"mode" "four_buttons"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_a"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button A, Activate, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_b"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button B, Back, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_x"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button X, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_y"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "1"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_up, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_down, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_right, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_left, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "2"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "3"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"deadzone_inner_radius" "7199"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "4"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "1"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "5"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "2"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "7"
|
||||
"mode" "switches"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_escape"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button start, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_menu"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button select, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"left_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_left, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"right_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_right, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_left"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_right"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_left_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_back_right_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "8"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "9"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_UP, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_DOWN, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_RIGHT, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_LEFT, , , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"requires_click" "0"
|
||||
}
|
||||
}
|
||||
"preset"
|
||||
{
|
||||
"id" "0"
|
||||
"name" "Default"
|
||||
"group_source_bindings"
|
||||
{
|
||||
"7" "switch active"
|
||||
"0" "button_diamond active"
|
||||
"1" "left_trackpad active"
|
||||
"2" "right_trackpad active"
|
||||
"3" "joystick active"
|
||||
"4" "left_trigger active"
|
||||
"5" "right_trigger active"
|
||||
"8" "right_joystick active"
|
||||
"9" "dpad active"
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"left_trackpad_mode" "0"
|
||||
"right_trackpad_mode" "0"
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,12 @@
|
||||
// the user is typing (mirrors the Tauri `isInputTarget` check).
|
||||
export global UiFocusState {
|
||||
in-out property <bool> text-input-focused: false;
|
||||
// Steam Deck OSK (Steam plan PR 4): fired on every focus transition so
|
||||
// Rust (steam_osk) can open/close the Steam OSK — every text field rides
|
||||
// it with zero per-field wiring. Mode: 0 = single-line (all fields today).
|
||||
in-out property <int> osk-mode: 0;
|
||||
callback osk(bool /* focused */, int /* mode */);
|
||||
changed text-input-focused => {
|
||||
root.osk(root.text-input-focused, root.osk-mode);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
// (same as the desktop AlbumPageView): a header (big cover + title + artist +
|
||||
// quality + Play/Shuffle) and a simple track list (number + title + duration).
|
||||
// Album track rows carry no artwork, so we show the track number.
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): the track list is the nav model (1-column
|
||||
// list, no tabs — index == track index). Enter plays the focused track. The
|
||||
// header buttons (Play / Shuffle / artist link) stay touch-only in v1.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { AlbumState, NowPlayingState } from "../state.slint";
|
||||
import { AlbumState, NowPlayingState, KioskNavState } from "../state.slint";
|
||||
|
||||
component PillButton inherits Rectangle {
|
||||
in property <string> label;
|
||||
@@ -37,7 +41,32 @@ export component KioskAlbum inherits Rectangle {
|
||||
|
||||
property <length> pad: 22px;
|
||||
|
||||
Flickable {
|
||||
// ---- Nav geometry publish (PR 2): no tabs, one index per track.
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 0;
|
||||
KioskNavState.shelves = false;
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = AlbumState.tracks.length;
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: AlbumState.tracks.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter pulse → play the focused track (the row TouchArea's own action).
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index >= 0 && KioskNavState.index < AlbumState.tracks.length) {
|
||||
root.media-action("track", AlbumState.tracks[KioskNavState.index].id, "play");
|
||||
}
|
||||
}
|
||||
|
||||
page-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -122,9 +151,30 @@ export component KioskAlbum inherits Rectangle {
|
||||
border-radius: Radius.sm;
|
||||
// Same local-track-id alias as TrackPlayCell (offline-cache tracks).
|
||||
property <bool> current: NowPlayingState.track-id == track.id || (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == track.id);
|
||||
background: trow.has-hover
|
||||
// PR 2 — focus ring + scroll-into-view. Page coords are
|
||||
// fixed arithmetic: 22px pad + ~180px header + 22px
|
||||
// spacing, then a 54px pitch (52px row + 2px spacing).
|
||||
// viewport-y is written from a callback on a settled
|
||||
// layout ("Recursion detected" class).
|
||||
property <bool> row-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content" && KioskNavState.index == i;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.row-focused) {
|
||||
if (224px + i * 54px < -page-flick.viewport-y) {
|
||||
page-flick.viewport-y = -(224px + i * 54px - 8px);
|
||||
} else if (224px + i * 54px + 52px > -page-flick.viewport-y + page-flick.height) {
|
||||
page-flick.viewport-y = -(224px + i * 54px + 52px - page-flick.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
background: self.row-focused
|
||||
? Theme.accent.with-alpha(0.18)
|
||||
: trow.has-hover
|
||||
? Theme.alpha-6
|
||||
: self.current ? Theme.accent.with-alpha(0.10) : transparent;
|
||||
border-width: self.row-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// plain full grid mounted every card at once and spiked CPU on entry — a Pi
|
||||
// can't afford that. Binds `[AlbumCardItem]` so artwork pushes land on the cards.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { AlbumCardItem } from "../state.slint";
|
||||
import { AlbumCardItem, KioskNavState } from "../state.slint";
|
||||
import { KioskCard } from "KioskCard.slint";
|
||||
|
||||
export component KioskAlbumGrid inherits Flickable {
|
||||
@@ -25,6 +25,40 @@ export component KioskAlbumGrid inherits Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: root.rows * (root.card-h + root.gap) + root.pad;
|
||||
|
||||
// ---- Keyboard/gamepad nav (PR 2). The HOSTING VIEW publishes the grid
|
||||
// geometry (tabs / columns / count) into KioskNavState; the grid only
|
||||
// mirrors the focus index (ring + scroll-into-view) and answers the
|
||||
// Enter pulse. The content index space skips `tabs` leading tab entries.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& root.focused-item >= 0
|
||||
&& root.focused-item < root.albums.length;
|
||||
|
||||
// Scroll the focused card's row into view. viewport-y is WRITTEN from
|
||||
// this callback (plain imperative assignment on a settled layout), never
|
||||
// read by a binding that feeds layout — the AlbumCollectionView
|
||||
// "Recursion detected" panic class.
|
||||
property <length> focus-top: root.pad + Math.floor(root.focused-item / root.columns) * (root.card-h + root.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (root.item-focused) {
|
||||
if (root.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(root.focus-top - 8px);
|
||||
} else if (root.focus-top + root.card-h > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(root.focus-top + root.card-h - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enter pulse → open the focused album.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (root.item-focused) {
|
||||
root.open(root.albums[root.focused-item].id);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for album[i] in root.albums: slot := Rectangle {
|
||||
x: root.pad + Math.mod(i, root.columns) * (root.card-w + root.gap);
|
||||
@@ -39,6 +73,7 @@ export component KioskAlbumGrid inherits Flickable {
|
||||
width: root.card-w;
|
||||
art-size: root.card-w;
|
||||
album: album;
|
||||
nav-focused: root.item-focused && root.focused-item == i;
|
||||
clicked(id) => {
|
||||
root.open(id);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
// ArtistState (same as the desktop ArtistPageView): a header (round portrait +
|
||||
// name + Play/Radio) and the discography as simple KioskCard shelves per release
|
||||
// section (the junk "other" bucket is skipped).
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): SHELF mode, same model as KioskDiscover — one
|
||||
// nav index per shelf (columns == 1, no tabs), Left/Right pulses `hmove`
|
||||
// consumed by the focused shelf's local cursor, skipped shelves ("other"
|
||||
// bucket / empty) are stepped over in the last travel direction. The header
|
||||
// buttons (Play / Radio) stay touch-only in v1.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { ArtistState } from "../state.slint";
|
||||
import { ArtistState, KioskNavState } from "../state.slint";
|
||||
import { KioskCard } from "KioskCard.slint";
|
||||
|
||||
export component KioskArtist inherits Rectangle {
|
||||
@@ -13,7 +19,23 @@ export component KioskArtist inherits Rectangle {
|
||||
|
||||
property <length> pad: 22px;
|
||||
|
||||
Flickable {
|
||||
// ---- Nav geometry publish (PR 2): no tab strip, one index per shelf.
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 0;
|
||||
KioskNavState.shelves = true;
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = ArtistState.release-sections.length;
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-count-probe: ArtistState.release-sections.length;
|
||||
changed nav-count-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
page-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -93,24 +115,101 @@ export component KioskArtist inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
for section in ArtistState.release-sections: VerticalLayout {
|
||||
for section[s] in ArtistState.release-sections: shelf-vl := VerticalLayout {
|
||||
// PR 2 — per-shelf nav state (same idiom as KioskDiscover).
|
||||
property <int> cursor: 0;
|
||||
property <bool> shelf-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& KioskNavState.index - KioskNavState.tabs == s;
|
||||
|
||||
// Skip the shelves this view doesn't render ("other" bucket,
|
||||
// empty sections) in the last travel direction.
|
||||
property <int> nav-skip-probe: KioskNavState.index;
|
||||
changed nav-skip-probe => {
|
||||
if (KioskNavState.zone == "content" && KioskNavState.nav-active
|
||||
&& KioskNavState.index - KioskNavState.tabs == s
|
||||
&& (section.release-type == "other" || section.albums.length == 0)
|
||||
&& KioskNavState.dir != 0) {
|
||||
KioskNavState.index = Math.max(KioskNavState.tabs,
|
||||
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
|
||||
}
|
||||
}
|
||||
|
||||
// Left/Right shelf move: consume the shell's `hmove` pulse.
|
||||
property <int> hmove-probe: KioskNavState.hmove;
|
||||
changed hmove-probe => {
|
||||
if (self.shelf-focused && KioskNavState.hmove != 0
|
||||
&& section.albums.length > 0) {
|
||||
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
|
||||
0, section.albums.length - 1);
|
||||
KioskNavState.hmove = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter pulse → open the cursor album.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.shelf-focused && section.albums.length > 0
|
||||
&& self.cursor < section.albums.length) {
|
||||
root.open-album(section.albums[self.cursor].id);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the focused shelf's strip inside the page viewport.
|
||||
// Fixed page arithmetic: 22px pad + 140px header + 22px
|
||||
// spacing, then a 240px pitch per shelf block (24px title +
|
||||
// 12px + 182px strip + 22px spacing). viewport-y is written
|
||||
// from a callback on a settled layout ("Recursion detected"
|
||||
// class — never a layout-feeding binding).
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.shelf-focused && section.albums.length > 0) {
|
||||
if (184px + s * 240px < -page-flick.viewport-y) {
|
||||
page-flick.viewport-y = -(184px + s * 240px - 8px);
|
||||
} else if (184px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
|
||||
page-flick.viewport-y = -(184px + s * 240px + 218px - page-flick.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if section.release-type != "other" && section.albums.length > 0: VerticalLayout {
|
||||
spacing: 12px;
|
||||
// Fixed-height title wrapper — keeps the shelf block a
|
||||
// constant 218px for the scroll arithmetic above.
|
||||
Rectangle {
|
||||
height: 24px;
|
||||
Text {
|
||||
text: section.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Flickable {
|
||||
}
|
||||
strip-flick := Flickable {
|
||||
height: 182px;
|
||||
viewport-width: strip.preferred-width;
|
||||
viewport-height: self.height;
|
||||
|
||||
// Keep the cursor card inside the strip's horizontal
|
||||
// viewport (card pitch = 128px art + 14px spacing).
|
||||
property <int> cursor-probe: shelf-vl.cursor;
|
||||
changed cursor-probe => {
|
||||
if (shelf-vl.shelf-focused) {
|
||||
if (shelf-vl.cursor * 142px < -self.viewport-x) {
|
||||
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
|
||||
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
|
||||
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strip := HorizontalLayout {
|
||||
spacing: 14px;
|
||||
alignment: start;
|
||||
for album in section.albums: KioskCard {
|
||||
for album[i] in section.albums: KioskCard {
|
||||
album: album;
|
||||
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
|
||||
clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
// SearchState.artists (SlimItem: id/title/artwork) and FavoritesState.artists
|
||||
// (FavoriteArtistItem: id/name/image).
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
|
||||
export component KioskArtistCard inherits Rectangle {
|
||||
in property <string> id;
|
||||
in property <string> name;
|
||||
in property <image> avatar;
|
||||
in property <length> size: 120px;
|
||||
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||
in property <bool> nav-focused: false;
|
||||
callback clicked(string);
|
||||
|
||||
width: root.size;
|
||||
border-width: root.nav-focused ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
|
||||
@@ -13,9 +13,17 @@ export component KioskCard inherits Rectangle {
|
||||
in property <AlbumCardItem> album;
|
||||
in property <length> art-size: 128px;
|
||||
in property <bool> round: false;
|
||||
// Keyboard/gamepad nav (PR 2): the hosting view sets this when the shell
|
||||
// focus index lands on this card. A 3px accent ring + tint, readable at
|
||||
// 10-foot distance.
|
||||
in property <bool> nav-focused: false;
|
||||
callback clicked(string);
|
||||
|
||||
width: root.art-size;
|
||||
border-width: root.nav-focused ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
|
||||
@@ -6,19 +6,32 @@
|
||||
// stack — lighter for a Pi. v1 covers the album-carousel rows (newReleases,
|
||||
// qobuzissimes, editorPicks, pressAwards, idealDiscography, mostStreamed); the
|
||||
// non-album rows (playlists, mixes, slim rails) are skipped for now.
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): SHELF mode. The nav index space is the 3 tab
|
||||
// pills (indices 0-2) followed by one index per shelf (columns == 1, so
|
||||
// Up/Down walk shelves). Left/Right can't move the global index (that would
|
||||
// jump shelves), so the shell emits an `hmove` pulse that the focused shelf
|
||||
// consumes into its LOCAL card cursor. Sections with no albums render
|
||||
// nothing (the playlist/mix/slim rows above) — the focused shelf skips over
|
||||
// them in the last travel direction (`KioskNavState.dir`), since Slint has
|
||||
// no cross-loop accumulator to pre-compact the index space.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { DiscoverState, HomeState, HomeActions } from "../state.slint";
|
||||
import { DiscoverState, HomeState, HomeActions, KioskNavState } from "../state.slint";
|
||||
import { KioskCard } from "KioskCard.slint";
|
||||
|
||||
component TabPill inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
// Keyboard/gamepad nav (PR 2): this pill holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback clicked;
|
||||
horizontal-stretch: 0;
|
||||
height: 40px;
|
||||
border-radius: Radius.sm;
|
||||
background: root.active ? Theme.accent : ta.has-hover ? Theme.alpha-6 : transparent;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
@@ -43,7 +56,48 @@ export component KioskDiscover inherits Rectangle {
|
||||
callback media-action(string, string, string);
|
||||
callback discover-view-all(string, string);
|
||||
|
||||
Flickable {
|
||||
// ---- Nav geometry publish (PR 2): 3 tab pills + one index per shelf.
|
||||
// Re-published on tab switch and whenever the section set changes.
|
||||
property <int> nav-section-count: (DiscoverState.active-tab == "editorPicks"
|
||||
? DiscoverState.editor-sections
|
||||
: DiscoverState.active-tab == "forYou"
|
||||
? DiscoverState.foryou-sections
|
||||
: DiscoverState.home-sections).length;
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 3;
|
||||
KioskNavState.shelves = true;
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 3 + root.nav-section-count;
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-count-probe: root.nav-section-count;
|
||||
changed nav-count-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <string> nav-tab-probe: DiscoverState.active-tab;
|
||||
changed nav-tab-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter on a tab pill → the same tab switch the pills' TouchAreas drive.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index < KioskNavState.tabs) {
|
||||
if (KioskNavState.index == 0) {
|
||||
HomeActions.select-tab("home");
|
||||
} else if (KioskNavState.index == 1) {
|
||||
HomeActions.select-tab("editorPicks");
|
||||
} else if (KioskNavState.index == 2) {
|
||||
HomeActions.select-tab("forYou");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
@@ -68,16 +122,19 @@ export component KioskDiscover inherits Rectangle {
|
||||
TabPill {
|
||||
label: @tr("Home");
|
||||
active: DiscoverState.active-tab == "home";
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||
clicked => { HomeActions.select-tab("home"); }
|
||||
}
|
||||
TabPill {
|
||||
label: @tr("Editor's Picks");
|
||||
active: DiscoverState.active-tab == "editorPicks";
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||
clicked => { HomeActions.select-tab("editorPicks"); }
|
||||
}
|
||||
TabPill {
|
||||
label: @tr("For You");
|
||||
active: DiscoverState.active-tab == "forYou";
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2;
|
||||
clicked => { HomeActions.select-tab("forYou"); }
|
||||
}
|
||||
}
|
||||
@@ -86,30 +143,111 @@ export component KioskDiscover inherits Rectangle {
|
||||
|
||||
// Album shelves for the active tab. Only rows carrying album data
|
||||
// render (length > 0); playlist/mix/slim rows are skipped in v1.
|
||||
for desc in (DiscoverState.active-tab == "editorPicks"
|
||||
for desc[s] in (DiscoverState.active-tab == "editorPicks"
|
||||
? DiscoverState.editor-sections
|
||||
: DiscoverState.active-tab == "forYou"
|
||||
? DiscoverState.foryou-sections
|
||||
: DiscoverState.home-sections): VerticalLayout {
|
||||
: DiscoverState.home-sections): shelf-vl := VerticalLayout {
|
||||
// PR 2 — per-shelf nav state. `cursor` is the focused card
|
||||
// inside this shelf; `shelf-focused` mirrors the global index.
|
||||
property <int> cursor: 0;
|
||||
property <bool> shelf-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& KioskNavState.index - KioskNavState.tabs == s;
|
||||
|
||||
// Skip over shelves that render nothing (empty / non-album
|
||||
// sections): nudge the index one more step in the last travel
|
||||
// direction. The write re-fires the probes of the next shelf,
|
||||
// so the cascade walks contiguous gaps and converges.
|
||||
property <int> nav-skip-probe: KioskNavState.index;
|
||||
changed nav-skip-probe => {
|
||||
if (KioskNavState.zone == "content" && KioskNavState.nav-active
|
||||
&& KioskNavState.index - KioskNavState.tabs == s
|
||||
&& desc.section.albums.length == 0 && KioskNavState.dir != 0) {
|
||||
KioskNavState.index = Math.max(KioskNavState.tabs,
|
||||
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
|
||||
}
|
||||
}
|
||||
|
||||
// Left/Right shelf move: the shell pulses `hmove`; the focused
|
||||
// shelf consumes it into its cursor (and resets the pulse).
|
||||
property <int> hmove-probe: KioskNavState.hmove;
|
||||
changed hmove-probe => {
|
||||
if (self.shelf-focused && KioskNavState.hmove != 0
|
||||
&& desc.section.albums.length > 0) {
|
||||
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
|
||||
0, desc.section.albums.length - 1);
|
||||
KioskNavState.hmove = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter pulse → open the cursor album.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.shelf-focused && desc.section.albums.length > 0
|
||||
&& self.cursor < desc.section.albums.length) {
|
||||
root.album-clicked(desc.section.albums[self.cursor].id);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the focused shelf's strip inside the page viewport.
|
||||
// Page coords are fixed arithmetic: 16px pad + 50px tab row +
|
||||
// 22px spacing, then a 240px pitch per shelf block (24px
|
||||
// title + 12px + 182px strip + 22px spacing). viewport-y is
|
||||
// WRITTEN from this callback on a settled layout, never read
|
||||
// by a layout-feeding binding ("Recursion detected" class).
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.shelf-focused && desc.section.albums.length > 0) {
|
||||
if (88px + s * 240px < -page-flick.viewport-y) {
|
||||
page-flick.viewport-y = -(88px + s * 240px - 8px);
|
||||
} else if (88px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
|
||||
page-flick.viewport-y = -(88px + s * 240px + 218px - page-flick.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if desc.section.albums.length > 0: VerticalLayout {
|
||||
spacing: 12px;
|
||||
|
||||
// Fixed-height title wrapper (was a bare Text): makes the
|
||||
// shelf block a constant 218px so the scroll-into-view
|
||||
// arithmetic above stays exact.
|
||||
Rectangle {
|
||||
height: 24px;
|
||||
Text {
|
||||
text: desc.section.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
strip-flick := Flickable {
|
||||
height: 182px;
|
||||
viewport-width: strip.preferred-width;
|
||||
viewport-height: self.height;
|
||||
|
||||
// Keep the cursor card inside the strip's horizontal
|
||||
// viewport (card pitch = 128px art + 14px spacing).
|
||||
property <int> cursor-probe: shelf-vl.cursor;
|
||||
changed cursor-probe => {
|
||||
if (shelf-vl.shelf-focused) {
|
||||
if (shelf-vl.cursor * 142px < -self.viewport-x) {
|
||||
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
|
||||
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
|
||||
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strip := HorizontalLayout {
|
||||
spacing: 14px;
|
||||
alignment: start;
|
||||
for album in desc.section.albums: KioskCard {
|
||||
for album[i] in desc.section.albums: KioskCard {
|
||||
album: album;
|
||||
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
|
||||
clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
// FavoritesActions.select-tab (Rust lazy-loads each tab). Covers are push-only
|
||||
// except album covers (windowed — v1 shows the first ~60; the window hook is a
|
||||
// follow-up).
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): the nav index space is the 5 tabs (indices
|
||||
// 0-4) followed by the active tab's items as a uniform grid (tracks = a
|
||||
// 1-column list). The view publishes that geometry into KioskNavState; each
|
||||
// container mirrors the focus index (ring + scroll-into-view) and answers
|
||||
// the Enter pulse. viewport-y is only ever WRITTEN from callbacks on a
|
||||
// settled layout, never read by layout-feeding bindings (the
|
||||
// AlbumCollectionView "Recursion detected" panic class).
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { FavoritesState, FavoritesActions } from "../state.slint";
|
||||
import { FavoritesState, FavoritesActions, KioskNavState } from "../state.slint";
|
||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||
@@ -16,9 +24,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||
component LibTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <string> id;
|
||||
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback picked(string);
|
||||
property <bool> active: FavoritesState.active-tab == root.id;
|
||||
horizontal-stretch: 0;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
HorizontalLayout {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
@@ -50,6 +63,64 @@ export component KioskLibrary inherits Rectangle {
|
||||
|
||||
property <length> pad: 16px;
|
||||
|
||||
// ---- Nav geometry publish (PR 2): 5 tabs + the active tab's items.
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 5;
|
||||
KioskNavState.shelves = false;
|
||||
if (FavoritesState.active-tab == "tracks") {
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 5 + FavoritesState.tracks-visible.length;
|
||||
} else if (FavoritesState.active-tab == "albums") {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 5 + FavoritesState.albums-visible.length;
|
||||
} else if (FavoritesState.active-tab == "artists") {
|
||||
KioskNavState.columns = 7;
|
||||
KioskNavState.count = 5 + FavoritesState.artists-visible.length;
|
||||
} else if (FavoritesState.active-tab == "playlists") {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 5 + FavoritesState.playlists-visible.length;
|
||||
} else {
|
||||
KioskNavState.columns = 7;
|
||||
KioskNavState.count = 5 + FavoritesState.labels-visible.length;
|
||||
}
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <string> nav-tab-probe: FavoritesState.active-tab;
|
||||
changed nav-tab-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: FavoritesState.tracks-visible.length
|
||||
+ FavoritesState.albums-visible.length
|
||||
+ FavoritesState.artists-visible.length
|
||||
+ FavoritesState.playlists-visible.length
|
||||
+ FavoritesState.labels-visible.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter on a tab → the same switch the tab TouchAreas drive. Items are
|
||||
// activated by their own container (each watches the same pulse).
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index < KioskNavState.tabs) {
|
||||
if (KioskNavState.index == 0) {
|
||||
FavoritesActions.select-tab("tracks");
|
||||
} else if (KioskNavState.index == 1) {
|
||||
FavoritesActions.select-tab("albums");
|
||||
} else if (KioskNavState.index == 2) {
|
||||
FavoritesActions.select-tab("artists");
|
||||
} else if (KioskNavState.index == 3) {
|
||||
FavoritesActions.select-tab("playlists");
|
||||
} else if (KioskNavState.index == 4) {
|
||||
FavoritesActions.select-tab("labels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
@@ -61,11 +132,11 @@ export component KioskLibrary inherits Rectangle {
|
||||
padding-right: root.pad;
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
LibTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Albums"); id: "albums"; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Artists"); id: "artists"; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Playlists"); id: "playlists"; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Labels"); id: "labels"; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Playlists"); id: "playlists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
LibTab { label: @tr("Labels"); id: "labels"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(t) => { FavoritesActions.select-tab(t); } }
|
||||
}
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
@@ -81,9 +152,33 @@ export component KioskLibrary inherits Rectangle {
|
||||
// ListView windows the rows (only the visible band is mounted) — a
|
||||
// plain VerticalLayout of every track spiked CPU on entry, which a Pi
|
||||
// can't afford.
|
||||
if FavoritesState.active-tab == "tracks": ListView {
|
||||
for track in FavoritesState.tracks-visible: KioskTrackRow {
|
||||
if FavoritesState.active-tab == "tracks": tracks-list := ListView {
|
||||
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < FavoritesState.tracks-visible.length;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focused-item * 62px < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
FavoritesActions.play-track(FavoritesState.tracks-visible[self.focused-item].id);
|
||||
}
|
||||
}
|
||||
|
||||
for track[i] in FavoritesState.tracks-visible: KioskTrackRow {
|
||||
track: track;
|
||||
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||
play(id) => {
|
||||
FavoritesActions.play-track(id);
|
||||
}
|
||||
@@ -103,6 +198,31 @@ export component KioskLibrary inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(FavoritesState.artists-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < FavoritesState.artists-visible.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
root.open-artist(FavoritesState.artists-visible[self.focused-item].id);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for artist[i] in FavoritesState.artists-visible: KioskArtistCard {
|
||||
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
||||
@@ -111,6 +231,7 @@ export component KioskLibrary inherits Rectangle {
|
||||
id: artist.id;
|
||||
name: artist.name;
|
||||
avatar: artist.image;
|
||||
nav-focused: arts.item-focused && arts.focused-item == i;
|
||||
clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
@@ -124,6 +245,33 @@ export component KioskLibrary inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(FavoritesState.labels-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < FavoritesState.labels-visible.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
FavoritesActions.open-label(
|
||||
FavoritesState.labels-visible[self.focused-item].id,
|
||||
FavoritesState.labels-visible[self.focused-item].name);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for label[i] in FavoritesState.labels-visible: KioskArtistCard {
|
||||
x: root.pad + Math.mod(i, labs.cols) * (labs.cell + labs.gap);
|
||||
@@ -132,6 +280,7 @@ export component KioskLibrary inherits Rectangle {
|
||||
id: label.id;
|
||||
name: label.name;
|
||||
avatar: label.image;
|
||||
nav-focused: labs.item-focused && labs.focused-item == i;
|
||||
clicked(id) => {
|
||||
FavoritesActions.open-label(label.id, label.name);
|
||||
}
|
||||
@@ -145,11 +294,40 @@ export component KioskLibrary inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(FavoritesState.playlists-visible.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < FavoritesState.playlists-visible.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
FavoritesActions.open-playlist(FavoritesState.playlists-visible[self.focused-item].id);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for pl[i] in FavoritesState.playlists-visible: Rectangle {
|
||||
x: root.pad + Math.mod(i, pls.cols) * (pls.cell + pls.gap);
|
||||
y: root.pad + Math.floor(i / pls.cols) * (pls.cell + 42px + pls.gap);
|
||||
width: pls.cell;
|
||||
border-width: pls.item-focused && pls.focused-item == i ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: pls.item-focused && pls.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
spacing: 7px;
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
// (open-album routes to ContentView.local-album in Rust). v1 simplifications:
|
||||
// no album-window / tracks-load-more hooks yet (first band loads), folders as a
|
||||
// flat grid.
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): same model as KioskLibrary — the index space
|
||||
// is the 4 tabs followed by the active tab's items as a uniform grid
|
||||
// (tracks = a 1-column list). The view publishes the geometry; containers
|
||||
// mirror the focus index and answer the Enter pulse.
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
||||
import { LocalLibraryState, LocalLibraryActions, KioskNavState } from "../state.slint";
|
||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||
@@ -16,9 +21,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||
component LLTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <string> id;
|
||||
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback picked(string);
|
||||
property <bool> active: LocalLibraryState.active-tab == root.id;
|
||||
horizontal-stretch: 0;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
HorizontalLayout {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
@@ -47,6 +57,57 @@ component LLTab inherits Rectangle {
|
||||
export component KioskLocalLibrary inherits Rectangle {
|
||||
property <length> pad: 16px;
|
||||
|
||||
// ---- Nav geometry publish (PR 2): 4 tabs + the active tab's items.
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 4;
|
||||
KioskNavState.shelves = false;
|
||||
if (LocalLibraryState.active-tab == "albums") {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 4 + LocalLibraryState.albums-visible.length;
|
||||
} else if (LocalLibraryState.active-tab == "artists") {
|
||||
KioskNavState.columns = 7;
|
||||
KioskNavState.count = 4 + LocalLibraryState.artists.length;
|
||||
} else if (LocalLibraryState.active-tab == "folders") {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 4 + LocalLibraryState.folders-visible.length;
|
||||
} else {
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 4 + LocalLibraryState.tracks-visible.length;
|
||||
}
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <string> nav-tab-probe: LocalLibraryState.active-tab;
|
||||
changed nav-tab-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: LocalLibraryState.albums-visible.length
|
||||
+ LocalLibraryState.artists.length
|
||||
+ LocalLibraryState.folders-visible.length
|
||||
+ LocalLibraryState.tracks-visible.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter on a tab → the same switch the tab TouchAreas drive.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index < KioskNavState.tabs) {
|
||||
if (KioskNavState.index == 0) {
|
||||
LocalLibraryActions.select-tab("albums");
|
||||
} else if (KioskNavState.index == 1) {
|
||||
LocalLibraryActions.select-tab("artists");
|
||||
} else if (KioskNavState.index == 2) {
|
||||
LocalLibraryActions.select-tab("folders");
|
||||
} else if (KioskNavState.index == 3) {
|
||||
LocalLibraryActions.select-tab("tracks");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
@@ -58,10 +119,10 @@ export component KioskLocalLibrary inherits Rectangle {
|
||||
padding-right: root.pad;
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
LLTab { label: @tr("Albums"); id: "albums"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Artists"); id: "artists"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Folders"); id: "folders"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Folders"); id: "folders"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
LLTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { LocalLibraryActions.select-tab(t); } }
|
||||
}
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
@@ -94,6 +155,32 @@ export component KioskLocalLibrary inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(LocalLibraryState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < LocalLibraryState.artists.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
// The TouchArea path passes the artist NAME as id.
|
||||
LocalLibraryActions.open-artist(LocalLibraryState.artists[self.focused-item].name);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for artist[i] in LocalLibraryState.artists: KioskArtistCard {
|
||||
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
|
||||
@@ -102,6 +189,7 @@ export component KioskLocalLibrary inherits Rectangle {
|
||||
id: artist.name;
|
||||
name: artist.display-name;
|
||||
avatar: artist.image;
|
||||
nav-focused: arts.item-focused && arts.focused-item == i;
|
||||
clicked(id) => {
|
||||
LocalLibraryActions.open-artist(id);
|
||||
}
|
||||
@@ -109,9 +197,33 @@ export component KioskLocalLibrary inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
if LocalLibraryState.active-tab == "tracks": ListView {
|
||||
for track in LocalLibraryState.tracks-visible: KioskTrackRow {
|
||||
if LocalLibraryState.active-tab == "tracks": tracks-list := ListView {
|
||||
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < LocalLibraryState.tracks-visible.length;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focused-item * 62px < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
LocalLibraryActions.track-action(LocalLibraryState.tracks-visible[self.focused-item].id, "play");
|
||||
}
|
||||
}
|
||||
|
||||
for track[i] in LocalLibraryState.tracks-visible: KioskTrackRow {
|
||||
track: track;
|
||||
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||
play(id) => {
|
||||
LocalLibraryActions.track-action(id, "play");
|
||||
}
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
// Collections (separate ContentView routes); simple cover cards, tap opens the
|
||||
// detail. Covers are push-only. Mounted for both ContentView.mixtapes and
|
||||
// ContentView.collections.
|
||||
//
|
||||
// Keyboard/gamepad nav (PR 2): the index space is the 2 tabs followed by the
|
||||
// active list as a 6-column grid. Enter on a tab routes through the same
|
||||
// header-menu navigation the tab TouchAreas use.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView } from "../state.slint";
|
||||
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView, KioskNavState } from "../state.slint";
|
||||
|
||||
component MyQbzTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback picked;
|
||||
horizontal-stretch: 0;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
HorizontalLayout {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
@@ -40,8 +49,14 @@ component MyQbzTab inherits Rectangle {
|
||||
component MyQbzCardLite inherits Rectangle {
|
||||
in property <MixtapeCardItem> item;
|
||||
in property <length> size: 150px;
|
||||
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||
in property <bool> nav-focused: false;
|
||||
callback clicked(string);
|
||||
width: root.size;
|
||||
border-width: root.nav-focused ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
spacing: 7px;
|
||||
@@ -94,6 +109,37 @@ export component KioskMyQBZ inherits Rectangle {
|
||||
property <length> pad: 16px;
|
||||
property <bool> on-collections: NavState.view == ContentView.collections;
|
||||
|
||||
// ---- Nav geometry publish (PR 2): 2 tabs + the active list.
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 2;
|
||||
KioskNavState.shelves = false;
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 2 + (root.on-collections
|
||||
? MyQbzState.collections.length
|
||||
: MyQbzState.mixtapes.length);
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: MyQbzState.collections.length + MyQbzState.mixtapes.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter on a tab → the same route the tab TouchAreas navigate to.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index < KioskNavState.tabs) {
|
||||
if (KioskNavState.index == 0) {
|
||||
root.navigate("myqbz-mixtapes");
|
||||
} else if (KioskNavState.index == 1) {
|
||||
root.navigate("myqbz-collections");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
@@ -108,6 +154,7 @@ export component KioskMyQBZ inherits Rectangle {
|
||||
MyQbzTab {
|
||||
label: @tr("Mixtapes");
|
||||
active: !root.on-collections;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||
picked => {
|
||||
root.navigate("myqbz-mixtapes");
|
||||
}
|
||||
@@ -115,6 +162,7 @@ export component KioskMyQBZ inherits Rectangle {
|
||||
MyQbzTab {
|
||||
label: @tr("Collections");
|
||||
active: root.on-collections;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||
picked => {
|
||||
root.navigate("myqbz-collections");
|
||||
}
|
||||
@@ -136,12 +184,44 @@ export component KioskMyQBZ inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil((root.on-collections ? MyQbzState.collections.length : MyQbzState.mixtapes.length) / self.cols) * (self.cell + 44px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < (root.on-collections
|
||||
? MyQbzState.collections.length
|
||||
: MyQbzState.mixtapes.length);
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 44px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 44px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 44px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
if (root.on-collections) {
|
||||
MyQbzActions.open-card(MyQbzState.collections[self.focused-item].id);
|
||||
} else {
|
||||
MyQbzActions.open-card(MyQbzState.mixtapes[self.focused-item].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for item[i] in (root.on-collections ? MyQbzState.collections : MyQbzState.mixtapes): MyQbzCardLite {
|
||||
x: root.pad + Math.mod(i, grid.cols) * (grid.cell + grid.gap);
|
||||
y: root.pad + Math.floor(i / grid.cols) * (grid.cell + 44px + grid.gap);
|
||||
size: grid.cell;
|
||||
item: item;
|
||||
nav-focused: grid.item-focused && grid.focused-item == i;
|
||||
clicked(id) => {
|
||||
MyQbzActions.open-card(id);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
// QueueState.panel-opened() on init so the paginated queue slice is fresh.
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState } from "../state.slint";
|
||||
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState, KioskNavState } from "../state.slint";
|
||||
import { LyricsLinesView } from "LyricsLinesView.slint";
|
||||
|
||||
component QueueRowLite inherits Rectangle {
|
||||
in property <QueueItem> item;
|
||||
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskTrackRow.
|
||||
in property <bool> nav-focused: false;
|
||||
callback play;
|
||||
height: 58px;
|
||||
border-radius: Radius.sm;
|
||||
background: qrow.has-hover ? Theme.alpha-6 : transparent;
|
||||
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : qrow.has-hover ? Theme.alpha-6 : transparent;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 12px;
|
||||
@@ -74,8 +78,15 @@ component QueueRowLite inherits Rectangle {
|
||||
component QueueTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||
// Only the right-panel Queue/History pair joins the nav model; the
|
||||
// left Cover/Lyrics pair leaves this false.
|
||||
in property <bool> nav-focused: false;
|
||||
callback picked;
|
||||
horizontal-stretch: 0;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
HorizontalLayout {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
@@ -115,11 +126,52 @@ export component KioskNowPlaying inherits Rectangle {
|
||||
// lightweight lyrics surface (no immersive visualizer needed, Pi-friendly).
|
||||
property <bool> show-lyrics: false;
|
||||
|
||||
// ---- Keyboard/gamepad nav (PR 2). The nav model is the right panel:
|
||||
// 2 queue tabs (Up Next / History) followed by the active tab's rows as
|
||||
// a 1-column list. Enter on a row plays it; Enter on a tab switches.
|
||||
// The left panel (cover/lyrics toggle, artist link, seek) stays
|
||||
// touch-only in v1; the transport row joins the "player" zone (ring
|
||||
// below; Enter = play/pause handled by the shell).
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 2;
|
||||
KioskNavState.shelves = false;
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 2 + (QueueState.tab == 0
|
||||
? QueueState.upcoming-page.length
|
||||
: QueueState.history.length);
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
property <int> nav-tab-probe: QueueState.tab;
|
||||
changed nav-tab-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: QueueState.upcoming-page.length + QueueState.history.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter pulse → queue tab switch / play the focused queue row.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content") {
|
||||
if (KioskNavState.index < KioskNavState.tabs) {
|
||||
QueueState.set-tab(KioskNavState.index);
|
||||
} else {
|
||||
if (QueueState.tab == 0 && KioskNavState.index - 2 < QueueState.upcoming-page.length) {
|
||||
QueueState.play-upcoming(KioskNavState.index - 2);
|
||||
} else if (QueueState.tab == 1 && KioskNavState.index - 2 < QueueState.history.length) {
|
||||
QueueState.play-history(KioskNavState.index - 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init => {
|
||||
QueueState.panel-opened();
|
||||
// Mount in Cover mode — make sure a stale follow flag from a prior visit
|
||||
// doesn't keep the sync engine running.
|
||||
ShellState.kiosk-lyrics-follow = false;
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
@@ -278,6 +330,12 @@ export component KioskNowPlaying inherits Rectangle {
|
||||
// space on short windows).
|
||||
Rectangle {
|
||||
height: 46px;
|
||||
// PR 2 — the "player" zone ring (PlayerBarSmall is hidden
|
||||
// on this view, so the in-page transport carries it).
|
||||
border-width: KioskNavState.nav-active && KioskNavState.zone == "player" ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: KioskNavState.nav-active && KioskNavState.zone == "player" ? Theme.accent.with-alpha(0.08) : transparent;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
TransportControls {
|
||||
@@ -321,6 +379,7 @@ export component KioskNowPlaying inherits Rectangle {
|
||||
QueueTab {
|
||||
label: @tr("Up Next");
|
||||
active: QueueState.tab == 0;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
|
||||
picked => {
|
||||
QueueState.set-tab(0);
|
||||
}
|
||||
@@ -328,6 +387,7 @@ export component KioskNowPlaying inherits Rectangle {
|
||||
QueueTab {
|
||||
label: @tr("History");
|
||||
active: QueueState.tab == 1;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
||||
picked => {
|
||||
QueueState.set-tab(1);
|
||||
}
|
||||
@@ -341,26 +401,66 @@ export component KioskNowPlaying inherits Rectangle {
|
||||
// the page is cheap.)
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
if QueueState.tab == 0: Flickable {
|
||||
if QueueState.tab == 0: up-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: up-body.preferred-height;
|
||||
|
||||
// PR 2 — scroll the focused row into view (60px
|
||||
// pitch: 58px row + 2px spacing). Written from a
|
||||
// callback on a settled layout.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < QueueState.upcoming-page.length;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focused-item * 60px < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focused-item * 60px - 8px);
|
||||
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
up-body := VerticalLayout {
|
||||
spacing: 2px;
|
||||
for item[i] in QueueState.upcoming-page: QueueRowLite {
|
||||
item: item;
|
||||
nav-focused: up-flick.item-focused && up-flick.focused-item == i;
|
||||
play => {
|
||||
QueueState.play-upcoming(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if QueueState.tab == 1: Flickable {
|
||||
if QueueState.tab == 1: hist-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: hist-body.preferred-height;
|
||||
|
||||
// PR 2 — same scroll-into-view as the Up Next list.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < QueueState.history.length;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focused-item * 60px < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focused-item * 60px - 8px);
|
||||
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hist-body := VerticalLayout {
|
||||
spacing: 2px;
|
||||
for item[i] in QueueState.history: QueueRowLite {
|
||||
item: item;
|
||||
nav-focused: hist-flick.item-focused && hist-flick.focused-item == i;
|
||||
play => {
|
||||
QueueState.play-history(i);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { SearchState, SearchActions } from "../state.slint";
|
||||
import { SearchState, SearchActions, KioskNavState } from "../state.slint";
|
||||
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
|
||||
import { KioskArtistCard } from "KioskArtistCard.slint";
|
||||
import { KioskTrackRow } from "KioskTrackRow.slint";
|
||||
@@ -17,9 +17,14 @@ import { KioskCard } from "KioskCard.slint";
|
||||
component SearchTab inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <int> index;
|
||||
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback picked(int);
|
||||
property <bool> active: SearchState.tab == root.index;
|
||||
horizontal-stretch: 0;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
HorizontalLayout {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
@@ -89,6 +94,55 @@ export component KioskSearch inherits Rectangle {
|
||||
|
||||
property <length> pad: 16px;
|
||||
|
||||
// ---- Keyboard/gamepad nav (PR 2). The index space is the 5 tabs
|
||||
// (All/Albums/Tracks/Artists/Playlists) followed by the active tab's
|
||||
// items as a uniform grid. The All dashboard publishes NO items in v1
|
||||
// (tabs only — its hero/preview mosaic isn't a uniform grid); the tab
|
||||
// strips remain fully reachable. Containers mirror the focus index and
|
||||
// answer the Enter pulse; the albums grid wires itself (KioskAlbumGrid).
|
||||
function publish-nav() {
|
||||
KioskNavState.tabs = 5;
|
||||
KioskNavState.shelves = false;
|
||||
if (SearchState.tab == 1) {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 5 + SearchState.albums.length;
|
||||
} else if (SearchState.tab == 2) {
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 5 + SearchState.tracks.length;
|
||||
} else if (SearchState.tab == 3) {
|
||||
KioskNavState.columns = 7;
|
||||
KioskNavState.count = 5 + SearchState.artists.length;
|
||||
} else if (SearchState.tab == 4) {
|
||||
KioskNavState.columns = 6;
|
||||
KioskNavState.count = 5 + SearchState.playlists.length;
|
||||
} else {
|
||||
KioskNavState.columns = 1;
|
||||
KioskNavState.count = 5;
|
||||
}
|
||||
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
|
||||
}
|
||||
init => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-tab-probe: SearchState.tab;
|
||||
changed nav-tab-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
property <int> nav-len-probe: SearchState.albums.length + SearchState.tracks.length
|
||||
+ SearchState.artists.length + SearchState.playlists.length;
|
||||
changed nav-len-probe => {
|
||||
root.publish-nav();
|
||||
}
|
||||
|
||||
// Enter on a tab → the same switch the tab TouchAreas drive.
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (KioskNavState.nav-active && KioskNavState.zone == "content"
|
||||
&& KioskNavState.index < KioskNavState.tabs) {
|
||||
SearchActions.tab-changed(KioskNavState.index);
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
@@ -101,11 +155,11 @@ export component KioskSearch inherits Rectangle {
|
||||
padding-right: root.pad;
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
SearchTab { label: @tr("All"); index: 0; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Albums"); index: 1; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Tracks"); index: 2; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Artists"); index: 3; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Playlists"); index: 4; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("All"); index: 0; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Albums"); index: 1; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Tracks"); index: 2; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Artists"); index: 3; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
SearchTab { label: @tr("Playlists"); index: 4; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(i) => { SearchActions.tab-changed(i); } }
|
||||
}
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
@@ -365,9 +419,33 @@ export component KioskSearch inherits Rectangle {
|
||||
}
|
||||
|
||||
// Tracks — windowed list (ListView mounts only the visible band).
|
||||
if SearchState.tab == 2: ListView {
|
||||
for track in SearchState.tracks: KioskTrackRow {
|
||||
if SearchState.tab == 2: tracks-list := ListView {
|
||||
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < SearchState.tracks.length;
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focused-item * 62px < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focused-item * 62px - 8px);
|
||||
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
root.media-action("track", SearchState.tracks[self.focused-item].id, "play");
|
||||
}
|
||||
}
|
||||
|
||||
for track[i] in SearchState.tracks: KioskTrackRow {
|
||||
track: track;
|
||||
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
|
||||
play(id) => {
|
||||
root.media-action("track", id, "play");
|
||||
}
|
||||
@@ -381,6 +459,31 @@ export component KioskSearch inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(SearchState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < SearchState.artists.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
root.open-artist(SearchState.artists[self.focused-item].id);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for artist[i] in SearchState.artists: KioskArtistCard {
|
||||
x: root.pad + Math.mod(i, agrid.cols) * (agrid.cell + agrid.gap);
|
||||
@@ -389,6 +492,7 @@ export component KioskSearch inherits Rectangle {
|
||||
id: artist.id;
|
||||
name: artist.title;
|
||||
avatar: artist.artwork;
|
||||
nav-focused: agrid.item-focused && agrid.focused-item == i;
|
||||
clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
@@ -403,11 +507,40 @@ export component KioskSearch inherits Rectangle {
|
||||
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
|
||||
viewport-width: self.width;
|
||||
viewport-height: Math.ceil(SearchState.playlists.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
|
||||
|
||||
// PR 2 — focus ring + Enter + scroll-into-view.
|
||||
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
|
||||
property <bool> item-focused: KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "content"
|
||||
&& self.focused-item >= 0
|
||||
&& self.focused-item < SearchState.playlists.length;
|
||||
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
|
||||
property <int> nav-index-probe: KioskNavState.index;
|
||||
changed nav-index-probe => {
|
||||
if (self.item-focused) {
|
||||
if (self.focus-top < -self.viewport-y) {
|
||||
self.viewport-y = -(self.focus-top - 8px);
|
||||
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
|
||||
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
property <int> act-probe: KioskNavState.activate-seq;
|
||||
changed act-probe => {
|
||||
if (self.item-focused) {
|
||||
root.media-action("playlist", SearchState.playlists[self.focused-item].id, "open");
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
for pl[i] in SearchState.playlists: Rectangle {
|
||||
x: root.pad + Math.mod(i, pgrid.cols) * (pgrid.cell + pgrid.gap);
|
||||
y: root.pad + Math.floor(i / pgrid.cols) * (pgrid.cell + 42px + pgrid.gap);
|
||||
width: pgrid.cell;
|
||||
border-width: pgrid.item-focused && pgrid.focused-item == i ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: pgrid.item-focused && pgrid.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
spacing: 7px;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { LineEdit } from "std-widgets.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Layout } from "../foundation/layout.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
|
||||
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState, NowPlayingState, KioskNavState } from "../state.slint";
|
||||
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
|
||||
import { PlayerBarSmall } from "PlayerBarSmall.slint";
|
||||
import { NavRail } from "NavRail.slint";
|
||||
@@ -73,6 +73,131 @@ export component KioskShell inherits Rectangle {
|
||||
callback logout();
|
||||
callback close-app();
|
||||
callback recovery-login();
|
||||
// Keyboard/gamepad nav (PR 2): emitted by the root FocusScope on Enter.
|
||||
// The internal dispatch runs through nav-activate() below; this public
|
||||
// seam exists so an outer layer (or a future gamepad driver) can observe
|
||||
// or trigger the same "activate the focused item" request.
|
||||
callback activate-focused();
|
||||
|
||||
// ---- Kiosk keyboard navigation (Steam Deck Game Mode plan, PR 2) ----
|
||||
// One root FocusScope drives the whole kiosk: arrows move a focus ring
|
||||
// through three zones — rail (bottom NavRail) → content (the mounted
|
||||
// view's items) → player (the transport bar) — Enter activates the
|
||||
// focused item, Esc goes back. The zone model lives in KioskNavState
|
||||
// (state.slint); the mounted content view publishes its geometry there
|
||||
// (tabs / columns / count / shelves) and mirrors `index` onto its focus
|
||||
// rings. Pure Slint: PR 3's gamepad feeds this same path by injecting
|
||||
// synthetic key events, so nothing here is keyboard-specific.
|
||||
//
|
||||
// Zone cycle (Down): rail → content → player → rail; Up reverses. The
|
||||
// content index space is `tabs` leading tab-strip entries followed by
|
||||
// the item rows (a `columns`-wide grid; columns == 1 = list/shelves).
|
||||
function nav-left() {
|
||||
KioskNavState.nav-active = true;
|
||||
KioskNavState.dir = 0;
|
||||
if (KioskNavState.zone == "rail") {
|
||||
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
|
||||
} else if (KioskNavState.zone == "content") {
|
||||
if (KioskNavState.index < KioskNavState.tabs) {
|
||||
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
|
||||
} else if (KioskNavState.shelves) {
|
||||
KioskNavState.hmove = -1;
|
||||
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) > 0) {
|
||||
KioskNavState.index = KioskNavState.index - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function nav-right() {
|
||||
KioskNavState.nav-active = true;
|
||||
KioskNavState.dir = 0;
|
||||
if (KioskNavState.zone == "rail") {
|
||||
KioskNavState.index = Math.min(rail.tile-count - 1, KioskNavState.index + 1);
|
||||
} else if (KioskNavState.zone == "content") {
|
||||
if (KioskNavState.index < KioskNavState.tabs) {
|
||||
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index + 1);
|
||||
} else if (KioskNavState.shelves) {
|
||||
KioskNavState.hmove = 1;
|
||||
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) < KioskNavState.columns - 1
|
||||
&& KioskNavState.index + 1 < KioskNavState.count) {
|
||||
KioskNavState.index = KioskNavState.index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function nav-up() {
|
||||
KioskNavState.nav-active = true;
|
||||
KioskNavState.dir = -1;
|
||||
if (KioskNavState.zone == "content") {
|
||||
if (KioskNavState.index >= KioskNavState.tabs + KioskNavState.columns) {
|
||||
KioskNavState.index = KioskNavState.index - KioskNavState.columns;
|
||||
} else if (KioskNavState.index >= KioskNavState.tabs) {
|
||||
// Grid row 0 → the tab strip above it (column → tab).
|
||||
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index - KioskNavState.tabs);
|
||||
} else {
|
||||
// Tab strip (top of the cycle) → wrap down to the rail.
|
||||
KioskNavState.zone = "rail";
|
||||
KioskNavState.index = 0;
|
||||
}
|
||||
} else if (KioskNavState.zone == "rail") {
|
||||
KioskNavState.zone = "player";
|
||||
KioskNavState.index = 0;
|
||||
} else {
|
||||
KioskNavState.zone = "content";
|
||||
KioskNavState.index = 0;
|
||||
}
|
||||
}
|
||||
function nav-down() {
|
||||
KioskNavState.nav-active = true;
|
||||
KioskNavState.dir = 1;
|
||||
if (KioskNavState.zone == "content") {
|
||||
if (KioskNavState.index < KioskNavState.tabs) {
|
||||
// Tab strip → the first item row (or the player bar when the
|
||||
// view carries no items).
|
||||
if (KioskNavState.count > KioskNavState.tabs) {
|
||||
KioskNavState.index = KioskNavState.tabs;
|
||||
} else {
|
||||
KioskNavState.zone = "player";
|
||||
KioskNavState.index = 0;
|
||||
}
|
||||
} else if (KioskNavState.index + KioskNavState.columns < KioskNavState.count) {
|
||||
KioskNavState.index = KioskNavState.index + KioskNavState.columns;
|
||||
} else {
|
||||
KioskNavState.zone = "player";
|
||||
KioskNavState.index = 0;
|
||||
}
|
||||
} else if (KioskNavState.zone == "player") {
|
||||
KioskNavState.zone = "rail";
|
||||
KioskNavState.index = 0;
|
||||
} else {
|
||||
KioskNavState.zone = "content";
|
||||
KioskNavState.index = 0;
|
||||
}
|
||||
}
|
||||
function nav-activate() {
|
||||
KioskNavState.nav-active = true;
|
||||
if (KioskNavState.zone == "player") {
|
||||
// The transport is ONE focusable unit: Enter = play/pause.
|
||||
if (NowPlayingState.has-track) {
|
||||
NowPlayingState.toggle-play();
|
||||
}
|
||||
} else if (KioskNavState.zone == "rail") {
|
||||
rail.activate-index(KioskNavState.index);
|
||||
} else {
|
||||
// Content views own their activation. They are conditionally
|
||||
// mounted (can't be addressed from here), so the request goes
|
||||
// out as a pulse the mounted view answers via a changed probe.
|
||||
KioskNavState.activate-seq = KioskNavState.activate-seq + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the nav model on every view switch: focus lands on the new
|
||||
// view's content, parked at the first item.
|
||||
property <ContentView> nav-view-probe: NavState.view;
|
||||
changed nav-view-probe => {
|
||||
KioskNavState.zone = "content";
|
||||
KioskNavState.index = 0;
|
||||
KioskNavState.dir = 0;
|
||||
KioskNavState.hmove = 0;
|
||||
}
|
||||
|
||||
// Kiosk chrome heights. The back bar + transport are one header-row tall
|
||||
// each; the NavRail is a fat touch target.
|
||||
@@ -180,6 +305,9 @@ export component KioskShell inherits Rectangle {
|
||||
// on-screen keyboard (squeekboard) closes. Otherwise the
|
||||
// search field keeps focus forever and the OSK never hides.
|
||||
search-field.clear-focus();
|
||||
// Hand keyboard focus back to the nav scope so arrows
|
||||
// keep working after a search (PR 2).
|
||||
keys.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +331,11 @@ export component KioskShell inherits Rectangle {
|
||||
// BEHIND the views (which handle their own taps); it only catches
|
||||
// empty-area taps.
|
||||
dismiss-osk-ta := TouchArea {
|
||||
clicked => { search-field.clear-focus(); }
|
||||
clicked => {
|
||||
search-field.clear-focus();
|
||||
// Same hand-back as the accepted path (PR 2).
|
||||
keys.focus();
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
@@ -500,7 +632,7 @@ export component KioskShell inherits Rectangle {
|
||||
|
||||
// --- Bottom NavRail (net-new). Each tile maps to the real route the
|
||||
// Sidebar/Rust dispatcher already uses. ------------------------------
|
||||
NavRail {
|
||||
rail := NavRail {
|
||||
height: root.navrail-height;
|
||||
discover => {
|
||||
root.header-menu-navigate("discover-home");
|
||||
@@ -526,6 +658,82 @@ export component KioskShell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Player-zone focus ring (PR 2). The transport bar is ONE focusable
|
||||
// unit (Enter toggles play/pause, handled in nav-activate). Drawn as an
|
||||
// overlay on the bar's exact slot (above the NavRail); the Now Playing
|
||||
// view hides PlayerBarSmall and draws its own ring on its in-page
|
||||
// transport row instead.
|
||||
if NavState.view != ContentView.now-playing
|
||||
&& KioskNavState.nav-active
|
||||
&& KioskNavState.zone == "player": Rectangle {
|
||||
x: 0;
|
||||
y: root.height - root.navrail-height - Layout.header-height;
|
||||
width: root.width;
|
||||
height: Layout.header-height;
|
||||
border-width: 3px;
|
||||
border-color: Theme.accent;
|
||||
border-radius: Radius.sm;
|
||||
background: Theme.accent.with-alpha(0.08);
|
||||
}
|
||||
|
||||
// --- Root nav FocusScope (PR 2). Holds keyboard focus for the whole
|
||||
// kiosk; TouchAreas never take focus, so it survives card/tile taps and
|
||||
// only yields to the search LineEdit (which then sets
|
||||
// UiFocusState.text-input-focused and every key is rejected here — typing
|
||||
// must never navigate). Rejected keys keep their normal path.
|
||||
keys := FocusScope {
|
||||
key-pressed(event) => {
|
||||
if (UiFocusState.text-input-focused) {
|
||||
return reject;
|
||||
}
|
||||
// The immersive visualizer overlay owns its own keys while open.
|
||||
if (ImmersiveState.open) {
|
||||
return reject;
|
||||
}
|
||||
if (event.text == Key.LeftArrow) {
|
||||
root.nav-left();
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.RightArrow) {
|
||||
root.nav-right();
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.UpArrow) {
|
||||
root.nav-up();
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.DownArrow) {
|
||||
root.nav-down();
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.Return) {
|
||||
root.activate-focused();
|
||||
root.nav-activate();
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.Escape) {
|
||||
if (NavState.can-back) {
|
||||
NavState.request-back();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
|
||||
// Deferred mount focus — synchronous focus during the initial layout
|
||||
// races it; a one-shot timer grabs it a tick later (the desktop modal
|
||||
// idiom, e.g. KeyboardShortcutsModal).
|
||||
focus-timer := Timer {
|
||||
interval: 30ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
keys.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Immersive / visualizer overlay — the same app-level view the desktop
|
||||
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
|
||||
// mount its own copy. Reached from the NavRail visualizer tile (which fires
|
||||
|
||||
@@ -8,11 +8,15 @@ import { TrackItem } from "../state.slint";
|
||||
|
||||
export component KioskTrackRow inherits Rectangle {
|
||||
in property <TrackItem> track;
|
||||
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
|
||||
in property <bool> nav-focused: false;
|
||||
callback play(string);
|
||||
|
||||
height: 62px;
|
||||
border-radius: Radius.sm;
|
||||
background: ta.has-hover ? Theme.alpha-6 : transparent;
|
||||
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : ta.has-hover ? Theme.alpha-6 : transparent;
|
||||
border-width: root.nav-focused ? 2px : 0px;
|
||||
border-color: Theme.accent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
|
||||
@@ -8,20 +8,24 @@
|
||||
// panel).
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState } from "../state.slint";
|
||||
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState, KioskNavState } from "../state.slint";
|
||||
|
||||
component NavTile inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
// Keyboard/gamepad nav (PR 2): this tile holds the shell focus ring.
|
||||
in property <bool> nav-focused: false;
|
||||
callback clicked;
|
||||
|
||||
border-radius: Radius.sm;
|
||||
background: root.active
|
||||
background: root.nav-focused
|
||||
? Theme.accent.with-alpha(0.22)
|
||||
: root.active
|
||||
? Theme.accent.with-alpha(0.12)
|
||||
: ta.has-hover ? Theme.alpha-6 : transparent;
|
||||
border-width: root.active ? 1px : 0px;
|
||||
border-color: Theme.accent.with-alpha(0.30);
|
||||
border-width: root.nav-focused ? 2px : root.active ? 1px : 0px;
|
||||
border-color: root.nav-focused ? Theme.accent : Theme.accent.with-alpha(0.30);
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
@@ -65,6 +69,20 @@ export component NavRail inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Keyboard/gamepad nav (PR 2): tile count for the shell's rail zone and
|
||||
// the activation seam for Enter (the rail is always mounted, so the
|
||||
// shell calls this directly instead of using the activate-seq pulse).
|
||||
out property <int> tile-count: 7;
|
||||
public function activate-index(i: int) {
|
||||
if (i == 0) { root.discover(); }
|
||||
else if (i == 1) { root.library(); }
|
||||
else if (i == 2) { root.local-library(); }
|
||||
else if (i == 3) { root.myqbz(); }
|
||||
else if (i == 4) { root.playing(); }
|
||||
else if (i == 5) { root.visualizer(); }
|
||||
else if (i == 6) { root.menu(); }
|
||||
}
|
||||
|
||||
// 7 tiles, 8px outer padding, 6px gaps (6 gaps). Search is intentionally
|
||||
// absent — the back-bar carries a persistent search field, so a footer
|
||||
// Search tile is redundant.
|
||||
@@ -87,6 +105,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/compass.svg");
|
||||
label: @tr("Discover");
|
||||
active: NavState.view == ContentView.home;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 0;
|
||||
clicked => {
|
||||
root.discover();
|
||||
}
|
||||
@@ -99,6 +118,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Library");
|
||||
active: NavState.view == ContentView.favorites;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 1;
|
||||
clicked => {
|
||||
root.library();
|
||||
}
|
||||
@@ -111,6 +131,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/hard-drive.svg");
|
||||
label: @tr("Local Library");
|
||||
active: NavState.view == ContentView.local-library || NavState.view == ContentView.local-album;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 2;
|
||||
clicked => {
|
||||
root.local-library();
|
||||
}
|
||||
@@ -125,6 +146,7 @@ export component NavRail inherits Rectangle {
|
||||
active: NavState.view == ContentView.mixtapes
|
||||
|| NavState.view == ContentView.collections
|
||||
|| NavState.view == ContentView.mixtape-detail;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 3;
|
||||
clicked => {
|
||||
root.myqbz();
|
||||
}
|
||||
@@ -137,6 +159,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
label: @tr("Now Playing");
|
||||
active: NavState.view == ContentView.now-playing;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 4;
|
||||
clicked => {
|
||||
root.playing();
|
||||
}
|
||||
@@ -149,6 +172,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/sliders-horizontal.svg");
|
||||
label: @tr("Visualizer");
|
||||
active: false;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 5;
|
||||
clicked => {
|
||||
root.visualizer();
|
||||
}
|
||||
@@ -161,6 +185,7 @@ export component NavRail inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/settings.svg");
|
||||
label: @tr("Settings");
|
||||
active: NavState.view == ContentView.settings;
|
||||
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 6;
|
||||
clicked => {
|
||||
root.menu();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Native gamepad input — Steam Deck plan PR 3
|
||||
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
|
||||
//!
|
||||
//! Steam Input's default template for non-Steam shortcuts exposes a VIRTUAL
|
||||
//! Xbox 360 pad (uinput), not keyboard events — and QBZ reads neither by
|
||||
//! default. `gilrs` (pure Rust, evdev) reads that pad and translates
|
||||
//! D-pad / left stick / A / B into synthetic Slint key events through the
|
||||
//! same `dispatch_event` pipeline as the winit synthetic-key hook
|
||||
//! (main.rs install_browser_mouse_nav). PR 2's kiosk FocusScope consumes
|
||||
//! them as ordinary arrow/Enter/Escape presses; any USB/BT pad on a HTPC
|
||||
//! gets the same ride.
|
||||
//!
|
||||
//! The thread is spawned only when the kiosk profile is active or
|
||||
//! `QBZ_GAMEPAD=1` forces it (see the call site in main.rs).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gilrs::{Axis, Button, EventType, Gilrs};
|
||||
use slint::platform::{Key, WindowEvent};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::AppWindow;
|
||||
|
||||
/// Axis→direction hysteresis: engage when |axis| crosses the high threshold,
|
||||
/// release below the low one (classic 10-foot anti-jitter; stick drift near
|
||||
/// center must cause zero ghost navigation).
|
||||
const AXIS_ENGAGE: f32 = 0.6;
|
||||
const AXIS_RELEASE: f32 = 0.4;
|
||||
/// Key repeat while a direction is held: initial delay, then interval.
|
||||
const REPEAT_DELAY: Duration = Duration::from_millis(250);
|
||||
const REPEAT_INTERVAL: Duration = Duration::from_millis(80);
|
||||
/// Double-input guard for a user-selected keyboard-EMULATING Steam layout:
|
||||
/// the same nav action can then arrive from Steam's kbd hook AND from gilrs —
|
||||
/// collapse repeats of the same key inside this window.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(30);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Dir {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
fn key(self) -> Key {
|
||||
match self {
|
||||
Dir::Up => Key::UpArrow,
|
||||
Dir::Down => Key::DownArrow,
|
||||
Dir::Left => Key::LeftArrow,
|
||||
Dir::Right => Key::RightArrow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the gamepad listener thread (no-op log on failure — gamepad input
|
||||
/// is a convenience, never fatal).
|
||||
pub fn spawn(weak: slint::Weak<AppWindow>) {
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("qbz-gamepad".into())
|
||||
.spawn(move || run(weak))
|
||||
{
|
||||
log::warn!("[gamepad] could not spawn thread: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Press+release `key` as one synthetic tap on the UI thread, debounced.
|
||||
/// A Return/Escape tap is a single action; arrows are also emitted by the
|
||||
/// repeat engine. While a text field is focused, Escape is SUPPRESSED (B
|
||||
/// on the pad must close Steam's OSK without also navigating back — plan
|
||||
/// §4.5) and Return passes through (the OSK's own accept).
|
||||
fn emit_tap(weak: &slint::Weak<AppWindow>, key: Key, last: &mut Option<(Key, Instant)>) {
|
||||
let now = Instant::now();
|
||||
if let Some((prev, at)) = last {
|
||||
if *prev == key && now.duration_since(*at) < DEBOUNCE {
|
||||
return;
|
||||
}
|
||||
}
|
||||
*last = Some((key, now));
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
if key == Key::Escape
|
||||
&& w.global::<crate::UiFocusState>().get_text_input_focused()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let text: slint::SharedString = key.into();
|
||||
w.window().dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
|
||||
w.window().dispatch_event(WindowEvent::KeyReleased { text });
|
||||
});
|
||||
}
|
||||
|
||||
fn run(weak: slint::Weak<AppWindow>) {
|
||||
let mut gilrs = match Gilrs::new() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
log::warn!("[gamepad] gilrs init failed (no evdev access?): {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let pads = gilrs.gamepads().count();
|
||||
log::info!("[gamepad] listening ({pads} pad(s) connected)");
|
||||
|
||||
// Held direction for repeat: (dir, engaged_at, last_emit). One at a
|
||||
// time — the newest direction wins, like any 10-foot UI.
|
||||
let mut held: Option<(Dir, Instant, Instant)> = None;
|
||||
let mut last_emit: Option<(Key, Instant)> = None;
|
||||
|
||||
loop {
|
||||
// Block until the next pad event, waking at most by the repeat tick
|
||||
// so a held direction keeps firing with no pad traffic.
|
||||
let wake = held.map(|(_, _, last_emit_at)| {
|
||||
REPEAT_INTERVAL.saturating_sub(last_emit_at.elapsed())
|
||||
});
|
||||
let event = match wake {
|
||||
Some(t) => gilrs.next_event_blocking(Some(t)),
|
||||
None => gilrs.next_event_blocking(None),
|
||||
};
|
||||
let now = Instant::now();
|
||||
|
||||
match event.map(|e| e.event) {
|
||||
Some(EventType::ButtonPressed(Button::South, _)) => {
|
||||
emit_tap(&weak, Key::Return, &mut last_emit);
|
||||
}
|
||||
Some(EventType::ButtonPressed(Button::East, _)) => {
|
||||
emit_tap(&weak, Key::Escape, &mut last_emit);
|
||||
}
|
||||
Some(EventType::ButtonPressed(btn, _)) => {
|
||||
if let Some(dir) = dpad_dir(btn) {
|
||||
emit_tap(&weak, dir.key(), &mut last_emit);
|
||||
held = Some((dir, now, now));
|
||||
}
|
||||
}
|
||||
Some(EventType::ButtonReleased(btn, _)) => {
|
||||
if let Some(dir) = dpad_dir(btn) {
|
||||
if held.map(|(d, _, _)| d) == Some(dir) {
|
||||
held = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(EventType::AxisChanged(axis, value, _)) => {
|
||||
let (neg, pos) = match axis {
|
||||
Axis::LeftStickX => (Dir::Left, Dir::Right),
|
||||
Axis::LeftStickY => (Dir::Up, Dir::Down),
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value.abs() >= AXIS_ENGAGE {
|
||||
let dir = if value < 0.0 { neg } else { pos };
|
||||
if held.map(|(d, _, _)| d) != Some(dir) {
|
||||
emit_tap(&weak, dir.key(), &mut last_emit);
|
||||
held = Some((dir, now, now));
|
||||
}
|
||||
} else if value.abs() < AXIS_RELEASE {
|
||||
// Only disengage when the held direction lives on THIS axis.
|
||||
if let Some((d, _, _)) = held {
|
||||
if d == neg || d == pos {
|
||||
held = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
// Timeout wake (or a spurious None): fire the repeat engine.
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Repeat: a held direction re-emits after the initial delay, then on
|
||||
// every interval.
|
||||
if let Some((dir, engaged_at, last_emit_at)) = held.as_mut() {
|
||||
if engaged_at.elapsed() >= REPEAT_DELAY
|
||||
&& last_emit_at.elapsed() >= REPEAT_INTERVAL
|
||||
{
|
||||
let key = dir.key();
|
||||
emit_tap(&weak, key, &mut last_emit);
|
||||
*last_emit_at = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dpad_dir(btn: Button) -> Option<Dir> {
|
||||
match btn {
|
||||
Button::DPadUp => Some(Dir::Up),
|
||||
Button::DPadDown => Some(Dir::Down),
|
||||
Button::DPadLeft => Some(Dir::Left),
|
||||
Button::DPadRight => Some(Dir::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Steam on-screen keyboard via the deeplink — Steam Deck plan PR 4
|
||||
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
|
||||
//!
|
||||
//! The Deck OSK only opens when something asks the Steam client for it. For
|
||||
//! a non-Steam shortcut the only programmatic path is the deeplink
|
||||
//! `steam://open/keyboard?...&Mode=N` (and `steam://close/keyboard`) — the
|
||||
//! same call Valve's own SDL fires on the Deck, proven for non-Steam apps.
|
||||
//! Entered text arrives as ordinary key events, so no extra plumbing: a text
|
||||
//! field gains focus → we fire open; it loses focus → we fire close. Mode 0
|
||||
//! (single-line) covers every field in the app today (login is browser-OAuth,
|
||||
//! so there is no in-app email field to flag Mode 2 for).
|
||||
//!
|
||||
//! Activation gate: `SteamDeck=1` env (set by SteamOS Game Mode) or
|
||||
//! `SDL_ENABLE_STEAM_SCREEN_KEYBOARD=1`, with a manual override
|
||||
//! `QBZ_STEAM_OSK=0/1` (0 wins over everything — diagnostics).
|
||||
|
||||
/// True when the Steam OSK integration should fire.
|
||||
fn enabled() -> bool {
|
||||
match std::env::var("QBZ_STEAM_OSK") {
|
||||
Ok(v) if v == "1" => return true,
|
||||
Ok(v) if v == "0" => return false,
|
||||
_ => {}
|
||||
}
|
||||
std::env::var("SteamDeck").map(|v| v == "1").unwrap_or(false)
|
||||
|| std::env::var_os("SDL_ENABLE_STEAM_SCREEN_KEYBOARD").is_some()
|
||||
}
|
||||
|
||||
/// A text field's focus flipped (wired to `UiFocusState.osk` in main.rs).
|
||||
/// Fire-and-forget: the deeplink has no dismissal callback, so ALWAYS send
|
||||
/// close on blur — a stranded OSK is worse than a redundant close.
|
||||
pub fn focus_changed(focused: bool, mode: i32) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let url = if focused {
|
||||
format!(
|
||||
"steam://open/keyboard?XPosition=0&YPosition=0&Width=0&Height=0&Mode={mode}"
|
||||
)
|
||||
} else {
|
||||
"steam://close/keyboard".to_string()
|
||||
};
|
||||
// xdg-open routes the steam:// scheme to the running Steam client; the
|
||||
// fallback pokes Steam directly (only if already running).
|
||||
if std::process::Command::new("xdg-open")
|
||||
.arg(&url)
|
||||
.spawn()
|
||||
.is_err()
|
||||
{
|
||||
let _ = std::process::Command::new("steam")
|
||||
.arg("-ifrunning")
|
||||
.arg(&url)
|
||||
.spawn();
|
||||
}
|
||||
log::info!("[steam-osk] {} (mode {mode})", if focused { "open" } else { "close" });
|
||||
}
|
||||
Executable
+546
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""qbz-add-to-steam — register QBZ as a non-Steam shortcut (Steam Deck / Game Mode).
|
||||
|
||||
Adds (or updates, idempotently) a "QBZ" entry in the user's shortcuts.vdf,
|
||||
copies QBZ grid artwork, and seeds a pure-gamepad Steam Input controller
|
||||
layout for the built-in Deck controller (neptune).
|
||||
|
||||
Stdlib only. Must run while Steam is CLOSED. See
|
||||
qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md (PR 5).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "QBZ"
|
||||
DEFAULT_EXE = "/usr/bin/qbz"
|
||||
ICON_PATH = "/usr/share/icons/hicolor/512x512/apps/com.blitzfc.qbz.png"
|
||||
LAYOUT_NAME = "qbz_controller_neptune.vdf"
|
||||
|
||||
GAMESCOPE_OPTS = "env -u WAYLAND_DISPLAY WINIT_X11_SCALE_FACTOR=1"
|
||||
KIOSK_OPTS = "QBZ_PROFILE=kiosk QBZ_KIOSK_FULLSCREEN=1"
|
||||
|
||||
ARTWORK = [ # (source name in assets/steam, grid name suffix)
|
||||
("portrait.png", "p.png"), # 600x900 -> <appid>p.png
|
||||
("landscape.png", ".png"), # 920x430 -> <appid>.png
|
||||
("hero.png", "_hero.png"), # 1920x620 -> <appid>_hero.png
|
||||
("logo.png", "_logo.png"), # square -> <appid>_logo.png
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Binary VDF (shortcuts.vdf) round-trip parser/serializer.
|
||||
# Field types: 0x00 nested map, 0x01 string, 0x02 little-endian u32, 0x08 end.
|
||||
# Keys and values are kept as raw bytes so unknown fields round-trip
|
||||
# byte-identically.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
T_MAP, T_STR, T_U32, T_END = 0x00, 0x01, 0x02, 0x08
|
||||
|
||||
|
||||
def _read_cstr(data, pos):
|
||||
end = data.index(b"\x00", pos)
|
||||
return data[pos:end], end + 1
|
||||
|
||||
|
||||
def _parse_fields(data, pos, is_root=False):
|
||||
fields = []
|
||||
while pos < len(data):
|
||||
ftype = data[pos]
|
||||
if ftype == T_END:
|
||||
return fields, pos + 1, True
|
||||
key, pos = _read_cstr(data, pos + 1)
|
||||
if ftype == T_MAP:
|
||||
value, pos, _ = _parse_fields(data, pos)
|
||||
elif ftype == T_STR:
|
||||
value, pos = _read_cstr(data, pos)
|
||||
elif ftype == T_U32:
|
||||
if pos + 4 > len(data):
|
||||
raise ValueError("truncated u32 field %r" % key)
|
||||
value = data[pos:pos + 4]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError("unknown VDF field type 0x%02x at %d" % (ftype, pos))
|
||||
fields.append((ftype, key, value))
|
||||
if not is_root:
|
||||
raise ValueError("unterminated map in VDF")
|
||||
return fields, pos, False
|
||||
|
||||
|
||||
def parse_vdf(data):
|
||||
"""Parse a binary VDF document -> (fields, root_end_marker)."""
|
||||
fields, _, ended = _parse_fields(data, 0, is_root=True)
|
||||
return fields, ended
|
||||
|
||||
|
||||
def _serialize_fields(fields, out):
|
||||
for ftype, key, value in fields:
|
||||
out.append(bytes([ftype]) + key + b"\x00")
|
||||
if ftype == T_MAP:
|
||||
_serialize_fields(value, out)
|
||||
out.append(b"\x08")
|
||||
elif ftype == T_STR:
|
||||
out.append(value + b"\x00")
|
||||
else:
|
||||
out.append(value)
|
||||
|
||||
|
||||
def serialize_vdf(fields, root_end_marker=True):
|
||||
out = []
|
||||
_serialize_fields(fields, out)
|
||||
if root_end_marker:
|
||||
out.append(b"\x08")
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def fld_str(key, value):
|
||||
return (T_STR, key.encode("utf-8"), value.encode("utf-8"))
|
||||
|
||||
|
||||
def fld_u32(key, value):
|
||||
return (T_U32, key.encode("utf-8"), struct.pack("<I", value & 0xFFFFFFFF))
|
||||
|
||||
|
||||
def fld_map(key, children):
|
||||
return (T_MAP, key.encode("utf-8"), children)
|
||||
|
||||
|
||||
def field_get(fields, name):
|
||||
"""First field whose key matches `name` (case-insensitive), or None."""
|
||||
want = name.lower().encode("utf-8")
|
||||
for f in fields:
|
||||
if f[1].lower() == want:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def shortcut_appid(exe, name=APP_NAME):
|
||||
"""Classic non-Steam shortcut appid: crc32('"'+exe+'"'+name) | 0x80000000."""
|
||||
key = ('"' + exe + '"' + name).encode("utf-8")
|
||||
return (zlib.crc32(key) | 0x80000000) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def launch_url(appid):
|
||||
return "steam://rungameid/%d" % ((appid << 32) | 0x02000000)
|
||||
|
||||
|
||||
def build_entry(exe, launch_options, appid):
|
||||
"""Shortcut fields in the order a current Steam client writes them."""
|
||||
return [
|
||||
fld_u32("appid", appid),
|
||||
fld_str("appname", APP_NAME),
|
||||
fld_str("Exe", '"%s"' % exe),
|
||||
fld_str("StartDir", '"%s/"' % os.path.dirname(exe)),
|
||||
fld_str("icon", ICON_PATH),
|
||||
fld_str("ShortcutPath", ""),
|
||||
fld_str("LaunchOptions", launch_options),
|
||||
fld_u32("IsHidden", 0),
|
||||
fld_u32("AllowDesktopConfig", 1),
|
||||
fld_u32("AllowOverlay", 1),
|
||||
fld_u32("OpenVR", 0),
|
||||
fld_u32("Devkit", 0),
|
||||
fld_str("DevkitGameID", ""),
|
||||
fld_u32("DevkitOverrideAppID", 0),
|
||||
fld_u32("LastPlayTime", 0),
|
||||
fld_str("FlatpakAppID", ""),
|
||||
fld_map("tags", []),
|
||||
]
|
||||
|
||||
|
||||
def _entry_matches(entry_fields, exe, name=APP_NAME):
|
||||
exe_f = field_get(entry_fields, "Exe")
|
||||
name_f = field_get(entry_fields, "appname")
|
||||
return (exe_f and name_f and exe_f[2] == ('"%s"' % exe).encode("utf-8")
|
||||
and name_f[2] == name.encode("utf-8"))
|
||||
|
||||
|
||||
def upsert_entry(shortcut_fields, exe, launch_options, appid):
|
||||
"""Insert or replace the QBZ entry (dedupe by Exe+appname). Returns action."""
|
||||
entry = build_entry(exe, launch_options, appid)
|
||||
top = field_get(shortcut_fields, "shortcuts")
|
||||
if top is None or top[0] != T_MAP:
|
||||
shortcut_fields = [fld_map("shortcuts", [])]
|
||||
top = shortcut_fields[0]
|
||||
entries = top[2]
|
||||
for i, (ftype, key, value) in enumerate(entries):
|
||||
if ftype == T_MAP and _entry_matches(value, exe):
|
||||
entries[i] = (ftype, key, entry)
|
||||
return shortcut_fields, "updated"
|
||||
indices = [int(k) for t, k, v in entries
|
||||
if t == T_MAP and k.decode("utf-8", "replace").isdigit()]
|
||||
entries.append(fld_map(str(max(indices) + 1 if indices else 0), entry))
|
||||
return shortcut_fields, "added"
|
||||
|
||||
|
||||
def remove_entry(shortcut_fields, exe):
|
||||
"""Remove the QBZ entry. Returns True if an entry was removed."""
|
||||
top = field_get(shortcut_fields, "shortcuts")
|
||||
if top is None or top[0] != T_MAP:
|
||||
return False
|
||||
before = len(top[2])
|
||||
top[2][:] = [e for e in top[2]
|
||||
if not (e[0] == T_MAP and _entry_matches(e[2], exe))]
|
||||
return len(top[2]) != before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Steam environment
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def find_steam_root(home):
|
||||
for cand in (home / ".steam/steam", home / ".local/share/Steam"):
|
||||
if cand.is_dir():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def is_flatpak_steam(root, home):
|
||||
fp = home / ".var/app/com.valvesoftware.Steam"
|
||||
try:
|
||||
return str(root.resolve()).startswith(str(fp))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def steam_pid(home, root):
|
||||
for pidfile in (home / ".steam/steam.pid", root / "steam.pid"):
|
||||
try:
|
||||
pid = int(pidfile.read_text().strip())
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def shutdown_steam(pid):
|
||||
print("Steam is running (pid %d); asking it to shut down..." % pid)
|
||||
if os.system("steam -shutdown >/dev/null 2>&1") != 0:
|
||||
print("warning: 'steam -shutdown' failed; waiting for exit anyway")
|
||||
for _ in range(60):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
print("Steam exited.")
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def pick_account(root, user_override):
|
||||
loginusers = root / "userdata/config/loginusers.vdf"
|
||||
if not loginusers.is_file():
|
||||
raise RuntimeError("not found: %s" % loginusers)
|
||||
text = loginusers.read_text(encoding="utf-8", errors="replace")
|
||||
accounts = []
|
||||
for m in re.finditer(r'"(\d{15,20})"\s*\{([^{}]*)\}', text, re.S):
|
||||
sid, body = m.group(1), m.group(2)
|
||||
|
||||
def val(key):
|
||||
fm = re.search(r'"%s"\s+"([^"]*)"' % key, body)
|
||||
return fm.group(1) if fm else ""
|
||||
|
||||
accounts.append({
|
||||
"steamid64": sid,
|
||||
"accountid": str(int(sid) & 0xFFFFFFFF),
|
||||
"name": val("AccountName"),
|
||||
"recent": val("MostRecent") == "1",
|
||||
})
|
||||
if not accounts:
|
||||
raise RuntimeError("no accounts in %s" % loginusers)
|
||||
if user_override:
|
||||
for a in accounts:
|
||||
if user_override.lower() in (a["accountid"].lower(),
|
||||
a["steamid64"].lower(),
|
||||
a["name"].lower()):
|
||||
return a
|
||||
raise RuntimeError("no account matching --user %r (have: %s)"
|
||||
% (user_override,
|
||||
", ".join(a["name"] or a["accountid"]
|
||||
for a in accounts)))
|
||||
for a in accounts:
|
||||
if a["recent"]:
|
||||
return a
|
||||
return accounts[0]
|
||||
|
||||
|
||||
def deck_serial(root):
|
||||
cfg = root / "config/config.vdf"
|
||||
if cfg.is_file():
|
||||
m = re.search(r'"SteamDeckRegisteredSerialNumber"\s+"([^"]+)"',
|
||||
cfg.read_text(encoding="utf-8", errors="replace"))
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def find_assets_dir():
|
||||
here = Path(__file__).resolve().parent
|
||||
for cand in (here.parent / "assets/steam",
|
||||
Path("/usr/share/qbz/steam"),
|
||||
Path("/usr/local/share/qbz/steam")):
|
||||
if cand.is_dir():
|
||||
return cand
|
||||
return here.parent / "assets/steam" # default for error messages
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# File operations (all honour dry-run; all mutations make timestamped backups)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _backup(path):
|
||||
if path.exists():
|
||||
bak = path.with_name(path.name + time.strftime(".bak-%Y%m%d-%H%M%S"))
|
||||
print(" backup %s -> %s" % (path, bak))
|
||||
shutil.copy2(path, bak)
|
||||
|
||||
|
||||
def write_atomic(path, data):
|
||||
"""Write bytes to `path` atomically, keeping a timestamped .bak."""
|
||||
_backup(path)
|
||||
tmp = path.with_name("%s.tmp-%d" % (path.name, os.getpid()))
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
print(" wrote %s (%d bytes)" % (path, len(data)))
|
||||
|
||||
|
||||
def copy_file(src, dst, dry_run):
|
||||
print(" %scopy %s -> %s" % ("[dry-run] would " if dry_run else "", src, dst))
|
||||
if not dry_run:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def update_shortcuts(path, exe, launch_options, appid, remove, dry_run):
|
||||
if path.is_file():
|
||||
fields, ended = parse_vdf(path.read_bytes())
|
||||
else:
|
||||
fields, ended = [], True
|
||||
if remove:
|
||||
changed = remove_entry(fields, exe)
|
||||
action = "removed" if changed else "not present"
|
||||
else:
|
||||
fields, action = upsert_entry(fields, exe, launch_options, appid)
|
||||
print("shortcut entry: %s in %s" % (action, path))
|
||||
if action == "not present":
|
||||
return
|
||||
if dry_run:
|
||||
print(" [dry-run] would write %s (backup first)" % path)
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_atomic(path, serialize_vdf(fields, ended))
|
||||
|
||||
|
||||
def install_artwork(grid_dir, assets, appid, remove, dry_run):
|
||||
for src_name, suffix in ARTWORK:
|
||||
dst = grid_dir / ("%d%s" % (appid, suffix))
|
||||
if remove:
|
||||
if dst.exists():
|
||||
print(" %sremove %s" % ("[dry-run] would " if dry_run else "", dst))
|
||||
if not dry_run:
|
||||
_backup(dst)
|
||||
dst.unlink()
|
||||
continue
|
||||
src = assets / src_name
|
||||
if not src.is_file():
|
||||
print(" warning: artwork source missing, skipping: %s" % src)
|
||||
continue
|
||||
copy_file(src, dst, dry_run)
|
||||
|
||||
|
||||
CONFIGSET_HEADER = '"controller_config"\n{\n'
|
||||
|
||||
|
||||
def _configset_entry(appid):
|
||||
return ('\t"%d"\n\t{\n\t\t"template"\t\t"%s"\n\t}\n'
|
||||
% (appid, LAYOUT_NAME))
|
||||
|
||||
|
||||
def _patch_configset_text(content, appid, remove):
|
||||
"""Return patched configset text, or None if the format is unrecognized."""
|
||||
text = content.lstrip("\ufeff")
|
||||
if not text.lstrip().startswith('"controller_config"'):
|
||||
return None
|
||||
if text.count("{") != text.count("}"):
|
||||
return None
|
||||
pattern = re.compile(r'"%d"\s*\{[^{}]*\}\n?' % appid)
|
||||
if remove:
|
||||
return pattern.sub("", text)
|
||||
entry = _configset_entry(appid)
|
||||
if pattern.search(text):
|
||||
return pattern.sub(entry, text)
|
||||
stripped = text.rstrip()
|
||||
if not stripped.endswith("}"):
|
||||
return None
|
||||
return stripped[:-1].rstrip() + "\n" + entry + "}\n"
|
||||
|
||||
|
||||
def patch_configset(path, appid, remove, dry_run):
|
||||
if path.exists():
|
||||
new_text = _patch_configset_text(
|
||||
path.read_text(encoding="utf-8", errors="replace"), appid, remove)
|
||||
if new_text is None:
|
||||
print(" warning: unrecognized format, leaving untouched: %s" % path)
|
||||
return
|
||||
print(" %spatch %s (%s)" % ("[dry-run] would " if dry_run else "",
|
||||
path, "remove entry" if remove else
|
||||
"set template " + LAYOUT_NAME))
|
||||
if not dry_run:
|
||||
_backup(path)
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
elif not remove:
|
||||
print(" %screate %s" % ("[dry-run] would " if dry_run else "", path))
|
||||
if not dry_run:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(CONFIGSET_HEADER + _configset_entry(appid) + "}\n",
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
def controller_config_roots(root, accountid):
|
||||
"""Directories that hold per-controller configsets + <appid>/ layout dirs.
|
||||
|
||||
Canonical location (DeckOps prior art, verified on a live install):
|
||||
steamapps/common/Steam Controller Configs/<accountid>/config.
|
||||
Some clients keep a userdata mirror; patch it too if it already exists.
|
||||
"""
|
||||
roots = [root / "steamapps/common/Steam Controller Configs"
|
||||
/ accountid / "config"]
|
||||
legacy = (root / "userdata" / accountid / "config"
|
||||
/ "Steam Controller Configs" / accountid / "config")
|
||||
if legacy.is_dir() and legacy not in roots:
|
||||
roots.append(legacy)
|
||||
return roots
|
||||
|
||||
|
||||
def install_layout(root, assets, accountid, appid, remove, dry_run):
|
||||
src = assets / LAYOUT_NAME
|
||||
serial = deck_serial(root)
|
||||
if not serial:
|
||||
print(" note: no SteamDeckRegisteredSerialNumber in config.vdf; "
|
||||
"only configset_controller_neptune.vdf will be patched")
|
||||
for cfg_root in controller_config_roots(root, accountid):
|
||||
app_dir = cfg_root / str(appid)
|
||||
if remove:
|
||||
if app_dir.is_dir():
|
||||
print(" %sremove seeded layout %s"
|
||||
% ("[dry-run] would " if dry_run else "", app_dir))
|
||||
if not dry_run:
|
||||
bak = app_dir.with_name(
|
||||
app_dir.name + time.strftime(".bak-%Y%m%d-%H%M%S"))
|
||||
shutil.copytree(app_dir, bak)
|
||||
shutil.rmtree(app_dir)
|
||||
else:
|
||||
if not src.is_file():
|
||||
print(" warning: layout source missing, skipping: %s" % src)
|
||||
return
|
||||
copy_file(src, app_dir / LAYOUT_NAME, dry_run)
|
||||
patch_configset(cfg_root / "configset_controller_neptune.vdf",
|
||||
appid, remove, dry_run)
|
||||
if serial:
|
||||
patch_configset(cfg_root / ("configset_%s.vdf" % serial),
|
||||
appid, remove, dry_run)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Register QBZ as a non-Steam shortcut (artwork, launch "
|
||||
"options, seeded controller layout). Steam must be closed.")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print every action without writing anything")
|
||||
ap.add_argument("--remove", action="store_true",
|
||||
help="remove the QBZ shortcut, artwork and seeded layout")
|
||||
ap.add_argument("--exe", default=DEFAULT_EXE, metavar="PATH",
|
||||
help="path to the qbz binary (default %(default)s)")
|
||||
ap.add_argument("--user", metavar="ACCOUNT",
|
||||
help="Steam account: accountid, steamid64 or account name "
|
||||
"(default: MostRecent in loginusers.vdf)")
|
||||
ap.add_argument("--no-kiosk", action="store_true",
|
||||
help="omit the kiosk-profile env vars from LaunchOptions")
|
||||
ap.add_argument("--shutdown-steam", action="store_true",
|
||||
help="run 'steam -shutdown' and wait instead of refusing "
|
||||
"when Steam is running")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
exe = os.path.abspath(args.exe)
|
||||
appid = shortcut_appid(exe)
|
||||
launch_options = GAMESCOPE_OPTS + " %command%"
|
||||
if not args.no_kiosk:
|
||||
launch_options = GAMESCOPE_OPTS + " " + KIOSK_OPTS + " %command%"
|
||||
|
||||
print("QBZ add-to-Steam%s" % (" (DRY RUN)" if args.dry_run else ""))
|
||||
print(" exe=%s appid=%d" % (exe, appid))
|
||||
|
||||
home = Path.home()
|
||||
root = find_steam_root(home)
|
||||
if root is None:
|
||||
print("error: no Steam installation found (~/.steam/steam or "
|
||||
"~/.local/share/Steam)", file=sys.stderr)
|
||||
return 1
|
||||
print(" steam root: %s" % root)
|
||||
|
||||
if is_flatpak_steam(root, home):
|
||||
print("warning: Flatpak Steam detected (%s). A host-path Exe is "
|
||||
"broken inside the sandbox; not writing anything."
|
||||
% root.resolve(), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
pid = steam_pid(home, root)
|
||||
if pid is not None:
|
||||
if not args.shutdown_steam:
|
||||
print("error: Steam is running (pid %d). Close it first, or "
|
||||
"re-run with --shutdown-steam." % pid, file=sys.stderr)
|
||||
return 1
|
||||
if args.dry_run:
|
||||
print(" [dry-run] would run 'steam -shutdown' and wait")
|
||||
elif not shutdown_steam(pid):
|
||||
print("error: Steam did not exit within 60s", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
account = pick_account(root, args.user)
|
||||
except RuntimeError as e:
|
||||
print("error: %s" % e, file=sys.stderr)
|
||||
return 1
|
||||
accountid = account["accountid"]
|
||||
print(" account: %s (accountid %s)" % (account["name"] or "?", accountid))
|
||||
|
||||
user_cfg = root / "userdata" / accountid / "config"
|
||||
assets = find_assets_dir()
|
||||
if not assets.is_dir():
|
||||
print(" warning: assets dir not found: %s "
|
||||
"(artwork/layout will be skipped)" % assets)
|
||||
|
||||
try:
|
||||
update_shortcuts(user_cfg / "shortcuts.vdf", exe, launch_options,
|
||||
appid, args.remove, args.dry_run)
|
||||
except ValueError as e:
|
||||
print("error: cannot parse %s: %s — leaving it untouched"
|
||||
% (user_cfg / "shortcuts.vdf", e), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
install_artwork(user_cfg / "grid", assets, appid, args.remove, args.dry_run)
|
||||
install_layout(root, assets, accountid, appid, args.remove, args.dry_run)
|
||||
|
||||
if args.remove:
|
||||
print("Done. QBZ was removed from Steam.")
|
||||
else:
|
||||
print("Done. Start Steam, then launch QBZ from the library or with:")
|
||||
print(" %s" % launch_url(appid))
|
||||
print("Tip: log in to QBZ once in Desktop Mode first (login needs a "
|
||||
"browser); Game Mode will then open the kiosk directly.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for scripts/qbz-add-to-steam (binary VDF parser/serializer,
|
||||
shortcut dedupe, appid formula, configset patching). Stdlib unittest only."""
|
||||
|
||||
import importlib.util
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent / "qbz-add-to-steam"
|
||||
_loader = SourceFileLoader("qbz_add_to_steam", str(SCRIPT))
|
||||
spec = importlib.util.spec_from_loader("qbz_add_to_steam", _loader)
|
||||
qats = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(qats)
|
||||
|
||||
|
||||
def make_fixture():
|
||||
"""A real-world-shaped shortcuts.vdf: two entries (one non-QBZ with
|
||||
quirky/unknown fields), nested tags map, non-ASCII strings."""
|
||||
other_entry = [
|
||||
qats.fld_u32("appid", 0x81234567),
|
||||
qats.fld_str("appname", "Cool Game — déjà vu"),
|
||||
qats.fld_str("Exe", '"/opt/cool game/run.sh"'),
|
||||
qats.fld_str("StartDir", '"/opt/cool game/"'),
|
||||
qats.fld_str("icon", ""),
|
||||
qats.fld_str("ShortcutPath", ""),
|
||||
qats.fld_str("LaunchOptions", "gamemoderun %command%"),
|
||||
qats.fld_u32("IsHidden", 0),
|
||||
qats.fld_u32("AllowDesktopConfig", 1),
|
||||
qats.fld_u32("AllowOverlay", 1),
|
||||
qats.fld_u32("OpenVR", 0),
|
||||
qats.fld_u32("Devkit", 0),
|
||||
qats.fld_str("DevkitGameID", ""),
|
||||
qats.fld_u32("DevkitOverrideAppID", 0),
|
||||
qats.fld_u32("LastPlayTime", 1753000000),
|
||||
qats.fld_str("FlatpakAppID", ""),
|
||||
qats.fld_str("sortas", "cool game"), # newer Steam field
|
||||
qats.fld_map("tags", [qats.fld_str("0", "favorite"),
|
||||
qats.fld_str("1", "deck")]),
|
||||
qats.fld_str("SomeFutureField", "w\xe9ird\xff"), # unknown field
|
||||
]
|
||||
qbz_entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%",
|
||||
qats.shortcut_appid("/usr/bin/qbz"))
|
||||
fields = [qats.fld_map("shortcuts", [
|
||||
qats.fld_map("0", other_entry),
|
||||
qats.fld_map("1", qbz_entry),
|
||||
])]
|
||||
return fields
|
||||
|
||||
|
||||
class TestRoundTrip(unittest.TestCase):
|
||||
def test_roundtrip_byte_identical(self):
|
||||
fields = make_fixture()
|
||||
blob = qats.serialize_vdf(fields, True)
|
||||
parsed, ended = qats.parse_vdf(blob)
|
||||
self.assertTrue(ended)
|
||||
self.assertEqual(qats.serialize_vdf(parsed, ended), blob)
|
||||
|
||||
def test_roundtrip_preserves_trailing_end_marker(self):
|
||||
fields = make_fixture()
|
||||
with_root_end = qats.serialize_vdf(fields, True)
|
||||
without_root_end = qats.serialize_vdf(fields, False)
|
||||
self.assertEqual(with_root_end, without_root_end + b"\x08")
|
||||
p1, e1 = qats.parse_vdf(with_root_end)
|
||||
p2, e2 = qats.parse_vdf(without_root_end)
|
||||
self.assertTrue(e1)
|
||||
self.assertFalse(e2)
|
||||
self.assertEqual(p1, p2)
|
||||
|
||||
def test_real_file_shape(self):
|
||||
# header is \x00shortcuts\x00, file ends with \x08\x08
|
||||
blob = qats.serialize_vdf(make_fixture(), True)
|
||||
self.assertTrue(blob.startswith(b"\x00shortcuts\x00"))
|
||||
self.assertTrue(blob.endswith(b"\x08\x08"))
|
||||
|
||||
def test_parse_types(self):
|
||||
fields, _ = qats.parse_vdf(qats.serialize_vdf(make_fixture(), True))
|
||||
top = qats.field_get(fields, "shortcuts")
|
||||
self.assertIsNotNone(top)
|
||||
self.assertEqual(top[0], qats.T_MAP)
|
||||
e0 = top[2][0][2]
|
||||
appid = qats.field_get(e0, "appid")
|
||||
self.assertEqual(appid[0], qats.T_U32)
|
||||
self.assertEqual(struct.unpack("<I", appid[2])[0], 0x81234567)
|
||||
tags = qats.field_get(e0, "tags")
|
||||
self.assertEqual(tags[0], qats.T_MAP)
|
||||
self.assertEqual(qats.field_get(tags[2], "0")[2], b"favorite")
|
||||
|
||||
def test_garbage_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
qats.parse_vdf(b"\x00shortcuts\x00\x07bogus\x00")
|
||||
|
||||
|
||||
class TestDedupe(unittest.TestCase):
|
||||
def test_update_in_place_never_duplicate(self):
|
||||
fields = make_fixture()
|
||||
blob0 = qats.serialize_vdf(fields, True)
|
||||
parsed, ended = qats.parse_vdf(blob0)
|
||||
new_fields, action = qats.upsert_entry(
|
||||
parsed, "/usr/bin/qbz", "env NEW=1 %command%",
|
||||
qats.shortcut_appid("/usr/bin/qbz"))
|
||||
self.assertEqual(action, "updated")
|
||||
top = qats.field_get(new_fields, "shortcuts")
|
||||
# still exactly two entries, QBZ still at key "1"
|
||||
self.assertEqual(len(top[2]), 2)
|
||||
self.assertEqual(top[2][1][1], b"1")
|
||||
lo = qats.field_get(top[2][1][2], "LaunchOptions")
|
||||
self.assertEqual(lo[2], b"env NEW=1 %command%")
|
||||
# the non-QBZ entry survives byte-identically: re-serialize and diff
|
||||
blob1 = qats.serialize_vdf(new_fields, ended)
|
||||
entry0_start = blob0.index(b"\x000\x00")
|
||||
entry0_end = blob0.index(b"\x001\x00")
|
||||
self.assertEqual(blob0[entry0_start:entry0_end],
|
||||
blob1[entry0_start:entry0_end])
|
||||
|
||||
def test_add_when_missing(self):
|
||||
fields = make_fixture()
|
||||
parsed, ended = qats.parse_vdf(qats.serialize_vdf(fields, True))
|
||||
removed = qats.remove_entry(parsed, "/usr/bin/qbz")
|
||||
self.assertTrue(removed)
|
||||
new_fields, action = qats.upsert_entry(
|
||||
parsed, "/usr/bin/qbz", "env X=1 %command%",
|
||||
qats.shortcut_appid("/usr/bin/qbz"))
|
||||
self.assertEqual(action, "added")
|
||||
top = qats.field_get(new_fields, "shortcuts")
|
||||
self.assertEqual(len(top[2]), 2)
|
||||
# appended reusing the freed index (indices stay contiguous)
|
||||
self.assertEqual(top[2][-1][1], b"1")
|
||||
|
||||
def test_dedupe_on_exe_and_name(self):
|
||||
# an entry with the same Exe but a different appname must NOT be
|
||||
# replaced — a new entry is appended instead
|
||||
fields = make_fixture()
|
||||
top = qats.field_get(fields, "shortcuts")
|
||||
# drop the real QBZ entry, keep only a "twin": same Exe, other name
|
||||
top[2][:] = [e for e in top[2] if e[1] != b"1"]
|
||||
twin = qats.build_entry("/usr/bin/qbz", "%command%", 0x81234568)
|
||||
for i, f in enumerate(twin):
|
||||
if f[1] == b"appname":
|
||||
twin[i] = qats.fld_str("appname", "QBZ (alt)")
|
||||
top[2].append(qats.fld_map("1", twin))
|
||||
parsed, _ = qats.parse_vdf(qats.serialize_vdf(fields, True))
|
||||
new_fields, action = qats.upsert_entry(
|
||||
parsed, "/usr/bin/qbz", "env X=1 %command%",
|
||||
qats.shortcut_appid("/usr/bin/qbz"))
|
||||
self.assertEqual(action, "added")
|
||||
top = qats.field_get(new_fields, "shortcuts")
|
||||
self.assertEqual(len(top[2]), 3)
|
||||
# the twin entry is untouched
|
||||
self.assertEqual(qats.field_get(top[2][1][2], "appname")[2],
|
||||
"QBZ (alt)".encode("utf-8"))
|
||||
|
||||
def test_remove_missing_is_noop(self):
|
||||
fields = make_fixture()
|
||||
blob0 = qats.serialize_vdf(fields, True)
|
||||
parsed, _ = qats.parse_vdf(blob0)
|
||||
self.assertFalse(qats.remove_entry(parsed, "/opt/other"))
|
||||
self.assertEqual(qats.serialize_vdf(parsed, True), blob0)
|
||||
|
||||
|
||||
class TestAppId(unittest.TestCase):
|
||||
def test_formula(self):
|
||||
exe = "/usr/bin/qbz"
|
||||
expected = (zlib.crc32(('"' + exe + '"QBZ').encode("utf-8"))
|
||||
| 0x80000000) & 0xFFFFFFFF
|
||||
self.assertEqual(qats.shortcut_appid(exe), expected)
|
||||
|
||||
def test_known_vector(self):
|
||||
# pinned so a regression in quoting/encoding is caught
|
||||
self.assertEqual(qats.shortcut_appid("/usr/bin/qbz"), 3124044104)
|
||||
|
||||
def test_fits_u32_and_top_bit(self):
|
||||
appid = qats.shortcut_appid("/usr/bin/qbz")
|
||||
self.assertTrue(0 <= appid <= 0xFFFFFFFF)
|
||||
self.assertTrue(appid & 0x80000000)
|
||||
struct.pack("<I", appid) # must not raise
|
||||
|
||||
def test_launch_url(self):
|
||||
appid = 3124044104
|
||||
url = qats.launch_url(appid)
|
||||
self.assertEqual(url, "steam://rungameid/%d"
|
||||
% ((appid << 32) | 0x02000000))
|
||||
|
||||
|
||||
class TestEntryFields(unittest.TestCase):
|
||||
def test_standard_field_order(self):
|
||||
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", 123)
|
||||
keys = [k.decode() for _, k, _ in entry]
|
||||
self.assertEqual(keys, [
|
||||
"appid", "appname", "Exe", "StartDir", "icon", "ShortcutPath",
|
||||
"LaunchOptions", "IsHidden", "AllowDesktopConfig", "AllowOverlay",
|
||||
"OpenVR", "Devkit", "DevkitGameID", "DevkitOverrideAppID",
|
||||
"LastPlayTime", "FlatpakAppID", "tags",
|
||||
])
|
||||
|
||||
def test_entry_values(self):
|
||||
appid = qats.shortcut_appid("/usr/bin/qbz")
|
||||
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", appid)
|
||||
self.assertEqual(qats.field_get(entry, "appname")[2], b"QBZ")
|
||||
self.assertEqual(qats.field_get(entry, "Exe")[2], b'"/usr/bin/qbz"')
|
||||
self.assertEqual(qats.field_get(entry, "StartDir")[2], b'"/usr/bin/"')
|
||||
self.assertEqual(qats.field_get(entry, "icon")[2],
|
||||
b"/usr/share/icons/hicolor/512x512/apps/"
|
||||
b"com.blitzfc.qbz.png")
|
||||
self.assertEqual(struct.unpack(
|
||||
"<I", qats.field_get(entry, "appid")[2])[0], appid)
|
||||
self.assertEqual(struct.unpack(
|
||||
"<I", qats.field_get(entry, "AllowDesktopConfig")[2])[0], 1)
|
||||
self.assertEqual(struct.unpack(
|
||||
"<I", qats.field_get(entry, "AllowOverlay")[2])[0], 1)
|
||||
self.assertEqual(struct.unpack(
|
||||
"<I", qats.field_get(entry, "IsHidden")[2])[0], 0)
|
||||
self.assertEqual(struct.unpack(
|
||||
"<I", qats.field_get(entry, "OpenVR")[2])[0], 0)
|
||||
|
||||
|
||||
class TestConfigsetPatch(unittest.TestCase):
|
||||
EXISTING = ('"controller_config"\n{\n\t"12345"\n\t{\n'
|
||||
'\t\t"template"\t\t"other.vdf"\n\t}\n}\n')
|
||||
|
||||
def test_insert_into_existing(self):
|
||||
out = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
||||
self.assertIn('"3124044104"', out)
|
||||
self.assertIn(qats.LAYOUT_NAME, out)
|
||||
self.assertIn('"12345"', out) # pre-existing entry preserved
|
||||
self.assertEqual(out.count("{"), out.count("}"))
|
||||
|
||||
def test_replace_existing_entry(self):
|
||||
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
||||
second = qats._patch_configset_text(first, 3124044104, False)
|
||||
self.assertEqual(second.count('"3124044104"'), 1) # idempotent
|
||||
self.assertIn('"12345"', second)
|
||||
|
||||
def test_remove_entry(self):
|
||||
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
||||
out = qats._patch_configset_text(first, 3124044104, True)
|
||||
self.assertNotIn('"3124044104"', out)
|
||||
self.assertIn('"12345"', out)
|
||||
|
||||
def test_bad_format_returns_none(self):
|
||||
self.assertIsNone(qats._patch_configset_text(
|
||||
'"something_else"\n{\n}\n', 1, False))
|
||||
self.assertIsNone(qats._patch_configset_text(
|
||||
'"controller_config"\n{\n', 1, False)) # unbalanced braces
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user