Files
qbz/crates/qbz-slint/ui/shell/AppShell.slint
T
vicrodh 391cbb86b9 feat(slint): ArtistView overhaul — server-driven buckets, dedicated discography, sort/load-more, Magazine, image fixes
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.

New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).

Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.

Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.

Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.

Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.

cc
2026-06-21 21:42:22 -06:00

669 lines
30 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 } from "../state.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 { TagEditorModal } from "../primitives/TagEditorModal.slint";
import { PlaylistImportModal } from "../primitives/PlaylistImportModal.slint";
import { DacWizardModal } from "../primitives/DacWizardModal.slint";
import { TrackInfoModal } from "../album/TrackInfoModal.slint";
import { AlbumCreditsModal } from "../album/AlbumCreditsModal.slint";
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
import { QconnectDevModal } from "QconnectDevModal.slint";
import { CastPicker } from "CastPicker.slint";
import { DiscoverConfigModal } from "../discover/DiscoverConfigModal.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 { 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 { 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 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-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.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.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();
}
// 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);
changed viz-should-run => {
VisualizerState.set-enabled(self.viz-should-run);
}
// Bottom layer: catches the mouse back / forward buttons for history
// navigation. Declared first so the real UI sits on top of it.
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();
}
}
}
}
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();
}
}
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: 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 ? Layout.header-height : Layout.player-bar-height)
- 8px;
vertical-stretch: 1;
background: Theme.surface-main;
border-radius: Radius.md;
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.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);
}
}
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.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-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; }
background: 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%;
}
// Qobuz Connect DEV diagnostics modal (opened from the connect flyout).
QconnectDevModal {
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%;
}
// 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%;
}
}