Files
qbz/crates/qbz-ui/ui/shell/KioskAlbum.slint
T
vicrodh f1eeaf9126 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
2026-07-25 12:04:58 -06:00

218 lines
8.9 KiB
Plaintext

// KioskAlbum — lightweight kiosk album detail (2.0.2 frente #3). Reads AlbumState
// (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, KioskNavState } from "../state.slint";
component PillButton inherits Rectangle {
in property <string> label;
in property <bool> primary;
callback clicked;
width: 132px;
height: 44px;
border-radius: Radius.sm;
background: root.primary ? Theme.accent : (ta.has-hover ? Theme.alpha-8 : Theme.surface-elevated);
border-width: root.primary ? 0px : 1px;
border-color: Theme.border-subtle;
Text {
text: root.label;
color: root.primary ? Theme.accent-text : Theme.text-primary;
font-size: 15px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
ta := TouchArea {
clicked => {
root.clicked();
}
}
}
export component KioskAlbum inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
property <length> pad: 22px;
// ---- 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;
page := VerticalLayout {
padding: root.pad;
spacing: 22px;
// Header.
HorizontalLayout {
spacing: 22px;
Rectangle {
width: 180px;
height: 180px;
border-radius: Radius.md;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: AlbumState.artwork;
image-fit: cover;
}
}
VerticalLayout {
alignment: center;
spacing: 10px;
Text {
text: AlbumState.title;
color: Theme.text-primary;
font-size: 25px;
font-weight: 700;
wrap: word-wrap;
}
artist-row := Rectangle {
height: 26px;
horizontal-stretch: 0;
HorizontalLayout {
alignment: start;
Text {
text: AlbumState.artist;
color: Theme.text-secondary;
font-size: 17px;
vertical-alignment: center;
}
}
art-ta := TouchArea {
clicked => {
root.open-artist(AlbumState.artist-id);
}
}
}
Text {
text: AlbumState.quality-detail;
color: Theme.text-muted;
font-size: 13px;
}
HorizontalLayout {
alignment: start;
spacing: 10px;
PillButton {
label: @tr("Play");
primary: true;
clicked => {
root.media-action("album", AlbumState.id, "play");
}
}
PillButton {
label: @tr("Shuffle");
clicked => {
root.media-action("album", AlbumState.id, "shuffle");
}
}
}
}
}
// Track list.
VerticalLayout {
spacing: 2px;
for track[i] in AlbumState.tracks: trow-rect := Rectangle {
height: 52px;
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);
// 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;
spacing: 14px;
Text {
text: track.number;
width: 30px;
color: Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
horizontal-alignment: right;
}
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
Text {
text: track.title;
color: trow-rect.current ? Theme.accent : Theme.text-primary;
font-size: 15px;
overflow: elide;
wrap: no-wrap;
}
}
Text {
text: track.duration;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
trow := TouchArea {
clicked => {
root.media-action("track", track.id, "play");
}
}
}
}
}
}
}