The OSK opened on kiosk search/inputs but never closed: focus stayed on the search field. Clear focus in the field's `accepted` handler and add a full-bleed background TouchArea that clears focus on any tap outside the input, so the on-screen keyboard dismisses. cc
541 lines
26 KiB
Plaintext
541 lines
26 KiB
Plaintext
// KioskShell — small-panel touch shell (2.0.2 frente #3, Axis B).
|
|
//
|
|
// A SIBLING of AppShell inside the same AppWindow, mounted when
|
|
// `AppScreen.kiosk` is active. It shares every `*State` global verbatim with the
|
|
// desktop shell (no mirroring), so it re-hosts the SAME view components; the
|
|
// only differences from AppShell are the chrome: a thin back bar + bottom
|
|
// NavRail replace the HeaderBar + Sidebar, the transport is forced to
|
|
// PlayerBarSmall, and there is no queue/lyrics side panel.
|
|
//
|
|
// The content-mount block below is a VERBATIM copy of AppShell's (every
|
|
// ContentView route + the offline placeholder), so navigation reaches the same
|
|
// views by construction. Keeping it in sync with AppShell is temporary debt: a
|
|
// shared `ShellContent` extraction is the DRY follow-up, deferred because the
|
|
// content-height arithmetic references `root.height` (= the shell/window height)
|
|
// and extracting it into a sub-component needs that height re-threaded — a
|
|
// change worth doing only once the kiosk shell is build-verified. Kept isolated
|
|
// here so the desktop shell is untouched and cannot regress.
|
|
import { LineEdit } from "std-widgets.slint";
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Layout } from "../foundation/layout.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
|
|
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
|
|
import { PlayerBarSmall } from "PlayerBarSmall.slint";
|
|
import { NavRail } from "NavRail.slint";
|
|
import { WindowControls } from "WindowControls.slint";
|
|
import { KioskDiscover } from "KioskDiscover.slint";
|
|
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
|
|
import { RecentAlbumsView } from "../discover/RecentAlbumsView.slint";
|
|
import { KioskAlbum } from "KioskAlbum.slint";
|
|
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
|
|
import { KioskArtist } from "KioskArtist.slint";
|
|
import { KioskNowPlaying } from "KioskNowPlaying.slint";
|
|
import { ArtistReleasesView } from "../artist/ArtistReleasesView.slint";
|
|
import { MusicianPageView } from "../musician/MusicianPageView.slint";
|
|
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
|
|
import { LabelPageView } from "../label/LabelPageView.slint";
|
|
import { AwardView } from "../award/AwardView.slint";
|
|
import { AwardAlbumsView } from "../award/AwardAlbumsView.slint";
|
|
import { PurchasesView } from "../purchases/PurchasesView.slint";
|
|
import { PurchaseDetailView } from "../purchases/PurchaseDetailView.slint";
|
|
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
|
|
import { KioskLibrary } from "KioskLibrary.slint";
|
|
import { FavoritesOfflineRail } from "../favorites/FavoritesOfflineRail.slint";
|
|
import { MixView } from "../mix/MixView.slint";
|
|
import { PlaylistView } from "../playlist/PlaylistView.slint";
|
|
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
|
|
import { KioskMyQBZ } from "KioskMyQBZ.slint";
|
|
import { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
|
|
import { DiscographyBuilderView } from "../myqbz/DiscographyBuilderView.slint";
|
|
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
|
|
import { BlacklistManagerView } from "../settings/BlacklistManagerView.slint";
|
|
import { OfflinePlaceholder } from "../offline/OfflinePlaceholder.slint";
|
|
import { KioskLocalLibrary } from "KioskLocalLibrary.slint";
|
|
import { SettingsView } from "../settings/SettingsView.slint";
|
|
import { KioskSearch } from "KioskSearch.slint";
|
|
|
|
export component KioskShell inherits Rectangle {
|
|
// Public interface mirrors AppShell 1:1 (app.slint forwards these to the
|
|
// AppWindow root callbacks handled in Rust).
|
|
callback open-album(string);
|
|
callback open-artist(string);
|
|
callback open-purchase-album(string);
|
|
callback media-action(string, string, string);
|
|
callback header-menu-navigate(string);
|
|
callback discover-view-all(string, string);
|
|
callback settings-bool(string, bool);
|
|
callback settings-select(string, int);
|
|
callback settings-slider(string, int);
|
|
callback settings-string(string, string);
|
|
callback settings-reset();
|
|
callback settings-release-device();
|
|
callback logout();
|
|
callback close-app();
|
|
callback recovery-login();
|
|
|
|
// Kiosk chrome heights. The back bar + transport are one header-row tall
|
|
// each; the NavRail is a fat touch target.
|
|
property <length> navrail-height: 80px;
|
|
|
|
// Offline gate (verbatim from AppShell:110-130): Qobuz-only views mount only
|
|
// online; local-DB views (LocalLibrary, local album, offline manager, My QBZ,
|
|
// Settings) keep mounting offline.
|
|
property <bool> qobuz-view-blocked: OfflineState.offline && (
|
|
NavState.view == ContentView.home
|
|
|| NavState.view == ContentView.discover-browse
|
|
|| NavState.view == ContentView.search
|
|
|| NavState.view == ContentView.favorites
|
|
|| NavState.view == ContentView.album
|
|
|| NavState.view == ContentView.artist
|
|
|| NavState.view == ContentView.artist-releases
|
|
|| NavState.view == ContentView.musician
|
|
|| NavState.view == ContentView.label
|
|
|| NavState.view == ContentView.label-releases
|
|
|| NavState.view == ContentView.award
|
|
|| NavState.view == ContentView.award-albums
|
|
|| NavState.view == ContentView.purchases
|
|
|| NavState.view == ContentView.purchase-album
|
|
|| NavState.view == ContentView.location
|
|
|| NavState.view == ContentView.mix
|
|
|| (NavState.view == ContentView.playlist
|
|
&& !PlaylistState.is-local && !PlaylistState.offline-subset));
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// --- Back bar: persistent global back (workstream B, wired to NavState
|
|
// history, ADR-004) + the kiosk search entry. The desktop search box
|
|
// lives in the HeaderBar, which the kiosk drops, so a persistent search
|
|
// field here is what makes Search functional; Enter submits through the
|
|
// same SearchActions seam the header uses and lands on the results view.
|
|
backbar := Rectangle {
|
|
height: Layout.header-height;
|
|
background: Theme.surface-card;
|
|
// The kiosk carries no HeaderBar, so it owns the window chrome too.
|
|
// macOS overlay leaves the native traffic-light inset; Linux frameless
|
|
// draws the custom WindowControls. OS-agnostic on purpose (the kiosk
|
|
// is not restricted to any platform).
|
|
property <length> mac-inset: AppearanceState.is-macos && !AppearanceState.system-title-bar-active ? 78px : 0px;
|
|
property <bool> lin-chrome: !AppearanceState.is-macos && !AppearanceState.system-title-bar-active && AppearanceState.show-window-controls;
|
|
HorizontalLayout {
|
|
padding-left: 6px + backbar.mac-inset;
|
|
padding-right: backbar.lin-chrome ? 118px : 8px;
|
|
padding-top: 4px;
|
|
padding-bottom: 4px;
|
|
spacing: 6px;
|
|
back-btn := Rectangle {
|
|
width: 44px;
|
|
border-radius: Radius.sm;
|
|
background: back-ta.has-hover && NavState.can-back ? Theme.alpha-8 : transparent;
|
|
opacity: NavState.can-back ? 1.0 : 0.32;
|
|
Image {
|
|
source: @image-url("../assets/icons/chevron-left.svg");
|
|
colorize: Theme.text-secondary;
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
back-ta := TouchArea {
|
|
enabled: NavState.can-back;
|
|
clicked => {
|
|
NavState.request-back();
|
|
}
|
|
}
|
|
}
|
|
fwd-btn := Rectangle {
|
|
width: 44px;
|
|
border-radius: Radius.sm;
|
|
background: fwd-ta.has-hover && NavState.can-forward ? Theme.alpha-8 : transparent;
|
|
opacity: NavState.can-forward ? 1.0 : 0.32;
|
|
Image {
|
|
source: @image-url("../assets/icons/chevron-right.svg");
|
|
colorize: Theme.text-secondary;
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
fwd-ta := TouchArea {
|
|
enabled: NavState.can-forward;
|
|
clicked => {
|
|
NavState.request-forward();
|
|
}
|
|
}
|
|
}
|
|
search-field := LineEdit {
|
|
horizontal-stretch: 1;
|
|
placeholder-text: @tr("Search");
|
|
// Suppress the global single-key hotkeys while typing (the
|
|
// desktop search box drives the same guard); without this,
|
|
// letters fire hotkeys instead of reaching the field.
|
|
// Probe property because a use-site `changed has-focus` on a
|
|
// std-widgets input never fires (has-focus is an alias into
|
|
// the inner TextInput) — #619.
|
|
property <bool> guard-focused: self.has-focus;
|
|
changed guard-focused => {
|
|
UiFocusState.text-input-focused = self.guard-focused;
|
|
}
|
|
accepted(text) => {
|
|
SearchActions.submit(text);
|
|
NavState.view = ContentView.search;
|
|
// Release focus so Slint sends text-input DISABLE → the OS
|
|
// on-screen keyboard (squeekboard) closes. Otherwise the
|
|
// search field keeps focus forever and the OSK never hides.
|
|
search-field.clear-focus();
|
|
}
|
|
}
|
|
}
|
|
// Linux custom window controls at the right edge.
|
|
if backbar.lin-chrome: WindowControls {
|
|
x: backbar.width - self.preferred-width - 8px;
|
|
y: (backbar.height - self.preferred-height) / 2;
|
|
}
|
|
}
|
|
|
|
// --- Content frame (mirrors AppShell:237-279 arithmetic-height pattern).
|
|
// A conditionally-mounted view will not stretch to fill a layout cell, so
|
|
// the inner Rectangle takes an EXPLICIT height and the views fill it via
|
|
// clip — this is the documented layout-collapse footgun. --------------
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
background: Theme.surface-card;
|
|
// Tap anywhere in the content area to dismiss the OS on-screen
|
|
// keyboard: release the search field's focus → Slint sends the
|
|
// text-input DISABLE → squeekboard closes. Declared FIRST so it sits
|
|
// BEHIND the views (which handle their own taps); it only catches
|
|
// empty-area taps.
|
|
dismiss-osk-ta := TouchArea {
|
|
clicked => { search-field.clear-focus(); }
|
|
}
|
|
VerticalLayout {
|
|
vertical-stretch: 1;
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
padding-top: 0px;
|
|
padding-bottom: 8px;
|
|
|
|
Rectangle {
|
|
// window - back bar - transport(PlayerBarSmall = one header
|
|
// row) - NavRail - this VL's bottom padding. The transport
|
|
// row is HIDDEN on Now Playing (redundant there), so DON'T
|
|
// subtract it then — otherwise the content stays short and the
|
|
// NavRail rides up, leaving dead space below the footer.
|
|
height: root.height - Layout.header-height
|
|
- (NavState.view == ContentView.now-playing ? 0px : Layout.header-height)
|
|
- root.navrail-height - 8px;
|
|
vertical-stretch: 1;
|
|
background: Theme.surface-main;
|
|
border-radius: Radius.md;
|
|
clip: true;
|
|
|
|
// --- Verbatim copy of AppShell's view mounts (286-526). ---
|
|
if root.qobuz-view-blocked && NavState.view != ContentView.favorites: OfflinePlaceholder { }
|
|
if root.qobuz-view-blocked && NavState.view == ContentView.favorites: OfflinePlaceholder {
|
|
FavoritesOfflineRail { }
|
|
}
|
|
|
|
if NavState.view == ContentView.home && !OfflineState.offline: KioskDiscover {
|
|
album-clicked(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
discover-view-all(e, t) => {
|
|
root.discover-view-all(e, t);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.discover-browse && !OfflineState.offline: DiscoverBrowseView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.recent-albums: RecentAlbumsView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.album && !OfflineState.offline: KioskAlbum {
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.local-album: LocalAlbumView {
|
|
open-artist(name) => {
|
|
root.open-artist(name);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.artist && !OfflineState.offline: KioskArtist {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.artist-releases && !OfflineState.offline: ArtistReleasesView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.musician && !OfflineState.offline: MusicianPageView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.label && !OfflineState.offline: LabelPageView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.label-releases && !OfflineState.offline: LabelReleasesView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.award && !OfflineState.offline: AwardView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.award-albums && !OfflineState.offline: AwardAlbumsView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.location && !OfflineState.offline: ArtistsByLocationView {
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.favorites && !OfflineState.offline: KioskLibrary {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.purchases && !OfflineState.offline: PurchasesView {
|
|
open-album(id) => {
|
|
root.open-purchase-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.purchase-album
|
|
&& !OfflineState.offline
|
|
&& PurchaseDetailState.album-id != "": PurchaseDetailView { }
|
|
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
|
|
if NavState.view == ContentView.mixtapes: KioskMyQBZ {
|
|
navigate(r) => {
|
|
root.header-menu-navigate(r);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.collections: KioskMyQBZ {
|
|
navigate(r) => {
|
|
root.header-menu-navigate(r);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.mixtape-detail: MixtapeDetailView { }
|
|
if NavState.view == ContentView.discography-builder: DiscographyBuilderView { }
|
|
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
|
|
if NavState.view == ContentView.blacklist-manager: BlacklistManagerView { }
|
|
if NavState.view == ContentView.local-library: KioskLocalLibrary { }
|
|
if NavState.view == ContentView.mix && !OfflineState.offline: MixView {
|
|
play-track(id) => {
|
|
root.media-action("track", id, "play");
|
|
}
|
|
play-all => {
|
|
root.media-action("mix", "", "play-all");
|
|
}
|
|
shuffle => {
|
|
root.media-action("mix", "", "shuffle");
|
|
}
|
|
refresh => {
|
|
root.media-action("mix", "", "refresh");
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.playlist
|
|
&& (!OfflineState.offline
|
|
|| PlaylistState.is-local
|
|
|| PlaylistState.offline-subset): PlaylistView {
|
|
play-track(id) => {
|
|
root.media-action("track", id, "play");
|
|
}
|
|
play-all => {
|
|
root.media-action("playlist", "", "play-all");
|
|
}
|
|
shuffle => {
|
|
root.media-action("playlist", "", "shuffle");
|
|
}
|
|
toggle-favorite => {
|
|
root.media-action("playlist", "", "favorite");
|
|
}
|
|
edit => {
|
|
root.media-action("playlist", "", "edit");
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.settings: SettingsView {
|
|
settings-bool(k, v) => {
|
|
root.settings-bool(k, v);
|
|
}
|
|
settings-select(k, i) => {
|
|
root.settings-select(k, i);
|
|
}
|
|
settings-slider(k, v) => {
|
|
root.settings-slider(k, v);
|
|
}
|
|
settings-string(k, s) => {
|
|
root.settings-string(k, s);
|
|
}
|
|
settings-reset => {
|
|
root.settings-reset();
|
|
}
|
|
settings-release-device => {
|
|
root.settings-release-device();
|
|
}
|
|
}
|
|
if NavState.view == ContentView.search && !OfflineState.offline: KioskSearch {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.now-playing: KioskNowPlaying {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Persistent transport: force PlayerBarSmall (npb=2 seam). Reused
|
|
// verbatim; it self-heights to one header row and reads its own globals.
|
|
// Hidden on the Now Playing view — that view already carries the full
|
|
// transport, so the bar would be redundant there.
|
|
if NavState.view != ContentView.now-playing: PlayerBarSmall {
|
|
height: Layout.header-height;
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
settings-bool(k, v) => {
|
|
root.settings-bool(k, v);
|
|
}
|
|
}
|
|
|
|
// --- Bottom NavRail (net-new). Each tile maps to the real route the
|
|
// Sidebar/Rust dispatcher already uses. ------------------------------
|
|
NavRail {
|
|
height: root.navrail-height;
|
|
discover => {
|
|
root.header-menu-navigate("discover-home");
|
|
}
|
|
library => {
|
|
root.header-menu-navigate("favorites-tracks");
|
|
}
|
|
local-library => {
|
|
root.header-menu-navigate("local-albums");
|
|
}
|
|
myqbz => {
|
|
root.header-menu-navigate("myqbz-mixtapes");
|
|
}
|
|
playing => {
|
|
NavState.view = ContentView.now-playing;
|
|
}
|
|
visualizer => {
|
|
root.media-action("npb-view", "", "immersive");
|
|
}
|
|
menu => {
|
|
NavState.request-settings();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Immersive / visualizer overlay — the same app-level view the desktop
|
|
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
|
|
// mount its own copy. Reached from the NavRail visualizer tile (which fires
|
|
// the "npb-view"/"immersive" action). Rendered last = on top of everything.
|
|
if ImmersiveState.open: ImmersiveView {
|
|
width: 100%;
|
|
height: 100%;
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
}
|