Files
qbz/crates/qbz-ui/ui/shell/KioskArtist.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

224 lines
10 KiB
Plaintext

// KioskArtist — lightweight kiosk artist detail (2.0.2 frente #3). Reads
// 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, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
export component KioskArtist inherits Rectangle {
callback open-album(string);
callback media-action(string, string, string);
property <length> pad: 22px;
// ---- 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;
page := VerticalLayout {
padding: root.pad;
spacing: 22px;
HorizontalLayout {
spacing: 22px;
Rectangle {
width: 140px;
height: 140px;
border-radius: 70px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: ArtistState.artwork;
image-fit: cover;
}
}
VerticalLayout {
alignment: center;
spacing: 12px;
Text {
text: ArtistState.name;
color: Theme.text-primary;
font-size: 25px;
font-weight: 700;
wrap: word-wrap;
}
HorizontalLayout {
alignment: start;
spacing: 10px;
Rectangle {
width: 150px;
height: 44px;
border-radius: Radius.sm;
background: playtop-ta.has-hover ? Theme.accent-hover : Theme.accent;
Text {
text: @tr("Play");
color: Theme.accent-text;
font-size: 15px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
playtop-ta := TouchArea {
clicked => {
root.media-action("artist", ArtistState.id, "play-top");
}
}
}
Rectangle {
width: 130px;
height: 44px;
border-radius: Radius.sm;
background: radio-ta.has-hover ? Theme.alpha-8 : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
Text {
text: @tr("Radio");
color: Theme.text-primary;
font-size: 15px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
radio-ta := TouchArea {
clicked => {
root.media-action("artist", ArtistState.id, "radio-qbz");
}
}
}
}
}
}
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;
}
}
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[i] in section.albums: KioskCard {
album: album;
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
clicked(id) => {
root.open-album(id);
}
}
}
}
}
}
}
}
}