352 lines
12 KiB
QML
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))
|
|
}
|
|
}
|
|
}
|
|
}
|