- 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
224 lines
10 KiB
Plaintext
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|