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:
13 files changed
+1182
-344
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 || "") : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user