Four things from the owner's smoke, all polish, all measured on real Plasma Wayland rather than inferred. ALWAYS-ON-TOP IS GONE. It shipped as owner ruling K1's default — new work the reference promises in three comments and implements nowhere — and it does not work: a Wayland client cannot raise itself above other clients. There is no such request in the core protocol; it takes a compositor policy or a KWin rule, and Qt has nothing to send. The owner's call was explicit, so the button, the property, the invokable and the pref are all removed rather than left as a control that lies. The capsule is nine buttons again. THE MINI'S SIZE IS NOW FIXED PER SURFACE, min == max. A Plasma tiling shortcut resized the mini to half the screen, the debounced persist wrote 1720 px into the shared ui_prefs.json, and every expanded surface opened giant from then on — with no way back, because this port never gave the mini a resize handle. So the only thing that could ever write those prefs was the window manager, i.e. the bug. The owner asked for exactly this: force the sizes even when the OS says otherwise, stay sensitive to position. min == max gives both — a compositor may still move the window; a resize is refused. That drops the reference's resizable expanded mini and the persisted pair with it (A-6b / AC-15). `saveGeometry` and `geometry_to_persist` stay, with UT-3: they are the right rule for the day a real resize handle exists, and deleting a tested guard to re-derive it later is how this comes back. The micro capsule now centres on the CARD, not on its 17 px header. The reference centres it on the header, but in micro that header is a strip at the top of a 45 px card while the capsule is 26 px tall, so it sat visibly high. A SEEK NOW REPORTS ITS NEW POSITION TO MPRIS. Position was only refreshed on the play/pause edge, so a desktop widget extrapolated from a base the seek had invalidated — the owner measured ~40 s of lag. The shared crate exposes no Seeked emitter and set_playback already carries a position, so the seek re-asserts the state at its target. The shipping Slint build has the same gap; closing it there is a shared-crate conversation, not this one. Not fixed, and it cannot be here: the mini does not remember its POSITION. Wayland gives a client no way to place itself. Contract ruling K4 said so; the owner's session confirms it. It would work on X11. cc
419 lines
19 KiB
QML
419 lines
19 KiB
QML
// MiniFooter — the miniplayer's control strip (2026-08-03 miniplayer/tray
|
|
// contract A-28, §4.3.2), port of
|
|
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint` (396 L, read whole).
|
|
//
|
|
// THREE LAYOUTS IN ONE COMPONENT, and its `mode` is numbered the OPPOSITE way
|
|
// from the surface enum (§15 trap 2): 0 full · 1 compact · 2 micro, against the
|
|
// surface's 0 micro · 1 compact · 2 artwork. MiniShell computes the inversion
|
|
// once into `footerMode` and this file consumes it — it never reads
|
|
// QbzMini.surface itself.
|
|
//
|
|
// In micro the footer IS the card: surface 0 has no surface area at all, the
|
|
// window is 380 x 57 and this component fills the 45 px card (§13-D10).
|
|
//
|
|
// THE PHANTOM SPACER (§15 trap 12). In full and compact the transport is
|
|
// centred by a trailing invisible box the width of the volume button
|
|
// (:318-319), NOT by a centred layout — the reference's own comment at :294-296
|
|
// says so. Drop it and the transport drifts right by half the volume button.
|
|
// MICRO HAS NO VOLUME CONTROL AND THEREFORE NO PHANTOM: it is the one mode that
|
|
// is genuinely centred, which is why the rule is scoped to full/compact.
|
|
//
|
|
// EVERY PER-MODE NUMBER IS A CACHED READONLY PROPERTY (§7-M2). The reference
|
|
// caches six and writes a seventh — the volume trigger's glyph size — inline at
|
|
// its mount (:306); six-plus-one is the inconsistency LESSONS L5 calls the
|
|
// smell, so all of them are cached here, including the paddings and the seek
|
|
// sizes the reference also spells inline.
|
|
//
|
|
// THE CAPSULE IS A CONDITIONAL MOUNT (§15 trap 7). `Loader { active: }`, never
|
|
// `visible: false`: when the pointer leaves the card the component is destroyed
|
|
// and its expanded flag and 250 ms collapse timer go with it.
|
|
//
|
|
// The two mode branches are Loaders for the same reason Slint uses `if`
|
|
// (:263, :332) rather than a visibility flip — an always-visible surface pays
|
|
// for every live binding it keeps, and only one of the two layouts is ever on
|
|
// screen.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
/// 0 full · 1 compact · 2 micro — MiniShell's `footerMode`, already
|
|
/// inverted. NOT the surface id.
|
|
property int mode: 0
|
|
/// The MINI window (§8 rule 3), for startSystemMove().
|
|
property var hostWindow: null
|
|
/// Drives the capsule's conditional mount. Fed by MiniShell's HoverHandler
|
|
/// on the card, which is §13-D4's replacement for the reference's winit
|
|
/// CursorMoved/CursorLeft feed.
|
|
property bool windowHovered: false
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// --- The cached per-mode tokens (§7-M2, §4.3.2's table) ----------------
|
|
readonly property bool compactMode: root.mode === 1
|
|
readonly property int tbtn: root.compactMode ? 26 : 30
|
|
readonly property int tgap: root.compactMode ? 14 : 18
|
|
readonly property int icShuffle: root.compactMode ? 13 : 16
|
|
readonly property int icSkip: root.compactMode ? 15 : 18
|
|
readonly property int icPlay: root.compactMode ? 16 : 20
|
|
readonly property int ctrlH: root.compactMode ? 34 : 40
|
|
/// THE SEVENTH (:306) — inline in the reference, cached here.
|
|
readonly property int icVolume: root.compactMode ? 15 : 18
|
|
// The paddings and the seek sizing, same treatment (:266-274, :284).
|
|
readonly property int padH: root.compactMode ? 10 : 14
|
|
readonly property int padBottom: root.compactMode ? 6 : 10
|
|
readonly property int seekH: root.compactMode ? 10 : 14
|
|
readonly property int seekTrack: root.compactMode ? 2 : 3
|
|
readonly property int timeFont: root.compactMode ? 10 : 11
|
|
|
|
readonly property bool hasTrack: QbzPlayer.npHasTrack
|
|
|
|
// --- The pending-seek machine (:214-249) -------------------------------
|
|
// All three constants are load-bearing: the -1.0 SENTINEL (every test is
|
|
// `>= 0`, so 0.0 is a legitimate position and not "none"), the 0.02
|
|
// convergence window, and the 8 s watchdog.
|
|
//
|
|
// The problem it solves: the engine takes a moment to seek, and
|
|
// QbzPlayer.npProgress keeps reporting the OLD position until it lands. A
|
|
// bar bound straight to npProgress therefore snaps back under the cursor
|
|
// after every click. `effProgress` holds the requested position until the
|
|
// player converges on it — or until the watchdog gives up, which is what
|
|
// keeps a failed seek from freezing the bar forever.
|
|
property real dragPreview: -1.0
|
|
property real pendingSeek: -1.0
|
|
property bool dragging: false
|
|
readonly property real effProgress: (root.dragging && root.dragPreview >= 0)
|
|
? root.dragPreview
|
|
: (root.pendingSeek >= 0 ? root.pendingSeek
|
|
: QbzPlayer.npProgress)
|
|
|
|
// The reference watches a mirrored copy of the progress (`watch-progress`,
|
|
// :222-228) because a Slint global cannot carry a `changed` handler; in Qt
|
|
// the singleton's own notify signal is the same edge, with no mirror.
|
|
Connections {
|
|
target: QbzPlayer
|
|
function onNpProgressChanged() {
|
|
if (root.pendingSeek >= 0
|
|
&& Math.abs(QbzPlayer.npProgress - root.pendingSeek) < 0.02) {
|
|
root.pendingSeek = -1.0
|
|
// The reference leaves the watchdog armed here (:223-228 clears
|
|
// only the sentinel) and lets it fire into a no-op. Stopping it
|
|
// on convergence is one wakeup fewer on a window whose whole
|
|
// point is to be left running (§7).
|
|
pendingTimer.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
// T2 (§4.12) — 8 s one-shot, re-armed on every commit. A Timer has no
|
|
// `parent` (§8 rule 2); everything it touches is reached through `root`.
|
|
Timer {
|
|
id: pendingTimer
|
|
interval: 8000
|
|
repeat: false
|
|
onTriggered: root.pendingSeek = -1.0
|
|
}
|
|
|
|
function previewSeek(f) {
|
|
root.dragging = true
|
|
root.dragPreview = f
|
|
}
|
|
|
|
function commitSeek(f) {
|
|
root.dragging = false
|
|
root.dragPreview = -1.0
|
|
root.pendingSeek = f
|
|
QbzPlayer.seek(f)
|
|
pendingTimer.restart()
|
|
}
|
|
|
|
/// m:ss. The four now-playing bars each declare this locally
|
|
/// (PlayerBar.qml:211-215 and its three twins); there is no shared
|
|
/// formatter in the tree, so the footer declares ONE rather than a copy per
|
|
/// row (§7-M2, L5).
|
|
function fmt(secs) {
|
|
var m = Math.floor(secs / 60)
|
|
var s = Math.floor(secs % 60)
|
|
return m + ":" + (s < 10 ? "0" : "") + s
|
|
}
|
|
|
|
color: QbzMini.backgroundBlur ? "#d906070a" : theme.surfaceCard // #06070ad9 in Slint; Qt is #AARRGGBB
|
|
|
|
/// The CARD's corner radius, handed down by MiniShell.
|
|
///
|
|
/// The footer must round its own corners, because the card CANNOT round
|
|
/// them for it: `qml/theme/RoundedImage.qml:3-6` records as measured fact
|
|
/// on this Qt build that a Rectangle with `radius` + `clip: true` does NOT
|
|
/// clip its children to the curve — "the child paints square over the
|
|
/// rounded fill". This footer is an opaque Rectangle anchored to the card's
|
|
/// bottom edge, so without this it paints square corners straight over the
|
|
/// card's rounded ones. That is exactly what the owner photographed against
|
|
/// a white background on 2026-08-04: every surface rounded on top and
|
|
/// SQUARE at the bottom, and the micro card — where the footer IS the whole
|
|
/// card — square on all four.
|
|
///
|
|
/// Per-corner radii need Qt 6.7+; this tree builds against 6.11.
|
|
property real cardRadius: 0
|
|
|
|
// In micro the footer is the entire card, so all four corners are its own.
|
|
// In full and compact it owns the bottom two and the surface area above it
|
|
// owns nothing (that Item is transparent and paints no corner).
|
|
topLeftRadius: root.mode === 2 ? root.cardRadius : 0
|
|
topRightRadius: root.mode === 2 ? root.cardRadius : 0
|
|
bottomLeftRadius: root.cardRadius
|
|
bottomRightRadius: root.cardRadius
|
|
|
|
// ======================================================================
|
|
// FULL / COMPACT (:263-329)
|
|
// ======================================================================
|
|
Loader {
|
|
active: root.mode !== 2
|
|
anchors.fill: parent
|
|
anchors.topMargin: 0
|
|
anchors.leftMargin: root.padH
|
|
anchors.rightMargin: root.padH
|
|
anchors.bottomMargin: root.padBottom
|
|
|
|
sourceComponent: Component {
|
|
Item {
|
|
MiniSeek {
|
|
id: seek
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: root.seekH
|
|
trackHeight: root.seekTrack
|
|
progress: root.effProgress
|
|
onPreview: function (f) { root.previewSeek(f) }
|
|
onCommit: function (f) { root.commitSeek(f) }
|
|
}
|
|
|
|
// The times row (:280-292). It takes whatever the seek bar and
|
|
// the controls row leave, which is how the reference's
|
|
// VerticalLayout distributes it — 16 px at full, 14 at compact.
|
|
Item {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: seek.bottom
|
|
anchors.bottom: ctrlRow.top
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.fmt(QbzPlayer.npElapsedSecs)
|
|
color: theme.textMuted
|
|
font.pixelSize: root.timeFont
|
|
}
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// EXACTLY ONE minus sign (§15 trap 14). The trap is
|
|
// about the SLINT property `NowPlayingState.remaining`,
|
|
// which arrives pre-signed from
|
|
// `crates/qbz/src/playback.rs:1679-1682`; the Qt bridge
|
|
// publishes no such property (there is no np_remaining
|
|
// in src/player_bridge.rs), so this is the tree's own
|
|
// idiom — PlayerBar.qml:339 — which supplies the sign
|
|
// itself.
|
|
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs
|
|
- QbzPlayer.npElapsedSecs))
|
|
color: theme.textMuted
|
|
font.pixelSize: root.timeFont
|
|
}
|
|
}
|
|
|
|
// The controls row (:297-328).
|
|
Item {
|
|
id: ctrlRow
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: root.ctrlH
|
|
|
|
MiniVolume {
|
|
id: vol
|
|
// Above the transport, so its popup and the
|
|
// click-outside layer inside it are not painted under
|
|
// the buttons they have to cover.
|
|
z: 2
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btn: root.tbtn
|
|
iconSize: root.icVolume
|
|
}
|
|
|
|
// THE PHANTOM SPACER (:318-319). Invisible, no children,
|
|
// exactly the volume button's width — it exists so the
|
|
// transport's two stretch spacers are balanced and the
|
|
// cluster lands on the WINDOW's centre line rather than on
|
|
// the centre of what is left after the volume button.
|
|
Item {
|
|
id: phantom
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: root.tbtn
|
|
height: root.tbtn
|
|
}
|
|
|
|
MiniTransport {
|
|
id: transport
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// Centred in the band BETWEEN the volume button and the
|
|
// phantom — the two equal stretch spacers, written out.
|
|
// Because the two ends are the same width this lands on
|
|
// the row's centre, which is the point of the phantom.
|
|
x: vol.x + vol.width
|
|
+ Math.round((phantom.x - vol.x - vol.width - width) / 2)
|
|
btn: root.tbtn
|
|
gap: root.tgap
|
|
icShuffle: root.icShuffle
|
|
icSkip: root.icSkip
|
|
icPlay: root.icPlay
|
|
btnEnabled: root.hasTrack
|
|
}
|
|
|
|
// The capsule (:322-327): right-aligned, vertically centred
|
|
// on the controls row, above everything including the
|
|
// volume popup's dismiss layer.
|
|
Loader {
|
|
z: 3
|
|
active: root.windowHovered
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
sourceComponent: Component {
|
|
MiniWindowControls {
|
|
activeSurface: QbzMini.surface
|
|
hostWindow: root.hostWindow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================================================================
|
|
// MICRO (:332-395) — 1 + 17 + 16 + 4 = 38 px of content in the 45 px card
|
|
// ======================================================================
|
|
Loader {
|
|
active: root.mode === 2
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
anchors.rightMargin: 8
|
|
anchors.topMargin: 1
|
|
// No bottom margin — the reference sets none (:335-338).
|
|
|
|
sourceComponent: Component {
|
|
Item {
|
|
// The header: one elided line, and the whole of it is a drag
|
|
// handle.
|
|
Item {
|
|
id: microHeader
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 17
|
|
|
|
Text {
|
|
anchors.fill: parent
|
|
// The separator is a LITERAL " - " (space, hyphen-minus,
|
|
// space) — :347, not an en dash and not translated.
|
|
text: QbzPlayer.npHasTrack
|
|
? (QbzPlayer.npArtist !== ""
|
|
? QbzPlayer.npTitle + " - " + QbzPlayer.npArtist
|
|
: QbzPlayer.npTitle)
|
|
: QbzSession.tr("No track playing", QbzSession.trRev)
|
|
color: theme.alphaTier(70)
|
|
font.pixelSize: 11
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
// THE DRAG HANDLE (:356-362). PRESSED, NOT CLICKED (§15
|
|
// trap 8): startSystemMove() hands the pointer grab to the
|
|
// compositor, so the release never returns to Qt and a
|
|
// clicked handler would never fire. It covers the whole
|
|
// header because the reference's TouchArea sets no geometry
|
|
// — in micro there is nothing else in this strip to hit.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onPressed: {
|
|
if (root.hostWindow)
|
|
root.hostWindow.startSystemMove()
|
|
}
|
|
}
|
|
|
|
// The capsule, with the EXTRA 8 px inset the full/compact
|
|
// mount does not have (:364).
|
|
//
|
|
// Vertically centred on the CARD, not on this 17 px header
|
|
// — owner call, 2026-08-04. The reference centres it on the
|
|
// header, but in micro the header is a 17 px strip at the
|
|
// TOP of a 45 px card while the capsule is 26 px tall, so
|
|
// the reference's own arithmetic leaves it overflowing
|
|
// upward and visibly high. `root` is the footer, which in
|
|
// micro IS the whole card (§13-D10), so its centre is the
|
|
// card's centre. Anchoring across the parent boundary is
|
|
// safe here and NOT the §8 rule-1 hazard: `root` is an id,
|
|
// not a `parent.<something>` walk.
|
|
Loader {
|
|
z: 1
|
|
active: root.windowHovered
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: root.verticalCenter
|
|
sourceComponent: Component {
|
|
MiniWindowControls {
|
|
activeSurface: QbzMini.surface
|
|
hostWindow: root.hostWindow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The controls strip (:371-384). NO VOLUME, therefore NO
|
|
// PHANTOM — micro is the one mode that is genuinely centred
|
|
// (`HorizontalLayout { alignment: center }`, :373-374).
|
|
Item {
|
|
id: microCtrl
|
|
anchors.top: microHeader.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 16
|
|
|
|
MiniTransport {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btn: 16
|
|
gap: 9
|
|
icShuffle: 8
|
|
icSkip: 9
|
|
icPlay: 9
|
|
btnEnabled: root.hasTrack
|
|
}
|
|
}
|
|
|
|
// The bottom seek (:387-394): 4 px tall, a 1 px SQUARE-ENDED
|
|
// track. The reference's comment calls it "full-bleed"; the
|
|
// layout's 8 px side padding says otherwise and the CODE wins
|
|
// (§12-P6).
|
|
MiniSeek {
|
|
anchors.top: microCtrl.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 4
|
|
trackHeight: 1
|
|
rounded: false
|
|
progress: root.effProgress
|
|
onPreview: function (f) { root.previewSeek(f) }
|
|
onCommit: function (f) { root.commitSeek(f) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|