- 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
218 lines
8.9 KiB
Plaintext
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|