The editor gains a Folder row — the same folders the sidebar and the manager file playlists into, "No folder" for the root — seeded from the playlist's current folder and applied on Save through the manager's own move verb, so an existing playlist no longer needs the sidebar's row menu to be filed (#785). "Delete playlist" joins the sidebar row menu (own and local playlists; sidebar entries now carry isOwner) and the playlist card menu (own playlists), behind a shell-level "Delete playlist?" confirmation that runs the same delete the editor's button runs and leaves the detail page only when it was the deleted one (#776). Eight catalogs updated. cc
1113 lines
54 KiB
QML
1113 lines
54 KiB
QML
// App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's
|
|
// chrome: HeaderBar (top, 42px) / { Sidebar | content frame | queue
|
|
// column } / NowPlayingBarSmall (bottom).
|
|
//
|
|
// The window chrome is surface-card throughout; the content area is a
|
|
// ROUNDED surface-main panel inset 8px left/right/bottom (0 top — it
|
|
// butts the header), the "Slack-style bezel on all four corners" of
|
|
// AppShell.slint:358-390. The recovery affordance and logout live in the
|
|
// header (offline badge flyout + app menu), like the Slint shell.
|
|
//
|
|
// The artwork-derived ambient background IS implemented, in BOTH of the
|
|
// reference's modes (AppShell.slint:206-242): 1 = Ambient, the album-triad
|
|
// metaball field (AmbientField) under a tunable dark scrim; 2 = Blurred art,
|
|
// the ImmersiveAtmosphere cover look, which carries its own `dim` and takes no
|
|
// scrim. `theme.ambientOn` says "a mode is picked and a track is loaded";
|
|
// `QbzShell.ambientMode` says WHICH. Until 2026-08-11 mode 2 rendered as mode 1
|
|
// (ambient_qt.rs said so out loud), so the two settings looked identical.
|
|
//
|
|
// THE LAYERING, which is the part that has to be exact. Three tiers, not one:
|
|
// chrome (Sidebar / HeaderBar / NPB / queue column / the CONTENT FRAME)
|
|
// -> surface-card @ 0.5
|
|
// panel (the inset content pane) -> surface-main @ 0.22 + a 1px hairline,
|
|
// composited ON TOP OF the frame, because in the reference it is the
|
|
// frame's CHILD (AppShell.slint:358-414)
|
|
// views -> transparent, with their thin bars at surface-main @ 0.3 and
|
|
// their controls at surface-elevated @ 0.5
|
|
// The frame tier was missing here: `contentFrame` was a sibling of the shell
|
|
// root, so the 8px gutter around the pane showed the RAW field instead of
|
|
// card @ 0.5, and the pane composited over the raw field too. Measured on the
|
|
// owner's side-by-side: Qt's sidebar (27,28,34) against its gutter (50,52,64) —
|
|
// a bright seam at x=240 — where Slint's sidebar (79,72,23) and gutter
|
|
// (82,74,20) are the same surface and read as one continuous frame.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../immersive"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
// OPAQUE base (the Slint root background is opaque surface-main too —
|
|
// invisible while the ambient field mounts over it; this IS the D4
|
|
// no-track fallback). The translucent chrome comes from each chrome
|
|
// piece's OWN background going surface-card @ 0.5 above the field.
|
|
color: theme.surfaceCard
|
|
|
|
// App-wide dynamic background (phase 14): active when the mode pref is
|
|
// on AND a track is loaded (D4: no track -> opaque theme restored). The
|
|
// predicate itself now lives on the theme object so the ~25 surfaces that
|
|
// read it cannot drift apart (they did: the carousel fades and the header
|
|
// search field never got it).
|
|
readonly property bool ambientOn: theme.ambientOn
|
|
// Window-level history navigation: mouse side buttons + two-finger
|
|
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
|
|
// hit-tests on top of it — a scroll reaches it only when nothing above
|
|
// consumed it, which is what keeps horizontal carousels scrolling instead
|
|
// of navigating. Never consumes a wheel event; accepts only the two side
|
|
// buttons.
|
|
NavGestureLayer {
|
|
anchors.fill: parent
|
|
}
|
|
|
|
// --- The background layers (AppShell.slint:206-242) --------------------
|
|
// The bottom-most visual layer, declared before every chrome surface so
|
|
// they all paint above it. Mode 1 and mode 2 are DIFFERENT looks and are
|
|
// mutually exclusive; neither is mounted while a mode is off or nothing is
|
|
// playing, which is the D4 "opaque theme restored" case.
|
|
readonly property bool ambientModeOn: root.ambientOn && QbzShell.ambientMode === 1
|
|
readonly property bool blurredModeOn: root.ambientOn && QbzShell.ambientMode === 2
|
|
|
|
// --- The polarity-aware legibility veil (owner, 2026-08-31) ------------
|
|
// The old scrim was a fixed BLACK layer at ambientDim — tuned for dark
|
|
// themes, where dark ground + light text is exactly right. Over a LIGHT
|
|
// theme the same dark scrim pushed the field to MID luminance, the worst
|
|
// possible ground for dark text (the weak grey ramp died completely).
|
|
// Light themes therefore veil with their own near-white surfaceMain, a
|
|
// notch stronger, and the in-shader/in-atmosphere darkening is turned
|
|
// OFF there (darkening and then whitening would just grey the field).
|
|
// Dark themes keep the exact previous look.
|
|
readonly property real veilStrength: theme.isDark
|
|
? QbzShell.ambientDim
|
|
: Math.min(0.72, QbzShell.ambientDim + 0.2)
|
|
|
|
// Mode 1 — the album-triad metaball field, plus the veil that keeps text
|
|
// legible over a bright album palette (QBZ_BG_DIM, default 0.35).
|
|
AmbientField {
|
|
anchors.fill: parent
|
|
visible: root.ambientModeOn
|
|
running: root.ambientModeOn
|
|
dim: theme.isDark ? QbzShell.ambientDim : 0.0
|
|
}
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.ambientModeOn
|
|
color: theme.isDark ? "#000000" : theme.surfaceMain
|
|
opacity: root.veilStrength
|
|
}
|
|
|
|
// Mode 2 — Blurred art: the SAME ImmersiveAtmosphere the immersive view
|
|
// and the album/artist headers use, at window size (AppShell.slint:221-231
|
|
// reuses the identical component). `animated` follows the transport, so a
|
|
// paused player holds the static pose instead of drifting forever, and the
|
|
// fallback is the plain cover for a track whose atmosphere bitmap has not
|
|
// been generated yet. On light themes its internal dark dim is disabled
|
|
// and the veil below provides the legibility layer instead (the
|
|
// atmosphere's baked gradient scrim stays — it reads as depth under the
|
|
// light veil, not as darkness).
|
|
ImmersiveAtmosphere {
|
|
anchors.fill: parent
|
|
visible: root.blurredModeOn
|
|
source: root.blurredModeOn ? QbzImmersive.atmosphereUrl : ""
|
|
fallbackSource: root.blurredModeOn ? QbzPlayer.npArtworkPath : ""
|
|
animated: root.blurredModeOn && QbzPlayer.npPlaying
|
|
dim: theme.isDark ? QbzShell.ambientDim : 0.0
|
|
}
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.blurredModeOn && !theme.isDark
|
|
color: theme.surfaceMain
|
|
opacity: root.veilStrength
|
|
}
|
|
|
|
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
|
|
property var hostWindow: null
|
|
|
|
// §1.4.1 + §4.1 (2026-08-03 hotkeys-port contract): the shell root is
|
|
// BOTH the dispatcher host and the fallback focus item — Qt delivers key
|
|
// events only to activeFocusItem and propagates up its parent chain, and
|
|
// the shell tree had ZERO focus items, so without this a fresh launch
|
|
// has activeFocusItem == null and the pipeline receives NOTHING until
|
|
// the first click (the round-1 BLOCKER; H0 is the RFB proof). The
|
|
// marker lets a modal anywhere in the tree walk up and re-focus this
|
|
// root on close (§1.4.2/§1.4.3).
|
|
focus: true
|
|
readonly property bool isQbzShellRoot: true
|
|
|
|
// The assert half of §1.4.1: declarative `focus: true` alone did NOT
|
|
// survive the splash -> shell Loader swap in a WM-less (VNC) session —
|
|
// activeFocusItem stayed null and the dispatcher received nothing
|
|
// until the first click (measured 2026-08-03, RFB H0 first pass). Grab
|
|
// once at mount; every other lifecycle arm (immersive close, modal
|
|
// closes) hands focus BACK here.
|
|
Component.onCompleted: {
|
|
root.forceActiveFocus()
|
|
Qt.callLater(function () { QbzAbout.updatesLaunch() })
|
|
}
|
|
|
|
// THE ONE key entry (§1.1 route (b), divergence K1): NOTHING is handled
|
|
// locally. The ordered pipeline (capture steal, search-dropdown Up/Down
|
|
// steal, central text-input gate, Ctrl+A, binding dispatch, the §1.2
|
|
// Escape stack) lives in Rust behind QbzHotkeys.keyPressed; the QML side
|
|
// only computes the gate (§1.4.4). Null activeFocusItem passes the gate
|
|
// (the semantically right case). Every existing Keys. handler accepts
|
|
// only what it owns. Shared controls accept their Space/Enter events
|
|
// before they bubble here; in particular a focused Settings toggle owns
|
|
// Space, so toggling it cannot also trigger global play/pause (§4.1).
|
|
InputFocusDismiss { id: inputFocusDismiss }
|
|
Connections {
|
|
target: QbzShell
|
|
function onCurrentViewChanged() { inputFocusDismiss.dismiss() }
|
|
}
|
|
|
|
Keys.onPressed: function (event) {
|
|
var w = root.Window.window
|
|
var afi = w !== null ? w.activeFocusItem : null
|
|
var textInputFocused = (afi instanceof TextInput) || (afi instanceof TextEdit)
|
|
event.accepted = QbzHotkeys.keyPressed(event.key, event.modifiers,
|
|
event.text, textInputFocused)
|
|
}
|
|
|
|
// --- Hotkeys routers (§4.3 / §4.6) -------------------------------------
|
|
// nav.search (Ctrl+f, divergence K6 — EXCEEDS Slint, whose focus_search
|
|
// only flipped cortinilla_open with no visible effect on an empty
|
|
// query): the Rust dispatch emits this signal and the field lands
|
|
// focused and ready to type.
|
|
Connections {
|
|
target: QbzHotkeys
|
|
function onFocusSearchRequested() { header.focusSearch() }
|
|
// playback.favorite — the player bar's own heart call, empty-id guard
|
|
// included. The now-playing track id is a QbzPlayer property, so the
|
|
// Rust dispatch signals and QML answers rather than resolving the id
|
|
// twice (PlayerBar.qml:769-773).
|
|
function onFavoriteRequested() {
|
|
var id = QbzPlayer.npTrackId
|
|
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
|
|
}
|
|
}
|
|
|
|
// The §4.6 Ctrl+A + multi-select Escape-exit seam. Selection state is
|
|
// in-view QML JS (library_bulk.rs:8: "select-all / clear never reach
|
|
// Rust"), so the QbzShell signals route to the mounted view's
|
|
// duck-typed interface: selectAll() / exitMultiSelectMode() /
|
|
// multiSelectOn. Implemented by LibraryView, LocalLibraryView and
|
|
// LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in
|
|
// Rust with no select-all arm); views without multi-select (Artist,
|
|
// Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4).
|
|
Connections {
|
|
target: QbzShell
|
|
function onSelectAllRequested() {
|
|
if (contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.selectAll === "function")
|
|
contentRouter.currentItem.selectAll()
|
|
}
|
|
function onExitMultiSelectRequested() {
|
|
if (contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.exitMultiSelectMode === "function")
|
|
contentRouter.currentItem.exitMultiSelectMode()
|
|
}
|
|
}
|
|
// The reporters: their QbzShell mirrors feed the Rust Ctrl+A
|
|
// consumption predicate (capable) and the §1.2 Escape stack arm 6
|
|
// (active). Duck-typed, so a view without the interface reports
|
|
// capable=false and Ctrl+A falls through to the binding lookup (the
|
|
// Slint select_all_active_surface false case).
|
|
Binding {
|
|
target: QbzShell
|
|
property: "multiSelectCapable"
|
|
value: contentRouter.currentItem !== null
|
|
&& typeof contentRouter.currentItem.selectAll === "function"
|
|
restoreMode: Binding.RestoreNone
|
|
}
|
|
Binding {
|
|
target: QbzShell
|
|
property: "multiSelectActive"
|
|
value: contentRouter.currentItem !== null
|
|
&& contentRouter.currentItem.multiSelectOn === true
|
|
restoreMode: Binding.RestoreNone
|
|
}
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
HeaderBar {
|
|
id: header
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
height: theme.headerHeight
|
|
hostWindow: root.hostWindow
|
|
onReportIssueRequested: reportIssueModal.open = true
|
|
// Square corners (phase 12: the window is opaque; any rounding is
|
|
// the compositor's business).
|
|
}
|
|
|
|
OrbitBanner {
|
|
id: orbitBanner
|
|
anchors.top: header.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
remoteActive: QbzOrbit.enabled && QbzOrbit.controllingRemote
|
|
connectionLost: QbzOrbit.connectionLost
|
|
hostName: QbzOrbit.hostName
|
|
}
|
|
|
|
// The player bar's FRAME — the same chrome tier as the sidebar and the
|
|
// content frame, so the gutter around the inset bar reads as one
|
|
// continuous frame and never as a band of raw background (the same rule
|
|
// the content frame documents below). It spans the bar's height plus
|
|
// the bottom gutter, full width, so the side gutters are covered too.
|
|
readonly property int npbGutter: Qt.platform.os === "osx" ? theme.npbGutterMac : theme.npbGutter
|
|
Rectangle {
|
|
id: npbFrame
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: npb.height + root.npbGutter
|
|
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
|
}
|
|
|
|
NowPlayingBar {
|
|
id: npb
|
|
// The small seek thumb/hit area extends above the bar by a few pixels.
|
|
// Only Small needs this overlap. Large's cover dock is a later
|
|
// sibling and must paint over the full-width player background.
|
|
z: QbzShell.npbMode === 2 ? 1 : 0
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
// Inset by the content pane's gutter on the three window edges
|
|
// (2026-09-13, every mode): the bar's ends line up with the pane's
|
|
// and nothing in it touches the window edge — wider on macOS, where
|
|
// the Liquid Glass corner curve ate the cover's corner
|
|
// (QbzTheme.npbGutterMac).
|
|
anchors.leftMargin: root.npbGutter
|
|
anchors.rightMargin: root.npbGutter
|
|
anchors.bottomMargin: root.npbGutter
|
|
// Mode-aware height (AppShell.slint:396): Small collapses to one
|
|
// header-tall row; New/Classic/Large keep the full 112px.
|
|
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
|
|
// The shared hover-tooltip overlay (declared further down — id
|
|
// references resolve at completion). All four modes consume it for
|
|
// Shuffle/Repeat state; the full bar also uses it for Qobuz Connect.
|
|
tooltip: tooltipOverlay
|
|
}
|
|
|
|
Sidebar {
|
|
id: sidebar
|
|
anchors.left: parent.left
|
|
anchors.top: orbitBanner.bottom
|
|
anchors.bottom: npb.top
|
|
// The shared hover-tooltip overlay (declared last, below). The sidebar
|
|
// clips its own overflow, so the collapsed rail's name bubble HAS to be
|
|
// rendered out here — same reason Slint mounts SidebarTooltip at the
|
|
// AppShell level rather than inside Sidebar.slint.
|
|
tooltip: tooltipOverlay
|
|
// Animated 3-state width lives inside the component.
|
|
resizing: sidebarHandle.active
|
|
}
|
|
|
|
// QueueView temporarily takes the queue drawer's visual slot without
|
|
// rewriting the user's logical toggle. Leaving the view therefore
|
|
// restores the exact sidebar state that was active before entering it;
|
|
// Lyrics remains independent and may stay open beside the full view.
|
|
readonly property bool queueSidebarVisible: QbzShell.queueOpen
|
|
&& QbzShell.currentView !== "queue-view"
|
|
|
|
// Right-side panel column — Queue and/or Lyrics, stacked vertically in
|
|
// a shared 300px column (Feishin-style, AppShell.slint:684-707). Each
|
|
// is toggled from its bar button and closed from its own X; the column
|
|
// is visible when either is open, animated 0 <-> 300 (160ms).
|
|
Rectangle {
|
|
id: queueColumn
|
|
anchors.right: parent.right
|
|
anchors.top: orbitBanner.bottom
|
|
anchors.bottom: npb.top
|
|
// #771: the open width is the user's (300..600, persisted).
|
|
width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
|
|
? QbzShell.queuePanelWidth : 0
|
|
clip: true
|
|
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
|
|
|
Behavior on width {
|
|
enabled: !queueHandle.active
|
|
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
|
}
|
|
|
|
// Loader-gated: while the drawer is closed the panel is not built.
|
|
// Its rows are paginated (PAGE_SIZE 40) so this one was never the
|
|
// freeze, but a closed panel should not be constructed on principle —
|
|
// and the audit cannot know about a cap that lives in Rust.
|
|
Loader {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
height: QbzShell.lyricsOpen
|
|
? (root.queueSidebarVisible ? parent.height / 2 : 0)
|
|
: parent.height
|
|
active: root.queueSidebarVisible
|
|
visible: active
|
|
sourceComponent: QueuePanel { }
|
|
}
|
|
Rectangle {
|
|
visible: root.queueSidebarVisible && QbzShell.lyricsOpen
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
y: QbzShell.lyricsOpen && root.queueSidebarVisible ? parent.height / 2 : 0
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
LyricsPanel {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: QbzShell.lyricsOpen
|
|
? (root.queueSidebarVisible ? parent.height / 2 - 1 : parent.height)
|
|
: 0
|
|
visible: QbzShell.lyricsOpen
|
|
}
|
|
}
|
|
|
|
// --- #771 resize handles ------------------------------------------------
|
|
// Siblings of the panels, not children: Sidebar clips and the column
|
|
// animates, and a handle inside either would be clipped or dragged along.
|
|
// Each sits on the border the user SEES — the content PANE's edge, 8px
|
|
// past the panel (the bezel between them is panel-coloured, so the
|
|
// panel's own edge is invisible) — nudged 1-2px toward its panel, above
|
|
// the content frame in z. The bridge (`panel_resize.rs`) decides
|
|
// clamp/snap/close; these only report the pointer, and pulse their notch
|
|
// when a drag lands on a snap point.
|
|
PanelResizeHandle {
|
|
id: sidebarHandle
|
|
// No edge to grab once the sidebar is closed; the header button
|
|
// reopens it, as before.
|
|
visible: QbzShell.sidebarState !== 2
|
|
// Pane edge at sidebar.width + 8; strip 245..249 at 240, centre 247.
|
|
x: sidebar.width + 5
|
|
anchors.top: orbitBanner.bottom
|
|
anchors.bottom: npb.top
|
|
z: 5
|
|
onDragged: function (px) { QbzShell.sidebarDrag(px) }
|
|
onReleased: QbzShell.sidebarDragEnd()
|
|
}
|
|
PanelResizeHandle {
|
|
id: queueHandle
|
|
visible: queueColumn.width > 0
|
|
// Pane edge at queueColumn.x - 8; strip x-9..x-4, centre x-6.5.
|
|
x: queueColumn.x - 9
|
|
anchors.top: orbitBanner.bottom
|
|
anchors.bottom: npb.top
|
|
z: 5
|
|
// The column hangs off the window's RIGHT edge: a pointer at x means
|
|
// a width of (window width - x).
|
|
onDragged: function (px) { QbzShell.queuePanelDrag(root.width - px) }
|
|
onReleased: QbzShell.queuePanelDragEnd()
|
|
// Parked at the maximum with the QUEUE in the column: there is more
|
|
// past here (push through -> Listen List). Lyrics-only: nothing.
|
|
beyond: active && root.queueSidebarVisible
|
|
&& QbzShell.queuePanelWidth === theme.queuePanelWidth * 2
|
|
beyondSide: -1
|
|
}
|
|
// Snap feedback: the bridge clamps and snaps; a width landing EXACTLY on
|
|
// a range end, or a state snap, while the pointer is down is the moment
|
|
// the notch pulses. A drag inside the range changes the width on every
|
|
// move and never hits these equalities twice in a row.
|
|
Connections {
|
|
target: QbzShell
|
|
function onSidebarWidthChanged() {
|
|
if (sidebarHandle.active
|
|
&& (QbzShell.sidebarWidth === theme.sidebarOpenWidth
|
|
|| QbzShell.sidebarWidth === theme.sidebarOpenWidth * 2))
|
|
sidebarHandle.pulse()
|
|
}
|
|
function onSidebarStateChanged() {
|
|
if (sidebarHandle.active)
|
|
sidebarHandle.pulse()
|
|
}
|
|
function onQueuePanelWidthChanged() {
|
|
if (queueHandle.active
|
|
&& (QbzShell.queuePanelWidth === theme.queuePanelWidth
|
|
|| QbzShell.queuePanelWidth === theme.queuePanelWidth * 2))
|
|
queueHandle.pulse()
|
|
}
|
|
}
|
|
|
|
// Content FRAME — the chrome slab the content pane is inset INTO
|
|
// (AppShell.slint:357-362). It is the same tier as the sidebar, the header
|
|
// and the player bar, and under the dynamic background it takes the same
|
|
// surface-card @ 0.5: the 8px gutter it leaves around the pane is the
|
|
// "bezel", and it must read as one continuous frame with the sidebar, not
|
|
// as a bright band of raw background between them.
|
|
//
|
|
// It also has to be the pane's PARENT, not its sibling. In the reference
|
|
// the pane is a child of this rectangle, so surface-main @ 0.22 composites
|
|
// over card @ 0.5 over the field; as a sibling it composited over the raw
|
|
// field and the pane came out roughly 1.8x brighter than the reference's.
|
|
// Measured at y=700 on the owner's capture, Slint's pane is exactly
|
|
// 0.78 x its gutter (51,48,34 against 65,62,44) — that ratio only falls out
|
|
// if the gutter is already under the pane.
|
|
//
|
|
// With the background OFF this is surface-card, i.e. the same colour the
|
|
// shell root paints, so the geometry and the look are byte-identical to
|
|
// what shipped before.
|
|
Rectangle {
|
|
id: contentFrame
|
|
anchors.left: sidebar.right
|
|
anchors.right: queueColumn.left
|
|
anchors.top: orbitBanner.bottom
|
|
anchors.bottom: npb.top
|
|
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
|
|
|
// Content PANE — the rounded, inset surface-main panel (Radius.md,
|
|
// 8px gaps left/right/bottom, flush to the header: the reference gives
|
|
// the frame no top padding so the pane butts the header band).
|
|
Rectangle {
|
|
id: contentPane
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
anchors.rightMargin: 8
|
|
anchors.bottomMargin: 8
|
|
radius: theme.radiusMd
|
|
// Frosted content panel while the ambient background is active
|
|
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10
|
|
// hairline), over the frame's card @ 0.5.
|
|
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
|
|
border.width: root.ambientOn ? 1 : 0
|
|
border.color: theme.frostBorder
|
|
clip: true
|
|
// QBZ_PANE_LAYER: collapse the pane into one cached texture instead
|
|
// of redrawing its whole (all-alpha, unbatchable) draw list on every
|
|
// whole-window repaint. Measurement knob, default off — see
|
|
// settings_qt::pane_layer.
|
|
layer.enabled: QbzShell.paneLayer
|
|
|
|
// The view-mount chain — every route arm, every hard-won comment
|
|
// and the MyQBZ `kind` discriminator now live in ContentRouter.qml,
|
|
// which the KIOSK shell mounts too (kiosk-port contract §4.2 / D3).
|
|
// The Slint kiosk copies AppShell's mount block verbatim and calls
|
|
// that copy debt in its own header (KioskShell.slint:10-17); this
|
|
// is the named fix, so the two shells cannot drift.
|
|
//
|
|
// `kiosk` defaults false, i.e. this instance routes every id to the
|
|
// desktop view it always did. Reached from elsewhere in this
|
|
// document through `contentRouter.currentItem` (the multi-select
|
|
// seam above) — a QML `id` does not cross documents, so
|
|
// `viewLoader` is no longer nameable here and that public property
|
|
// is the replacement.
|
|
ContentRouter {
|
|
id: contentRouter
|
|
anchors.fill: parent
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Bezel corners ----------------------------------------------------
|
|
// `clip: true` on contentPane is a RECTANGULAR scissor in Qt Quick — it
|
|
// is a glScissor / QPainter setClipRect and it does NOT follow `radius`.
|
|
// The radius only shapes that Rectangle's own fill, so anything that
|
|
// paints opaquely at the frame's corners paints straight over the bezel
|
|
// and the panel reads square. Slint has no such split: its `clip: true`
|
|
// on a Rectangle clips to the ROUNDED shape (AppShell.slint:409-414 sets
|
|
// border-radius + clip on the same element and the album atmosphere
|
|
// inside it is cut by the arc), which is why the identical structure is
|
|
// correct there and wrong here. The visible offender is the album/artist
|
|
// header band (controls/HeaderGradient.qml, full-bleed at y:0), but the
|
|
// defect is the frame's, not the band's: the band scrolls, so rounding
|
|
// the band itself would only hold at scroll offset 0 — measured, the
|
|
// corners go square again the moment the page scrolls and the band's body
|
|
// reaches the top edge. The mask therefore belongs here, once, and it
|
|
// covers every present and future view.
|
|
//
|
|
// SIBLINGS of the pane, not children of it. Inside it they sat
|
|
// at z:0 with the view Loader, so any overlay a VIEW mounts painted over
|
|
// them and the corners read square while it was open — the pane-filling
|
|
// modals are exactly that: DiscoverConfigModal z:3100 (HomeView:1130),
|
|
// GenreFilterPopup z:3000 (Home/Library/DiscoverBrowse/PlaylistBrowse),
|
|
// TrackInfoModal z:3000 (rows/TrackRow.qml:565, and TrackRow mounts
|
|
// inside views). Raising the nubs to z:3200 would only win until the next
|
|
// modal, because ADR-009 fixes in-pane modals at z >= 3000 and says
|
|
// nothing about a ceiling. Out here the question does not arise: the mask
|
|
// describes the FRAME's silhouette, so nothing the frame contains can
|
|
// reach it, whatever z it picks. Window-level overlays declared after
|
|
// this block still paint over the nubs — Cortinilla, the shared text
|
|
// modal, the drag ghost, ArtPreviewOverlay — and that is correct: a
|
|
// window-wide scrim covers the pane corners too. SidebarNowPlayingDock is
|
|
// also later but lives inside the 240px sidebar (x:16, w:208) and never
|
|
// touches the frame.
|
|
//
|
|
// Mechanism: four small Canvas nubs, each filling its corner square with
|
|
// the shell colour and punching the quarter-disc back out.
|
|
//
|
|
// DOCTRINE CORRECTION (2026-07-29). This paragraph used to justify the
|
|
// Canvas by claiming "ShaderEffect / OpacityMask render NOTHING on the
|
|
// software path, and `layer.enabled` would cost an FBO every frame".
|
|
// Both halves are FALSE as stated: effects need shaders, and this port
|
|
// runs on the GPU (OpenGL RHI, measured via QSG_INFO 2026-07-29) — the
|
|
// "renders nothing" note came from an offscreen session, which forces the
|
|
// software renderer by definition. A `layer` FBO is also rendered once and
|
|
// CACHED for static content, not re-rendered per frame. Where a software
|
|
// path is genuinely possible, detect it with `GraphicsInfo.api` rather
|
|
// than assuming it (theme/RoundedImage.qml does exactly that and is the
|
|
// canonical statement of this rule — read its "HOW THE ROUNDING IS DONE"
|
|
// header before touching any masking code).
|
|
//
|
|
// The Canvas here is still the right call, but on its own merits, not that
|
|
// one: the nubs are 4 x 12x12 px rastered ONCE and repainted only when the
|
|
// theme colour or the radius changes, whereas masking the frame would mean
|
|
// an FBO the size of the whole content pane, re-rendered on every window
|
|
// resize and invalidated by anything animating inside the pane.
|
|
//
|
|
// The fill is `contentFrame.color` because that is literally what shows
|
|
// through the bezel — the frame IS the 8px gutter, and with the background
|
|
// off it is surface-card, the same colour as the HeaderBar above and the
|
|
// NPB gap below. (It was `root.color` before the frame existed; identical
|
|
// value, but now it tracks the surface it is actually cutting into rather
|
|
// than one that happens to match.)
|
|
//
|
|
// Gated on the ambient background being OFF, and that is not a
|
|
// shortcut: with the dynamic background active the pane goes
|
|
// translucent (surface-main @ 0.22 + hairline) and the field
|
|
// is SUPPOSED to show through the corners, so an opaque nub would be
|
|
// the regression. The same flag also suppresses the album/artist
|
|
// atmosphere (AlbumView.qml:71 `headerAtmoOn = pref && !ambientOn`,
|
|
// AlbumPageView.slint:168) — the two states are complementary, never
|
|
// both.
|
|
//
|
|
// WHAT COVERS THE AMBIENT-ON CASE, since this block cannot: nothing that
|
|
// paints opaquely may reach the frame's corners while the field is
|
|
// showing through them, and by construction almost nothing does — every
|
|
// view root goes `color: "transparent"` under ambient (HomeView.qml:53 and
|
|
// its twelve siblings), and the ones that stay opaque round their own fill
|
|
// (`radius: 12`, the same trick, applied by the offender instead of by a
|
|
// mask). Sweeping the pane-level overlay set (ADR-009's z >= 3000) turned
|
|
// up ONE that did neither: controls/DiscoverConfigModal.qml's scrim, a
|
|
// full-bleed #bf000000 Rectangle at z:3100.
|
|
// It now carries `radius: theme.radiusMd` too. GenreFilterPopup's backdrop
|
|
// is a bare MouseArea (paints nothing) and TrackInfoModal / CastPicker are
|
|
// parented to the window Overlay, where covering the pane corners is the
|
|
// correct behaviour. A future full-bleed opaque pane child must round
|
|
// itself the same way — there is no mask that can do it for it here.
|
|
// Positions are the PANE's rect in shell coordinates. The pane is a child
|
|
// of the frame now, so its own x/y are frame-local (8, 0) and have to be
|
|
// offset by the frame's origin — reading contentPane.x directly here would
|
|
// put every nub 8px to the left of the corner it is meant to cut.
|
|
readonly property real _paneX: contentFrame.x + contentPane.x
|
|
readonly property real _paneY: contentFrame.y + contentPane.y
|
|
|
|
BezelCorner { corner: 0; fill: contentFrame.color; r: contentPane.radius
|
|
visible: !root.ambientOn
|
|
x: root._paneX; y: root._paneY }
|
|
BezelCorner { corner: 1; fill: contentFrame.color; r: contentPane.radius
|
|
visible: !root.ambientOn
|
|
x: root._paneX + contentPane.width - width; y: root._paneY }
|
|
BezelCorner { corner: 2; fill: contentFrame.color; r: contentPane.radius
|
|
visible: !root.ambientOn
|
|
x: root._paneX + contentPane.width - width
|
|
y: root._paneY + contentPane.height - height }
|
|
BezelCorner { corner: 3; fill: contentFrame.color; r: contentPane.radius
|
|
visible: !root.ambientOn
|
|
x: root._paneX
|
|
y: root._paneY + contentPane.height - height }
|
|
|
|
// The bezel nub itself. Kept as an inline component (no outer `id` is
|
|
// referenced inside it — inline components do not share the document's
|
|
// scope, so the colour and the radius arrive as properties).
|
|
//
|
|
// `corner`: 0 = top-left, 1 = top-right, 2 = bottom-right, 3 = bottom-left.
|
|
// The arc centre is the corner of the r x r square that points INTO the
|
|
// panel, so the punched-out quarter-disc is the panel side and the painted
|
|
// remainder is the sliver outside the arc — the exact pixels Qt's
|
|
// rectangular clip fails to cut.
|
|
component BezelCorner: Canvas {
|
|
id: nub
|
|
property int corner: 0
|
|
property color fill: "#000000"
|
|
property int r: 12
|
|
|
|
width: nub.r
|
|
height: nub.r
|
|
// Purely decorative: never take a click meant for the view underneath.
|
|
enabled: false
|
|
// Same pair as RoundedImage/PlaylistCollage — CPU raster, and Immediate
|
|
// so a repaint cannot land on a pixmap whose Canvas is already gone.
|
|
renderTarget: Canvas.Image
|
|
renderStrategy: Canvas.Immediate
|
|
|
|
onFillChanged: nub.requestPaint()
|
|
onRChanged: nub.requestPaint()
|
|
onCornerChanged: nub.requestPaint()
|
|
|
|
onPaint: {
|
|
var ctx = nub.getContext("2d")
|
|
if (!ctx || nub.r <= 0)
|
|
return
|
|
ctx.reset()
|
|
ctx.clearRect(0, 0, nub.width, nub.height)
|
|
ctx.fillStyle = nub.fill
|
|
ctx.fillRect(0, 0, nub.width, nub.height)
|
|
// destination-out = QPainter's CompositionMode_DestinationOut, so
|
|
// the disc erases the fill instead of drawing over it. Its edge is
|
|
// antialiased against transparency, which is what makes the nub
|
|
// blend into the arc rather than staircase along it.
|
|
ctx.globalCompositeOperation = "destination-out"
|
|
ctx.beginPath()
|
|
ctx.arc(nub.corner === 0 || nub.corner === 3 ? nub.r : 0,
|
|
nub.corner === 0 || nub.corner === 1 ? nub.r : 0,
|
|
nub.r, 0, 2 * Math.PI)
|
|
ctx.fill()
|
|
ctx.globalCompositeOperation = "source-over"
|
|
}
|
|
}
|
|
|
|
// --- Large NPB (mode 3) cover dock (phase 18) -------------------------
|
|
// The L's vertical arm: the square now-playing cover + FFT band, pinned
|
|
// flush to the window bottom-left over the sidebar
|
|
// (SidebarNowPlayingDock.slint, AppShell.slint:747). Only while Large is
|
|
// ACTIVE (mode 3 + sidebar open).
|
|
//
|
|
// The height comes from QbzShell.largeDockHeight (it changes with the
|
|
// band's eye toggle) and Sidebar.qml reserves the same value minus the
|
|
// bar height — pinning here with a literal would desync the two.
|
|
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
|
SidebarNowPlayingDock {
|
|
visible: root.largeActive
|
|
// Equal 16px gutters inside the 240px sidebar; the cover is square,
|
|
// so this width also sets the art size.
|
|
x: 16
|
|
width: 208
|
|
// Flush with the bar's bottom edge, which now sits a gutter above
|
|
// the window's (npbGutter) — the dock lifts with it.
|
|
y: npb.y + npb.height - QbzShell.largeDockHeight
|
|
ambientOn: root.ambientOn
|
|
}
|
|
|
|
// Search cortinilla (phase 15): the live-search dropdown overlay.
|
|
// Declared here, ABOVE the shell but BELOW the four siblings that follow
|
|
// (toast host, immersive, art preview, tooltip) — declaration order is
|
|
// z-order. It is NOT the last child, and does not need to be: immersive
|
|
// dismisses the cortinilla on open, and the other three are transient
|
|
// layers that are meant to sit above it.
|
|
Cortinilla {
|
|
anchors.fill: parent
|
|
headerBar: header
|
|
}
|
|
|
|
// --- Shared text modal (phase 16) ---------------------------------------
|
|
// The AppShell-level modal layer (the Slint AppShell modals mount at
|
|
// window level, ADR-009): the scrim covers the WHOLE window — sidebar /
|
|
// header / NPB included — and the panel centers on the WINDOW, not on
|
|
// the content frame. Views reach it via `openTextModal(title, body)`.
|
|
property bool textModalOpen: false
|
|
property string textModalTitle: ""
|
|
property string textModalBody: ""
|
|
function openTextModal(title, body) {
|
|
textModalTitle = title
|
|
textModalBody = body
|
|
textModalOpen = true
|
|
}
|
|
|
|
Rectangle {
|
|
visible: root.textModalOpen
|
|
anchors.fill: parent
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.textModalOpen = false
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: Math.min(root.width - 80, 560)
|
|
height: Math.min(root.height - 120, 460)
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.margins: 24
|
|
spacing: 14
|
|
Row {
|
|
width: parent.width
|
|
Text {
|
|
width: parent.width - 28
|
|
text: root.textModalTitle
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Rectangle {
|
|
width: 28
|
|
height: 28
|
|
color: tmCloseArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
radius: 6
|
|
QbzIcon {
|
|
name: "x"
|
|
width: 18
|
|
height: 18
|
|
anchors.centerIn: parent
|
|
tintName: tmCloseArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: tmCloseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.textModalOpen = false
|
|
}
|
|
}
|
|
}
|
|
Flickable {
|
|
width: parent.width
|
|
height: parent.height - 42
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: tmText.implicitHeight
|
|
Text {
|
|
id: tmText
|
|
width: parent.width
|
|
text: root.textModalBody
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Drag ghost (DragGhost.slint, phase 17) -----------------------------
|
|
// The dark pill that follows the cursor while tracks are dragged onto
|
|
// the sidebar: "N tracks" for a group, or title + "artist · album" for
|
|
// one. Visual only (never blocks the pointer).
|
|
Rectangle {
|
|
visible: QbzShell.dragActive && !QbzShell.dragInlineVisual
|
|
x: QbzShell.dragX + 10
|
|
y: QbzShell.dragY + 14
|
|
width: ghostCol.width + 28
|
|
height: ghostCol.height + 16
|
|
radius: 8
|
|
color: "#e01e1e28"
|
|
border.width: 1
|
|
border.color: "#1fffffff"
|
|
Column {
|
|
id: ghostCol
|
|
anchors.centerIn: parent
|
|
spacing: 2
|
|
Text {
|
|
text: QbzShell.dragCount > 1
|
|
? QbzShell.dragCount + " " + QbzSession.tr("tracks", QbzSession.trRev)
|
|
: QbzShell.dragTitle
|
|
color: "#ffffff"
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
}
|
|
Text {
|
|
visible: QbzShell.dragCount === 1 && QbzShell.dragSubtitle !== ""
|
|
text: QbzShell.dragSubtitle
|
|
color: "#8cffffff"
|
|
font.pixelSize: 10
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- MyQBZ global overlays + the shared toast host ---------------------
|
|
// Three window-level overlays, mounted ONCE here because each is reachable
|
|
// from anywhere and must outlive the view that triggered it:
|
|
//
|
|
// AddToMixtapeModal — the "Add to Mixtape/Collection" picker, opened from
|
|
// a TrackRow menu, an album/playlist page, a Local Library bulk bar or
|
|
// the now-playing bar. The view that opened it is often unmounted by
|
|
// the time the user picks a target.
|
|
// MyQbzModals — Create / Edit (rename · description · delete) / DJ
|
|
// Mix, three sibling panels in one file (spec 01 §10/§11). Delete in
|
|
// particular navigates away from the page that raised it.
|
|
// QbzToast — the ONE in-app toast host (toast_qt.rs publishes
|
|
// onto QbzShell.toastJson; the auto-hide timer lives in the QML).
|
|
//
|
|
// NO `z` on any of them, and that is the point. This file's convention is
|
|
// DECLARATION ORDER (see the note at ArtPreviewOverlay below: "LAST child =
|
|
// above every surface"), and the bezel-corner note at :200-264 explains why
|
|
// `z` is the wrong tool out here — ADR-009 pins in-pane modals at z >= 3000
|
|
// with no ceiling, so any number picked here loses to the next one added.
|
|
// Position in the chain is byte-equivalent to Slint, where `Toast { }` sits
|
|
// at AppShell.slint:950: after DragGhost, before TooltipOverlay and
|
|
// ArtPreviewOverlay. Covering the content pane's rounded corners is CORRECT
|
|
// for a window-level overlay — a window-wide scrim covers them too.
|
|
//
|
|
// All three self-gate on their own document, so an unopened overlay is an
|
|
// invisible, non-interactive Item and costs nothing.
|
|
AddToMixtapeModal {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistPickerModal — the "Add to playlist" picker, opened from the
|
|
// queue footer (save the queue as a playlist) and the queue row menu.
|
|
// Same reason it lives out here as its Mixtape sibling: the surface that
|
|
// opened it is often gone by the time the user picks a target, and the
|
|
// queue panel in particular is `visible`-gated.
|
|
PlaylistPickerModal {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistImportModal — the public-playlist importer (Spotify / Apple
|
|
// Music / Tidal / Deezer), opened from the sidebar `...` menu and from the
|
|
// closed-sidebar flyout. It mounts HERE and not in the sidebar by explicit
|
|
// contract (05 §5.8.5): closing it must not cancel an in-flight import, and
|
|
// neither surface that opens it has a lifetime guarantee across a
|
|
// navigation — the importer's own completion arm navigates to the imported
|
|
// playlist while the modal is still up.
|
|
PlaylistImportModal {
|
|
anchors.fill: parent
|
|
}
|
|
// Open Music Link — app-wide because both the header row and Ctrl+L open
|
|
// it, and a playlist result hands off to the importer beside it.
|
|
LinkResolverModal {
|
|
anchors.fill: parent
|
|
}
|
|
// Album Quick View — one navigation-free preview shared by every eligible
|
|
// AlbumCard. It owns no page lifetime: a card can live in Home, Search,
|
|
// Library or an artist rail, and Library's local/media-server ids are
|
|
// source-routed by its controller rather than sent to Qobuz. The preview
|
|
// always renders at shell level from its generation-guarded document.
|
|
// Self-gated on `quickViewJson.open`, so the closed state is an invisible,
|
|
// non-interactive Item.
|
|
AlbumQuickView {
|
|
anchors.fill: parent
|
|
}
|
|
// TrackReplacementModal — "Find available version" for a track Qobuz
|
|
// pulled from the catalogue (2026-08-17 unavailable-tracks contract §6),
|
|
// opened from the playlist row's context menu.
|
|
//
|
|
// Out here for the strongest form of its neighbours' reason: the apply
|
|
// RELOADS the playlist underneath it (`refresh_after_membership_change`),
|
|
// so a modal parented into the playlist view would be destroyed by its own
|
|
// success — while a write is still settling. Self-gates on
|
|
// QbzTrackReplace.replaceJson, so while closed it is an invisible,
|
|
// non-interactive Item.
|
|
TrackReplacementModal {
|
|
anchors.fill: parent
|
|
}
|
|
// The two DISC modals. Out here for the same reason as their neighbours:
|
|
// both of them RENAME the open session as their success case, so a modal
|
|
// parented into the Local Library pane would be torn down by the very
|
|
// publish that means it worked. Each self-gates on its own document, so a
|
|
// closed one is an invisible, non-interactive Item.
|
|
DiscMetaModal {
|
|
anchors.fill: parent
|
|
}
|
|
RipWizardModal {
|
|
anchors.fill: parent
|
|
}
|
|
// The rip's progress panel. Out here because the JOB outlives the pane —
|
|
// the user can navigate away mid-rip and the drive keeps spinning — so it
|
|
// must not be parented into a view that gets destroyed on a tab change.
|
|
RipProgressModal {
|
|
anchors.fill: parent
|
|
}
|
|
MyQbzModals {
|
|
anchors.fill: parent
|
|
}
|
|
// MusicianModal — where a WEAK / NONE / resolve-error musician click lands.
|
|
// It is the majority branch, not an edge case: most credited sidemen are
|
|
// not Qobuz artists, and before this the click did nothing at all.
|
|
//
|
|
// Out here, and not in any of its six openers, for the strongest version of
|
|
// the reason its neighbours give: FOUR of those six openers are themselves
|
|
// modals or overlays (album credits, desktop track info, the immersive
|
|
// track-info panel), and each CLOSES ITSELF as it dispatches — so a modal
|
|
// parented into the opener would be destroyed by the very click that
|
|
// summoned it. Self-gates on QbzMusician.modalJson, so while closed it is
|
|
// an invisible, non-interactive Item.
|
|
MusicianModal {
|
|
anchors.fill: parent
|
|
}
|
|
// FolderModals — the "New folder" create panel, the full folder editor and
|
|
// their delete confirm (contract D21). Out here for the same reason as its
|
|
// neighbours: it is opened from the SIDEBAR's "..." menu and row menu as
|
|
// well as from the Playlist Manager view, and the sidebar animates to
|
|
// width 0 with clip: true, so a modal parented into it would disappear
|
|
// with it. Self-gates on QbzFolderEdit.createJson / editJson, so while
|
|
// both are closed it is an invisible, non-interactive Item.
|
|
FolderModals {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistEditModal — the ONE playlist editor (rename · description ·
|
|
// offline-only · delete), for both `local:` and Qobuz playlists (contract
|
|
// D20/D21). Out here for the same reason as its neighbours: it is opened
|
|
// from the manager's cards and rows, from the SIDEBAR's row context menu
|
|
// and from the playlist detail header — and its delete arm navigates away
|
|
// from the page that raised it. It replaces the inline Popup that used to
|
|
// live in views/PlaylistView.qml, which could express neither a
|
|
// description nor the offline-only flag. Self-gates on
|
|
// QbzPlaylistEdit.editJson, so while closed it is an invisible,
|
|
// non-interactive Item.
|
|
PlaylistEditModal {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistDeleteConfirm — the "Delete playlist?" confirmation the sidebar
|
|
// row menu and the playlist cards summon (QbzPlaylistEdit.askDelete).
|
|
// Self-gates on QbzPlaylistEdit.deleteJson; invisible and inert while
|
|
// closed. Out here for the same reason as FolderModals above.
|
|
PlaylistDeleteConfirm {
|
|
anchors.fill: parent
|
|
}
|
|
// PlaylistCreateModal — "New playlist" (name · description · folder ·
|
|
// public · offline-only), raised by the sidebar's "+" and, like its
|
|
// neighbour, self-gating on its own document (QbzPlaylistEdit.createJson).
|
|
// It rides the editor's singleton because it is the same domain — a
|
|
// playlist's own metadata — with a document that never interacts with the
|
|
// editor's. It replaces the POC shortcut that created a playlist named
|
|
// "New Playlist" with no way to set any of the five.
|
|
PlaylistCreateModal {
|
|
anchors.fill: parent
|
|
}
|
|
// Whole-album / whole-playlist offline preflight. Mounted once at shell
|
|
// level because either detail view can open it and the retained track
|
|
// snapshot lives in QbzOffline, not in the page that launched it.
|
|
OfflineCacheChoiceModal {
|
|
anchors.fill: parent
|
|
}
|
|
// The hotkeys pair (2026-08-03 hotkeys-port contract §4.4/§4.5, block
|
|
// B3): the read-only cheatsheet (`?` / the HeaderBar menu row) and the
|
|
// editable customize editor. Same declaration-order convention as their
|
|
// neighbours — Slint mounts them with the global modals too
|
|
// (AppShell.slint:908-917, after the other modals, BEFORE the immersive
|
|
// overlay and the toast — so both keep painting above, byte-parity).
|
|
// Both self-gate on QbzHotkeys.cheatsheetOpen / .customizeOpen, so while
|
|
// closed each is an invisible, non-interactive Item.
|
|
KeyboardShortcutsModal {
|
|
anchors.fill: parent
|
|
}
|
|
CustomizeShortcutsModal {
|
|
anchors.fill: parent
|
|
}
|
|
// The four overlays below are ALWAYS-ON-TOP: in the reference they are
|
|
// mounted after every modal (AppShell.slint:941-953) and so paint above
|
|
// them. This port relied on declaration order alone, which held only while
|
|
// no modal carried an explicit `z` — and the modal band is 3000-3200
|
|
// (ADR-009 as this port spells it), so any z-carrying modal buried them:
|
|
// a toast raised by a background event behind an opaque panel is simply
|
|
// never seen. They now carry an explicit band of their own, ABOVE the
|
|
// modals, keeping the relative order they already had.
|
|
QbzToast {
|
|
anchors.fill: parent
|
|
z: 3500
|
|
}
|
|
|
|
// Immersive mode root overlay (2026-08-02 immersive-port contract §5.1):
|
|
// plain Item, LAST-child class like its Slint twin (a Popup would grab
|
|
// the pointer and kill typing in the immersive search field — the
|
|
// cortinilla doctrine, Cortinilla.qml:1-5). Self-gates on
|
|
// QbzImmersive.open, so while closed it is an invisible, non-interactive
|
|
// Item. Sits AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip so
|
|
// those two keep painting above it; covering the content pane's rounded
|
|
// corners is correct for a window-level overlay (:555-563 above).
|
|
ImmersiveView {
|
|
anchors.fill: parent
|
|
z: 3510
|
|
}
|
|
|
|
// Above every surface (see the always-on-top band above), exactly like
|
|
// ArtPreviewOverlay.slint's mount in AppShell.slint. Non-interactive, so it
|
|
// never steals the hover that is keeping it open (see the file header).
|
|
ArtPreviewOverlay { z: 3520 }
|
|
|
|
// The shell's ONE hover-tooltip overlay — the port of Slint's
|
|
// TooltipOverlay/SidebarTooltip mechanism, topmost of the always-on-top
|
|
// band for the reason TooltipOverlay.slint's header gives: "mounted last in
|
|
// AppShell so no neighbour can cover it". Surfaces do not own a popup; they call
|
|
// showRight()/showAbove()/hide() on this instance (see QbzTooltip.qml).
|
|
//
|
|
// WIRED SO FAR: the collapsed sidebar rail (Sidebar.qml). Everything else
|
|
// Slint tooltips is still un-wired — the list is in the handoff notes; each
|
|
// one is a two-line change in its own file, not a change here.
|
|
QbzTooltip {
|
|
id: tooltipOverlay
|
|
anchors.fill: parent
|
|
z: 3530
|
|
}
|
|
|
|
// The applied-filters summary rides the bridge (shell_bridge.rs
|
|
// `filter_tip_json`) because the funnels that raise it live deep inside
|
|
// view toolbars and cannot name this overlay — the same reason, and the
|
|
// same solution, as the art preview above. Numbers in, numbers out: a
|
|
// toolbar that is destroyed while its bubble is up leaves nothing dangling.
|
|
Connections {
|
|
target: QbzShell
|
|
function onFilterTipJsonChanged() {
|
|
var d = {}
|
|
try {
|
|
d = JSON.parse(QbzShell.filterTipJson)
|
|
} catch (e) {
|
|
d = {}
|
|
}
|
|
if (!d || !d.key) {
|
|
tooltipOverlay.hideAll()
|
|
return
|
|
}
|
|
tooltipOverlay.showSummaryAt(d.key, d.x || 0, d.y || 0,
|
|
d.w || 0, d.h || 0, d.groups || [])
|
|
}
|
|
}
|
|
|
|
// Qobuz Connect bootstrap conflict: mounted globally because it fences the
|
|
// session loop until the user chooses which queue/renderer wins.
|
|
QconnectPlaybackConflictModal { }
|
|
|
|
// Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) —
|
|
// mounted LAST, mirroring QconnectDevModal.slint's topmost mount in
|
|
// AppShell.slint. Ordering against the tooltip above is a non-issue: this
|
|
// is a Popup parented to Overlay.overlay (the CastPicker pattern), so it
|
|
// renders in the overlay layer above EVERY plain Item in this tree —
|
|
// the tooltip's "LAST child so no neighbour can cover it" invariant is
|
|
// about Items, and no Item was added after it. Self-gates on
|
|
// QbzQConnect.diagOpen, so while closed it is an invisible,
|
|
// non-interactive Item.
|
|
QconnectDevModal { }
|
|
|
|
// The log viewer (Settings > "Share logs", and Developer). Mounted at the
|
|
// AppShell root for the same reason as every other global modal: it is
|
|
// opened FROM Settings but must not live inside its Flickable. Self-gates
|
|
// on the document's `open`, so while closed it is an invisible,
|
|
// non-interactive Item that parses one small JSON string.
|
|
LogViewerModal { }
|
|
|
|
// Report an issue (the header hamburger's row): explains the manual,
|
|
// redacted log-sharing flow and offers "Go to logs" + the GitHub bug
|
|
// template. Its `open` is LOCAL state — nothing in Rust needs to know it
|
|
// is up — so the menu row flips it directly through this id.
|
|
ReportIssueModal { id: reportIssueModal }
|
|
|
|
// About QBZ + What's New (the header hamburger menu's last two rows),
|
|
// mirroring AppShell.slint:925-936 where both are mounted with the global
|
|
// modals. Each self-gates on its half of QbzAbout's two documents, so
|
|
// while closed it is an invisible, non-interactive Item that parses one
|
|
// small JSON string — and each carries an explicit `z: 3000` (ADR-009 as
|
|
// this port spells it) rather than relying on declaration order alone.
|
|
UpdatesModal { }
|
|
AboutModal {
|
|
anchors.fill: parent
|
|
}
|
|
WhatsNewModal {
|
|
anchors.fill: parent
|
|
}
|
|
// A LOADER, not a static mount. `visible: false` still CONSTRUCTS the
|
|
// whole subtree -- panel, Flickable, fourteen Repeater delegates, their
|
|
// texts and bindings -- on Linux and macOS, where this modal can never be
|
|
// shown. The Loader makes that cost real only where the modal exists.
|
|
//
|
|
// Last of the three so it lands on top at equal z: it is shown once per
|
|
// version at startup and has to be answered.
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzShell.isWindows && QbzShell.windowsDisclaimerOpen === true
|
|
sourceComponent: WindowsDisclaimerModal { }
|
|
}
|
|
}
|