Extend the AppShell bottom-layer TouchArea (the one already catching the mouse back/forward buttons) with a scroll-event handler that accumulates horizontal touchpad deltas and fires one NavState.request-back/forward per gesture — the same callbacks the header nav buttons drive, so entry swap, scroll restore and nav flags all follow the existing path. Delivery is front-to-back, so a horizontal carousel Flickable consumes horizontal deltas before they reach this layer (carousels keep scrolling and never trigger navigation); a vertical-only page scroller rejects horizontal-only deltas as an orthogonal direction, letting them fall through to the gesture handler. The handler always returns reject, so scroll routing is untouched everywhere. One navigation per gesture via an idle-timeout/direction-flip reset and a fired latch; 70px threshold; fingers-right = back / fingers-left = forward under libinput natural scrolling (commented in code). km
1032 lines
48 KiB
Plaintext
1032 lines
48 KiB
Plaintext
// App shell — the post-login frame: header bar, sidebar, content
|
|
// region, and player bar. The content region shows the Discover Home,
|
|
// Album detail, or Artist detail view, driven by NavState.
|
|
//
|
|
// Regions are delineated by their surface colors (chrome over the darker
|
|
// content well); no hairline borders.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Layout } from "../foundation/layout.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import { NavState, ContentView, ShellState, OfflineState, PlaylistState, ImmersiveState, SearchActions, VisualizerState, PurchaseDetailState, NowPlayingState, AppearanceState, TrackMenuState, LocalAlbumActions } from "../state.slint";
|
|
import { ImmersiveAtmosphere } from "../immersive/ImmersiveAtmosphere.slint";
|
|
import { Sidebar } from "Sidebar.slint";
|
|
import { SidebarNowPlayingDock } from "SidebarNowPlayingDock.slint";
|
|
import { HeaderBar } from "HeaderBar.slint";
|
|
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
|
|
import { TooltipOverlay } from "TooltipOverlay.slint";
|
|
import { ArtPreviewOverlay } from "ArtPreviewOverlay.slint";
|
|
import { SidebarFolderPopup } from "SidebarFolderPopup.slint";
|
|
import { SidebarTooltip } from "SidebarTooltip.slint";
|
|
import { SidebarPlaylistsPopup } from "SidebarPlaylistsPopup.slint";
|
|
import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
|
|
import { PlaylistDuplicateConfirmModal } from "../primitives/PlaylistDuplicateConfirmModal.slint";
|
|
import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
|
|
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
|
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
|
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
|
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
|
import { MyQbzMixModal } from "../myqbz/MyQbzMixModal.slint";
|
|
import { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
|
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
|
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
|
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
|
import { SettingsExportModal } from "../settings/SettingsExportModal.slint";
|
|
import { TagEditorModal } from "../primitives/TagEditorModal.slint";
|
|
import { PlaylistImportModal } from "../primitives/PlaylistImportModal.slint";
|
|
import { DacWizardModal } from "../primitives/DacWizardModal.slint";
|
|
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
|
|
import { TrackInfoModal } from "../album/TrackInfoModal.slint";
|
|
import { AlbumCreditsModal } from "../album/AlbumCreditsModal.slint";
|
|
import { AlbumBookletModal } from "../album/AlbumBookletModal.slint";
|
|
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
|
|
import { QconnectDevModal } from "QconnectDevModal.slint";
|
|
import { LogViewerModal } from "LogViewerModal.slint";
|
|
import { ReportIssueModal } from "ReportIssueModal.slint";
|
|
import { CastPicker } from "CastPicker.slint";
|
|
import { DiscoverConfigModal } from "../discover/DiscoverConfigModal.slint";
|
|
import { KeyboardShortcutsModal } from "KeyboardShortcutsModal.slint";
|
|
import { CustomizeShortcutsModal } from "CustomizeShortcutsModal.slint";
|
|
import { LinkResolverModal } from "LinkResolverModal.slint";
|
|
import { AboutModal } from "AboutModal.slint";
|
|
import { WhatsNewModal } from "WhatsNewModal.slint";
|
|
import { DragGhost } from "../primitives/DragGhost.slint";
|
|
import { Toast } from "../primitives/Toast.slint";
|
|
import { NowPlayingBar } from "NowPlayingBar.slint";
|
|
import { QueueSidebar } from "QueueSidebar.slint";
|
|
import { LyricsSidebar } from "LyricsSidebar.slint";
|
|
import { HomeView } from "../discover/HomeView.slint";
|
|
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
|
|
import { PlaylistBrowseView } from "../discover/PlaylistBrowseView.slint";
|
|
import { RecentAlbumsView } from "../discover/RecentAlbumsView.slint";
|
|
import { MostPlayedAlbumsView } from "../discover/MostPlayedAlbumsView.slint";
|
|
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
|
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
|
|
import { ArtistPageView } from "../artist/ArtistPageView.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 { FavoritesView } from "../favorites/FavoritesView.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 { MixtapesView } from "../myqbz/MixtapesView.slint";
|
|
import { CollectionsView } from "../myqbz/CollectionsView.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 { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
|
|
import { SettingsView } from "../settings/SettingsView.slint";
|
|
import { SearchResultsView } from "../search/SearchResultsView.slint";
|
|
import { Cortinilla } from "../search/Cortinilla.slint";
|
|
|
|
export component AppShell inherits Rectangle {
|
|
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();
|
|
// D2 recovery sign-in with the saved token — fired by the header
|
|
// status badge's flyout (formerly an in-layout banner).
|
|
callback recovery-login();
|
|
|
|
// Offline gating (slice 3d): the Qobuz-only content views are replaced by
|
|
// the OfflinePlaceholder while ANY offline flavor is active. Views that
|
|
// work without Qobuz (LocalLibrary, local album, offline cache manager,
|
|
// My QBZ lists/detail — local-DB resolve, Settings) keep mounting.
|
|
property <bool> qobuz-view-blocked: OfflineState.offline && (
|
|
NavState.view == ContentView.home
|
|
|| NavState.view == ContentView.discover-browse
|
|
|| NavState.view == ContentView.playlist-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
|
|
// Playlist: LOCAL playlists and the offline sidecar rendering of a
|
|
// MIXED one (D11.a) mount below; only a plain Qobuz detail blocks.
|
|
|| (NavState.view == ContentView.playlist
|
|
&& !PlaylistState.is-local && !PlaylistState.offline-subset));
|
|
|
|
background: Theme.surface-main;
|
|
|
|
// Close the live search "cortinilla" on ANY view change, whatever drove it
|
|
// (header back/forward, the nav-tab menu, a sidebar item, the player bar, a
|
|
// row click that navigates). Every navigation path ultimately sets
|
|
// `NavState.view`, so a single `changed` on a property mirroring it is the
|
|
// one chokepoint that covers them all — otherwise the dropdown + its
|
|
// click-outside scrim stay mounted over the new page and the scrim eats the
|
|
// first click. The cortinilla's own handlers (submit / search-all / row /
|
|
// dismiss / short-query) still close it directly; this just adds the
|
|
// "navigated away by other means" case.
|
|
property <ContentView> tracked-view: NavState.view;
|
|
changed tracked-view => {
|
|
SearchActions.cortinilla-dismiss();
|
|
// Remember the last top-level view for "Startup page = where you left
|
|
// off" (Rust filters to safe, id-free views before persisting).
|
|
ShellState.persist-view();
|
|
}
|
|
|
|
// FFT visualizer tap — single source of truth. The tap (frontend-agnostic
|
|
// producer in qbz-audio, driven by crate::visualizer) must run whenever the
|
|
// immersive overlay OR the Large-NPB sidebar dock is showing a spectrum, and
|
|
// idle otherwise (no capture, no FFT cost). This `changed` catches every
|
|
// transition: immersive open/close, Large enter/exit, and the sidebar
|
|
// cycling out of "open" (which reverts Large in cycle-sidebar). The Large
|
|
// menu arm and immersive-open path in Rust also prime it for the initial
|
|
// value (changed only fires on transitions). Immersive's own close handlers
|
|
// defer to `large-active` so closing it never kills an active Large dock.
|
|
property <bool> viz-should-run: ImmersiveState.open
|
|
|| (ShellState.large-active && ShellState.large-visualizer-on)
|
|
// App-wide Ambient background (mode 1) needs the 30fps shader drain
|
|
// running even with the immersive overlay closed. Blurred art (mode 2)
|
|
// is Slint-animated and needs no drain. has-track gates it so nothing
|
|
// playing → drain idle (the FFT tap self-freezes on !playing anyway).
|
|
|| (AppearanceState.app-background-mode-index == 1
|
|
&& AppearanceState.app-background-available
|
|
&& NowPlayingState.has-track);
|
|
changed viz-should-run => {
|
|
VisualizerState.set-enabled(self.viz-should-run);
|
|
}
|
|
|
|
// Reduce-motion clock: on the GL/software tiers (ShellState.reduce-motion,
|
|
// seeded from Rust) the ONE timer below advances a coarse ~8fps millisecond
|
|
// counter that loading indicators / eq bars use instead of the display-rate
|
|
// animation-tick() — every step is a full-window femtovg repaint, so 60→8fps
|
|
// cuts the transient CPU of spinners/skeletons on weak GPUs by ~7x. Wraps
|
|
// daily to keep the int well away from overflow.
|
|
//
|
|
// ALSO ticks while audio plays on the strong (wgpu) tier: the equalizer
|
|
// bars on the current row now animate from this clock on EVERY tier — at
|
|
// display rate they forced a full-window repaint per frame, which sat at
|
|
// ~4.5% CPU with a playlist view open on a 165 Hz monitor. When the
|
|
// playing row is not on screen nothing reads the property, so the tick
|
|
// dirties no binding and costs nothing.
|
|
Timer {
|
|
interval: 125ms;
|
|
running: ShellState.reduce-motion || NowPlayingState.playing;
|
|
triggered => {
|
|
ShellState.coarse-tick-ms = Math.mod(ShellState.coarse-tick-ms + 125, 86400000);
|
|
}
|
|
}
|
|
|
|
// APP-WIDE DYNAMIC BACKGROUND ("modo Cider") — the bottom-most visual layer,
|
|
// behind every shell surface (which goes translucent via
|
|
// ShellState.app-background-active). Only mounts when a mode is picked, the
|
|
// wgpu tier allows it, AND a track is loaded (D4: nothing playing → opaque
|
|
// theme restored). Ambient (mode 1) shows the GPU shader texture the drain
|
|
// renders into ImmersiveState.shader-texture; Blurred art (mode 2) reuses the
|
|
// ImmersiveAtmosphere blurred-cover. Edge-pinned to root (absolute layout).
|
|
if ShellState.app-background-active && AppearanceState.app-background-mode-index == 1: Image {
|
|
x: 0px;
|
|
y: 0px;
|
|
width: root.width;
|
|
height: root.height;
|
|
image-fit: fill;
|
|
source: ImmersiveState.shader-texture;
|
|
}
|
|
if ShellState.app-background-active && AppearanceState.app-background-mode-index == 2: ImmersiveAtmosphere {
|
|
x: 0px;
|
|
y: 0px;
|
|
width: root.width;
|
|
height: root.height;
|
|
source-image: ImmersiveState.bg-image;
|
|
fallback-image: NowPlayingState.artwork-large.width > 0
|
|
? NowPlayingState.artwork-large
|
|
: NowPlayingState.artwork;
|
|
animated: NowPlayingState.playing;
|
|
dim: AppearanceState.app-background-dim;
|
|
}
|
|
// Extra tunable dark scrim over the Ambient shader (the atmosphere applies
|
|
// its own `dim`). Keeps chrome text legible over a bright album palette;
|
|
// QBZ_BG_DIM tunes it live at startup.
|
|
if ShellState.app-background-active && AppearanceState.app-background-mode-index == 1: Rectangle {
|
|
x: 0px;
|
|
y: 0px;
|
|
width: root.width;
|
|
height: root.height;
|
|
background: #000000.with-alpha(AppearanceState.app-background-dim);
|
|
}
|
|
|
|
// Bottom layer: catches the mouse back / forward buttons AND two-finger
|
|
// horizontal touchpad swipes for history navigation (#653). Declared
|
|
// first so the real UI sits on top of it: a scroll event only reaches
|
|
// this layer when nothing above consumed it — a horizontal carousel
|
|
// Flickable accepts horizontal deltas (carousel keeps scrolling, no
|
|
// navigation fires), while a vertical-only page scroller REJECTS
|
|
// horizontal-only deltas (orthogonal direction, see Flickable's
|
|
// is_allowed_scroll_direction) and they fall through to here. The
|
|
// handler always returns EventResult.reject, so no scroll routing
|
|
// changes anywhere in the shell.
|
|
TouchArea {
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down) {
|
|
if (e.button == PointerEventButton.back) {
|
|
NavState.request-back();
|
|
}
|
|
if (e.button == PointerEventButton.forward) {
|
|
NavState.request-forward();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Swipe gesture accumulation: one navigation per gesture. Deltas
|
|
// arrive as pointer scroll events (Wayland/X11 touchpad). Slint's
|
|
// PointerScrollEvent carries no phase, so the gesture boundary is
|
|
// approximated: idle timeout (swipe-idle) + direction flips reset
|
|
// the accumulator, and `swipe-fired` suppresses re-triggers until
|
|
// the gesture goes idle.
|
|
property <length> swipe-accum: 0px;
|
|
property <bool> swipe-fired: false;
|
|
swipe-idle := Timer {
|
|
interval: 300ms;
|
|
triggered => {
|
|
swipe-accum = 0px;
|
|
swipe-fired = false;
|
|
swipe-idle.stop();
|
|
}
|
|
}
|
|
scroll-event(e) => {
|
|
// Only clearly horizontal-dominant deltas count toward the
|
|
// gesture; vertical scroll jitter is ignored.
|
|
if (e.delta-x != 0 && Math.abs(e.delta-x) > Math.abs(e.delta-y)) {
|
|
// A direction flip starts a fresh gesture.
|
|
if (swipe-accum != 0 && (swipe-accum > 0) != (e.delta-x > 0)) {
|
|
swipe-accum = e.delta-x;
|
|
swipe-fired = false;
|
|
} else {
|
|
swipe-accum += e.delta-x;
|
|
}
|
|
swipe-idle.restart();
|
|
// 70 logical px total — high enough that resting fingers or
|
|
// small trackpad noise never trip it, low enough that a
|
|
// deliberate swipe crosses it in one motion.
|
|
if (!swipe-fired && Math.abs(swipe-accum) >= 70px) {
|
|
// DIRECTION (macOS/GNOME natural-scrolling convention):
|
|
// fingers RIGHT = back, fingers LEFT = forward. With
|
|
// libinput natural scrolling ON (GNOME default) fingers
|
|
// right yields NEGATIVE delta-x, hence the signs below.
|
|
// Under non-natural scrolling the mapping feels inverted
|
|
// — accepted trade-off (distro scroll-direction setting
|
|
// is not visible to the app).
|
|
if (swipe-accum < 0 && NavState.can-back) {
|
|
NavState.request-back();
|
|
}
|
|
if (swipe-accum > 0 && NavState.can-forward) {
|
|
NavState.request-forward();
|
|
}
|
|
swipe-fired = true;
|
|
swipe-accum = 0px;
|
|
}
|
|
}
|
|
// Never consume the event: views above/below keep their exact
|
|
// pre-gesture scroll behavior.
|
|
return EventResult.reject;
|
|
}
|
|
}
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// Header spans the full width at the top; the sidebar starts below it.
|
|
// The offline/recovery status now lives INSIDE the header (badge +
|
|
// flyout, left of the app-menu button) — never as an in-layout row,
|
|
// which displaced everything below it (the sidebar and its anchored
|
|
// section flyouts) by its height.
|
|
HeaderBar {
|
|
logout => {
|
|
root.logout();
|
|
}
|
|
close-app => {
|
|
root.close-app();
|
|
}
|
|
recovery-login => {
|
|
root.recovery-login();
|
|
}
|
|
navigate(route) => {
|
|
root.header-menu-navigate(route);
|
|
}
|
|
}
|
|
|
|
HorizontalLayout {
|
|
vertical-stretch: 1;
|
|
spacing: 0px;
|
|
|
|
Sidebar {
|
|
navigate(route) => {
|
|
root.header-menu-navigate(route);
|
|
}
|
|
}
|
|
|
|
// Content frame — surface-card chrome around a rounded, inset
|
|
// content panel (Slack-style bezel on all four corners).
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
background: ShellState.app-background-active
|
|
? Theme.surface-card.with-alpha(AppearanceState.app-background-surface-alpha)
|
|
: Theme.surface-card;
|
|
VerticalLayout {
|
|
vertical-stretch: 1;
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
// No top padding: the contiguous surface-card gap below the
|
|
// header made the items look high (the perceived header
|
|
// band was taller than its real height). The panel now
|
|
// butts the header; its rounded top corners still bezel.
|
|
padding-top: 0px;
|
|
padding-bottom: 8px;
|
|
|
|
Rectangle {
|
|
// EXPLICIT height = window - header - player bar - this
|
|
// VL's bottom padding. A Slint `VerticalLayout` ignores an
|
|
// explicit `height` (it sizes to content), and a layout
|
|
// does not auto-fill its parent Rectangle — so the content
|
|
// row could not stretch on its own and collapsed once the
|
|
// right panels (which used to drive the height) became
|
|
// conditionally mounted. A Rectangle DOES take an explicit
|
|
// height, so we size the content area arithmetically; the
|
|
// height then cascades UP (this -> VL -> frame -> row ->
|
|
// shell). `root.height` is the AppShell height (pinned to
|
|
// the window in app.slint).
|
|
//
|
|
// NOTE: the bar height is mode-aware. New + Classic + Large
|
|
// use the full `Layout.player-bar-height` (112px); only Small
|
|
// (npb-mode 2) collapses the bar to one header-tall row
|
|
// (42px), so the content area gains ~70px. Computed inline
|
|
// (mirrors the same expression in PlayerBar.slint `height:`)
|
|
// to keep `Layout` a pure leaf independent of ShellState.
|
|
// Large (mode 3) keeps the full bar height — its only
|
|
// restructuring (cover + spectrum) lives inside the sidebar,
|
|
// not in this content-height math.
|
|
height: root.height - Layout.header-height
|
|
- (ShellState.npb-mode == 2 ? ShellState.npb-small-height : Layout.player-bar-height)
|
|
- 8px;
|
|
vertical-stretch: 1;
|
|
// When the background is active the content area reads as a
|
|
// distinct frosted-glass PANEL: a faint tint (so it's set
|
|
// apart from the sharper sidebar/background) plus a 1px
|
|
// hairline border framing it — the Cider content-area look.
|
|
// (True backdrop blur isn't a Slint primitive; the tint +
|
|
// border approximate the "frosted, bordered panel" read.)
|
|
background: ShellState.app-background-active
|
|
? Theme.surface-main.with-alpha(0.22)
|
|
: Theme.surface-main;
|
|
border-radius: Radius.md;
|
|
border-width: ShellState.app-background-active ? 1px : 0px;
|
|
border-color: ShellState.app-background-active
|
|
? #ffffff.with-alpha(0.10)
|
|
: transparent;
|
|
clip: true;
|
|
|
|
// Offline: every Qobuz-only view below mounts only while
|
|
// online; the shared placeholder takes the pane otherwise
|
|
// (ADR-010 — the `if` lives here at the mount site). The
|
|
// Favorites mount alone injects the B9 "playable favorites"
|
|
// rail into the placeholder's children slot.
|
|
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: HomeView {
|
|
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.playlist-browse && !OfflineState.offline: PlaylistBrowseView {
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
// Recently Played Albums full page — LOCAL play-history data,
|
|
// so it mounts offline too (like local-album below); opening a
|
|
// Qobuz card from it offline hits the album view's own gate.
|
|
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.most-played-albums: MostPlayedAlbumsView {
|
|
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: AlbumPageView {
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
// Dedicated Local Library album detail (separate from AlbumView).
|
|
if NavState.view == ContentView.local-album: LocalAlbumView {
|
|
open-artist(name) => {
|
|
root.open-artist(name);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.artist && !OfflineState.offline: ArtistPageView {
|
|
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);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.favorites && !OfflineState.offline: FavoritesView {
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
}
|
|
if NavState.view == ContentView.purchases && !OfflineState.offline: PurchasesView {
|
|
// A grid/list card click opens the purchase DETAIL view
|
|
// (Slice 11 adds nav-history + persistence on top).
|
|
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);
|
|
}
|
|
}
|
|
// Detail mounts ONLY with a non-empty selected id (ADR-010
|
|
// conditional-mount guard; the controller seeds the id before
|
|
// navigating, and the view's `changed album-id` fires the load).
|
|
if NavState.view == ContentView.purchase-album
|
|
&& !OfflineState.offline
|
|
&& PurchaseDetailState.album-id != "": PurchaseDetailView { }
|
|
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
|
|
if NavState.view == ContentView.mixtapes: MixtapesView { }
|
|
if NavState.view == ContentView.collections: CollectionsView { }
|
|
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: LocalLibraryView { }
|
|
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);
|
|
}
|
|
}
|
|
// LOCAL playlists (PlaylistState.is-local) render OFFLINE
|
|
// too — they live in library.db, not on Qobuz (D7/D11) —
|
|
// and so does the local-sidecar rendering of a MIXED
|
|
// Qobuz playlist (PlaylistState.offline-subset, D11.a).
|
|
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: SearchResultsView {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right-side panel column — Queue and/or Lyrics stacked vertically,
|
|
// sharing the height (Feishin-style). A layout sibling of the content
|
|
// (shrinks it, no overlay). The column width animates 0<->240 (= the
|
|
// open left-sidebar width); it collapses only when BOTH are closed.
|
|
// When both are open each section vertical-stretches to half; when
|
|
// only one is open it fills.
|
|
Rectangle {
|
|
// 300px so the lines/queue gain +20px of CONTENT width
|
|
// (padding/margins unchanged inside the panels). Both share
|
|
// the column (stacked mode), so both widen together — keeping
|
|
// Queue and Lyrics aligned when stacked.
|
|
width: (ShellState.queue-open || ShellState.lyrics-open) ? 300px : 0px;
|
|
clip: true;
|
|
animate width { duration: 160ms; easing: ease-in-out; }
|
|
// Transparent when the app-background is active so the inner
|
|
// QueueSidebar / LyricsSidebar (already translucent) carry the
|
|
// tint once, not twice (no double-darkening of this column).
|
|
background: ShellState.app-background-active
|
|
? transparent
|
|
: Theme.surface-card;
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
if ShellState.queue-open: QueueSidebar {
|
|
vertical-stretch: 1;
|
|
}
|
|
if ShellState.queue-open && ShellState.lyrics-open: Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
if ShellState.lyrics-open: LyricsSidebar {
|
|
vertical-stretch: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mode-aware bottom bar shell: New/Classic -> PlayerBar, Small ->
|
|
// PlayerBarSmall. Forwards the same bindings the bar always took.
|
|
NowPlayingBar {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Large-NPB cover + spectrum dock — a ROOT overlay pinned flush to the window
|
|
// bottom-left (the L's vertical arm), painting over the bar's surface-card in
|
|
// that corner. Declared AFTER the outer VerticalLayout so it stacks above the
|
|
// bar, and BEFORE HeaderMenuOverlay so header flyouts (and every modal/toast
|
|
// below) still stack above it (ADR-009 declaration-order z). Only mounted
|
|
// while Large is active (sidebar open + npb-mode 3). x/width give equal L/R
|
|
// gutters (16 + 208 + 16 = 240); y pins the cover flush to the window bottom.
|
|
// The dock self-derives its height (must equal ShellState.large-dock-height).
|
|
if ShellState.large-active: SidebarNowPlayingDock {
|
|
width: ShellState.large-dock-width;
|
|
x: 16px;
|
|
y: root.height - ShellState.large-dock-height;
|
|
media-action(k, i, a) => {
|
|
root.media-action(k, i, a);
|
|
}
|
|
}
|
|
|
|
// Header nav-tab dropdown menus. Declared LAST so it draws on top of
|
|
// the header and the content region. A plain in-window overlay (not
|
|
// a PopupWindow) — it does not grab the pointer, so the header tabs
|
|
// stay hoverable and menus can be hover-switched instantly.
|
|
HeaderMenuOverlay {
|
|
navigate(route) => {
|
|
root.header-menu-navigate(route);
|
|
}
|
|
}
|
|
|
|
// Live as-you-type search dropdown (the "cortinilla"), anchored under the
|
|
// centered header search box. A plain in-window overlay (NOT a PopupWindow)
|
|
// so the search box stays typeable and the rows stay hoverable while
|
|
// results stream in. Declared after HeaderMenuOverlay (same header-anchored
|
|
// z-tier, above content) and before the feature modals / toasts so those
|
|
// still stack on top. Its callbacks forward straight to SearchActions.
|
|
Cortinilla {
|
|
row-clicked(fi) => {
|
|
SearchActions.cortinilla-row-clicked(fi);
|
|
}
|
|
view-more(kind) => {
|
|
SearchActions.cortinilla-view-more(kind);
|
|
}
|
|
search-all => {
|
|
SearchActions.cortinilla-search-all();
|
|
}
|
|
dismiss => {
|
|
SearchActions.cortinilla-dismiss();
|
|
}
|
|
}
|
|
|
|
// Collapsed-sidebar folder flyout (mini state). Drawn above the content.
|
|
SidebarFolderPopup { }
|
|
|
|
// Collapsed-sidebar hover tooltip (mini state) — name + folder count.
|
|
SidebarTooltip { }
|
|
|
|
// Closed-sidebar playlists flyout (opened from the header playlists button).
|
|
SidebarPlaylistsPopup { }
|
|
|
|
// Global "Add to playlist" picker overlay, opened from any track
|
|
// context menu.
|
|
PlaylistPickerModal { }
|
|
|
|
// "Duplicate tracks" confirm sub-modal — mounted AFTER the picker so it
|
|
// stacks above it (Slint stacking = declaration order; ADR-009/010).
|
|
PlaylistDuplicateConfirmModal { }
|
|
|
|
// Global "Create playlist" modal, opened from the sidebar "+".
|
|
CreatePlaylistModal { }
|
|
|
|
// Global "Edit playlist" modal (rename / delete).
|
|
EditPlaylistModal { }
|
|
|
|
// Global "Create folder" modal.
|
|
CreateFolderModal { }
|
|
|
|
// Global "Create Mixtape / Collection" modal (My QBZ "+ New").
|
|
CreateMyQbzModal { }
|
|
|
|
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
|
MyQbzEditModal { }
|
|
|
|
// Global My QBZ "DJ-mix / Random queue" sampler modal (TrackMixModal).
|
|
MyQbzMixModal { }
|
|
|
|
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
|
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
|
AddToMixtapeModal { }
|
|
|
|
// Ephemeral "already playing" add-vs-replace choice.
|
|
EphemeralPlayChoiceModal { }
|
|
|
|
// Global "Folder editor" modal (Playlist Manager create/edit folder).
|
|
FolderEditModal { }
|
|
|
|
// Global "Local Library folder settings" modal (Settings > Local Library).
|
|
LibFolderEditModal { }
|
|
|
|
// Global "Tag editor" modal (local album metadata).
|
|
TagEditorModal { }
|
|
|
|
// Global "Import Playlist" modal (public-playlist importer). Mounted
|
|
// LAST among the feature modals so it stacks above them (ADR-009
|
|
// declaration-order z; ADR-010 conditional mount via the global's
|
|
// `open`), keeping QconnectDevModal topmost.
|
|
PlaylistImportModal { }
|
|
|
|
// HiFi Wizard (DAC setup) — opened from Audio settings (PipeWire only).
|
|
// Overlay modal; playback keeps running underneath so the test step works.
|
|
DacWizardModal { }
|
|
|
|
// Track Info + Album Info (Credits/Review) modals — fed by crate::info_modals
|
|
// through TrackInfoState / AlbumInfoState. Conditional-mount on their own
|
|
// `open`; stacked above the feature modals (ADR-009/010).
|
|
TrackInfoModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
AlbumCreditsModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Album Booklet modal — in-app PDF reader (crate qbz-booklet feeds
|
|
// BookletState). Mounted right after the credits modal; self-gates on
|
|
// BookletState.open (ADR-009/010).
|
|
AlbumBookletModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Qobuz Connect DEV diagnostics modal (opened from the connect flyout).
|
|
QconnectDevModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// In-app log viewer (Developer settings). Declaration-order z keeps it above
|
|
// the page; self-gated on LogViewerState.open (ADR-009/010).
|
|
LogViewerModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// "Export settings" modal (Developer settings, 04 §4.2). Self-gated on
|
|
// SettingsExportState.open; declaration-order z keeps it above the page.
|
|
SettingsExportModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Report-an-issue modal (header hamburger). Mounted after the log viewer so
|
|
// "Go to logs" stacks the viewer on top. Self-gated on ReportIssueState.open.
|
|
ReportIssueModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Discover section configurator (Slice 5). Mounted after the other modals
|
|
// so its declaration order keeps it on top of them (ADR-009).
|
|
DiscoverConfigModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Cast (Chromecast / DLNA) device picker.
|
|
CastPicker {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Keyboard shortcuts: read-only cheatsheet (`?`) + editable customize
|
|
// editor. Self-gate on KeyboardShortcutsState.open / .customize-open.
|
|
KeyboardShortcutsModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
CustomizeShortcutsModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// "Open Qobuz Link" (Ctrl+L) cross-platform link resolver.
|
|
LinkResolverModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// About QBZ (hamburger menu). Self-gated on AboutState.open (ADR-009/010).
|
|
AboutModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// What's New (hamburger menu). Self-gated on WhatsNewState.open; opening it
|
|
// also kicks off the GitHub release fetch (crate::whats_new).
|
|
WhatsNewModal {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Floating track-drag ghost (follows the cursor, on top of all).
|
|
DragGhost { }
|
|
|
|
if ImmersiveState.open: ImmersiveView {
|
|
width: 100%;
|
|
height: 100%;
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
|
|
// In-app toast notifications — bottom-center, above everything.
|
|
Toast { }
|
|
|
|
// Global hover-tooltip overlay — drawn LAST so no control can cover it.
|
|
TooltipOverlay {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// Floating album-art hover preview — overlay-rendered (not a PopupWindow) so
|
|
// it never steals hover from the trigger. On top of everything.
|
|
ArtPreviewOverlay {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
// === Unified track context menu (ONE instance for the whole app) ===
|
|
// Replaces the per-row / per-view `track-menu := TrackContextMenu` popups:
|
|
// popup windows are ALWAYS inlined by the Slint compiler, so every site
|
|
// (and every TrackRow repeater) generated its own full copy — 18 structs,
|
|
// ~22k generated lines, for one logical menu. Openers write TrackMenuState
|
|
// and bump `open-token`; this single instance shows at those coordinates.
|
|
property <bool> track-menu-qobuz-source: TrackMenuState.source != "local"
|
|
&& TrackMenuState.source != "plex"
|
|
&& TrackMenuState.source != "ephemeral";
|
|
property <int> track-menu-open-watch: TrackMenuState.open-token;
|
|
changed track-menu-open-watch => {
|
|
unified-track-menu.show();
|
|
}
|
|
// The menu belongs to the view it was opened from — never let it survive
|
|
// a navigation (its row is gone and dispatch is view-scoped).
|
|
property <ContentView> track-menu-view-watch: NavState.view;
|
|
changed track-menu-view-watch => {
|
|
unified-track-menu.close();
|
|
}
|
|
unified-track-menu := TrackContextMenu {
|
|
x: TrackMenuState.x;
|
|
y: TrackMenuState.y;
|
|
track-id: TrackMenuState.track-id;
|
|
nav-only: TrackMenuState.row-inert;
|
|
// Gate expressions ported 1:1 from the former TrackRow menu instance
|
|
// (TrackRow.slint): same inputs, same menu, one copy. Rationales kept
|
|
// from that block:
|
|
// - qobuz-actions: the Qobuz block (radio, share, offline, go-to,
|
|
// track-info) only for Qobuz/offline rows, never on library
|
|
// surfaces (force-local-menu — its arms are dead on library row
|
|
// ids, spec §3.4); transient play/queue always.
|
|
// - favorite-action: on library surfaces only qobuz_download rows
|
|
// (source renders "qobuz") get the entry, wired to the REAL Qobuz
|
|
// id in Rust; plain local/Plex rows get NO favorite entry —
|
|
// deliberate deviation from Tauri, which sends the LOCAL row id
|
|
// to the Qobuz favorites API (spec §1.4/§3.2 latent bug): we port
|
|
// the intent, not the bug.
|
|
// - mixtape-action: on library surfaces for every persistent row
|
|
// (local + Plex + qobuz_download; spec §3.1 — Tauri's
|
|
// Tracks-tab-only asymmetry is an artifact, not a design).
|
|
// - playlist-action: for every non-ephemeral source; NOT hidden
|
|
// offline (deviation from Tauri, spec §3.3): local playlists
|
|
// accept tracks offline (D7/D8) and the picker already hides the
|
|
// Qobuz set offline.
|
|
qobuz-actions: !TrackMenuState.force-local-menu && root.track-menu-qobuz-source;
|
|
favorite-action: TrackMenuState.force-local-menu
|
|
? TrackMenuState.source == "qobuz"
|
|
: root.track-menu-qobuz-source;
|
|
mixtape-action: TrackMenuState.force-local-menu
|
|
? TrackMenuState.source != "ephemeral"
|
|
: root.track-menu-qobuz-source;
|
|
playlist-action: TrackMenuState.source != "ephemeral";
|
|
remove-from-playlist-action: TrackMenuState.remove-from-playlist-action;
|
|
track-info-action: TrackMenuState.track-info-action;
|
|
go-album-action: self.qobuz-actions || TrackMenuState.local-goto-actions;
|
|
go-artist-action: self.qobuz-actions || TrackMenuState.local-goto-actions;
|
|
cache-status: TrackMenuState.cache-status;
|
|
media-action(k, i, a) => {
|
|
unified-track-menu.close();
|
|
// Dispatch mirrors LocalAlbumView's own split (play stays on
|
|
// play-track, the rest resolves against the version track cache);
|
|
// every other surface uses the window's central media-action,
|
|
// which internally reroutes local "play" on the album view.
|
|
if (NavState.view == ContentView.local-album) {
|
|
if (a == "play") {
|
|
LocalAlbumActions.play-track(i);
|
|
} else {
|
|
LocalAlbumActions.track-menu-action(i, a);
|
|
}
|
|
} else {
|
|
root.media-action(k, i, a);
|
|
}
|
|
}
|
|
}
|
|
}
|