Files
qbz/crates/qbz-qt/qml/immersive/ImmersivePlayerBar.qml
T

352 lines
12 KiB
QML

// ImmersivePlayerBar — the immersive compact player (contract
// 2026-08-02-immersive-port §5.7; ImmersiveView.slint:240-458). Mounted by
// ImmersiveView as layer 5, centered, y = height-90-24, fading with the
// auto-hide chrome (the host drives the opacity binding; this component
// knows nothing about the hide timer).
//
// Bar: width min(viewport-32, 600) x 90, radius 16, #00000080, border
// #ffffff2e, shadow 32/#00000066/8 (:257-265). Composition 1:1:
// LEFT ghosts (x=24/56/88/120, y=22): favorite / shuffle / repeat / ∞
// CENTER: elapsed · prev · play-disc-48 · next · remaining (11px #ffffffbf)
// RIGHT: volume ghost (width-156) · VolumeBar 64 (width-112) · ⋯ (width-42)
// TinyBar seek at y=71, width-32 — changed(v) -> seek(min(v, npSeekableMax))
//
// GhostButton / NavButton / PlayDisc are hand-rolled here (NOT QbzIconButton):
// the Slint immersive chrome paints FIXED white-alpha glyphs/fills on the
// dark overlay under EVERY theme (#ffffffa6 idle etc.), and QbzIconButton's
// theme-following tints + fills would go dark on light themes. The header's
// capsule glyphs (ImmersiveHeader.qml) made the same call. All colors are
// Slint RRGGBBAA converted to Qt #AARRGGBB.
import QtQuick
import QtQuick.Effects
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
// The host viewport width (the overlay root's width) — drives the
// min(viewport-32, 600) bar width without a parent-binding loop.
property real viewportWidth: 720
// The Slint bar wakes the auto-hide chrome when the ⋯ menu opens
// (:372-374); the host owns the hide timer.
signal wakeRequested()
implicitWidth: bar.width
implicitHeight: bar.height
// Volume lock, both halves, VERBATIM (contract §5.8 / trap 8;
// PlayerBar.qml:84-94): npVolumeLocked carries the ALSA-Direct hw
// derivation INSIDE it, and that local bit-perfect lock is LIFTED when a
// peer owns playback (the `&& !npIsRemote` term) so the user CAN adjust
// the remote renderer; npRemoteVolumeLocked is the sink-pushed "peer
// disallows remote volume" half.
readonly property bool volLocked:
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|| QbzPlayer.npRemoteVolumeLocked
// The now-playing favorite + ephemeral flags ride the queue document's
// `current` row (Slint: QueueState.now-playing-favorite /
// NowPlayingState.is-ephemeral; the PlayerBar.qml npFavorite + the
// QueuePanel.qml currentEphemeral precedents).
readonly property var queueDoc: {
try {
return JSON.parse(QbzQueue.queueJson)
} catch (e) {
return ({})
}
}
readonly property bool npFavorite: !!(queueDoc.current && queueDoc.current.isFavorite)
readonly property bool npEphemeral: !!(queueDoc.current && queueDoc.current.isEphemeral === true)
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
return m + ":" + (s < 10 ? "0" : "") + s
}
// Software-renderer detection (the ImmersiveView _noShaders idiom) —
// the two drop shadows are MultiEffect blurs; no shaders -> plain
// offset rects.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- Bar shadow: 32 / #00000066 / offset-y 8 (:262-265) --------------
Rectangle {
x: 0
y: 8
width: bar.width
height: bar.height
radius: 16
color: "#66000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: 0.5 // 32/64
}
}
Rectangle {
id: bar
width: Math.min(root.viewportWidth - 32, 600)
height: 90
radius: 16
color: "#80000000"
border.width: 1
border.color: "#2effffff"
// --- Inline chrome controls (see the file header for why these are
// NOT QbzIconButton) ----------------------------------------------
// GhostButton (:27-56): 28px circle, transparent idle, #ffffff40
// fill while ACTIVE, glyph 12px white / white-65% (opacity on the
// glyph — there is no 65%-white tint bake).
component GhostBtn: Rectangle {
property string icon: ""
property bool active: false
property bool btnEnabled: true
signal clicked()
width: 28
height: 28
radius: 14
opacity: btnEnabled ? 1.0 : 0.4
color: active ? "#40ffffff" : "transparent"
QbzIcon {
name: parent.icon
width: 12
height: 12
anchors.centerIn: parent
tintName: "white"
opacity: (parent.active || ghostArea.containsMouse) ? 1.0 : 0.65
}
MouseArea {
id: ghostArea
anchors.fill: parent
enabled: parent.btnEnabled
hoverEnabled: true
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (parent.btnEnabled) parent.clicked()
}
}
// NavButton (:58-81): 28px circle, fill #ffffff26 -> #ffffff40 on
// hover, glyph 14px white.
component NavBtn: Rectangle {
property string icon: ""
signal clicked()
width: 28
height: 28
radius: 14
color: navArea.containsMouse ? "#40ffffff" : "#26ffffff"
QbzIcon {
name: parent.icon
width: 14
height: 14
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: navArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// --- LEFT utility group (x=24/56/88/120, y=22; :268-300) ---------
GhostBtn {
x: 24
y: 22
icon: root.npFavorite ? "heart-filled" : "heart"
active: root.npFavorite
// Gated npHasTrack && !ephemeral (FavToggle.qml:47-48 precedent;
// an ephemeral play must leave nothing behind).
btnEnabled: QbzPlayer.npHasTrack && !root.npEphemeral
onClicked: QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId)
}
GhostBtn {
x: 56
y: 22
icon: "shuffle"
active: QbzPlayer.npShuffle
onClicked: QbzPlayer.toggleShuffle()
}
GhostBtn {
x: 88
y: 22
icon: "repeat"
active: QbzPlayer.npRepeatMode !== 0
onClicked: QbzPlayer.cycleRepeat()
}
GhostBtn {
x: 120
y: 22
icon: "infinity"
active: root.queueDoc.infinitePlay === true
onClicked: QbzQueue.queueToggleInfinitePlay()
}
// --- CENTER playback group (:303-336): positioned from the play
// disc so play is exactly centered ------------------------------
Text {
x: bar.width / 2 - 108
y: 31
width: 42
text: root.fmt(QbzPlayer.npElapsedSecs)
color: "#bfffffff"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
}
NavBtn {
x: bar.width / 2 - 64
y: 22
icon: "skip-back"
onClicked: QbzPlayer.previous()
}
// PlayDisc (:83-108): 48px white disc, hover #ffffffe6, glyph 20px
// black (play nudged +1px right), shadow 12/#0000004d/4.
Item {
x: bar.width / 2 - 24
y: 12
width: 48
height: 48
Rectangle {
anchors.fill: parent
y: 4
radius: 24
color: "#4d000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 24
blur: 0.5 // 12/24
}
}
Rectangle {
anchors.fill: parent
radius: 24
color: playArea.containsMouse ? "#e6ffffff" : "#ffffff"
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
x: Math.round((parent.width - width) / 2) + (QbzPlayer.npPlaying ? 0 : 1)
y: Math.round((parent.height - height) / 2)
tintName: "black"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.togglePlay()
}
}
}
NavBtn {
x: bar.width / 2 + 36
y: 22
icon: "skip-forward"
onClicked: QbzPlayer.next()
}
Text {
x: bar.width / 2 + 76
y: 31
width: 46
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs - QbzPlayer.npElapsedSecs))
color: "#bfffffff"
font.pixelSize: 11
}
// --- RIGHT volume/menu group (:339-372) ---------------------------
GhostBtn {
x: bar.width - 156
y: 22
// Under lock the fill pins to 100%, so the icon must NOT show
// the muted/zero glyph (Tauri VolumeSlider: Volume2 when locked).
icon: (!root.volLocked && (QbzPlayer.npMuted || QbzPlayer.npVolume === 0))
? "volume-x" : "volume-2"
btnEnabled: !root.volLocked
onClicked: QbzPlayer.toggleMute()
}
VolumeBar {
x: bar.width - 112
y: 31
width: 64
value: QbzPlayer.npVolume
locked: root.volLocked
onChanged: function (v) {
if (!root.volLocked)
QbzPlayer.setVolume(v)
}
onReleased: function (v) {
if (!root.volLocked)
QbzPlayer.persistVolume(v)
}
}
// The ⋯ trigger + the ONE-entry menu ("Exit Immersive" — exit path
// 1, §5.4). Slint shows the popup ABOVE the bar (:375-390); the
// shared CardMenu/QbzContextMenu surface clamps into the window.
Rectangle {
id: menuBtn
x: bar.width - 42
y: 22
width: 28
height: 28
radius: 14
color: menuArea.containsMouse ? "#26ffffff" : "transparent"
QbzIcon {
name: "ellipsis"
width: 12
height: 12
anchors.centerIn: parent
tintName: "white"
opacity: 0.65
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.wakeRequested()
// Above the trigger, right edges aligned (Slint: x=-172,
// y=-58 for a 200x46 popup off a 28px button).
playerMenu.openAtCursor(menuBtn,
menuBtn.width - playerMenu.menuWidth,
-playerMenu.implicitHeight - 12)
}
}
}
CardMenu {
id: playerMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Exit Immersive", QbzSession.trRev),
"icon": "x", "action": "exit" }
]
onPicked: function (a) {
if (a === "exit")
QbzImmersive.open = false
}
}
// --- TinyBar seek (:450-457): full width-32 at y=71 --------------
TinyBar {
x: 16
y: 71
width: bar.width - 32
value: QbzPlayer.npProgress
onChanged: function (v) {
QbzPlayer.seek(Math.min(v, QbzPlayer.npSeekableMax))
}
}
}
}