feat(qt): the mini footer, the hover capsule and always-on-top

Block B3. The owner's verdict on the four blocks that landed before it was
that the miniplayer was "no draggeable, no hay controles de ningun tipo. Ni
de reproducción, ni para cambiar entre modos, no es pinneable." All of that
lived here.

qml/miniplayer/ — MiniFooter with its three modes, and the five primitives
the reference declares as inner components: MiniSeek, MiniTransport,
MiniVolume, TBtn and CapBtn, one file each because a 396-line Slint footer
folded into one QML file lands well past the size rule. MiniWindowControls
is the hover capsule: five surface tabs, a separator, then expand, pin,
backdrop, drag and close, plus the collapsed square trigger.

The transport in full and compact is centred by a PHANTOM SPACER the width
of the volume button, not by a centred layout — micro has no volume and is
the one mode genuinely centred. The seek carries the reference's pending
machine: the -1.0 sentinel, the 0.02 convergence window and the 8s watchdog.
The capsule is a conditional MOUNT, so leaving the card destroys its
expanded flag and its 250ms timer with it.

ALWAYS-ON-TOP is new work, not a port: three comments in the Slint tree
promise it and no code implements it. The owner asked for it back because
Tauri had it and Slint structurally cannot. Qt.WindowStaysOnTopHint, a pin
button, and a persisted pref that defaults ON.

COLOUR LITERALS: six were carried over from the reference verbatim and every
one of them was WRONG. Slint writes #RRGGBBAA; Qt parses #AARRGGBB, so
#ffffff33 is not white at 20% but opaque pale yellow — which is exactly how
the capsule rendered until it was driven and looked at. This is the second
time this trap has landed in this port. Each converted literal now carries
the reference form beside it. One of the six is MiniShell's, from B2.

mini_qt.rs — surface 0 leaves the temporary clamp, because the micro footer
IS the card and now exists. Surfaces 3 and 4 stay clamped until B4.

Driven and seen, not inferred: the footer in artwork and micro, the capsule
revealing on hover and collapsing on leave, the micro card at 380x57 with
its centred transport, and the pin persisting mini_always_on_top across a
toggle. The drag handle is wired to startSystemMove on pressed; the VNC
platform cannot move a window, so that one is owed a real compositor.

cc
This commit is contained in:
vicrodh committed 2026-08-04 08:43:02 -06:00
1 parent 5dd450fb9f
commit 6b17db90dd
31 files changed
+1425 -24

No files matched your search

+9 -2
View File
@@ -316,14 +316,21 @@ fn main() {
// Miniplayer (2026-08-03 miniplayer/tray-port contract §3.4) —
// its own module directory, like immersive/ and kiosk/. B2
// ships the window, the card shell and the two DISPLAY
// surfaces; B3 adds the footer + capsule, B4 the queue and
// lyrics surfaces.
// surfaces; B3 the footer, its four primitives and the hover
// capsule; B4 the queue and lyrics surfaces.
"qml/miniplayer/MiniWindow.qml",
"qml/miniplayer/MiniShell.qml",
"qml/miniplayer/MiniExplicitBadge.qml",
"qml/miniplayer/MiniCoverArt.qml",
"qml/miniplayer/MiniCompactSurface.qml",
"qml/miniplayer/MiniArtworkSurface.qml",
"qml/miniplayer/MiniFooter.qml",
"qml/miniplayer/MiniWindowControls.qml",
"qml/miniplayer/MiniSeek.qml",
"qml/miniplayer/MiniTransport.qml",
"qml/miniplayer/MiniVolume.qml",
"qml/miniplayer/TBtn.qml",
"qml/miniplayer/CapBtn.qml",
],
qrc_files: &qrc_refs,
..Default::default()
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

+79
View File
@@ -0,0 +1,79 @@
// CapBtn — the hover capsule's button (2026-08-03 miniplayer/tray contract
// A-29, §4.3.4), port of `component CapBtn inherits Rectangle` at
// `crates/qbz-ui/ui/miniplayer/MiniWindowControls.slint:14-53`.
//
// 22 x 20, radius 6 — NOT a disc like TBtn, and the trigger overrides the
// height to 22 so the collapsed capsule is a circle.
//
// TWO SIGNALS, and the difference is load-bearing (§15 trap 8): `clicked` is
// the ordinary press-release, `pressed` fires on pointer DOWN. `a-move` and the
// micro header's drag handle use the second one, because `startSystemMove()`
// hands the pointer grab to the compositor and a press-release would never
// arrive — a clicked handler would simply never drag.
//
// `danger` paints the close button's red hover fill, and the glyph on it stays
// a LITERAL #ffffff (tint "white"). QbzTheme's owner-approved
// glyph-on-accent selector (`accentGlyphTint`) does NOT apply: that decides the
// glyph on an ACCENT fill, and #ef4444 is not the accent (§4.11).
//
// The idle tint is `Theme.alpha-65`, spelled as textPrimary at 0.65 opacity for
// the reason TBtn.qml's header gives.
//
// The drop shadow the reference draws on the capsule chrome is dropped by the
// port's standing convention (QbzToast.qml:30, QbzTooltip.qml:51-61,
// MyQbzModals.qml:23) — it lives on MiniWindowControls, not here.
import QtQuick
import "../theme"
Rectangle {
id: root
property string name: ""
property int iconSize: 13
property bool active: false
/// The close button: red hover fill, white glyph on it.
property bool danger: false
/// `a-move` only — the grab cursor that says "this drags the window".
property bool dragCursor: false
/// Read by MiniWindowControls' collapse machine when it aggregates hover
/// per button; the shipped machine uses one HoverHandler on the chrome
/// instead, so this stays available without being on a hot path.
readonly property bool hovered: ta.containsMouse
signal clicked()
/// Pointer DOWN. See the header.
signal pressed()
QbzTheme { id: theme }
width: 22
height: 20
radius: 6
antialiasing: true
color: ta.containsMouse
? (root.danger ? "#ccef4444" : theme.alphaTier(12)) // #ef4444cc in Slint; Qt is #AARRGGBB
: "transparent"
QbzIcon {
name: root.name
width: root.iconSize
height: root.iconSize
// Pixel-snapped, exactly as TBtn does (:35-36).
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
tintName: root.active
? "accent"
: ((ta.containsMouse && root.danger) ? "white" : "textPrimary")
opacity: (root.active || ta.containsMouse) ? 1.0 : 0.65
}
MouseArea {
id: ta
anchors.fill: parent
hoverEnabled: true
cursorShape: root.dragCursor ? Qt.OpenHandCursor : Qt.PointingHandCursor
onClicked: root.clicked()
onPressed: root.pressed()
}
}
+383
View File
@@ -0,0 +1,383 @@
// 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
// ======================================================================
// 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).
Loader {
z: 1
active: root.windowHovered
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.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) }
}
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// MiniSeek — the footer's seek bar (2026-08-03 miniplayer/tray contract A-29,
// §4.3.3), port of `component MiniSeek inherits Rectangle` at
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:168-209`.
//
// A THIN TRACK INSIDE A TALL HIT AREA: the visible bar is `trackHeight`
// (3 px full, 2 px compact, 1 px micro) and the pointer target is the whole
// root height (14 / 10 / 4). No thumb, no hover growth, no buffered overlay —
// the reference has none of the three (:168-209 read whole), and the deferred
// polish its file header records (a pending-seek spinner thumb, a micro marquee)
// is deferred THERE too.
//
// This component owns NO state. It reports `preview` while the pointer is down
// and `commit` on release, and MiniFooter owns the pending-seek machine — which
// is why `progress` is an input rather than a read of QbzPlayer: the footer
// feeds it the EFFECTIVE progress, so the fill does not snap back to the
// player's stale value between the click and the engine catching up.
//
// DELIBERATELY NOT clamped to `QbzPlayer.npSeekableMax`, unlike the main bars:
// the reference's mini has no seekable-max notion at all, and §12-P16 keeps the
// mini's "ignores is-remote / volume-locked / cast-active" behaviour as a
// REPRODUCE-1:1 row rather than half-fixing it here.
import QtQuick
import "../theme"
Item {
id: root
/// Effective 0..1, fed by MiniFooter's pending-seek machine.
property real progress: 0.0
property int trackHeight: 3
/// Micro's bar is square-ended (:390).
property bool rounded: true
signal preview(real fraction)
signal commit(real fraction)
QbzTheme { id: theme }
function fractionAt(px) {
return root.width > 0 ? Math.max(0, Math.min(1, px / root.width)) : 0
}
Rectangle {
id: track
width: root.width
height: root.trackHeight
y: Math.round((root.height - height) / 2)
radius: root.rounded ? height / 2 : 0
color: theme.alphaTier(12)
clip: true
Rectangle {
x: 0
y: 0
width: parent.width * Math.max(0, Math.min(1, root.progress))
height: parent.height
radius: parent.radius
color: theme.accent
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// down -> preview, drag -> preview, up -> commit (:195-207).
onPressed: function (mouse) { root.preview(root.fractionAt(mouse.x)) }
onPositionChanged: function (mouse) {
if (pressed)
root.preview(root.fractionAt(mouse.x))
}
onReleased: function (mouse) { root.commit(root.fractionAt(mouse.x)) }
}
}
+36 -5
View File
@@ -24,10 +24,23 @@ Rectangle {
QbzTheme { id: theme }
// §8 rule 3: the window, handed down explicitly by MiniWindow. Consumed by
// the footer's drag handle and the hover capsule (B3), which call
// the footer's drag handle and the hover capsule, which call
// startSystemMove() on it rather than walking a parent chain.
property var hostWindow: null
// §13-D4 — the reference feeds `MiniPlayerState.window-hovered` from winit
// CursorMoved/CursorLeft (`crates/qbz/src/miniplayer.rs:420-429`) because a
// borderless Slint card has no reliable root hover; `state.slint:4506`
// names "or a root TouchArea" as the alternative and Qt takes that branch.
// A HoverHandler, NOT a MouseArea: a MouseArea would lose containsMouse the
// moment the pointer crossed onto any button in the footer, which is
// exactly the measured failure the tree records at
// qml/immersive/SuggestionsPanel.qml:316-325 and
// qml/shell/Cortinilla.qml:215-222. This drives the capsule's conditional
// mount, so getting it wrong means the capsule vanishes under the cursor
// that is reaching for it.
HoverHandler { id: cardHover }
// --- Cached per-mode values (§7-M2) ------------------------------------
// The reference computes each of these ONCE into a named property
// (MiniShell.slint:18, :23, :62). An always-visible surface pays a repeated
@@ -160,7 +173,7 @@ Rectangle {
// track, the card is the flat scrim over #050506.
Rectangle {
anchors.fill: parent
color: "#050506bf"
color: "#bf050506" // #050506bf in Slint; Qt is #AARRGGBB
}
}
@@ -186,9 +199,9 @@ Rectangle {
// minus the footer, which is how §4.2's "surface-area H" column is
// reproduced: 102 at compact, 448 at the 540-tall expanded default.
//
// MiniFooter mounts under it and arrives with B3; the space it will occupy
// is reserved here, by footerHeight, because that reservation is what makes
// the surface geometry above it correct in the first place.
// MiniFooter mounts under it; the space it occupies is reserved here, by
// footerHeight, because that reservation is what makes the surface geometry
// above it correct in the first place.
Item {
id: surfaceArea
anchors.left: parent.left
@@ -217,4 +230,22 @@ Rectangle {
}
}
}
// --- The footer (MiniShell.slint:59-63) --------------------------------
// In micro it IS the card: `surfaceArea` is invisible at surface 0 and this
// fills all 45 px. Its `mode` is the INVERTED number (§15 trap 2), computed
// once above.
//
// Declared AFTER surfaceArea so the hover capsule — which overflows the
// 17 px micro header by 4.5 px in each direction — paints above it.
MiniFooter {
id: footer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.footerHeight
mode: root.footerMode
hostWindow: root.hostWindow
windowHovered: cardHover.hovered
}
}
@@ -0,0 +1,72 @@
// MiniTransport — the five-button transport row (2026-08-03 miniplayer/tray
// contract A-29, §4.3.3), port of `component MiniTransport inherits
// HorizontalLayout` at `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:52-103`.
//
// Mounted THREE times with three different size sets: full (btn 30), compact
// (btn 26) and micro (btn 16) — which is why every dimension is a property and
// none is a literal.
//
// NO LOADING STATE ON PLAY (§12-P4). The reference mirrors
// `NowPlayingState.loading` into the mini (`crates/qbz/src/miniplayer.rs:559`)
// and never renders it; the main bar's spinner has no counterpart here. Do not
// add one.
//
// The repeat button reuses `icShuffle` for its glyph size — it has no size
// token of its own in the reference (:100), and that is not an oversight worth
// "fixing": shuffle and repeat are the two outer buttons and they match.
import QtQuick
import com.blitzfc.qbz
Row {
id: root
property int btn: 30
property int icShuffle: 16
property int icSkip: 18
property int icPlay: 20
property int gap: 18
/// Gated on `NowPlayingState.has-track` by the footer (:315).
property bool btnEnabled: true
spacing: root.gap
TBtn {
name: "shuffle"
iconSize: root.icShuffle
btn: root.btn
active: QbzPlayer.npShuffle
btnEnabled: root.btnEnabled
onClicked: QbzPlayer.toggleShuffle()
}
TBtn {
name: "skip-back"
iconSize: root.icSkip
btn: root.btn
btnEnabled: root.btnEnabled
onClicked: QbzPlayer.previous()
}
TBtn {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
iconSize: root.icPlay
btn: root.btn
btnEnabled: root.btnEnabled
onClicked: QbzPlayer.togglePlay()
}
TBtn {
name: "skip-forward"
iconSize: root.icSkip
btn: root.btn
btnEnabled: root.btnEnabled
onClicked: QbzPlayer.next()
}
TBtn {
// 0 off · 1 all · 2 one (src/player_bridge.rs:52).
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
iconSize: root.icShuffle
btn: root.btn
active: QbzPlayer.npRepeatMode !== 0
btnEnabled: root.btnEnabled
onClicked: QbzPlayer.cycleRepeat()
}
}
+146
View File
@@ -0,0 +1,146 @@
// MiniVolume — the footer's volume trigger and its popup (2026-08-03
// miniplayer/tray contract A-29, §4.3.3), port of `component MiniVolume
// inherits Rectangle` at `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:106-164`.
//
// THE POPUP OPENS TO THE RIGHT, not below: `x: root.btn + 8px`,
// `y: (root.height - 34px) / 2` (:122-123) — 8 px past the trigger's right edge
// and vertically centred on the trigger's own row, so it sits over the
// transport rather than over the seek bar.
//
// INHERITED ODDITY, REPRODUCED (§12-P5): the popup's mute button is hard-coded
// `volume-x.svg` and never reflects the mute state (:139), where the TRIGGER
// does swap between volume-x and volume-2 (:114). Not a bug worth diverging
// for — it is a mute BUTTON, not a mute indicator.
//
// WHY THE DISMISS LAYER IS A HUGE RECTANGLE. Slint gets close-on-click-outside
// from `PopupWindow`'s `close-policy` (:126). Qt's equivalent would be a
// QtQuick.Controls Popup, whose overlay would take focus from the frameless
// mini and whose window-level surface is exactly what §4.3.4 refuses for the
// capsule. So dismissal is a plain MouseArea sized far past this component and
// CLIPPED BY THE CARD — MiniShell sets `clip: true`, and Qt clips hit-testing to
// the clip rect as well as painting, so the layer covers the whole card and
// nothing beyond it. It sits under the popup (z 1 vs 2) and over the trigger,
// which is why clicking the trigger a second time closes rather than reopens —
// 1:1 with a close-on-click-outside policy consuming that click.
//
// The reference's 24 px drop shadow (:132-133) is dropped by the port's
// standing convention for Slint shadows (QbzToast.qml:30, QbzTooltip.qml:51-61,
// MyQbzModals.qml:23, AddToMixtapeModal.qml:19).
//
// NOT PORTED, and it is a REFERENCE GAP rather than an omission: the main bar
// persists the settled volume on drag-end (`QbzSlider.onReleased` ->
// QbzPlayer.persistVolume, qml/shell/PlayerBar.qml:582), and the Slint mini
// does NOT — its slider binds `changed` only (:151) even though
// `NowPlayingState.persist-volume` exists beside `set-volume`
// (state.slint:4482-4483). Reported as a divergence PROPOSAL rather than taken,
// because §13.0 rule 5 is that a divergence never lands without its §13 row.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
property int btn: 30
/// The TRIGGER's glyph size — the SEVENTH per-mode value, cached by the
/// footer beside the other six (contract §4.3.2).
property int iconSize: 18
/// Read by MiniFooter so it can raise this whole subtree above the
/// transport while the popup is up.
property bool popupOpen: false
function closePopup() { root.popupOpen = false }
QbzTheme { id: theme }
width: root.btn
height: root.btn
color: "transparent"
TBtn {
name: (QbzPlayer.npMuted || QbzPlayer.npVolume === 0) ? "volume-x" : "volume-2"
iconSize: root.iconSize
btn: root.btn
onClicked: root.popupOpen = true
}
// Close-on-click-outside. See the header for why it is shaped like this.
MouseArea {
z: 1
visible: root.popupOpen
enabled: root.popupOpen
x: -1000
y: -1000
width: 2000
height: 2000
// hoverEnabled stays FALSE so the card's HoverHandler — the thing that
// mounts the capsule — keeps seeing the pointer through this layer.
onClicked: root.closePopup()
}
Rectangle {
id: popup
z: 2
visible: root.popupOpen
x: root.btn + 8
y: Math.round((root.height - height) / 2)
width: 172
height: 34
radius: 17
antialiasing: true
color: theme.surfaceCard
border.width: 1
border.color: theme.alphaTier(12)
// Swallow clicks on the popup's own background so they do not fall
// through to the dismiss layer (the Cortinilla.qml:227-232 idiom).
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
onClicked: {}
}
Row {
x: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 7
TBtn {
// Always volume-x — see the header (§12-P5).
name: "volume-x"
iconSize: 14
btn: 20
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleMute()
}
QbzSlider {
width: 96
anchors.verticalCenter: parent.verticalCenter
minimum: 0
// 0..1000, not 0..100: the slider is integer-stepped, so a
// 0..100 scale quantizes the volume to 1 % where 0.1 % drags
// fluidly (:146-149).
maximum: 1000
value: Math.round(QbzPlayer.npVolume * 1000)
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
}
Text {
// 26, not the reference's `min-width: 22`: 8 + 20 + 7 + 96 + 7
// + 26 = 164 leaves exactly the 8 px right padding the Slint
// layout ends with, where a hard 22 would leave 12. The number
// it renders is at most three digits at 10 px.
width: 26
anchors.verticalCenter: parent.verticalCenter
text: Math.round(QbzPlayer.npVolume * 100)
color: theme.textMuted
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+55 -6
View File
@@ -33,14 +33,27 @@ Window {
// step of enter() (§4.6). Explicitly null, never inherited.
transientParent: null
// Borderless, 1:1 with `no-frame: true` (app.slint:293).
// Borderless, 1:1 with `no-frame: true` (app.slint:293), plus always-on-top
// — owner ruling K1, §13-D15, and it DEFAULTS ON.
//
// Always-on-top is owner ruling K1 and defaults ON, but its property
// (QbzMini.alwaysOnTop, A-5) and the pin button that toggles it are block
// B3's; B3 adds the `| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)`
// term to this expression. Naming a property that does not exist would be
// the L1 failure this contract's symbol rule exists to prevent.
// The AOT term is NEW WORK rather than a port: §4.8's two probes found
// three comments in the reference promising the affordance
// (state.slint:4497, miniplayer.rs:170-171, MiniWindowControls.slint:2) and
// no code anywhere that sets it. The property is seeded from
// `mini_always_on_top` at bridge construction and toggled by the capsule's
// pin button.
//
// ⚠ THE ONE RUNTIME RISK, and it is §16 U-6: reassigning `flags` on a
// MAPPED window can make Qt destroy and recreate the platform window, which
// on Wayland loses the compositor-chosen position. §4.8 pre-specifies the
// fallback rather than leaving it to be improvised — evaluate the flags at
// enter() only and keep the pref and the button state — and the fallback is
// NOT taken: driven over RFB, toggling the pin left the window's geometry,
// its contents and its input focus untouched (no flicker, no jump, no
// re-created surface). A real compositor is still the owner's smoke item;
// the pref persists either way, so the next open honours it regardless.
flags: Qt.Window | Qt.FramelessWindowHint
| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
title: "QBZ Mini Player"
@@ -97,6 +110,35 @@ Window {
minimumWidth: 340
minimumHeight: root.winMinHeight
// --- The geometry persist (§4.7, A-14, §13-D14) ------------------------
// Qt has no `WindowEvent::Resized`, so the resize handler is this debounce.
// The template is the main window's own (qml/Main.qml:377-385), and the
// 400 ms interval is the same.
//
// It is DEBOUNCED because the alternative is what the reference does: a
// whole-file ui_prefs.json load + save per qualifying resize event, with no
// dirty check (`crates/qbz/src/miniplayer.rs:460-463`), which in Qt would
// be blocking file IO on the GUI thread inside a resize drag (§7-M10).
//
// It needs NO arming latch of its own, unlike Main.qml's 1200 ms one: the
// two guards inside `mini_qt::geometry_to_persist` already reject exactly
// the frames a latch would — `surface >= 2` drops micro and compact, whose
// sizes are constants, and `height >= 320` drops the mid-transition frames
// a surface switch produces (a 178 px compact frame is precisely what that
// filter is for). Note the filter is 320 and the stored floor is 420 — two
// different numbers, both behavioural (§15 trap 17).
Timer {
id: geometrySaveTimer
interval: 400
repeat: false
// A Timer has no `parent` (§8 rule 2, §15 trap 29) — the window is
// reached through its own id.
onTriggered: QbzMini.saveGeometry(root.width, root.height)
}
onWidthChanged: geometrySaveTimer.restart()
onHeightChanged: geometrySaveTimer.restart()
// A compositor close (Alt+F4, a panel's close entry) behaves EXACTLY like
// the capsule's expand button — §13-D13, and it closes a hole the reference
// has: Slint has no on_close_requested on the mini, so its default
@@ -105,6 +147,13 @@ Window {
// (§3.1), so `accepted = false` is load-bearing twice.
onClosing: function (close) {
close.accepted = false
// Flush a pending resize (§4.7). Only when one is actually pending —
// an unconditional call would put a prefs write on every close, and the
// bridge's own dirty check would then be the only thing stopping it.
if (geometrySaveTimer.running) {
geometrySaveTimer.stop()
QbzMini.saveGeometry(root.width, root.height)
}
QbzMini.exit()
}
@@ -0,0 +1,237 @@
// MiniWindowControls — the hover capsule (2026-08-03 miniplayer/tray contract
// A-28, §4.3.4), port of
// `crates/qbz-ui/ui/miniplayer/MiniWindowControls.slint` (184 L, read whole).
//
// A collapsed 26 px disc pinned to the RIGHT that unfolds LEFT into ten
// buttons: five surface tabs, a separator, then expand / pin / background /
// move / close. INLINE CHROME, NOT A POPUP — the reference's header says so at
// :1-4 and gives the reason: a popup surface would steal focus from a frameless
// always-on-top window.
//
// THE PIN IS NEW WORK, NOT A PORT (K1, §4.8, §13-D15). Three of the reference's
// own comments promise always-on-top — state.slint:4497, miniplayer.rs:170-171
// and this component's own header, which describes the cluster as
// "expand/pin/move/close" — and ZERO code implements it. The shipped Slint
// cluster has nine buttons; this one has ten, and the width arithmetic below
// carries the K1 column of §4.3.4's table (cluster 243, expanded 272), not the
// nine-button one (219 / 248).
//
// THIS COMPONENT IS MOUNTED CONDITIONALLY, NEVER `visible: false` (§15 trap 7).
// MiniFooter mounts it through a `Loader { active: windowHovered }`, so when the
// pointer leaves the card the whole thing is destroyed and `expanded` plus the
// 250 ms collapse timer die with it. A `visible` flip would leave a capsule
// mid-unfold waiting to be re-revealed in that state.
//
// THE WIDTH IS COMPUTED FROM CONSTANTS, NOT FROM THE CHILDREN (§8 rule 1).
// The reference reads `cluster.preferred-width` (:81); a QML port that reached
// for `cluster.implicitWidth` through a `parent` chain would be exactly the
// binding qmllint cannot verify. `clusterWidth` is spelled out with its
// arithmetic instead, and `implicitWidth`/`implicitHeight` are what the Loader
// sizes itself from.
//
// ONE HOVER SENSOR, NOT TEN. The reference ORs ten per-button `has-hover`
// flags (:58-61) because a Slint Rectangle has no whole-area hover. A single
// HoverHandler on the chrome reports for the item's whole area regardless of
// what is stacked above it (the Cortinilla.qml:215-222 / SuggestionsPanel.qml
// :316-325 precedent, both measured), which is the same behaviour minus nine
// bindings on an always-visible surface (§7-M2, §7-M7). The only difference is
// the 2-3 px gaps BETWEEN buttons, where the reference would start its 250 ms
// collapse and cancel it on the next motion.
//
// The drop shadow (:93-95, discretely switched on `expanded` — never animated)
// is dropped by the port's standing convention for Slint shadows
// (QbzToast.qml:30, QbzTooltip.qml:51-61, MyQbzModals.qml:23).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
/// The lit tab. Passed in rather than read off QbzMini here so the mount
/// site owns the binding (:56, `active-surface`).
property int activeSurface: 2
/// The MINI window, handed down MiniWindow -> MiniShell -> MiniFooter ->
/// here (§8 rule 3). `a-move` calls startSystemMove() on it; a
/// parent.parent chain is what that rule exists to forbid.
property var hostWindow: null
QbzTheme { id: theme }
// 5 tabs x 22 + 4 gaps x 2 = 118 · separator 1 · 5 actions x 22 + 4 x 2 =
// 118 · two 3 px gaps in the outer row = 118 + 3 + 1 + 3 + 118.
readonly property int clusterWidth: 243
property bool expanded: false
// A1 (§4.12): 220 ms, cubic-bezier(0.4, 0, 0.2, 1). The ONLY animated
// geometry in the miniplayer.
property real slotW: root.expanded ? root.clusterWidth : 0
Behavior on slotW {
NumberAnimation {
duration: 220
easing.type: Easing.Bezier
easing.bezierCurve: [0.4, 0, 0.2, 1, 1, 1]
}
}
// 2 left pad + animated slot + a DISCRETELY switched 3 px gap + the 22 px
// trigger + 2 right pad (:88). Collapsed 26, expanded 272.
implicitWidth: 2 + root.slotW + (root.expanded ? 3 : 0) + 22 + 2
implicitHeight: 26
radius: 13
antialiasing: true
color: "#e606070a" // #06070ae6 in Slint; Qt is #AARRGGBB
border.width: 1
border.color: "#33ffffff" // #ffffff33 in Slint; Qt is #AARRGGBB
HoverHandler {
onHoveredChanged: {
if (hovered) {
collapseTimer.stop()
root.expanded = true
} else {
collapseTimer.restart()
}
}
}
// T1 (§4.12): the 250 ms collapse grace. A Timer is a QtObject and has NO
// `parent` — an unqualified `parent` in here would resolve up and out of
// the block (§8 rule 2, §15 trap 29), so it says `root`.
Timer {
id: collapseTimer
interval: 250
repeat: false
onTriggered: root.expanded = false
}
// The cluster slot: clipped so the buttons slide out of a shrinking box
// rather than being scaled.
Item {
id: slot
x: 2
y: Math.round((root.height - height) / 2)
width: root.slotW
height: 22
clip: true
// A2 (§4.12): 160 ms, default easing.
opacity: root.expanded ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 160 } }
Row {
id: cluster
height: 22
spacing: 3
// --- Group A: the five surface tabs -------------------------
// QML writes the surface by ASSIGNMENT. There is NO setSurface
// member on QbzMini — the property carries a custom WRITE whose
// Rust name collides with the generated setter's, so the funnel
// (persist + the surface-3 queue kick) is reachable only this way
// (src/mini_bridge.rs:46-57).
Row {
height: 22
spacing: 2
CapBtn {
name: "minus"
active: root.activeSurface === 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.surface = 0
}
CapBtn {
name: "disc-3"
active: root.activeSurface === 1
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.surface = 1
}
CapBtn {
name: "image"
active: root.activeSurface === 2
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.surface = 2
}
CapBtn {
name: "list-music"
active: root.activeSurface === 3
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.surface = 3
}
CapBtn {
name: "align-start-vertical"
active: root.activeSurface === 4
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.surface = 4
}
}
Rectangle {
width: 1
height: 12
color: "#29ffffff" // #ffffff29 in Slint; Qt is #AARRGGBB
anchors.verticalCenter: parent.verticalCenter
}
// --- Group B: the actions -----------------------------------
Row {
height: 22
spacing: 2
// `expand()` in the reference IS exit — `mp.on_expand(|| exit())`
// (crates/qbz/src/miniplayer.rs:295).
CapBtn {
name: "maximize-2"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.exit()
}
// K1. The pin the reference's own comments promise.
CapBtn {
name: "pin"
active: QbzMini.alwaysOnTop
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.toggleAlwaysOnTop()
}
CapBtn {
name: "layers"
active: QbzMini.backgroundBlur
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.toggleBackgroundBlur()
}
// PRESSED, NOT CLICKED (§15 trap 8): startSystemMove() hands
// the pointer grab to the compositor, so the release never
// comes back and a clicked handler would never fire.
CapBtn {
name: "move"
iconSize: 12
dragCursor: true
anchors.verticalCenter: parent.verticalCenter
onPressed: {
if (root.hostWindow)
root.hostWindow.startSystemMove()
}
}
CapBtn {
name: "x"
danger: true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.closeApp()
}
}
}
}
// The trigger: always visible, HOVER-ONLY — it has no clicked handler in
// the reference either (:176-183). 22 x 22 overrides CapBtn's 22 x 20 so
// the collapsed capsule is a circle inside the 26 px radius-13 chrome.
CapBtn {
name: "square"
iconSize: 12
width: 22
height: 22
x: root.width - 2 - 22
y: Math.round((root.height - height) / 2)
}
}
+75
View File
@@ -0,0 +1,75 @@
// TBtn — the miniplayer footer's transport/volume button (2026-08-03
// miniplayer/tray contract A-29, §4.3.3), port of the inner
// `component TBtn inherits Rectangle` at
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:16-49`.
//
// A file of its own rather than a QML inline component: the rule is A-29's and
// it is a SIZE rule, not a language rule — a 396-line MiniFooter.slint whose
// port folded five inner types in would land well past TRACK-RULES' ~500-line
// line. (QML does have `component X: Rectangle { … }`, and this tree uses it at
// qml/immersive/QueueTabsPanel.qml:135; nothing in this diff qualifies.)
//
// TWO details that look like bugs and are not:
//
// - THE ICON TINT DOES NOT CHECK `enabled` (§12-P7). A disabled button still
// lightens its glyph under the cursor; only the root's 0.3 opacity conveys
// disabled. That is the reference's behaviour (:36-38 has no enabled arm
// where :28 does), and it is reproduced rather than "fixed".
// - The hover BACKGROUND does check it (:28).
//
// The idle glyph is `Theme.alpha-70`, which is not a name in QbzIcon's tint
// vocabulary — the vocabulary carries theme TOKENS, not the alpha ramp. It is
// spelled here as textPrimary at 0.70 opacity, which is what alpha-70 IS: the
// ramp is white-based on dark themes and black-based on light ones
// (QbzTheme.qml's alphaTier), and textPrimary follows the same polarity. Using
// "secondary" instead — the QbzIconButton idle tint — would be a different
// colour (#cccccc, 80 %) and would not track the ramp on light themes.
//
// `btnEnabled`, not `enabled`: `enabled` is a QQuickItem property and
// shadowing it disarms the whole subtree. Same spelling as QbzIconButton.
import QtQuick
import "../theme"
Rectangle {
id: root
/// Icon file stem, e.g. "shuffle" (QbzIcon appends ".svg").
property string name: ""
property int iconSize: 16
/// The square edge; the radius is half of it, so the hover fill is a disc.
property int btn: 30
property bool active: false
property bool btnEnabled: true
signal clicked()
QbzTheme { id: theme }
width: root.btn
height: root.btn
radius: root.btn / 2
antialiasing: true
opacity: root.btnEnabled ? 1.0 : 0.3
color: (ta.containsMouse && root.btnEnabled) ? theme.alphaTier(8) : "transparent"
QbzIcon {
name: root.name
width: root.iconSize
height: root.iconSize
// Pixel-snapped centring, 1:1 with the reference's
// `Math.round((parent.width - self.width) / 2 / 1px) * 1px` (:34-35):
// a half-pixel offset on a 13 px glyph is visible as a blur.
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
tintName: root.active ? "accent" : "textPrimary"
opacity: (root.active || ta.containsMouse) ? 1.0 : 0.70
}
MouseArea {
id: ta
anchors.fill: parent
hoverEnabled: true
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.btnEnabled) root.clicked()
}
}
+3
View File
@@ -155,6 +155,7 @@ pub struct QbzIconTintRust {
#[rustfmt::skip]
const MASTERS: &[(&str, &str)] = &[
("add-to-list", include_str!("../qml/assets/icons/primary/add-to-list.svg")),
("align-start-vertical", include_str!("../qml/assets/icons/primary/align-start-vertical.svg")),
("arrow-up-down", include_str!("../qml/assets/icons/primary/arrow-up-down.svg")),
("audio-lines", include_str!("../qml/assets/icons/primary/audio-lines.svg")),
("award", include_str!("../qml/assets/icons/primary/award.svg")),
@@ -224,6 +225,7 @@ const MASTERS: &[(&str, &str)] = &[
("monitor", include_str!("../qml/assets/icons/primary/monitor.svg")),
("monitor-speaker", include_str!("../qml/assets/icons/primary/monitor-speaker.svg")),
("moon", include_str!("../qml/assets/icons/primary/moon.svg")),
("move", include_str!("../qml/assets/icons/primary/move.svg")),
("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")),
("music-library-2", include_str!("../qml/assets/icons/primary/music-library-2.svg")),
("music", include_str!("../qml/assets/icons/primary/music.svg")),
@@ -256,6 +258,7 @@ const MASTERS: &[(&str, &str)] = &[
("sliders-horizontal", include_str!("../qml/assets/icons/primary/sliders-horizontal.svg")),
("smartphone", include_str!("../qml/assets/icons/primary/smartphone.svg")),
("speaker", include_str!("../qml/assets/icons/primary/speaker.svg")),
("square", include_str!("../qml/assets/icons/primary/square.svg")),
("square-check-big", include_str!("../qml/assets/icons/primary/square-check-big.svg")),
("star", include_str!("../qml/assets/icons/primary/star.svg")),
("sun-moon", include_str!("../qml/assets/icons/primary/sun-moon.svg")),
+98 -7
View File
@@ -58,6 +58,13 @@ pub mod qbz_mini {
// The static artwork-derived card background (`mini_background_blur`).
// Written by `toggle_background_blur` (contract A-10).
#[qproperty(bool, background_blur)]
// K1 / contract A-5, and it is NEW WORK rather than a port: the Slint
// miniplayer has three comments promising always-on-top and zero code
// implementing it (§4.8). Seeded from `mini_always_on_top`, DEFAULT
// TRUE, and consumed by `MiniWindow.qml`'s `flags` expression as
// `| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)`. Written by
// `toggle_always_on_top` (contract A-11).
#[qproperty(bool, always_on_top)]
// The navigable queue: the current track first, then the FULL
// unfiltered upcoming list. Built by `mini_qt::mini_queue_doc` and
// published from the ONE queue funnel (`queue_qt::publish`), never from
@@ -95,6 +102,28 @@ pub mod qbz_mini {
#[qinvokable]
fn exit(self: Pin<&mut QbzMini>);
/// Flip + persist the card backdrop (contract A-10). The capsule's
/// `a-bg` button is its only caller; the whole render path already
/// exists in `MiniShell.qml` gated on this one property.
#[qinvokable]
fn toggle_background_blur(self: Pin<&mut QbzMini>);
/// Flip + persist always-on-top (contract A-11, K1). The capsule's
/// `a-pin` button is its only caller. Reassigning `flags` on a MAPPED
/// window is the one runtime risk §4.8 names (U-6); the pref persists
/// either way, so the next open honours it even if a platform were to
/// refuse the live change.
#[qinvokable]
fn toggle_always_on_top(self: Pin<&mut QbzMini>);
/// Persist the expanded window size (contract A-14). Qt has no
/// `WindowEvent::Resized`, so `MiniWindow.qml`'s 400 ms debounce calls
/// in. The §4.7 guards live in `mini_qt::geometry_to_persist` — the
/// surface is read from THIS object rather than passed, so no call site
/// can disagree with the property the window is sized from.
#[qinvokable]
fn save_geometry(self: Pin<&mut QbzMini>, width: f32, height: f32);
/// Close the whole APP from the mini (contract A-12, §4.6). Exits the
/// mini first, then routes to the ONE close choreography by emitting
/// `close_app_requested` below.
@@ -154,6 +183,7 @@ pub struct QbzMiniRust {
open: bool,
surface: i32,
background_blur: bool,
always_on_top: bool,
mini_queue_json: QString,
mini_width: f32,
mini_height: f32,
@@ -169,6 +199,7 @@ impl Default for QbzMiniRust {
open: false,
surface: crate::mini_qt::initial_surface(),
background_blur: crate::mini_qt::initial_background_blur(),
always_on_top: crate::mini_qt::initial_always_on_top(),
mini_queue_json: QString::from(crate::mini_qt::MINI_QUEUE_EMPTY),
mini_width: crate::mini_qt::initial_width(),
mini_height: crate::mini_qt::initial_height(),
@@ -349,6 +380,64 @@ impl qbz_mini::QbzMini {
exit_now(self);
}
/// Contract A-10 — the capsule's `a-bg` toggle.
///
/// Read-flip-write through the GENERATED setter, so the change
/// notification QML's backdrop gate depends on is emitted for free
/// (`miniplayer.rs:328-330` does the same two steps).
pub fn toggle_background_blur(mut self: Pin<&mut Self>) {
let value = !self.background_blur;
self.as_mut().set_background_blur(value);
crate::mini_qt::save_background_blur(value);
}
/// Contract A-11 / K1 — the capsule's `a-pin` toggle.
///
/// The reference has no counterpart to copy: §4.8's two probes found three
/// COMMENTS promising the affordance and no code. Flipping this property
/// re-evaluates `MiniWindow.qml`'s `flags` binding, which is where the
/// `Qt.WindowStaysOnTopHint` term actually lives.
pub fn toggle_always_on_top(mut self: Pin<&mut Self>) {
let value = !self.always_on_top;
self.as_mut().set_always_on_top(value);
crate::mini_qt::save_always_on_top(value);
}
/// Contract A-14 — the debounced resize persist.
///
/// Three things happen here and the ORDER matters. The §4.7 guards decide
/// first (`surface >= 2` AND `height >= 320`, two different numbers from
/// the 420 floor — §15 trap 17); then a dirty check drops the write when
/// the clamped pair already matches what this object holds, which is what
/// makes the write-back below safe; then the clamped pair goes to BOTH the
/// A-6b properties and the prefs document.
///
/// The write-back is A-14's own requirement: the window is sized from
/// `miniWidth`/`miniHeight`, so a condensed -> expanded switch inside one
/// session restores the size the user last dragged without re-reading the
/// file. It also closes the loop it appears to open — the properties feed
/// the window's size binding, which feeds the debounce, which calls back in
/// here with the same numbers and stops at the dirty check.
///
/// The reference has NO dirty check and does a whole-file load+save per
/// qualifying resize event (`miniplayer.rs:455-463`) — §13-D14 — where its
/// own main window carries one for exactly this reason
/// (`crates/qbz/src/main.rs:1392-1393`).
pub fn save_geometry(mut self: Pin<&mut Self>, width: f32, height: f32) {
let Some((w, h)) = crate::mini_qt::geometry_to_persist(self.surface, width, height) else {
return;
};
// Sub-pixel equality, the same shape as the main window's `> 0.5`
// guard: the WM emits many no-op geometry events and each miss here is
// a blocking JSON read+write on the GUI thread (§7-M10).
if (self.mini_width - w).abs() < 0.5 && (self.mini_height - h).abs() < 0.5 {
return;
}
self.as_mut().set_mini_width(w);
self.as_mut().set_mini_height(h);
crate::mini_qt::save_geometry(w, h);
}
/// Contract A-12 — close the APP from the mini's red X.
///
/// The order is the reference's (`crates/qbz/src/miniplayer.rs:235-251`):
@@ -385,14 +474,16 @@ impl qbz_mini::QbzMini {
// `QbzMini.surface = n` uses, so the keyboard path cannot skip the
// persist or the surface-3 queue kick.
//
// `clamp_to_implemented` is the TEMPORARY guard that keeps `1`, `4` and
// `5` from landing the user on a blank card while the micro footer
// (B3) and the queue + lyrics surfaces (B4) do not exist yet. It is
// deleted by those blocks — see the function's own doc. The keys stay
// BOUND rather than being removed from the table, because the table is
// the user's rebindable one and the cheatsheet already lists all five.
// `clamp_to_implemented` is the TEMPORARY guard that keeps a key from
// landing the user on a blank card while its surface does not exist
// yet. **B3 has taken its own entry out**: the micro card IS the footer
// this block landed, so `mini.micro` writes 0 unclamped. Only `4` and
// `5` are still clamped, and B4 deletes the function with them — see
// the function's own doc. The keys stay BOUND rather than being removed
// from the table, because the table is the user's rebindable one and
// the cheatsheet already lists all five.
match action.id {
"mini.micro" => apply_surface(self, crate::mini_qt::clamp_to_implemented(0)),
"mini.micro" => apply_surface(self, 0),
"mini.compact" => apply_surface(self, 1),
"mini.artwork" => apply_surface(self, 2),
"mini.queue" => apply_surface(self, crate::mini_qt::clamp_to_implemented(3)),
+62 -4
View File
@@ -32,11 +32,21 @@ const KEY_WIDTH: &str = "mini_width";
const KEY_HEIGHT: &str = "mini_height";
const KEY_BACKGROUND_BLUR: &str = "mini_background_blur";
const KEY_DEFAULT_VIEW: &str = "mini_default_view";
/// K1 / contract §4.7 — the ONE key in this table with no Slint reader.
/// Always-on-top does not exist in the reference (§4.8: three comments promise
/// it, zero code implements it), so this key is written and read by the Qt side
/// alone. It still goes through `save_pref`'s additive single-key patch like
/// every other one, because the DOCUMENT is co-owned even where the key is not.
const KEY_ALWAYS_ON_TOP: &str = "mini_always_on_top";
/// Defaults, from `crates/qbz/src/ui_prefs.rs:677-688`.
const DEFAULT_SURFACE: i32 = 2;
const DEFAULT_WIDTH: f32 = 380.0;
const DEFAULT_HEIGHT: f32 = 540.0;
/// K1: always-on-top ships DEFAULT ON (contract §4.7's last row). A miniplayer
/// that sinks behind the browser is the class-defining complaint, and the
/// reference's own comments describe pinning as intended behaviour.
const DEFAULT_ALWAYS_ON_TOP: bool = true;
/// Resize-persistence guards and floors (`miniplayer.rs:455`, `:459`, `:461-462`).
/// **Note the two different numbers**: 320 is the mid-transition-frame filter,
@@ -175,9 +185,9 @@ pub(crate) fn queue_row_target(has_current: bool, row: i32) -> MiniRowTarget {
/// Port of `miniplayer.rs:455-463`: persist only while an EXPANDED surface is
/// shown (micro/compact sizes are constants), ignore sub-320 heights (a
/// mid-transition frame), and clamp what is stored to 340 x 420.
// Consumed by `QbzMini.save_geometry` (contract row A-14), which lands with the
// window's resize debounce rather than with this projection.
#[allow(dead_code)]
///
/// Consumed by `QbzMini.save_geometry` (contract row A-14), which arrived with
/// block B3's 400 ms resize debounce in `MiniWindow.qml`.
pub(crate) fn geometry_to_persist(surface: i32, width: f32, height: f32) -> Option<(f32, f32)> {
if surface < EXPANDED_SURFACE_FLOOR || height < TRANSITION_HEIGHT_FILTER {
return None;
@@ -232,9 +242,14 @@ pub(crate) fn initial_surface() -> i32 {
/// surface: B3 drops 0 from the list, B4 drops 3 and 4, and the last one to go
/// deletes this function. The keyboard arms in `mini_bridge::key_pressed` are
/// clamped by the same rule and lose it the same way.
///
/// **B3 has dropped 0**: the micro card IS `MiniFooter.qml` in mode 2, so
/// surface 0 now renders and its keyboard arm (`mini.micro`) is unclamped.
/// Only 3 (queue) and 4 (lyrics) remain, and B4 deletes this function with
/// them.
pub(crate) fn clamp_to_implemented(surface: i32) -> i32 {
match surface {
1 | 2 => surface,
0 | 1 | 2 => surface,
_ => DEFAULT_SURFACE,
}
}
@@ -243,6 +258,11 @@ pub(crate) fn initial_background_blur() -> bool {
crate::settings_qt::pref_bool(KEY_BACKGROUND_BLUR, false)
}
/// K1 — the seed for `QbzMini.always_on_top` (contract A-5).
pub(crate) fn initial_always_on_top() -> bool {
crate::settings_qt::pref_bool(KEY_ALWAYS_ON_TOP, DEFAULT_ALWAYS_ON_TOP)
}
pub(crate) fn initial_width() -> f32 {
crate::settings_qt::pref_f32(KEY_WIDTH, DEFAULT_WIDTH)
}
@@ -259,6 +279,29 @@ pub(crate) fn save_surface(surface: i32) {
crate::settings_qt::save_pref(KEY_SURFACE, serde_json::Value::Number(surface.into()));
}
/// Persist the card backdrop toggle (`QbzMini.toggle_background_blur`, A-10).
pub(crate) fn save_background_blur(value: bool) {
crate::settings_qt::save_pref(KEY_BACKGROUND_BLUR, serde_json::Value::Bool(value));
}
/// Persist the pin (`QbzMini.toggle_always_on_top`, A-11 / K1).
pub(crate) fn save_always_on_top(value: bool) {
crate::settings_qt::save_pref(KEY_ALWAYS_ON_TOP, serde_json::Value::Bool(value));
}
/// Persist the expanded geometry, ALREADY clamped by `geometry_to_persist`.
///
/// Two single-key patches, not one struct write: `ui_prefs.json` is co-owned
/// with the shipping Slint app and `save_pref` is the additive path (§15 trap
/// 16). The reference writes the same two keys from its resize handler
/// (`miniplayer.rs:460-463`) — with a whole-file load+save per event, which is
/// exactly what the 400 ms debounce in `MiniWindow.qml` exists to keep off the
/// Qt GUI thread (§7-M10, §13-D14).
pub(crate) fn save_geometry(width: f32, height: f32) {
crate::settings_qt::save_pref(KEY_WIDTH, serde_json::json!(width));
crate::settings_qt::save_pref(KEY_HEIGHT, serde_json::json!(height));
}
#[cfg(test)]
mod tests {
use super::*;
@@ -380,6 +423,21 @@ mod tests {
assert_eq!(geometry_to_persist(4, 500.0, 700.0), Some((500.0, 700.0)));
}
/// The temporary clamp's own boundary, asserted so the block that removes
/// an entry cannot forget the arm it owns. B3 landed the micro footer, so
/// 0 passes; 3 and 4 still fall back to the artwork surface until B4.
#[test]
fn clamp_admits_every_implemented_surface() {
assert_eq!(clamp_to_implemented(0), 0);
assert_eq!(clamp_to_implemented(1), 1);
assert_eq!(clamp_to_implemented(2), 2);
assert_eq!(clamp_to_implemented(3), DEFAULT_SURFACE);
assert_eq!(clamp_to_implemented(4), DEFAULT_SURFACE);
// Out-of-range values from a co-owned prefs document, too.
assert_eq!(clamp_to_implemented(-1), DEFAULT_SURFACE);
assert_eq!(clamp_to_implemented(9), DEFAULT_SURFACE);
}
#[test]
fn default_view_pref_maps_to_the_slint_surface_table() {
// miniplayer.rs:110-119 — "remember"/unknown fall back to the last