feat(qbz-qt): Track Info in the now-playing bars, and PlayerBar split up

The info button on both bars did nothing. The modal is ported from Slint, and
the document behind it — title, album, artist, duration, quality, ISRC, label,
copyright and the grouped credits — comes from a new track_info_qt module.
Credit parsing and its role localization were already frontend-agnostic in
qbz-qobuz, so this only fetches and shapes; it does not re-implement them. A
local or Plex id is rejected before the request rather than sent to the Qobuz
catalog API, which is how a local album path ended up as a 404 in the owner's log.

PlayerBar was 708 lines and still carried an inline SongCard, transport cluster
and favourite toggle; those are their own files now, so the bar leaves this
change smaller than it arrived.

The volume controls in both bars are wired to the lock from the previous commit.

cc
This commit is contained in:
vicrodh committed 2026-07-28 16:23:08 -06:00
1 parent c8a5b03aee
commit c63889b487
13 files changed
+1182 -344

No files matched your search

+7
View File
@@ -97,6 +97,13 @@ fn main() {
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
"qml/shell/FavToggle.qml",
"qml/shell/InfoCreditCell.qml",
"qml/shell/InfoMetaCell.qml",
"qml/shell/SongCard.qml",
"qml/shell/TrackInfoBody.qml",
"qml/shell/TrackInfoModal.qml",
"qml/shell/TransportControls.qml",
"qml/controls/QualityBadgeFull.qml",
"qml/shell/AudioStamp.qml",
"qml/shell/SongCardStamp.qml",
+43
View File
@@ -0,0 +1,43 @@
// FavToggle — the inline favorite heart the Classic bar mounts next to "+"
// (Tauri's `.control-btn` heart with fill=currentColor when favorited),
// extracted verbatim from PlayerBar.qml (phase 25 size split).
//
// NOT a QbzIconButton: that control only tints accent/primary/secondary/muted
// and the favorited heart must take the theme's `favorite` red (Slint
// TransportControls uses Theme.favorite the same way).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
/// Favorite state of the now-playing track (QueueState.now-playing-favorite
/// in Slint) — the host bar already parses the queue document.
property bool favorite: false
QbzTheme { id: theme }
width: 32
height: 32
radius: theme.radiusSm
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: (favArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.favorite ? "heart-filled" : "heart"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.favorite ? "favorite"
: (favArea.containsMouse ? "primary" : "secondary")
}
MouseArea {
id: favArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack && QbzPlayer.npTrackId !== "")
QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId)
}
}
@@ -0,0 +1,67 @@
// InfoCreditCell — one credit cell of the Track Info modal: the role label
// (11px semibold muted, letter-spacing 0.5) over the performer names, ONE
// CLICKABLE NAME PER LINE. 1:1 with `CreditCell` in
// crates/qbz-ui/ui/album/TrackInfoModal.slint, including its reasoning: there
// is no flow layout, so a wrapping row of links would clip — a vertical stack
// keeps click-to-musician AND never overflows the column.
//
// `cell` is one entry of the trackInfo document's `credits` array:
// { "role": "PRODUCER", "roleRaw": "Producer", "names": ["A", "B"] }
import QtQuick
import "../theme"
Column {
id: cc
property var cell: null
/// Fixed cell width handed down by the modal so the two columns stay
/// aligned and the value text wraps inside the column instead of
/// dictating its width.
property int colW: 280
signal nameClicked(string name, string roleRaw)
QbzTheme { id: theme }
width: colW
spacing: 6
Text {
width: cc.colW
text: cc.cell ? (cc.cell.role || "") : ""
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
wrapMode: Text.WordWrap
}
Column {
spacing: 2
Repeater {
model: cc.cell ? (cc.cell.names || []) : []
delegate: Item {
id: nameRow
required property string modelData
width: cc.colW
height: nameText.implicitHeight
Text {
id: nameText
width: cc.colW
text: nameRow.modelData
color: nameArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: 14
wrapMode: Text.WordWrap
}
MouseArea {
id: nameArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: cc.nameClicked(nameRow.modelData,
cc.cell ? (cc.cell.roleRaw || "") : "")
}
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// InfoMetaCell — one metadata cell of the Track Info modal: an 11px semibold
// muted label (letter-spacing 0.5) over a value supplied as a child. 1:1 with
// `MetaCell` in crates/qbz-ui/ui/album/TrackInfoModal.slint, which exports it
// for the immersive split panel to reuse the same way.
//
// The value goes in as a plain child — QML appends use-site children to the
// Column's default `data`, so they land under the label:
//
// InfoMetaCell {
// cellWidth: 180
// label: QbzSession.tr("Duration", QbzSession.trRev)
// Text { text: "3:45" }
// }
import QtQuick
import "../theme"
Column {
id: mc
property string label: ""
property int cellWidth: 0
QbzTheme { id: theme }
width: cellWidth
spacing: 6
Text {
width: mc.cellWidth
text: mc.label
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
elide: Text.ElideRight
}
}
+44 -68
View File
@@ -13,16 +13,19 @@
// Linux).
//
// BUTTON SET (phase 24): identical to PlayerBar's, in the Small bar's own
// order — the transport is the shared cluster (info · shuffle · prev · play ·
// next · repeat · "+"), and the right cluster keeps PlayerBarSmall's mount
// order (Connect · Cast · Settings · ViewMode · Lyrics · Volume · Queue).
// order — the transport is LITERALLY the shared cluster since phase 25
// (shell/TransportControls.qml: info · shuffle · prev · play · next · repeat ·
// "+"), and the right cluster keeps PlayerBarSmall's mount order (Connect ·
// Cast · Settings · ViewMode · Lyrics · Volume · Queue).
// The "+" opens the same "Add to…" flyout as the full bar; the quality stamp
// is AudioStamp.qml — the inline 2-row stamp (quality line over the backend /
// mode LEDs) that PlayerBarSmall.slint mounts, shared with the Large bar.
//
// Connect / Cast / Settings / ViewMode are still inert visual replicas where
// no Qt invokable exists (TODO comments at the call sites); Volume, Queue,
// Shuffle, Repeat, Mute, Lyrics and the add flyout are live.
// Shuffle, Repeat, Mute, Lyrics and the add flyout are live. Track Info (the
// (i) button and the song-card title) opens shell/TrackInfoModal.qml — it
// needs the QbzAlbum.openTrackInfo glue, see that file's header.
import QtQuick
import com.blitzfc.qbz
@@ -68,6 +71,23 @@ Rectangle {
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Track Info (album/TrackInfoModal.slint) -------------------------
// The (i) button and the song-card title open the MODAL (scrim + centered
// card) — PlayerBarSmall.slint fires media-action("track", id,
// "track-info") and AppShell mounts TrackInfoModal. Slint gates it on
// !NowPlayingState.is-ephemeral (no metadata page for local / Plex /
// ephemeral rows); the Qt port publishes no source flag, so a numeric
// track id stands in for it. TODO(glue): publish np_source.
readonly property bool qobuzTrack: QbzPlayer.npHasTrack
&& /^[0-9]+$/.test(QbzPlayer.npTrackId)
function openTrackInfo() {
if (!root.qobuzTrack)
return
trackInfo.openFor(QbzPlayer.npTrackId)
}
TrackInfoModal { id: trackInfo }
// Square compact icon button (BarControls.IconButton): disabled = dim
// 0.3 + non-clickable; active = accent tint.
@@ -218,6 +238,15 @@ Rectangle {
font.pixelSize: 13
font.weight: theme.weightMedium
elide: Text.ElideRight
// Title -> Track Info (SongCard.slint fires
// track-info from the title). Child of the
// Text so it takes no room in the Column.
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.qobuzTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.openTrackInfo()
}
}
Text {
visible: QbzPlayer.npHasTrack
@@ -270,71 +299,16 @@ Rectangle {
width: (root.width - 6) * root.colCentre
clip: true
Row {
// The SHARED cluster (shell/TransportControls.qml) — the
// Small bar's own copy was a byte-for-byte twin of the full
// bar's at playCircle:false / classicActions:false.
TransportControls {
anchors.centerIn: parent
spacing: 2
QbzIconButton {
name: "info"
btnEnabled: QbzPlayer.npHasTrack
// TODO(qt-bridge): no track-info panel in the Qt port
// yet (Slint: media-action(track, id, "track-info")).
}
QbzIconButton {
name: "shuffle"
active: QbzPlayer.npShuffle
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.toggleShuffle()
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.previous()
}
// Play/pause — the Classic plain glyph (play-circle:
// false in Small): 34px, 20px glyph, text-primary,
// accent on hover.
Rectangle {
width: 34
height: 34
radius: theme.radiusSm
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: (playArea.containsMouse && QbzPlayer.npHasTrack)
? theme.surfaceHover : "transparent"
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
anchors.centerIn: parent
tintName: playArea.containsMouse ? "accent" : "primary"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack) QbzPlayer.togglePlay()
}
}
QbzIconButton {
name: "skip-forward"
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.next()
}
QbzIconButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
active: QbzPlayer.npRepeatMode !== 0
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.cycleRepeat()
}
// "+" — Tauri's add-to-playlist button, grouped into the
// shared "Add to…" flyout in 2.0.
QbzIconButton {
id: smallAddBtn
name: "plus"
btnEnabled: QbzPlayer.npHasTrack
onClicked: smallAddMenu.openBelowRight(smallAddBtn)
}
height: 34
playCircle: false
favorite: root.npFavorite
onAddRequested: function (anchorItem) { smallAddMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
}
@@ -419,12 +393,14 @@ Rectangle {
// Volume — mute icon + (WIDE) inline horizontal slider.
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
btnEnabled: !QbzPlayer.npVolumeLocked
active: QbzPlayer.npMuted
onClicked: QbzPlayer.toggleMute()
}
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
// same primitive at 72px on the 0..1000 scale).
QbzSlider {
enabled: !QbzPlayer.npVolumeLocked
visible: root.volumeWide
width: 72
anchors.verticalCenter: parent.verticalCenter
+43 -276
View File
@@ -40,10 +40,17 @@
// Wired: transport (shuffle/prev/play/next/repeat), the add flyout
// (library / queue / play next / play later / album), seek, volume + mute +
// steppers, lyrics, queue, the Now-Playing-view flyout (npbSetMode),
// normalization, open album/artist via the SongCard meta links.
// normalization, open album/artist via the SongCard meta links, and Track
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
// (needs the QbzAlbum.openTrackInfo glue; see that file's header).
// Inert (TODO comments at the call sites): Cast, Connect (device flyouts),
// track-info, add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw /
// remote) is still not enforced.
// add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw / remote) is
// still not enforced.
//
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
// shell/FavToggle.qml in phase 25 — TransportControls is now shared with the
// Small bar instead of being duplicated there.
import QtQuick
import QtQuick.Controls
@@ -120,280 +127,30 @@ Rectangle {
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Track Info (album/TrackInfoModal.slint) ---------------------------
// The (i) button and the song-card title open the MODAL (scrim + centered
// card), which is what the .slint does: the bars fire
// media-action("track", id, "track-info") and AppShell mounts
// TrackInfoModal gated on TrackInfoState.open. Slint gates the trigger on
// NowPlayingState.source == "qobuz" (no DB row / metadata page for local /
// Plex / ephemeral); the Qt port publishes no np_source, so a numeric
// track id stands in for it. TODO(glue): publish np_source.
function openTrackInfo() {
if (!QbzPlayer.npHasTrack)
return
var id = QbzPlayer.npTrackId
if (!/^[0-9]+$/.test(id))
return
trackInfo.openFor(id)
}
TrackInfoModal { id: trackInfo }
// --- Shared bits -------------------------------------------------------
// SongCard (SongCard.slint): cover + title + context meta + the in-card
// audio stamp. `glass` renders the Classic contained card.
component SongCard: Rectangle {
property bool showArt: true
property bool showBadges: true
property bool glass: false
property int artSize: glass ? 60 : 74
width: 200
height: glass ? 64 : 74
radius: glass ? 8 : 0
color: ambientOn ? "transparent" : (glass ? theme.surfaceElevated : "transparent")
Row {
anchors.left: parent.left
// SongCard.slint's inner-row width formula: the card minus the
// stamp + a 4px gap ONLY when the stamp is rendered (Large hides
// it — the AudioStamp moves to the right column — so the text
// column must reclaim those 132px).
anchors.right: stamp.visible ? stamp.left : parent.right
anchors.rightMargin: stamp.visible ? 4 : 0
height: parent.height
spacing: 0
Item {
visible: showArt
width: showArt ? artSize : 0
height: parent.height
Rectangle {
width: artSize
height: artSize
anchors.centerIn: parent
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: 6
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: artSize * 0.5
height: artSize * 0.5
anchors.centerIn: parent
tintName: "muted"
}
// Fetch overlay (resolving/downloading).
Rectangle {
visible: QbzPlayer.npHasTrack && QbzPlayer.npLoading
anchors.fill: parent
radius: 6
color: "#aa000000"
QbzSpinner {
width: artSize * 0.5
height: artSize * 0.5
anchors.centerIn: parent
size: artSize * 0.5
}
}
}
}
Item { width: showArt ? 11 : 0; height: 1 }
Column {
width: parent.width - (showArt ? artSize + 11 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: QbzPlayer.npHasTrack ? QbzPlayer.npTitle : QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Row {
visible: QbzPlayer.npHasTrack
spacing: 7
height: 18
// Context-stack icon ("Show track playing context" pref).
Rectangle {
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && QbzPlayer.npArtistId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npArtistId !== "") QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && QbzPlayer.npAlbumId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
}
SongCardStamp {
id: stamp
visible: showBadges && QbzPlayer.npHasTrack
anchors.right: parent.right
// Classic (glass) insets the stamp by pad + stamp-right-margin
// (2px each) so it clears the visible card border.
anchors.rightMargin: glass ? 4 : 0
anchors.verticalCenter: parent.verticalCenter
}
}
// Favorite toggle — the inline heart Classic mounts next to "+" (Tauri's
// `.control-btn` heart with fill=currentColor when favorited). NOT a
// QbzIconButton: that control only tints accent/primary/secondary/muted
// and the favorited heart must take the theme's `favorite` red (Slint
// TransportControls uses Theme.favorite the same way).
component FavToggle: Rectangle {
width: 32
height: 32
radius: theme.radiusSm
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: (favArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.npFavorite ? "heart-filled" : "heart"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.npFavorite ? "favorite"
: (favArea.containsMouse ? "primary" : "secondary")
}
MouseArea {
id: favArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack && QbzPlayer.npTrackId !== "")
QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId)
}
}
// Transport cluster (TransportControls.slint): track-info · shuffle ·
// prev · play · next · repeat · "+" (Add to… flyout), plus the inline
// favorite heart in Classic. 2px spacing on 32px buttons = the same 34px
// pitch as Tauri's 30px `.control-btn` + 4px gap.
// play-circle = the filled accent disc (New/Large); Classic/Small get the
// plain 34px glyph, 20px in BOTH modes exactly like Tauri's size={20}.
component TransportControls: Row {
id: tc
property bool playCircle: true
// Classic ADDS the inline favorite toggle (Tauri parity).
property bool classicActions: false
/// Emitted by "+" with the button as anchor, so the flyout is owned
/// by the bar (one menu, both mount points).
signal addRequested(var anchorItem)
spacing: 2
height: 44
// Track info — the first control, left of shuffle (1:1 with the
// other views; it also restores PLAY's symmetric centring).
QbzIconButton {
name: "info"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
// TODO(qt-bridge): no track-info panel in the Qt port yet
// (Slint: media-action("track", id, "track-info")). Inert.
}
QbzIconButton {
name: "shuffle"
active: QbzPlayer.npShuffle
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleShuffle()
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.previous()
}
Rectangle {
width: tc.playCircle ? 38 : 34
height: tc.playCircle ? 38 : 34
radius: tc.playCircle ? width / 2 : theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: tc.playCircle
? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent)
: ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent")
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
anchors.centerIn: parent
tintName: tc.playCircle ? "black"
: (playArea.containsMouse ? "accent" : "primary")
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack) QbzPlayer.togglePlay()
}
}
QbzIconButton {
name: "skip-forward"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.next()
}
QbzIconButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
active: QbzPlayer.npRepeatMode > 0
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.cycleRepeat()
}
// "+" — Tauri's add-to-playlist button, grouped into the shared
// "Add to…" flyout in 2.0.
QbzIconButton {
id: addBtn
name: "plus"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: tc.addRequested(addBtn)
}
FavToggle {
visible: tc.classicActions
anchors.verticalCenter: parent.verticalCenter
}
}
// SongCard / TransportControls / FavToggle were inline components here
// until phase 25; they now live in their own files (shell/SongCard.qml,
// shell/TransportControls.qml, shell/FavToggle.qml) and TransportControls
// is shared with the Small bar.
// ============================ the bar =================================
Column {
@@ -492,6 +249,7 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
showArt: !root.largeActive
showBadges: !root.largeActive
onTrackInfoRequested: root.openTrackInfo()
}
// Classic: transport cluster hugging the left edge (plain
// play glyph + inline favorite, the Tauri arrangement).
@@ -501,7 +259,9 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
playCircle: false
classicActions: true
favorite: root.npFavorite
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
}
@@ -518,7 +278,9 @@ Rectangle {
visible: !root.isClassic
anchors.centerIn: parent
playCircle: true
favorite: root.npFavorite
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
// Classic: the contained glass song card (<=560px cap).
SongCard {
@@ -527,6 +289,7 @@ Rectangle {
width: Math.min(parent.width, 560)
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
onTrackInfoRequested: root.openTrackInfo()
}
}
@@ -612,11 +375,13 @@ Rectangle {
// preference like the Slint bar).
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
btnEnabled: !QbzPlayer.npVolumeLocked
active: QbzPlayer.npMuted
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleMute()
}
QbzSlider {
enabled: !QbzPlayer.npVolumeLocked
width: 81
anchors.verticalCenter: parent.verticalCenter
minimum: 0
@@ -631,6 +396,7 @@ Rectangle {
name: "minus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !QbzPlayer.npVolumeLocked
onClicked: QbzPlayer.setVolume(Math.max(0.0, QbzPlayer.npVolume - 0.05))
}
QbzIconButton {
@@ -638,6 +404,7 @@ Rectangle {
name: "plus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !QbzPlayer.npVolumeLocked
onClicked: QbzPlayer.setVolume(Math.min(1.0, QbzPlayer.npVolume + 0.05))
}
+194
View File
@@ -0,0 +1,194 @@
// SongCard — the now-playing card of the full bar (cover + title + context
// meta + the in-card audio stamp), extracted VERBATIM from PlayerBar.qml
// (phase 25 size split). 1:1 with crates/qbz-ui/ui/shell/SongCard.slint.
//
// `glass` renders the Classic contained card (60px art, 64px tall, radius 8,
// surface-elevated fill); New uses the flush 74px variant. Large mounts it
// with showArt/showBadges false — the cover lives in the sidebar dock and the
// AudioStamp moves to the right cluster, so the text column reclaims the
// stamp's 132px.
//
// The title click opens Track Info (SongCard.slint: `root.track-info()` from
// the title TouchArea, Qobuz-only) — the host bar owns the modal, this just
// emits `trackInfoRequested()`.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool showArt: true
property bool showBadges: true
property bool glass: false
property int artSize: glass ? 60 : 74
/// Title clicked — the host opens the Track Info modal.
signal trackInfoRequested()
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Track Info is a Qobuz-only surface (Slint gates it on
// NowPlayingState.source == "qobuz"). The Qt port publishes no np_source,
// so a numeric track id is the proxy — local / Plex / ephemeral rows do
// not carry one. TODO(glue): publish np_source (see the report).
readonly property bool qobuzTrack: QbzPlayer.npHasTrack
&& /^[0-9]+$/.test(QbzPlayer.npTrackId)
QbzTheme { id: theme }
width: 200
height: glass ? 64 : 74
radius: glass ? 8 : 0
color: ambientOn ? "transparent" : (glass ? theme.surfaceElevated : "transparent")
Row {
anchors.left: parent.left
// SongCard.slint's inner-row width formula: the card minus the
// stamp + a 4px gap ONLY when the stamp is rendered (Large hides
// it — the AudioStamp moves to the right column — so the text
// column must reclaim those 132px).
anchors.right: stamp.visible ? stamp.left : parent.right
anchors.rightMargin: stamp.visible ? 4 : 0
height: parent.height
spacing: 0
Item {
visible: root.showArt
width: root.showArt ? root.artSize : 0
height: parent.height
Rectangle {
width: root.artSize
height: root.artSize
anchors.centerIn: parent
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: 6
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: root.artSize * 0.5
height: root.artSize * 0.5
anchors.centerIn: parent
tintName: "muted"
}
// Fetch overlay (resolving/downloading).
Rectangle {
visible: QbzPlayer.npHasTrack && QbzPlayer.npLoading
anchors.fill: parent
radius: 6
color: "#aa000000"
QbzSpinner {
width: root.artSize * 0.5
height: root.artSize * 0.5
anchors.centerIn: parent
size: root.artSize * 0.5
}
}
}
}
Item { width: root.showArt ? 11 : 0; height: 1 }
Column {
width: parent.width - (root.showArt ? root.artSize + 11 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: QbzPlayer.npHasTrack ? QbzPlayer.npTitle
: QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
// Title -> Track Info (SongCard.slint). The MouseArea is a
// CHILD of the Text so it can't take layout space in the
// Column.
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.qobuzTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.qobuzTrack) root.trackInfoRequested()
}
}
Row {
visible: QbzPlayer.npHasTrack
spacing: 7
height: 18
// Context-stack icon ("Show track playing context" pref).
Rectangle {
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && QbzPlayer.npArtistId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npArtistId !== "") QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && QbzPlayer.npAlbumId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
}
SongCardStamp {
id: stamp
visible: root.showBadges && QbzPlayer.npHasTrack
anchors.right: parent.right
// Classic (glass) insets the stamp by pad + stamp-right-margin
// (2px each) so it clears the visible card border.
anchors.rightMargin: root.glass ? 4 : 0
anchors.verticalCenter: parent.verticalCenter
}
}
+335
View File
@@ -0,0 +1,335 @@
// TrackInfoBody — the scrolling content of the Track Info modal (the
// `card-body` VerticalLayout of crates/qbz-ui/ui/album/TrackInfoModal.slint),
// split out of TrackInfoModal.qml for the size rule. `host` is the modal: it
// owns the parsed document, the close() and the nav actions.
//
// Sections, verbatim from the .slint:
// loading -> 60px padding all round, centered muted body text
// error -> 60px padding, 8px gap, headline + the error string (legal)
// loaded -> header (24 L/R, 16 T/B, 16 gap, 32x32 close X with an 18px
// glyph) then the content block (24 L/R, 24 bottom):
// metadata rows 24 apart in three equal columns · credits
// behind 20 / 1px surface-elevated rule / 20, TWO independent
// columns 24 apart with 20 between cells · copyright behind the
// same 20 / rule / 20, 12px muted, wrapped.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Column {
id: body
/// The TrackInfoModal that owns the data + actions.
required property var host
QbzTheme { id: theme }
readonly property var doc: host ? host.doc : ({})
// ---- Loading ---------------------------------------------------------
Item {
visible: body.host && body.host.loading && body.host.errorText === ""
width: parent.width
height: visible ? 120 + loadingText.implicitHeight : 0
Text {
id: loadingText
anchors.centerIn: parent
width: Math.max(0, parent.width - 120)
text: QbzSession.tr("Loading track info...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
}
// ---- Error -----------------------------------------------------------
Item {
visible: body.host && body.host.errorText !== ""
width: parent.width
height: visible ? 120 + errCol.implicitHeight : 0
Column {
id: errCol
anchors.centerIn: parent
width: Math.max(0, parent.width - 120)
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Failed to load track info", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: body.host ? body.host.errorText : ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
// ---- Loaded ----------------------------------------------------------
Column {
id: loadedCol
visible: body.host && !body.host.loading && body.host.errorText === ""
width: parent.width
// Fixed credits-column width: card minus the 24px L/R content padding
// (48) minus the 24px inter-column gutter, halved. Pinning each credit
// cell to this keeps the right column aligned on every row.
readonly property int contentW: Math.max(0, width - 48)
readonly property int creditsColW: Math.max(0, (contentW - 24) / 2)
readonly property int metaColW: Math.max(0, (contentW - 48) / 3)
// --- Header: title / album / artist + close X ---------------------
Item {
width: parent.width
height: Math.max(headCol.implicitHeight, 32) + 32
Column {
id: headCol
x: 24
y: 16
// 24 L + 24 R padding, 16 gap, 32 close X.
width: Math.max(0, parent.width - 24 - 24 - 16 - 32)
spacing: 4
Text {
width: parent.width
text: body.doc.title || ""
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
visible: (body.doc.album || "") !== ""
width: parent.width
text: body.doc.album || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
// Artist — a link only when an id exists.
Item {
visible: (body.doc.artist || "") !== ""
width: parent.width
height: visible ? artistText.implicitHeight : 0
Text {
id: artistText
text: body.doc.artist || ""
color: (body.doc.artistId || "") !== ""
? theme.accent : theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
}
MouseArea {
width: Math.min(artistText.implicitWidth, parent.width)
height: artistText.implicitHeight
cursorShape: (body.doc.artistId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: body.host.openArtist(body.doc.artistId || "")
}
}
}
// Close X — the Slint is a bare 32x32 touch area with an 18px
// glyph, muted -> primary, with NO hover fill; QbzIconButton
// always paints one and idles at `secondary`, so this one stays
// hand-rolled to keep the port 1:1.
Item {
width: 32
height: 32
x: parent.width - 24 - 32
y: 16
QbzIcon {
name: "x"
width: 18
height: 18
anchors.centerIn: parent
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: body.host.close()
}
}
}
// --- Content block (24 L/R, 24 bottom) ----------------------------
Item {
width: parent.width
height: contentCol.implicitHeight + 24
Column {
id: contentCol
x: 24
width: loadedCol.contentW
// Metadata: fixed 3-column rows.
Row {
width: parent.width
spacing: 24
InfoMetaCell {
cellWidth: loadedCol.metaColW
label: QbzSession.tr("Duration", QbzSession.trRev)
Text {
width: loadedCol.metaColW
text: body.doc.duration || ""
color: theme.textPrimary
font.pixelSize: 14
elide: Text.ElideRight
}
}
InfoMetaCell {
cellWidth: loadedCol.metaColW
label: QbzSession.tr("Quality", QbzSession.trRev)
Text {
width: loadedCol.metaColW
text: body.doc.quality || ""
color: theme.textPrimary
font.pixelSize: 14
elide: Text.ElideRight
}
}
// ISRC — the cell is dropped (not blanked) when absent;
// the empty third keeps the columns fixed.
InfoMetaCell {
visible: (body.doc.isrc || "") !== ""
cellWidth: loadedCol.metaColW
label: "ISRC"
Text {
width: loadedCol.metaColW
text: body.doc.isrc || ""
color: theme.textMuted
font.pixelSize: 14
elide: Text.ElideRight
}
}
Item {
visible: (body.doc.isrc || "") === ""
width: loadedCol.metaColW
height: 1
}
}
Item {
visible: (body.doc.label || "") !== ""
width: 1
height: 24
}
Row {
visible: (body.doc.label || "") !== ""
width: parent.width
spacing: 24
InfoMetaCell {
cellWidth: loadedCol.metaColW
label: QbzSession.tr("Label", QbzSession.trRev)
Item {
width: loadedCol.metaColW
height: labelText.implicitHeight
Text {
id: labelText
width: loadedCol.metaColW
text: body.doc.label || ""
color: (labelArea.containsMouse
&& (body.doc.labelId || "") !== "")
? theme.accent : theme.textPrimary
font.pixelSize: 14
elide: Text.ElideRight
}
MouseArea {
id: labelArea
width: Math.min(labelText.implicitWidth, loadedCol.metaColW)
height: labelText.implicitHeight
hoverEnabled: true
cursorShape: (body.doc.labelId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: body.host.openLabel(body.doc.labelId || "")
}
}
}
}
// --- Credits: two INDEPENDENT columns ---------------------
Item {
visible: body.host && body.host.credits.length > 0
width: 1
height: 20
}
Rectangle {
visible: body.host && body.host.credits.length > 0
width: parent.width
height: 1
color: theme.surfaceElevated
}
Item {
visible: body.host && body.host.credits.length > 0
width: 1
height: 20
}
Row {
visible: body.host && body.host.credits.length > 0
width: parent.width
spacing: 24
Column {
width: loadedCol.creditsColW
spacing: 20
Repeater {
model: body.host ? body.host.creditsLeft : []
delegate: InfoCreditCell {
required property var modelData
colW: loadedCol.creditsColW
cell: modelData
onNameClicked: function (n, r) { body.host.openMusician(n, r) }
}
}
}
Column {
width: loadedCol.creditsColW
spacing: 20
Repeater {
model: body.host ? body.host.creditsRight : []
delegate: InfoCreditCell {
required property var modelData
colW: loadedCol.creditsColW
cell: modelData
onNameClicked: function (n, r) { body.host.openMusician(n, r) }
}
}
}
}
// --- Copyright --------------------------------------------
Item {
visible: (body.doc.copyright || "") !== ""
width: 1
height: 20
}
Rectangle {
visible: (body.doc.copyright || "") !== ""
width: parent.width
height: 1
color: theme.surfaceElevated
}
Item {
visible: (body.doc.copyright || "") !== ""
width: 1
height: 20
}
Text {
visible: (body.doc.copyright || "") !== ""
width: parent.width
text: body.doc.copyright || ""
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
}
}
+166
View File
@@ -0,0 +1,166 @@
// Track Info modal — 1:1 QML port of crates/qbz-ui/ui/album/TrackInfoModal.slint.
//
// HOW IT OPENS (taken from the .slint, not chosen): a MODAL — a full-window
// #000000bf scrim with a centered card, NOT a flyout and NOT a docked panel.
// Slint mounts it in AppShell as `Rectangle scrim + centered card` gated on
// TrackInfoState.open; Escape and a scrim click close it (ADR-009: modal
// z >= 3000). Here it is a Popup parented to `Overlay.overlay` so the bars can
// own it without AppShell.qml having to mount anything (the NowPlayingBar
// Loader guarantees only ONE bar — hence only one instance — is alive).
//
// Geometry, verbatim from the .slint:
// card width = min(window - 40, 728)
// card height = min(window * 0.8, content preferred height)
// credits col = (card width - 48 /*content padding*/ - 24 /*gutter*/) / 2
// metadata col = (card width - 48 - 2 * 24) / 3 (three equal columns)
// scrollbar = 14px, inset 4px from the card's right edge
// The Slint card carries drop-shadow-blur 32px; QML shadows need an effect
// (Qt5Compat / MultiEffect) and effects render NOTHING on the software path in
// this port, so the shadow is dropped rather than faked.
//
// DATA — see the report's GLUE NEEDED; the Qt port has no track-info bridge
// yet. Contract: QbzAlbum.openTrackInfo(trackId) fetches and publishes
// QbzAlbum.trackInfoJson while QbzAlbum.trackInfoLoading is true. Document
// shape (1:1 with crates/qbz/src/info_modals.rs `TrackInfoData`):
// { "error": "", "title": "", "album": "", "artist": "", "artistId": "",
// "duration": "3:45", "quality": "24-bit / 96kHz", "isrc": "",
// "label": "", "labelId": "", "copyright": "",
// "credits": [ { "role": "PRODUCER", "roleRaw": "Producer",
// "names": ["A", "B"] } ] }
// Every read is guarded, so while the glue is missing the modal degrades to
// its (already 1:1) loading state instead of throwing.
//
// Inert, matching the existing port precedent (cards/LabelCard.qml): the label
// and musician links render and hover exactly like the Slint but have no Qt
// invokable to call. The artist link IS live (QbzArtist.openArtist).
//
// Split (project size rule): the scrolling content is TrackInfoBody.qml and
// the two cell primitives are InfoMetaCell.qml / InfoCreditCell.qml — the
// .slint exports those two as components as well.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Popup {
id: root
parent: Overlay.overlay
x: 0
y: 0
width: parent ? parent.width : 0
height: parent ? parent.height : 0
padding: 0
z: 3000
modal: true
// Our own scrim (the Slint's #000000bf) — the default modal dimmer would
// darken it twice.
dim: false
closePolicy: Popup.CloseOnEscape
QbzTheme { id: theme }
// --- Data ------------------------------------------------------------
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzAlbum.trackInfoJson || "{}")
} catch (e) {
return ({})
}
}
readonly property bool loading: {
try {
return QbzAlbum.trackInfoLoading === true
} catch (e) {
return false
}
}
readonly property string errorText: doc.error || ""
readonly property var credits: doc.credits || []
// Even index -> left column, odd -> right (info_modals.rs step_by(2)).
readonly property var creditsLeft: credits.filter(function (c, i) { return i % 2 === 0 })
readonly property var creditsRight: credits.filter(function (c, i) { return i % 2 === 1 })
// --- Actions ---------------------------------------------------------
/// Open the modal for a track id — the Slint's media-action("track", id,
/// "track-info"). The caller does the Qobuz-only gating.
function openFor(trackId) {
if (!trackId || trackId === "")
return
try {
QbzAlbum.openTrackInfo(trackId)
} catch (e) {
// TODO(glue): no openTrackInfo on the Qt bridge yet — the modal
// still opens and shows its loading state.
console.warn("[qbz-qt] track info: no bridge invokable yet")
}
open()
}
function openArtist(artistId) {
if (!artistId || artistId === "")
return
close()
QbzArtist.openArtist(artistId)
}
// TODO(qt-bridge): no label view in the Qt port (cards/LabelCard.qml has
// the same hole) — the link is rendered 1:1 and inert.
function openLabel(labelId) {}
// TODO(qt-bridge): no musician navigation in the Qt port (Slint routes to
// NetworkSidebarActions.musician-clicked) — names hover 1:1, inert.
function openMusician(name, roleRaw) {}
background: Rectangle { color: "#bf000000" }
contentItem: Item {
// Scrim — click outside dismisses.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: card
width: Math.min(root.width - 40, 728)
height: Math.min(root.height * 0.8, body.implicitHeight)
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they don't reach the scrim.
MouseArea { anchors.fill: parent }
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: body.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
TrackInfoBody {
id: body
host: root
width: flick.width
}
}
// The Slint mounts a 14px ListScrollbar inset 4px from the right.
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
@@ -0,0 +1,111 @@
// TransportControls — the shared transport cluster of BOTH now-playing bars
// (TransportControls.slint): track-info · shuffle · prev · play · next ·
// repeat · "+" (Add to… flyout), plus the inline favorite heart in Classic.
// Extracted verbatim from PlayerBar.qml (phase 25 size split) and mounted by
// NowPlayingBarSmall too — its cluster was a byte-for-byte twin at
// playCircle:false / classicActions:false.
//
// 2px spacing on 32px buttons = the same 34px pitch as Tauri's 30px
// `.control-btn` + 4px gap. play-circle = the filled accent disc (New/Large);
// Classic/Small get the plain 34px glyph, 20px in BOTH modes exactly like
// Tauri's size={20}.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Row {
id: tc
property bool playCircle: true
/// Classic ADDS the inline favorite toggle (Tauri parity).
property bool classicActions: false
/// Now-playing favorite state, for the Classic heart.
property bool favorite: false
/// Emitted by "+" with the button as anchor, so the flyout is owned
/// by the bar (one menu, both mount points).
signal addRequested(var anchorItem)
/// Emitted by the (i) button — the bar owns the Track Info modal.
signal trackInfoRequested()
QbzTheme { id: theme }
spacing: 2
height: 44
// Track info — the first control, left of shuffle (1:1 with the
// other views; it also restores PLAY's symmetric centring).
QbzIconButton {
name: "info"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: tc.trackInfoRequested()
}
QbzIconButton {
name: "shuffle"
active: QbzPlayer.npShuffle
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleShuffle()
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.previous()
}
Rectangle {
width: tc.playCircle ? 38 : 34
height: tc.playCircle ? 38 : 34
radius: tc.playCircle ? width / 2 : theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: tc.playCircle
? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent)
: ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent")
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
anchors.centerIn: parent
tintName: tc.playCircle ? "black"
: (playArea.containsMouse ? "accent" : "primary")
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack) QbzPlayer.togglePlay()
}
}
QbzIconButton {
name: "skip-forward"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.next()
}
QbzIconButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
active: QbzPlayer.npRepeatMode > 0
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.cycleRepeat()
}
// "+" — Tauri's add-to-playlist button, grouped into the shared
// "Add to…" flyout in 2.0.
QbzIconButton {
id: addBtn
name: "plus"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: tc.addRequested(addBtn)
}
FavToggle {
visible: tc.classicActions
favorite: tc.favorite
anchors.verticalCenter: parent.verticalCenter
}
}
+15
View File
@@ -39,6 +39,9 @@ pub mod qbz_album_bridge {
// ONE JSON document (album_qt.rs AlbumViewData: header + track rows
// + works/goodies/related buckets); QML JSON.parse()s it once.
#[qproperty(QString, album_json)]
// --- Track Info modal (qml/shell/TrackInfoModal.qml) --------------
#[qproperty(bool, track_info_loading)]
#[qproperty(QString, track_info_json)]
type QbzAlbum = super::QbzAlbumRust;
@@ -50,6 +53,10 @@ pub mod qbz_album_bridge {
/// Open the album detail view (pushes "album" on the nav stack).
#[qinvokable]
fn open_album(self: Pin<&mut QbzAlbum>, album_id: QString);
/// Info button on either now-playing bar: fetch + publish the Track
/// Info document (track_info_qt.rs).
#[qinvokable]
fn open_track_info(self: Pin<&mut QbzAlbum>, track_id: QString);
}
impl cxx_qt::Threading for QbzAlbum {}
@@ -61,6 +68,8 @@ use qbz_album_bridge::QbzAlbum;
pub struct QbzAlbumRust {
album_loading: bool,
album_json: QString,
track_info_loading: bool,
track_info_json: QString,
}
impl Default for QbzAlbumRust {
@@ -68,6 +77,8 @@ impl Default for QbzAlbumRust {
Self {
album_loading: false,
album_json: QString::from("{}"),
track_info_loading: false,
track_info_json: QString::from("{}"),
}
}
}
@@ -96,4 +107,8 @@ impl qbz_album_bridge::QbzAlbum {
pub fn open_album(self: Pin<&mut Self>, album_id: QString) {
crate::open_album(album_id.to_string());
}
pub fn open_track_info(self: Pin<&mut Self>, track_id: QString) {
crate::track_info_qt::open(track_id.to_string());
}
}
+1
View File
@@ -53,6 +53,7 @@ mod output_labels;
mod quality_state;
mod offline_fwd;
mod album_qt;
mod track_info_qt;
mod ambient_qt;
mod artist_qt;
mod lyrics_qt;
+119
View File
@@ -0,0 +1,119 @@
//! Track Info document for the now-playing bars' info button.
//!
//! Port of `qbz/src/info_modals.rs::map_track_info`. The credit parsing and
//! its role localization are frontend-agnostic already
//! (`qbz_qobuz::performers`, ADR-006), so this module only fetches the track
//! and shapes the document `qml/shell/TrackInfoModal.qml` parses.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::Track;
use qbz_qobuz::performers::{format_role_label, group_credits_ordered, parse_performers};
use serde::Serialize;
use crate::album_bridge;
use cxx_qt_lib::QString;
#[derive(Serialize)]
struct CreditRow {
/// Display form, upper-cased like the Slint modal ("PRODUCER").
role: String,
/// The untranslated role, kept so the UI can group without re-parsing.
#[serde(rename = "roleRaw")]
role_raw: String,
names: Vec<String>,
}
#[derive(Serialize, Default)]
struct TrackInfoDoc {
title: String,
album: String,
artist: String,
#[serde(rename = "artistId")]
artist_id: String,
duration: String,
quality: String,
isrc: String,
label: String,
#[serde(rename = "labelId")]
label_id: String,
copyright: String,
credits: Vec<CreditRow>,
}
fn duration_text(secs: u32) -> String {
let (m, s) = (secs / 60, secs % 60);
format!("{m}:{s:02}")
}
fn map(track: Track) -> TrackInfoDoc {
let (artist, artist_id) = match track.performer.as_ref() {
Some(a) if a.id != 0 => (a.name.clone(), a.id.to_string()),
Some(a) => (a.name.clone(), String::new()),
None => (String::new(), String::new()),
};
let (label, label_id) = match track.album.as_ref().and_then(|a| a.label.as_ref()) {
Some(l) => (l.name.clone(), l.id.to_string()),
None => (String::new(), String::new()),
};
let credits = group_credits_ordered(&parse_performers(
track.performers.as_deref().unwrap_or_default(),
))
.into_iter()
.map(|(role, names)| CreditRow {
role: format_role_label(&role).to_uppercase(),
role_raw: role,
names,
})
.collect();
TrackInfoDoc {
title: track.title.clone(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default(),
artist,
artist_id,
duration: duration_text(track.duration),
// The same tier/detail string the quality stamp shows.
quality: crate::quality_state::detail(track.maximum_bit_depth, track.maximum_sampling_rate),
isrc: track.isrc.clone().unwrap_or_default(),
label,
label_id,
copyright: track.copyright.clone().unwrap_or_default(),
credits,
}
}
fn publish(doc: &TrackInfoDoc, loading: bool) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
album_bridge::ui(move |mut b| {
b.as_mut().set_track_info_json(QString::from(json.as_str()));
b.as_mut().set_track_info_loading(loading);
});
}
/// Info button: fetch the track and publish its document. A LOCAL or Plex id
/// is not a Qobuz catalog id, so it is rejected here rather than sent to the
/// API — the bars hide the button for those rows, this is the backstop.
pub fn open(track_id: String) {
let Ok(id) = track_id.parse::<u64>() else {
log::debug!("[qbz-qt] track info skipped for non-catalog id '{track_id}'");
return;
};
album_bridge::ui(|mut b| b.as_mut().set_track_info_loading(true));
let runtime: Arc<AppRuntime<LoggingAdapter>> = crate::app();
crate::spawn(async move {
match runtime.core().get_track(id).await {
Ok(track) => publish(&map(track), false),
Err(e) => {
log::warn!("[qbz-qt] track info load failed for {id}: {e}");
publish(&TrackInfoDoc::default(), false);
}
}
});
}