- 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
475 lines
21 KiB
Plaintext
475 lines
21 KiB
Plaintext
// KioskNowPlaying — the kiosk Now Playing centerpiece (2.0.2 frente #3).
|
|
//
|
|
// A 50/50 full screen: the current track (big cover + title/artist + AudioStamp
|
|
// + SeekBar + the 7-control TransportControls) on the left, the Up-Next queue on
|
|
// the right. Reuses the real transport/seek/stamp components and reads the same
|
|
// NowPlayingState/QueueState the desktop uses (all covers push-only). Must call
|
|
// 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, 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: 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;
|
|
spacing: 12px;
|
|
Rectangle {
|
|
width: 44px;
|
|
Rectangle {
|
|
width: 44px;
|
|
height: 44px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: 5px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
if root.item.artwork.width > 0: Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: root.item.artwork;
|
|
image-fit: cover;
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
alignment: center;
|
|
spacing: 2px;
|
|
Text {
|
|
text: root.item.title;
|
|
color: root.item.playing ? Theme.accent : Theme.text-primary;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
overflow: elide;
|
|
wrap: no-wrap;
|
|
}
|
|
Text {
|
|
text: root.item.artist;
|
|
color: Theme.text-muted;
|
|
font-size: 12.5px;
|
|
overflow: elide;
|
|
wrap: no-wrap;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.item.duration;
|
|
color: Theme.text-muted;
|
|
font-size: 12.5px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
qrow := TouchArea {
|
|
clicked => {
|
|
root.play();
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
VerticalLayout {
|
|
alignment: start;
|
|
spacing: 4px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.active ? Theme.text-primary : Theme.text-muted;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
vertical-alignment: center;
|
|
}
|
|
Rectangle {
|
|
height: 2px;
|
|
background: root.active ? Theme.accent : transparent;
|
|
border-radius: 2px;
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
clicked => {
|
|
root.picked();
|
|
}
|
|
}
|
|
}
|
|
import { SeekBar } from "SeekBar.slint";
|
|
import { TransportControls } from "TransportControls.slint";
|
|
import { AudioStamp } from "AudioStamp.slint";
|
|
|
|
export component KioskNowPlaying inherits Rectangle {
|
|
callback open-album(string);
|
|
callback open-artist(string);
|
|
callback media-action(string, string, string);
|
|
|
|
// Left panel toggles between the big cover and synced lyrics — the kiosk's
|
|
// 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 {
|
|
spacing: 0px;
|
|
|
|
// LEFT — the current track.
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
VerticalLayout {
|
|
padding: 18px;
|
|
spacing: 12px;
|
|
|
|
// Cover / Lyrics toggle — pinned to the TOP so it lines up with
|
|
// the Up Next / History tabs on the right panel. vertical-stretch:0
|
|
// is REQUIRED — a nested layout defaults to greedy stretch in
|
|
// Slint, so without it the tabs row eats vertical space and pushes
|
|
// the cover down (dead space above it).
|
|
HorizontalLayout {
|
|
vertical-stretch: 0;
|
|
alignment: start;
|
|
spacing: 18px;
|
|
QueueTab {
|
|
label: @tr("Cover");
|
|
active: !root.show-lyrics;
|
|
picked => {
|
|
root.show-lyrics = false;
|
|
// Stop the lyrics-sync engine (see ShellState).
|
|
ShellState.kiosk-lyrics-follow = false;
|
|
}
|
|
}
|
|
QueueTab {
|
|
label: @tr("Lyrics");
|
|
active: root.show-lyrics;
|
|
picked => {
|
|
root.show-lyrics = true;
|
|
// Open the gate so the karaoke follow runs (the
|
|
// wrapper's panel-opened() kick then lands instantly).
|
|
ShellState.kiosk-lyrics-follow = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flexible art/lyrics area — takes the slack (pins the toggle
|
|
// above and the transport below) and CLIPS, so on a short window
|
|
// (e.g. 1080x600) the cover shrinks instead of pushing the
|
|
// controls off-screen.
|
|
art-area := Rectangle {
|
|
vertical-stretch: 1;
|
|
clip: true;
|
|
|
|
// Cover: contained so it is as large as fits and scales with
|
|
// the window WITHOUT reading parent geometry (the old
|
|
// Math.min(art-area.height) collapsed it to ~55px). Fills the
|
|
// dominant area — big on tall windows, shrinks on short ones.
|
|
if !root.show-lyrics: Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: NowPlayingState.artwork-large;
|
|
image-fit: contain;
|
|
}
|
|
|
|
// Lyrics fill the panel width (no more right-edge clipping) and
|
|
// the flexible height. Mount the karaoke view ONLY when lyrics
|
|
// are READY (status 2 + lines): instantiating LyricsLinesView
|
|
// against empty/loading state during the tap's own relayout
|
|
// trips its geometry FOOTGUN (properties.rs:583 "Recursion
|
|
// detected"). status==2 lands async in a settled layout.
|
|
if root.show-lyrics: Rectangle {
|
|
width: 100%;
|
|
height: 100%;
|
|
init => {
|
|
LyricsState.panel-opened();
|
|
}
|
|
if LyricsState.status == 2 && LyricsState.lines.length > 0: LyricsLinesView {
|
|
width: 100%;
|
|
height: 100%;
|
|
side-padding: 8px;
|
|
line-gap: 13px;
|
|
size-inactive: 19px;
|
|
size-active: 23px;
|
|
dimming-mode: LyricsState.dimming-mode;
|
|
auto-follow: true;
|
|
uppercase: LyricsState.uppercase;
|
|
show-attribution: false;
|
|
font-name: LyricsState.font-index == 1 ? "LINE Seed JP"
|
|
: LyricsState.font-index == 2 ? "Montserrat"
|
|
: LyricsState.font-index == 3 ? "Noto Sans"
|
|
: LyricsState.font-index == 4 ? "Source Sans 3"
|
|
: "";
|
|
active-color: Theme.accent;
|
|
}
|
|
if !(LyricsState.status == 2 && LyricsState.lines.length > 0): Text {
|
|
width: 100%;
|
|
height: 100%;
|
|
text: LyricsState.status == 5 ? @tr("Lyrics unavailable offline")
|
|
: (LyricsState.status == 3 || LyricsState.status == 4) ? @tr("No lyrics available")
|
|
: @tr("Fetching lyrics...");
|
|
color: Theme.text-muted;
|
|
font-size: 14px;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pinned metadata + transport — always on screen even when the
|
|
// art area is squeezed on a short window. vertical-stretch:0 keeps
|
|
// it natural-height at the bottom (nested layouts stretch greedily
|
|
// by default) so ALL slack goes to the art area above.
|
|
VerticalLayout {
|
|
vertical-stretch: 0;
|
|
spacing: 8px;
|
|
|
|
Text {
|
|
text: NowPlayingState.title;
|
|
color: Theme.text-primary;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
horizontal-alignment: center;
|
|
overflow: elide;
|
|
wrap: no-wrap;
|
|
}
|
|
artist-row := Rectangle {
|
|
height: 18px;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: NowPlayingState.artist;
|
|
color: Theme.text-secondary;
|
|
font-size: 14px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
art-ta := TouchArea {
|
|
clicked => {
|
|
root.open-artist(NowPlayingState.artist-id);
|
|
}
|
|
}
|
|
}
|
|
|
|
SeekBar {
|
|
show-times: true;
|
|
progress: NowPlayingState.progress;
|
|
cache: NowPlayingState.cache;
|
|
seekable-max: NowPlayingState.seekable-max;
|
|
elapsed: NowPlayingState.elapsed;
|
|
remaining: NowPlayingState.remaining;
|
|
duration-secs: NowPlayingState.duration-secs;
|
|
seek(fraction) => {
|
|
NowPlayingState.seek(fraction);
|
|
}
|
|
}
|
|
|
|
// Transport centered; the quality stamp floats at the right of
|
|
// the same row instead of owning a whole line (saves vertical
|
|
// 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 {
|
|
play-circle: true;
|
|
classic-actions: true;
|
|
media-action(k, i, a) => {
|
|
root.media-action(k, i, a);
|
|
}
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
alignment: end;
|
|
padding-right: 2px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
AudioStamp { }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// RIGHT — the queue, with Queue / History tabs. ListView windows the
|
|
// rows so a long history never mounts every cover at once (Pi-safe).
|
|
Rectangle {
|
|
width: (parent.width - 1px) * 0.42;
|
|
background: Theme.surface-card;
|
|
VerticalLayout {
|
|
padding: 18px;
|
|
spacing: 12px;
|
|
HorizontalLayout {
|
|
height: 30px;
|
|
spacing: 20px;
|
|
alignment: start;
|
|
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);
|
|
}
|
|
}
|
|
QueueTab {
|
|
label: @tr("History");
|
|
active: QueueState.tab == 1;
|
|
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
|
|
picked => {
|
|
QueueState.set-tab(1);
|
|
}
|
|
}
|
|
}
|
|
// Body: the desktop QueueSidebar pattern — a stretching Rectangle
|
|
// holds a Flickable whose viewport-height IS the content height,
|
|
// so the rows top-anchor. (A ListView with height:100% bottom-
|
|
// anchored a short list — the recurrent Slint distribution trap;
|
|
// upcoming-page/history are already paginated slices, so mounting
|
|
// the page is cheap.)
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
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: 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|