1853 lines
89 KiB
QML
1853 lines
89 KiB
QML
// Album detail page — QML port of album/AlbumPageView.slint.
|
|
//
|
|
// Header (224px cover, title, credited-artist line, meta with label link,
|
|
// description + Read more, CircleAction row), divider, toolbar (quality
|
|
// badge + track search), column header, track list (Disc/work headers,
|
|
// TrackRow replica with the playing-row pill + number↔play cell + live
|
|
// heart), label/awards sidebar, and the two bottom carousels ("From the
|
|
// same artist", "Listening suggestions").
|
|
//
|
|
// POC-NOTEs: the offline download column (TrackRow status glyphs + ⋯ menu
|
|
// rows) and multi-select + bulk bar are LIVE. Still out: DiscHeaderMenu
|
|
// per-disc actions.
|
|
//
|
|
// Header atmosphere (AlbumPageView.slint:161-189, 221-257): the
|
|
// artwork-tinted band IS wired now, through the shared
|
|
// controls/HeaderGradient.qml (route B — see that file for why the blurred
|
|
// route is not available on this path). It brings the .slint's header-colour
|
|
// rules with it: with the band on, the header sits on a DARK backdrop, so
|
|
// the text goes light regardless of theme (`hdrStrong` / `hdrBody`,
|
|
// .slint:169-172) and the CircleActions switch to their overlay palette
|
|
// (`hdrOverlay` = the .slint's inverted `hdr-on-surface`, :179).
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Window
|
|
import Qt.labs.qmlmodels
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../rows"
|
|
import "../shell"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
// Transparent while the ambient background is active (phase 14 —
|
|
// HomeView.slint:163: the frosted content panel shows through).
|
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: theme.ambientOn
|
|
|
|
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
|
// are rectangular, so the frame's own rounding never reaches the
|
|
// view — the view's own fill must round instead.
|
|
radius: 12
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
/// Header cover edge. One constant because the real header, its skeleton
|
|
/// twin and both text columns all measure off it — they drifted apart at
|
|
/// five separate literals before. The Row has no explicit height, so the
|
|
/// header grows with this on its own.
|
|
readonly property int coverPx: 260
|
|
|
|
/// How far past the header divider the artwork atmosphere reaches: the 8px
|
|
/// spacer below the divider plus half of the 52px track toolbar band, so
|
|
/// the gradient dies across the middle of the quality chip and the search
|
|
/// box instead of stopping at the divider.
|
|
readonly property int atmoReach: 1 + 8 + 26
|
|
|
|
// The view's album + url-keyed cover map (artwork pipeline).
|
|
//
|
|
// Applied imperatively so a SAME-ALBUM deferred rail publish can preserve
|
|
// the ListView offset. Binding `model` to a freshly parsed JS array makes
|
|
// QQuickItemView reset contentY whenever Similar albums / Suggestions / More
|
|
// from this artist lands — the forced jump the owner caught while reading.
|
|
property var album: ({})
|
|
property string documentAlbumId: ""
|
|
property int albumDocumentEpoch: 0
|
|
property real albumRestoreY: 0
|
|
|
|
function parseAlbumDocument() {
|
|
try {
|
|
return JSON.parse(QbzAlbum.albumJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
|
|
function applyAlbumDocument() {
|
|
var next = root.parseAlbumDocument()
|
|
var nextHeader = next.header || ({})
|
|
var nextId = nextHeader.id !== undefined && nextHeader.id !== null
|
|
? String(nextHeader.id) : ""
|
|
var sameAlbum = nextId !== "" && nextId === root.documentAlbumId
|
|
var savedY = sameAlbum ? pageFlick.contentY : 0
|
|
root.albumDocumentEpoch += 1
|
|
var epoch = root.albumDocumentEpoch
|
|
root.album = next
|
|
if (nextId !== "")
|
|
root.documentAlbumId = nextId
|
|
if (!sameAlbum || savedY <= pageFlick.originY)
|
|
return
|
|
root.albumRestoreY = savedY
|
|
// The model reset and its content-height polish happen after the
|
|
// document assignment. One deferred restore coalesces a publish burst
|
|
// and clamps if the replacement document is genuinely shorter.
|
|
Qt.callLater(function () {
|
|
if (epoch !== root.albumDocumentEpoch
|
|
|| nextId !== root.documentAlbumId)
|
|
return
|
|
pageFlick.forceLayout()
|
|
var minY = pageFlick.originY
|
|
var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height)
|
|
pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY))
|
|
})
|
|
}
|
|
|
|
readonly property var albumHeader: album.header || ({})
|
|
readonly property var tracks: album.tracks || []
|
|
// `header.awards`, NOT `album.awards`. It is a field of AlbumHeader
|
|
// (src/album_qt.rs:171), not of AlbumViewData — read one level too high it
|
|
// is `undefined`, `|| []` swallows that, and the AWARDS block simply never
|
|
// renders. Which is what it did, for every album, since the block was
|
|
// written: an empty Repeater draws nothing and reports nothing, and
|
|
// `hasSidebar` counted the same empty list, so an album whose ONLY sidebar
|
|
// content was its awards showed no sidebar at all.
|
|
readonly property var awards: albumHeader.awards || []
|
|
property var coverMap: ({})
|
|
// Client-side track search (AlbumActions.search equivalent).
|
|
property string trackQuery: ""
|
|
|
|
// ---- Header atmosphere (AlbumPageView.slint:161-189) -----------------
|
|
// The pref, LIVE where possible: the settings snapshot is only published
|
|
// on settings-view open / mutation, so on a cold start it is empty and
|
|
// the document's own copy (album_qt.rs `headerGradient`) answers instead.
|
|
readonly property bool headerGradientPref: {
|
|
var raw = QbzBridge.settingsJson
|
|
if (raw && raw.length > 2) {
|
|
try {
|
|
var d = JSON.parse(raw)
|
|
if (d.albumHeaderGradient !== undefined)
|
|
return d.albumHeaderGradient === true
|
|
} catch (e) { /* fall through to the document copy */ }
|
|
}
|
|
return album.headerGradient !== false
|
|
}
|
|
// .slint:168 — the album's own atmosphere is SUPPRESSED under the
|
|
// app-wide dynamic background (they clash); the dynamic background then
|
|
// provides the dark backdrop instead.
|
|
readonly property bool headerAtmoOn: headerGradientPref && !ambientOn
|
|
// .slint:167 — dark backdrop from EITHER source means light header text.
|
|
readonly property bool headerLight: headerGradientPref || ambientOn
|
|
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
|
|
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
|
|
// (the .slint declares an `hdr-muted` tier too, :173, but never binds it
|
|
// to anything — not ported rather than ported dead)
|
|
// .slint:179 — with no dark backdrop the circles use the on-surface arm.
|
|
readonly property bool hdrOverlay: headerLight
|
|
|
|
readonly property var visibleTracks: {
|
|
if (trackQuery === "") return tracks
|
|
var q = trackQuery.toLowerCase()
|
|
return tracks.filter(function (t) {
|
|
return t.title.toLowerCase().indexOf(q) >= 0
|
|
})
|
|
}
|
|
|
|
// ---- Loading staging (album_qt.rs publishes in passes) ---------------
|
|
// The PRIMARY document (header + tracks) lands as soon as /album/get
|
|
// answers; each bottom rail arrives later carrying its own flag, so the
|
|
// page is usable while Qobuz suggestions and the Last.fm row resolve.
|
|
//
|
|
// A rail is shown when it HAS cards, its placeholder when its flag is
|
|
// still up. Both false = the section is ABSENT: `moreLoading` is seeded
|
|
// false when the album has no artist id and `similarLoading` false when
|
|
// Last.fm is not connected, so nothing ever spins forever on a row that
|
|
// will never arrive.
|
|
readonly property bool primaryLoading: QbzAlbum.albumLoading && tracks.length === 0
|
|
readonly property bool moreLoading: album.moreLoading === true
|
|
&& (album.moreFromArtist || []).length === 0
|
|
readonly property bool suggestionsLoading: album.suggestionsLoading === true
|
|
&& (album.suggestions || []).length === 0
|
|
readonly property bool similarLoading: album.similarLoading === true
|
|
&& (album.similarAlbums || []).length === 0
|
|
|
|
// ONE 900ms phase for every placeholder on the page (QbzSkeleton's COST
|
|
// note: N placeholders, 1 timer). Stops dead when nothing is pending.
|
|
Timer {
|
|
id: skeletonPhase
|
|
property bool on: false
|
|
interval: 900
|
|
repeat: true
|
|
running: root.visible && (root.primaryLoading || root.moreLoading
|
|
|| root.suggestionsLoading || root.similarLoading)
|
|
onTriggered: on = !on
|
|
}
|
|
|
|
// Placeholder cards that fill one rail (SectionRail's 232px pitch).
|
|
readonly property int railSkeletonCount:
|
|
Math.max(1, Math.min(8, Math.ceil((root.width - 64) / 232)))
|
|
|
|
// The vertical ListView gets ONE uniform 10px layout cell per slice of
|
|
// content. A visual item lives in the first cell of its run and paints
|
|
// over the following inert cells (50px row = 5 cells, 40px header = 4).
|
|
// Uniform delegate height is deliberate: Qt estimates contentHeight for
|
|
// variable delegates/sections, which makes a scrollbar thumb resize and
|
|
// skip as new sizes are discovered. Here count * 10 is exact from frame 1.
|
|
readonly property int listCellPx: 10
|
|
readonly property int trackRowPx: 50
|
|
readonly property int trackHeaderPx: 40
|
|
// SectionRail/RailSkeleton are 286px. Reserve 290 so the tape stays on
|
|
// the 10px grid; the final 4px is harmless air before the next section.
|
|
readonly property int railSlotPx: 290
|
|
|
|
function appendRun(out, head, pixels) {
|
|
out.push(head)
|
|
for (var y = root.listCellPx; y < pixels; y += root.listCellPx)
|
|
out.push({ "kind": "gap" })
|
|
}
|
|
|
|
function buildTrackCells() {
|
|
var out = []
|
|
var list = root.visibleTracks
|
|
var multi = list.length > 0 && (list[list.length - 1].disc || 1) > 1
|
|
for (var i = 0; i < list.length; i++) {
|
|
var t = list[i]
|
|
var prev = i > 0 ? list[i - 1] : null
|
|
var disc = (i === 0)
|
|
? (multi ? (t.disc || 1) : 0)
|
|
: (multi && t.disc !== prev.disc ? (t.disc || 1) : 0)
|
|
var work = (i === 0 || t.workHeader !== prev.workHeader)
|
|
? (t.workHeader || "") : ""
|
|
if (disc > 0)
|
|
root.appendRun(out, { "kind": "disc", "disc": disc }, root.trackHeaderPx)
|
|
if (work !== "") {
|
|
root.appendRun(out, {
|
|
"kind": "work",
|
|
"work": work,
|
|
"composerName": t.workComposerName || "",
|
|
"composerId": t.workComposerId || ""
|
|
}, root.trackHeaderPx)
|
|
}
|
|
root.appendRun(out, {
|
|
"kind": "track",
|
|
"track": t,
|
|
"trackNumber": i + 1
|
|
}, root.trackRowPx)
|
|
}
|
|
return out
|
|
}
|
|
|
|
readonly property var trackCells: buildTrackCells()
|
|
readonly property int trackTapePx: trackCells.length * listCellPx
|
|
|
|
function appendRailCells(out, kind, loading, items) {
|
|
var rows = items || []
|
|
if (!loading && rows.length === 0)
|
|
return
|
|
root.appendRun(out, { "kind": "gap" }, 40)
|
|
root.appendRun(out, {
|
|
"kind": loading ? "railSkeleton" : kind,
|
|
"items": rows
|
|
}, root.railSlotPx)
|
|
}
|
|
|
|
readonly property var listCells: {
|
|
// Copy: the deferred rail passes rebuild this array without mutating
|
|
// the track-only value used by the header/sidebar overlap calculation.
|
|
var out = root.trackCells.slice(0)
|
|
root.appendRailCells(out, "moreRail", root.moreLoading,
|
|
root.album.moreFromArtist || [])
|
|
root.appendRailCells(out, "suggestionsRail", root.suggestionsLoading,
|
|
root.album.suggestions || [])
|
|
root.appendRailCells(out, "similarRail", root.similarLoading,
|
|
root.album.similarAlbums || [])
|
|
return out
|
|
}
|
|
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryArtworkReady(key, path) {
|
|
var m = root.coverMap
|
|
m[key] = path
|
|
root.coverMap = Object.assign({}, m)
|
|
}
|
|
// The SETTLED heart from Rust: the flipped value when the write
|
|
// landed, the UNCHANGED one when it failed. The header click writes
|
|
// its optimistic flip into localToggles and nothing used to correct
|
|
// it, so a 404'd un-favorite stayed visibly un-favorited until the
|
|
// user navigated away — LibraryView.qml was this signal's only
|
|
// listener. Key shape is `library_qt::feed_key` (`{kind}:{id}`).
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
var id = (albumHeader && albumHeader.id) ? albumHeader.id : ""
|
|
if (id !== "" && key === "album:" + id)
|
|
root.setToggleState("album", value)
|
|
}
|
|
}
|
|
// Album-blacklist settle / rollback. `blacklistChanged` carries the state
|
|
// the write actually produced — flipped on success, UNCHANGED on failure
|
|
// (blacklist_qt.rs `album_toggle`), so this is both the cross-surface walk
|
|
// (the manager's row `x`, a card's "Block this album") and the rollback for
|
|
// the header menu's optimistic flip below. Same two-arg `{kind}:{id}` shape
|
|
// as pinChanged / libraryFavoriteChanged above; a separate Connections
|
|
// block only because the signal lives on a different singleton.
|
|
Connections {
|
|
target: QbzBlacklist
|
|
function onBlacklistChanged(key, value) {
|
|
var id = (albumHeader && albumHeader.id) ? albumHeader.id : ""
|
|
if (id !== "" && key === "album:" + id)
|
|
root.setToggleState("blocked", value)
|
|
}
|
|
}
|
|
Component.onCompleted: {
|
|
root.applyAlbumDocument()
|
|
syncAlbumState()
|
|
dispatchCovers()
|
|
}
|
|
Connections {
|
|
target: QbzAlbum
|
|
function onAlbumJsonChanged() { root.applyAlbumDocument() }
|
|
}
|
|
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
|
|
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
|
|
// redispatch when the header itself updates.
|
|
onAlbumHeaderChanged: { syncAlbumState(); dispatchCovers() }
|
|
|
|
// Optimistic heart / pin state. The document is republished once per
|
|
// deferred rail now, and every republish re-parses `album` — a toggle
|
|
// written straight onto the parsed object would silently pop back a
|
|
// second later. Overrides live here and win until the album changes.
|
|
// (Same pattern, same reason, as ArtistView.localToggles.)
|
|
property var localToggles: ({})
|
|
function toggleState(key, fallback) {
|
|
return localToggles[key] !== undefined ? localToggles[key] : fallback === true
|
|
}
|
|
function setToggleState(key, value) {
|
|
var m = localToggles
|
|
m[key] = value
|
|
localToggles = Object.assign({}, m)
|
|
}
|
|
|
|
// Per-album view state is reset ONLY when the id actually changes, or a
|
|
// deferred rail landing would yank the user's toggles back mid-read.
|
|
property string loadedAlbumId: ""
|
|
function syncAlbumState() {
|
|
var id = (albumHeader && albumHeader.id) ? albumHeader.id : ""
|
|
if (id === loadedAlbumId)
|
|
return
|
|
loadedAlbumId = id
|
|
localToggles = ({})
|
|
dispatchedCovers = ({})
|
|
// A new album carries no selection over (Slint resets multi-select
|
|
// on every album load/reset, album.rs:692-694).
|
|
setMultiSelect(false)
|
|
// Re-seed the live offline map from the new document (track rows
|
|
// carry their own seeded cacheStatus; this map tracks the LIVE one).
|
|
var seed = ({})
|
|
var ts = album.tracks || []
|
|
for (var i = 0; i < ts.length; i++)
|
|
seed[ts[i].id] = ts[i].cacheStatus || 0
|
|
cacheStates = seed
|
|
}
|
|
|
|
// --- Multi-select (AlbumPageView.slint:736-807) -------------------------
|
|
// The selection lives here in QML (LibraryView precedent): select-all and
|
|
// clear never reach Rust, everything else goes down as a JSON id array
|
|
// through QbzAlbum.albumBulkAction.
|
|
property bool multiSelect: false
|
|
property var selected: ({})
|
|
readonly property int selectedCount: Object.keys(root.selected).length
|
|
readonly property bool multiSelectOn: root.multiSelect
|
|
function setMultiSelect(on) {
|
|
root.multiSelect = on
|
|
if (!on) { root.selected = ({}); sel.anchorId = "" }
|
|
}
|
|
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
|
|
/// holds the anchor and the Shift-range rule; this view keeps owning its
|
|
/// map. `mods` is the mouse event's modifiers, forwarded by the row; a
|
|
/// caller with no event (the checkbox, a keyboard path) may omit it.
|
|
SelectionModel { id: sel }
|
|
function toggleSelected(id, mods) {
|
|
root.selected = sel.next(root.selected, id, root.visibleTracks,
|
|
mods === undefined ? Qt.NoModifier : mods)
|
|
}
|
|
/// Selected ids in VISIBLE order — never Object.keys (integer-like keys
|
|
/// iterate in NUMERIC order, not the user's).
|
|
function selectedIdsInOrder() {
|
|
var rows = root.visibleTracks
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function bulkAction(action) {
|
|
if (action === "select-all") {
|
|
var m = {}
|
|
var rows = root.visibleTracks
|
|
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
|
|
root.selected = m
|
|
return
|
|
}
|
|
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
|
var ids = root.selectedIdsInOrder()
|
|
if (ids.length === 0) return
|
|
QbzAlbum.albumBulkAction(albumHeader.id, JSON.stringify(ids), action)
|
|
// The Slint keeps the selection while a picker is still open (a
|
|
// failed write is retried from the same modal) and clears after
|
|
// everything else.
|
|
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
|
root.selected = ({})
|
|
}
|
|
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
|
|
function selectAll() {
|
|
if (!root.multiSelect) root.setMultiSelect(true)
|
|
root.bulkAction("select-all")
|
|
}
|
|
function exitMultiSelectMode() {
|
|
if (root.multiSelect) root.setMultiSelect(false)
|
|
}
|
|
|
|
// --- Offline cache live state ------------------------------------------
|
|
// Row glyphs own their live status (TrackRow's Connections). This map
|
|
// exists for ONE consumer: the ⋯ menu's "Make available offline" /
|
|
// "Refresh offline copy" swap, which needs the all-rows-ready aggregate
|
|
// (Slint recomputes album-fully-cached on every row flip,
|
|
// main.rs:2426-2438).
|
|
property var cacheStates: ({})
|
|
readonly property bool fullyCachedLive: {
|
|
var ts = album.tracks || []
|
|
if (ts.length === 0)
|
|
return false
|
|
var m = cacheStates
|
|
for (var i = 0; i < ts.length; i++) {
|
|
var st = m[ts[i].id]
|
|
if (st === undefined) st = ts[i].cacheStatus || 0
|
|
if (st !== 3)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
Connections {
|
|
target: QbzShell
|
|
function onTrackCacheStatusChanged(trackId, status, progress) {
|
|
if (!(trackId in root.cacheStates))
|
|
return
|
|
var m = root.cacheStates
|
|
m[trackId] = status
|
|
root.cacheStates = Object.assign({}, m)
|
|
}
|
|
}
|
|
|
|
// Already-requested artwork keys. The document is now published in FOUR
|
|
// passes (primary, then each rail), and every pass re-fires this — resending
|
|
// the whole list each time is pure waste, so only what is new goes out.
|
|
property var dispatchedCovers: ({})
|
|
function dispatchCovers() {
|
|
var urls = []
|
|
if (albumHeader && albumHeader.artUrl) urls.push(albumHeader.artUrl)
|
|
var more = album.moreFromArtist || []
|
|
for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl)
|
|
var sug = album.suggestions || []
|
|
for (i = 0; i < sug.length; i++) if (sug[i].artUrl) urls.push(sug[i].artUrl)
|
|
// The Last.fm row's covers ride the same dispatch — without this its
|
|
// cards would render as empty frames.
|
|
var sim = album.similarAlbums || []
|
|
for (i = 0; i < sim.length; i++) if (sim[i].artUrl) urls.push(sim[i].artUrl)
|
|
|
|
var seen = dispatchedCovers
|
|
var fresh = []
|
|
for (i = 0; i < urls.length; i++) {
|
|
if (!seen[urls[i]]) {
|
|
seen[urls[i]] = true
|
|
fresh.push(urls[i])
|
|
}
|
|
}
|
|
if (fresh.length > 0) {
|
|
dispatchedCovers = seen
|
|
QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
|
|
}
|
|
}
|
|
|
|
// Ghost CircleAction (secondary, on-surface variant): elevated disc,
|
|
// strong ring, text-primary icon (accent when active).
|
|
|
|
|
|
// Track list row (TrackRow.slint replica: number cell, no artwork,
|
|
// Sidebar label/award card (SidebarCard).
|
|
component SidebarCard: Rectangle {
|
|
id: sidebarCard
|
|
property string name: ""
|
|
property color gradA: "#6366f1"
|
|
property color gradB: "#8b5cf6"
|
|
property string iconName: "disc"
|
|
property bool compact: false
|
|
signal clicked()
|
|
width: parent ? parent.width : 0
|
|
height: 48
|
|
radius: theme.radiusSm
|
|
color: scArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
x: sidebarCard.compact ? Math.round((sidebarCard.width - width) / 2) : 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: sidebarCard.compact ? 28 : parent.width - 12
|
|
height: parent.height - 12
|
|
spacing: 10
|
|
Rectangle {
|
|
width: 28
|
|
height: 28
|
|
radius: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0.0; color: gradA }
|
|
GradientStop { position: 1.0; color: gradB }
|
|
}
|
|
QbzIcon {
|
|
name: iconName
|
|
width: 13
|
|
height: 13
|
|
anchors.centerIn: parent
|
|
// On the card's fixed brand gradient disc (indigo/violet
|
|
// or amber), never on a theme surface.
|
|
tintName: "white"
|
|
}
|
|
}
|
|
Text {
|
|
visible: !sidebarCard.compact
|
|
width: parent.width - 38
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: name
|
|
color: theme.textSecondary
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: scArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: parent.clicked()
|
|
ToolTip.visible: sidebarCard.compact && containsMouse
|
|
&& sidebarCard.name !== ""
|
|
ToolTip.text: sidebarCard.name
|
|
ToolTip.delay: 350
|
|
}
|
|
}
|
|
component SidebarHeading: Text {
|
|
property bool compact: false
|
|
width: parent ? parent.width : implicitWidth
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1
|
|
horizontalAlignment: compact ? Text.AlignHCenter : Text.AlignLeft
|
|
wrapMode: compact ? Text.WordWrap : Text.NoWrap
|
|
}
|
|
|
|
// Placeholder for a bottom rail that has not resolved yet: the SAME 28px
|
|
// header band, 232px pitch and 246px card band SectionRail uses, so the
|
|
// page does not jump when the real cards replace it. Built out of the
|
|
// shared QbzSkeleton — no local skeleton primitive.
|
|
//
|
|
// Everything it needs is a property, not a file-scope id: an inline
|
|
// `component` does not see the enclosing document's ids (the gotcha
|
|
// QbzSkeleton.qml documents), so `phase` is passed in by the host.
|
|
component RailSkeleton: Column {
|
|
id: railSk
|
|
property bool phase: false
|
|
property int cardCount: 4
|
|
width: parent ? parent.width : 0
|
|
spacing: 12
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 28
|
|
QbzSkeleton {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
variant: "block"
|
|
width: 180
|
|
height: 20
|
|
phase: railSk.phase
|
|
}
|
|
}
|
|
Item {
|
|
width: parent.width
|
|
height: 246
|
|
clip: true
|
|
Row {
|
|
spacing: 32
|
|
Repeater {
|
|
model: railSk.cardCount
|
|
// "card" carries its own 200 x (200+42) footprint.
|
|
delegate: QbzSkeleton {
|
|
required property int index
|
|
variant: "card"
|
|
cellIndex: index
|
|
phase: railSk.phase
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One EXTERNAL LINKS brand icon (AlbumPageView.slint BrandLink): the bare
|
|
// brand SVG in its NATIVE colors — no tint pass, no visible label, the
|
|
// name lives in the hover tooltip (Feishin-style inline links).
|
|
component BrandLink: Rectangle {
|
|
property string iconSource: ""
|
|
property string name: ""
|
|
property string url: ""
|
|
width: 30
|
|
height: 30
|
|
radius: 6
|
|
color: brandArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Image {
|
|
anchors.centerIn: parent
|
|
source: iconSource
|
|
width: 18
|
|
height: 18
|
|
sourceSize: Qt.size(36, 36)
|
|
fillMode: Image.PreserveAspectFit
|
|
opacity: brandArea.containsMouse ? 1.0 : 0.85
|
|
Behavior on opacity { NumberAnimation { duration: 120 } }
|
|
}
|
|
MouseArea {
|
|
id: brandArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
// Deep link only — the browser does the work, nothing is fetched
|
|
// here and no integration has to be connected.
|
|
onClicked: if (url !== "") Qt.openUrlExternally(url)
|
|
// The Slint BrandLink carries the name in the shared tooltip
|
|
// bubble; the Qt port rides Qt's own ToolTip (LocalMultiSelectBar
|
|
// precedent).
|
|
ToolTip.visible: containsMouse && name !== ""
|
|
ToolTip.text: name
|
|
ToolTip.delay: 350
|
|
}
|
|
}
|
|
|
|
// Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a
|
|
// relative URL resolves against the CONSUMER's document depth.
|
|
readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/"
|
|
|
|
// Whether the right-hand album sidebar has anything to show at all.
|
|
readonly property bool hasSidebar: (albumHeader.label || "") !== ""
|
|
|| awards.length > 0
|
|
|| albumHeader.showExternalLinks === true
|
|
// Same contract as ShellState.content-constrained in the Slint view: the
|
|
// album sidebar gives the track table priority only when a right panel is
|
|
// consuming a sub-1366px window. In that state it becomes the 56px icon
|
|
// rail from AlbumPageView.slint; names remain available as tooltips.
|
|
readonly property bool contentConstrained:
|
|
Window.width > 0 && Window.width < 1366
|
|
&& (QbzShell.queueOpen || QbzShell.lyricsOpen)
|
|
readonly property bool sidebarCompact: contentConstrained
|
|
readonly property int sidebarPx: sidebarCompact ? 56 : 200
|
|
readonly property int sidebarReservePx: hasSidebar ? sidebarPx + 32 : 0
|
|
|
|
// ============================ the page ================================
|
|
ListView {
|
|
id: pageFlick
|
|
anchors.fill: parent
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.listCells
|
|
reuseItems: true
|
|
currentIndex: -1
|
|
// The track tape is cheap enough that a fast wheel can consume one
|
|
// viewport before the first heavy card rail has finished incubating.
|
|
// Two viewports give those bottom rails roughly one kinetic-scroll
|
|
// window of lead time. Cache delegates are asynchronous and pooled;
|
|
// this does not restore the old eager footer mount.
|
|
cacheBuffer: Math.max(900, 2 * height)
|
|
|
|
header: Item {
|
|
width: pageFlick.width
|
|
height: page.implicitHeight
|
|
|
|
// Artwork-tinted header band. FIRST child so it paints under the
|
|
// page, and inside the ListView header so it scrolls with content.
|
|
// Full-bleed on purpose: the page's 32px padding must NOT clip it.
|
|
HeaderGradient {
|
|
x: 0
|
|
y: 0
|
|
width: pageFlick.width
|
|
// .slint:189 `atmo-height: page.y + header-divider.y` — the band
|
|
// ends EXACTLY on the header/track-list divider, whatever height
|
|
// a long editorial description gave the header.
|
|
height: page.y + headerDivider.y + root.atmoReach
|
|
tint: album.headerColor || ""
|
|
atmosphere: album.headerAtmosphere || ""
|
|
active: root.headerAtmoOn
|
|
}
|
|
|
|
Column {
|
|
id: page
|
|
width: parent.width
|
|
leftPadding: 32
|
|
rightPadding: 32
|
|
topPadding: 11
|
|
spacing: 0
|
|
|
|
// NavButtons is a 0px placeholder in the Slint source.
|
|
Item { width: 1; height: 22 }
|
|
|
|
// --- Album header skeleton ----------------------------------
|
|
// Mounted on the primary flag, and the real header is hidden by
|
|
// the same flag: opening album B never renders a half-empty
|
|
// header frame while B's document is in flight.
|
|
Row {
|
|
visible: root.primaryLoading
|
|
width: parent.width - 64
|
|
spacing: 32
|
|
|
|
QbzSkeleton {
|
|
variant: "block"
|
|
width: root.coverPx
|
|
height: root.coverPx
|
|
blockRadius: 12
|
|
phase: skeletonPhase.on
|
|
}
|
|
Column {
|
|
width: parent.width - root.coverPx - 32
|
|
spacing: 12
|
|
Item { width: 1; height: 6 }
|
|
QbzSkeleton { variant: "block"; width: Math.min(420, parent.width); height: 30; cellIndex: 0; phase: skeletonPhase.on }
|
|
QbzSkeleton { variant: "block"; width: Math.min(260, parent.width); height: 18; cellIndex: 1; phase: skeletonPhase.on }
|
|
QbzSkeleton { variant: "block"; width: Math.min(340, parent.width); height: 14; cellIndex: 2; phase: skeletonPhase.on }
|
|
Item { width: 1; height: 14 }
|
|
Row {
|
|
spacing: 12
|
|
Repeater {
|
|
model: 4
|
|
delegate: QbzSkeleton {
|
|
required property int index
|
|
variant: "circle"
|
|
width: 44
|
|
height: 44
|
|
cellIndex: index
|
|
phase: skeletonPhase.on
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Album header -------------------------------------------
|
|
Row {
|
|
visible: !root.primaryLoading
|
|
width: parent.width - 64
|
|
spacing: 32
|
|
|
|
Rectangle {
|
|
width: root.coverPx
|
|
height: root.coverPx
|
|
radius: 12
|
|
color: theme.surfaceElevated
|
|
// No clip: RoundedImage confines itself on both arms; a clip is an
|
|
// unconditional batch root. coverMenu and CoverLightbox are
|
|
// view-root siblings, never descendants of this frame.
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
// A custom cover override (shared custom_artwork
|
|
// store) beats the url-keyed pipeline image.
|
|
source: (albumHeader.customCoverPath || "") !== ""
|
|
? "file://" + albumHeader.customCoverPath
|
|
: (root.coverMap[albumHeader.artUrl] || "")
|
|
radius: 12
|
|
}
|
|
// Left-click: the lightbox (NEW — Slint's left-click is
|
|
// inert). Right-click: the cover menu (Slint
|
|
// AlbumPageView.slint:300-370).
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
coverMenu.openAtCursor(coverMenuAnchor, mouse.x, mouse.y)
|
|
} else {
|
|
coverLightbox.openWith(root.bestCoverSource())
|
|
}
|
|
}
|
|
}
|
|
Item { id: coverMenuAnchor; anchors.fill: parent }
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - root.coverPx - 32
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 4
|
|
spacing: 0
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: albumHeader.title || ""
|
|
color: root.hdrStrong
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
}
|
|
Item { width: 1; height: 4 }
|
|
// Credited-artist line (links + role suffixes).
|
|
Flow {
|
|
width: parent.width
|
|
spacing: 0
|
|
Repeater {
|
|
model: albumHeader.credits || []
|
|
delegate: Row {
|
|
required property var modelData
|
|
required property int index
|
|
spacing: 0
|
|
Text {
|
|
visible: index > 0
|
|
text: " • "
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightBold
|
|
}
|
|
Text {
|
|
text: modelData[0]
|
|
color: creditArea.containsMouse && modelData[1] !== "" ? root.hdrStrong : root.hdrBody
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightBold
|
|
MouseArea {
|
|
id: creditArea
|
|
anchors.fill: parent
|
|
enabled: modelData[1] !== ""
|
|
hoverEnabled: true
|
|
cursorShape: modelData[1] !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: QbzArtist.openArtist(modelData[1])
|
|
}
|
|
}
|
|
Text {
|
|
visible: modelData[2] !== ""
|
|
text: " (" + modelData[2] + ")"
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontHeading
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Item { width: 1; height: 10 }
|
|
// Meta line (label as a clickable link when navigable).
|
|
Row {
|
|
spacing: 0
|
|
visible: (albumHeader.labelId || "") !== "" && (albumHeader.label || "") !== ""
|
|
Text {
|
|
visible: (albumHeader.metaPre || "") !== ""
|
|
text: (albumHeader.metaPre || "") + " • "
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontBody
|
|
}
|
|
Text {
|
|
text: albumHeader.label || ""
|
|
color: labelArea.containsMouse ? theme.accent : root.hdrBody
|
|
font.pixelSize: theme.fontBody
|
|
MouseArea {
|
|
id: labelArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzHome.openLabel(albumHeader.labelId)
|
|
}
|
|
}
|
|
Text {
|
|
visible: (albumHeader.metaPost || "") !== ""
|
|
text: " • " + (albumHeader.metaPost || "")
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontBody
|
|
}
|
|
}
|
|
Text {
|
|
visible: (albumHeader.labelId || "") === "" || (albumHeader.label || "") === ""
|
|
width: parent.width
|
|
text: albumHeader.infoLine || ""
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontBody
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
// Editorial description + Read more.
|
|
Item { visible: (albumHeader.description || "") !== ""; width: 1; height: 12 }
|
|
Text {
|
|
visible: (albumHeader.description || "") !== ""
|
|
width: parent.width
|
|
text: albumHeader.descriptionShort || ""
|
|
color: root.hdrBody
|
|
font.pixelSize: theme.fontLegal
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Item { visible: (albumHeader.description || "") !== (albumHeader.descriptionShort || ""); width: 1; height: 4 }
|
|
Text {
|
|
visible: (albumHeader.description || "") !== (albumHeader.descriptionShort || "")
|
|
text: QbzSession.tr("Read more", QbzSession.trRev)
|
|
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
|
|
font.pixelSize: theme.fontLegal
|
|
MouseArea {
|
|
id: readMoreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
var shell = root.parent
|
|
while (shell && shell.openTextModal === undefined) shell = shell.parent
|
|
if (shell) shell.openTextModal(QbzSession.tr("About this album", QbzSession.trRev), albumHeader.description || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 20 }
|
|
// Action row — AlbumPageView.slint:504-640. One shared
|
|
// CircleAction for every button including Play (the
|
|
// hand-rolled 44px disc it used to be drifted from the
|
|
// control on ring, hover and glyph tint); the palette arm
|
|
// follows the header backdrop, exactly like the .slint's
|
|
// `on-surface: root.hdr-on-surface`.
|
|
Row {
|
|
spacing: 12
|
|
QbzCircleAction {
|
|
primary: true
|
|
overlay: root.hdrOverlay
|
|
name: "play-fill"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPlayer.playAlbum(albumHeader.id)
|
|
}
|
|
QbzCircleAction {
|
|
name: "shuffle"
|
|
overlay: root.hdrOverlay
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPlayer.playAlbumShuffled(albumHeader.id)
|
|
}
|
|
QbzCircleAction {
|
|
readonly property bool favorite: root.toggleState("album", albumHeader.isFavorite)
|
|
name: favorite ? "heart-filled" : "heart"
|
|
overlay: root.hdrOverlay
|
|
active: favorite
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: {
|
|
root.setToggleState("album", !favorite)
|
|
QbzLibrary.libraryToggleFavorite("album", albumHeader.id)
|
|
}
|
|
}
|
|
// Radio: the Qobuz /radio/album endpoint, via the
|
|
// For-You tile seam (foryou_qt::start_album_radio —
|
|
// minimal track objects are enriched before play).
|
|
QbzCircleAction {
|
|
name: "radio"
|
|
overlay: root.hdrOverlay
|
|
// A radio is a fetch + an enrich + a queue write,
|
|
// and until the first track resolves nothing on
|
|
// screen moves. The key is per-album so this disc
|
|
// spins and the Discover rail's stations do not.
|
|
loading: QbzHome.radioPending === "album:" + albumHeader.id
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.startAlbumRadio(albumHeader.id)
|
|
}
|
|
// Booklet: only when the album carries a PDF goody
|
|
// (AlbumPageView.slint:575-585); downloads via save-as.
|
|
QbzCircleAction {
|
|
visible: albumHeader.hasBooklet === true
|
|
name: "book-open"
|
|
overlay: root.hdrOverlay
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzAlbum.downloadBooklet()
|
|
}
|
|
// Mixtape/Collection picker (an album is accepted by
|
|
// BOTH kinds — myqbz_add_qt's Accepts engine).
|
|
QbzCircleAction {
|
|
name: "cassette-tape"
|
|
overlay: root.hdrOverlay
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzAlbum.addToMixtape(albumHeader.id)
|
|
}
|
|
// Album info: the Credits/Review modal
|
|
// (AlbumInfoModal.qml, data via album_info_qt.rs).
|
|
QbzCircleAction {
|
|
name: "info"
|
|
overlay: root.hdrOverlay
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: albumInfo.openFor(albumHeader.id)
|
|
}
|
|
QbzCircleAction {
|
|
id: albumMenuBtn
|
|
name: "ellipsis"
|
|
overlay: root.hdrOverlay
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 20 }
|
|
// Header divider. The gradient band above sizes itself to THIS
|
|
// item's y (.slint:189 atmo-height), so it keeps its id.
|
|
// Kept, deliberately invisible. The gradient band above sizes itself
|
|
// off THIS item's y (.slint:189 atmo-height) and the whole column
|
|
// below is positioned after it, so removing the item would move the
|
|
// page; only the paint is unwanted.
|
|
Rectangle { id: headerDivider; width: parent.width - 64; height: 1; color: "transparent" }
|
|
Item { width: 1; height: 8 }
|
|
|
|
// --- Track list + label/awards sidebar ----------------------
|
|
Row {
|
|
id: trackChromeRow
|
|
width: parent.width - 64
|
|
// Usually only the toolbar/header chrome contributes to the
|
|
// ListView header. The sidebar is allowed to paint beside the
|
|
// recycled rows; for a very short album reserve just enough
|
|
// extra height to keep the first full-width rail below it.
|
|
height: Math.max(trackChrome.implicitHeight,
|
|
albumSidebar.implicitHeight - root.trackTapePx)
|
|
spacing: 32
|
|
|
|
Column {
|
|
id: trackChrome
|
|
width: parent.width - root.sidebarReservePx
|
|
spacing: 0
|
|
|
|
// Track-list placeholder — same flag the spinner used,
|
|
// now in the shape of the list it is standing in for
|
|
// (toolbar band + column-header band + 8 rows at the
|
|
// TrackRow 50px pitch), so nothing shifts on arrival.
|
|
Column {
|
|
visible: root.primaryLoading
|
|
width: parent.width
|
|
spacing: 0
|
|
|
|
Item { width: 1; height: 52 }
|
|
Item { width: 1; height: 40 }
|
|
Repeater {
|
|
model: 8
|
|
delegate: Item {
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
height: 50
|
|
QbzSkeleton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 12
|
|
variant: "block"
|
|
width: 20
|
|
height: 12
|
|
cellIndex: index
|
|
phase: skeletonPhase.on
|
|
}
|
|
QbzSkeleton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 60
|
|
variant: "block"
|
|
width: Math.max(90, parent.width * 0.36)
|
|
height: 14
|
|
cellIndex: index
|
|
phase: skeletonPhase.on
|
|
}
|
|
QbzSkeleton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 128
|
|
variant: "block"
|
|
width: 52
|
|
height: 12
|
|
cellIndex: index
|
|
phase: skeletonPhase.on
|
|
}
|
|
QbzSkeleton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 48
|
|
variant: "block"
|
|
width: 52
|
|
height: 12
|
|
cellIndex: index
|
|
phase: skeletonPhase.on
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multi-select bulk bar (controls/QbzMultiSelectBar.qml),
|
|
// INLINE at the top of the track column — the Local
|
|
// Library album view's layout (bar in flow, content below)
|
|
// instead of a floating overlay.
|
|
QbzMultiSelectBar {
|
|
id: bulkBar
|
|
visible: root.multiSelect && !root.primaryLoading
|
|
width: parent.width
|
|
selectedCount: root.selectedCount
|
|
// The reference's full AlbumView inventory
|
|
// (AlbumPageView.slint:792-807), make-offline included.
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
|
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
|
]
|
|
onAction: function (id) { root.bulkAction(id) }
|
|
}
|
|
|
|
// Toolbar — quality badge + track search (+ inert select).
|
|
Row {
|
|
visible: !QbzAlbum.albumLoading
|
|
width: parent.width
|
|
height: 52
|
|
spacing: 16
|
|
// AlbumPageView.slint:692 mounts QualityBadgeFull —
|
|
// the contained chip (format mark + tier label over
|
|
// the exact bit-depth/rate line), NOT a loose mark
|
|
// plus a plain "16-bit / 44.1 kHz" string. The 1:1
|
|
// control already exists; this drew its own.
|
|
QualityBadgeFull {
|
|
id: qualityRow
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tier: albumHeader.qualityTier || ""
|
|
detail: albumHeader.qualityDetail || ""
|
|
}
|
|
// Clamped, and the badge slot only counts when the
|
|
// badge is actually there (QualityBadgeFull hides
|
|
// itself on an empty tier — an unclamped negative
|
|
// width is a silent layout trap).
|
|
Item {
|
|
width: Math.max(0, parent.width
|
|
- (qualityRow.visible ? qualityRow.width + 16 : 0)
|
|
- 168 - 30 - 2 * 16)
|
|
height: 1
|
|
}
|
|
Rectangle {
|
|
width: 168
|
|
height: 34
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 7
|
|
QbzIcon {
|
|
name: "search"
|
|
width: 14
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "muted"
|
|
}
|
|
TextInput {
|
|
width: parent.width - 21
|
|
height: parent.height
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
clip: true
|
|
onTextEdited: root.trackQuery = text
|
|
Text {
|
|
visible: parent.text === ""
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Multi-select toggle (AlbumPageView.slint:752-787):
|
|
// accent border + tint while active; leaving the mode
|
|
// clears the selection.
|
|
Rectangle {
|
|
width: 30
|
|
height: 30
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: selectToggleArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
|
|
QbzIcon {
|
|
name: "square-check-big"
|
|
width: 15
|
|
height: 15
|
|
anchors.centerIn: parent
|
|
tintName: root.multiSelect ? "accent" : "secondary"
|
|
}
|
|
MouseArea {
|
|
id: selectToggleArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.setMultiSelect(!root.multiSelect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Column header — rows/TrackListHeader.qml, i.e. the SAME
|
|
// rows/TrackCols.qml geometry the TrackRows below use.
|
|
//
|
|
// What was here disagreed with the rows on every number:
|
|
// spacing 16 vs the row's 14, Duration 80 vs 70, Quality
|
|
// 80 vs 92, and a title width that counted five gaps
|
|
// where the layout draws six. Those are Slint's own
|
|
// header numbers (AlbumPageView.slint:811-880) and they
|
|
// do NOT match primitives/TrackRow.slint — the reference
|
|
// has the same defect the owner reported here, so the
|
|
// port keeps the row's numbers and drops the second
|
|
// hardcoded copy entirely.
|
|
//
|
|
// The heart / cloud glyphs stay (they are the only
|
|
// labelling those two columns get) — and they still fill
|
|
// the band so `centerIn` centres them, which is the fix
|
|
// this block used to document at length.
|
|
TrackListHeader {
|
|
visible: !QbzAlbum.albumLoading
|
|
width: parent.width
|
|
bandHeight: 40
|
|
labelSpacing: 0.5
|
|
showDownload: true
|
|
favoriteGlyph: true
|
|
downloadGlyph: true
|
|
}
|
|
|
|
}
|
|
|
|
// Label / awards / external-links sidebar (200px).
|
|
Column {
|
|
id: albumSidebar
|
|
visible: root.hasSidebar
|
|
width: root.sidebarPx
|
|
spacing: 24
|
|
|
|
Column {
|
|
visible: (albumHeader.label || "") !== ""
|
|
width: parent.width
|
|
spacing: 8
|
|
SidebarHeading {
|
|
compact: root.sidebarCompact
|
|
text: QbzSession.tr("LABEL", QbzSession.trRev)
|
|
}
|
|
SidebarCard {
|
|
name: albumHeader.label || ""
|
|
iconName: "disc"
|
|
compact: root.sidebarCompact
|
|
gradA: "#6366f1"
|
|
gradB: "#8b5cf6"
|
|
// The label page HAS existed for a long time — the
|
|
// header chip above (:691) has been opening it all
|
|
// along. This card carried a "no label view yet"
|
|
// note and no handler, so the sidebar's LABEL entry
|
|
// looked clickable (it hovers) and did nothing.
|
|
onClicked: if ((albumHeader.labelId || "") !== "")
|
|
QbzHome.openLabel(albumHeader.labelId)
|
|
}
|
|
}
|
|
Column {
|
|
visible: awards.length > 0
|
|
width: parent.width
|
|
spacing: 8
|
|
SidebarHeading {
|
|
compact: root.sidebarCompact
|
|
text: QbzSession.tr("AWARDS", QbzSession.trRev)
|
|
}
|
|
Repeater {
|
|
model: awards
|
|
delegate: SidebarCard {
|
|
required property var modelData
|
|
name: modelData[1]
|
|
iconName: "award"
|
|
compact: root.sidebarCompact
|
|
gradA: "#b45309"
|
|
gradB: "#eab308"
|
|
// `modelData` is the (id, name) pair
|
|
// album_qt publishes. Qobuz omits the id on
|
|
// some /album/get entries, so with one we open
|
|
// directly and without one we hand the NAME to
|
|
// the resolver — 1:1 with the reference's
|
|
// "open" / "resolve-open" split
|
|
// (AlbumPageView.slint:1049-1055).
|
|
onClicked: {
|
|
if ((modelData[0] || "") !== "")
|
|
QbzHome.openAward(modelData[0], modelData[1] || "")
|
|
else if ((modelData[1] || "") !== "")
|
|
QbzHome.openAwardByName(modelData[1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// EXTERNAL LINKS — Last.fm / Discogs / MusicBrainz deep
|
|
// links for this release. Present whenever the album has
|
|
// an artist and a title; they are ordinary web URLs, so
|
|
// they neither require nor touch a connected integration.
|
|
Column {
|
|
visible: albumHeader.showExternalLinks === true
|
|
width: parent.width
|
|
spacing: 8
|
|
SidebarHeading {
|
|
compact: root.sidebarCompact
|
|
text: QbzSession.tr("EXTERNAL LINKS", QbzSession.trRev)
|
|
}
|
|
Row {
|
|
visible: !root.sidebarCompact
|
|
spacing: 8
|
|
BrandLink {
|
|
visible: (albumHeader.lastfmUrl || "") !== ""
|
|
iconSource: root.brandDir + "brand-lastfm.svg"
|
|
name: "Last.fm"
|
|
url: albumHeader.lastfmUrl || ""
|
|
}
|
|
BrandLink {
|
|
visible: (albumHeader.discogsUrl || "") !== ""
|
|
iconSource: root.brandDir + "brand-discogs.svg"
|
|
name: "Discogs"
|
|
url: albumHeader.discogsUrl || ""
|
|
}
|
|
BrandLink {
|
|
visible: (albumHeader.musicbrainzUrl || "") !== ""
|
|
iconSource: root.brandDir + "brand-musicbrainz.svg"
|
|
name: "MusicBrainz"
|
|
url: albumHeader.musicbrainzUrl || ""
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.sidebarCompact
|
|
width: parent.width
|
|
spacing: 8
|
|
BrandLink {
|
|
visible: (albumHeader.lastfmUrl || "") !== ""
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
iconSource: root.brandDir + "brand-lastfm.svg"
|
|
name: "Last.fm"
|
|
url: albumHeader.lastfmUrl || ""
|
|
}
|
|
BrandLink {
|
|
visible: (albumHeader.discogsUrl || "") !== ""
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
iconSource: root.brandDir + "brand-discogs.svg"
|
|
name: "Discogs"
|
|
url: albumHeader.discogsUrl || ""
|
|
}
|
|
BrandLink {
|
|
visible: (albumHeader.musicbrainzUrl || "") !== ""
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
iconSource: root.brandDir + "brand-musicbrainz.svg"
|
|
name: "MusicBrainz"
|
|
url: albumHeader.musicbrainzUrl || ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// Real viewport recycling: every delegate root has the SAME height.
|
|
// The child paints over the inert cells reserved by buildTrackCells().
|
|
// cacheBuffer keeps the root alive until the overflowing child is well
|
|
// outside the viewport, and creates the next heavy row asynchronously.
|
|
delegate: DelegateChooser {
|
|
role: "kind"
|
|
|
|
DelegateChoice {
|
|
roleValue: "gap"
|
|
delegate: Item {
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "disc"
|
|
delegate: Item {
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
Rectangle {
|
|
x: 32
|
|
width: parent.width - 64 - root.sidebarReservePx
|
|
height: root.trackHeaderPx
|
|
color: "transparent"
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Disc", QbzSession.trRev)
|
|
+ " " + modelData.disc
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "work"
|
|
delegate: Item {
|
|
id: workCell
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
Row {
|
|
x: 32
|
|
width: parent.width - 64 - root.sidebarReservePx
|
|
height: root.trackHeaderPx
|
|
leftPadding: 12
|
|
rightPadding: 12
|
|
topPadding: 14
|
|
bottomPadding: 4
|
|
spacing: 0
|
|
Text {
|
|
text: workCell.modelData.work
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightBold
|
|
}
|
|
Text {
|
|
visible: workCell.modelData.composerName !== ""
|
|
text: " ("
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightBold
|
|
}
|
|
Text {
|
|
visible: workCell.modelData.composerName !== ""
|
|
text: workCell.modelData.composerName
|
|
color: composerArea.containsMouse
|
|
&& workCell.modelData.composerId !== ""
|
|
? theme.textPrimary : theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightBold
|
|
MouseArea {
|
|
id: composerArea
|
|
anchors.fill: parent
|
|
enabled: workCell.modelData.composerId !== ""
|
|
hoverEnabled: true
|
|
cursorShape: enabled
|
|
? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: QbzArtist.openArtist(
|
|
workCell.modelData.composerId)
|
|
}
|
|
}
|
|
Text {
|
|
visible: workCell.modelData.composerName !== ""
|
|
text: ")"
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightBold
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "track"
|
|
delegate: Item {
|
|
id: trackCell
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
|
|
TrackRow {
|
|
id: trackDelegate
|
|
x: 32
|
|
width: parent.width - 64 - root.sidebarReservePx
|
|
item: trackCell.modelData.track
|
|
number: trackCell.modelData.trackNumber
|
|
zebra: true
|
|
clickPlays: false
|
|
artistLink: true
|
|
qualityStyle: "text"
|
|
showDownload: true
|
|
downloadGlyph: true
|
|
selectMode: root.multiSelect
|
|
checked: root.selected[item.id] === true
|
|
onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
|
|
// "Play later" is ON now. It was off because
|
|
// `enqueue_album_track` had no block-tail arm — "later"
|
|
// and "queue" both plain-appended, so the two entries
|
|
// would have done the same thing. That arm exists, so
|
|
// the entry can.
|
|
menuShowGoTo: false
|
|
onPlayRequested: QbzPlayer.playAlbumFrom(albumHeader.id, item.id)
|
|
onEnqueueRequested: function (m) {
|
|
QbzPlayer.enqueueAlbumTrack(albumHeader.id, item.id,
|
|
m === "next" ? "next" : "later")
|
|
}
|
|
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": "qobuz",
|
|
"sourceItemId": item.id, "title": item.title || "",
|
|
"subtitle": item.artist || "",
|
|
// The HEADER's url, not `item.artUrl`: album-view
|
|
// track rows are published with an empty artUrl
|
|
// (album_qt.rs:90 says so out loud), and every
|
|
// track on an album shares the album cover anyway.
|
|
"artworkUrl": (albumHeader && albumHeader.artUrl) || "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
|
|
ListView.onPooled: {
|
|
trackDelegate.recycleActive = false
|
|
trackDelegate.releaseForReuse()
|
|
}
|
|
ListView.onReused: trackDelegate.recycleActive = true
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "railSkeleton"
|
|
delegate: Item {
|
|
id: railSkeletonCell
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
property bool live: true
|
|
Loader {
|
|
x: 32
|
|
width: parent.width - 64
|
|
active: railSkeletonCell.live
|
|
sourceComponent: RailSkeleton {
|
|
phase: skeletonPhase.on
|
|
cardCount: root.railSkeletonCount
|
|
}
|
|
}
|
|
ListView.onPooled: live = false
|
|
ListView.onReused: live = true
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "moreRail"
|
|
delegate: Item {
|
|
id: moreRailCell
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
property bool live: true
|
|
Loader {
|
|
x: 32
|
|
width: parent.width - 64
|
|
active: moreRailCell.live
|
|
sourceComponent: SectionRail {
|
|
title: QbzSession.tr("From the same artist",
|
|
QbzSession.trRev)
|
|
items: moreRailCell.modelData.items
|
|
coverMap: root.coverMap
|
|
showViewAll: true
|
|
onViewAllClicked: QbzArtist.openReleases(
|
|
root.albumHeader.artistId || "",
|
|
root.albumHeader.artist || "", "album")
|
|
}
|
|
}
|
|
ListView.onPooled: live = false
|
|
ListView.onReused: live = true
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "suggestionsRail"
|
|
delegate: Item {
|
|
id: suggestionsRailCell
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
property bool live: true
|
|
Loader {
|
|
x: 32
|
|
width: parent.width - 64
|
|
active: suggestionsRailCell.live
|
|
sourceComponent: SectionRail {
|
|
title: QbzSession.tr("Listening suggestions",
|
|
QbzSession.trRev)
|
|
items: suggestionsRailCell.modelData.items
|
|
coverMap: root.coverMap
|
|
}
|
|
}
|
|
ListView.onPooled: live = false
|
|
ListView.onReused: live = true
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "similarRail"
|
|
delegate: Item {
|
|
id: similarRailCell
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
height: root.listCellPx
|
|
property bool live: true
|
|
Loader {
|
|
x: 32
|
|
width: parent.width - 64
|
|
active: similarRailCell.live
|
|
sourceComponent: SectionRail {
|
|
title: QbzSession.tr("Similar albums",
|
|
QbzSession.trRev)
|
|
items: similarRailCell.modelData.items
|
|
coverMap: root.coverMap
|
|
}
|
|
}
|
|
ListView.onPooled: live = false
|
|
ListView.onReused: live = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// ListView header/footer instances are eager in Qt. The real rails
|
|
// therefore live in the model above; this footer is intentionally a
|
|
// cheap fixed spacer matching the page's old bottomPadding.
|
|
footer: Item {
|
|
width: ListView.view.width
|
|
height: 100
|
|
}
|
|
}
|
|
|
|
// Thin auto-hiding scrollbar (ListScrollbar).
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
|
// this container's offset while it is the live page, and restores it
|
|
// when a back/forward step arms this route.
|
|
ScrollMemory { target: pageFlick; scope: "album" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: pageFlick
|
|
}
|
|
|
|
// Best available cover source for the lightbox: the custom override
|
|
// file when set, else the remote best() URL (mega → … → large).
|
|
function bestCoverSource() {
|
|
var custom = albumHeader.customCoverPath || ""
|
|
if (custom !== "") return "file://" + custom
|
|
return albumHeader.artUrl || ""
|
|
}
|
|
|
|
// The cover menu's rows, rebuilt per open (Add vs Change+Remove flips
|
|
// on hasCustomCover; "View cover" is this port's addition).
|
|
function buildCoverMenuModel() {
|
|
var rows = []
|
|
if (albumHeader.hasCustomCover === true) {
|
|
rows.push({ "label": QbzSession.tr("Change cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
|
rows.push({ "label": QbzSession.tr("Remove cover", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
|
|
} else {
|
|
rows.push({ "label": QbzSession.tr("Add cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
|
}
|
|
rows.push({ "label": QbzSession.tr("View cover", QbzSession.trRev), "icon": "eye", "action": "view" })
|
|
rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" })
|
|
rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" })
|
|
return rows
|
|
}
|
|
|
|
// Cover right-click menu (AlbumPageView.slint:300-370) + "View cover",
|
|
// the lightbox entry that is NEW in this port.
|
|
QbzContextMenu {
|
|
id: coverMenu
|
|
menuWidth: 196
|
|
// Rebuilt on every open so Add/Change/Remove track the live flag.
|
|
onAboutToShow: coverMenuRepeater.model = root.buildCoverMenuModel()
|
|
Repeater {
|
|
id: coverMenuRepeater
|
|
model: []
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 33
|
|
radius: 5
|
|
color: cmiArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
spacing: 8
|
|
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
|
Text {
|
|
height: parent.height
|
|
width: parent.width - 23
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: cmiArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
coverMenu.close()
|
|
var a = modelData.action
|
|
if (a === "add") QbzAlbum.coverAddCustom(albumHeader.id, albumHeader.artUrl)
|
|
else if (a === "remove") QbzAlbum.coverRemoveCustom(albumHeader.id)
|
|
else if (a === "view") coverLightbox.openWith(root.bestCoverSource())
|
|
else if (a === "browser") QbzShell.openExternalUrl(albumHeader.artUrl)
|
|
else if (a === "save") QbzAlbum.coverSaveAs(albumHeader.id, albumHeader.title, albumHeader.artUrl)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CoverLightbox { id: coverLightbox }
|
|
|
|
// Album Info (Credits/Review) modal — mounted by its host view, the
|
|
// TrackInfoModal pattern (one AlbumView instance exists at a time; the
|
|
// Popup reparents to the window overlay).
|
|
AlbumInfoModal { id: albumInfo }
|
|
|
|
// Album ⋯ menu (AlbumContextMenu subset — "Play later" as a distinct
|
|
// block-tail insert has no mode in playback_qt::enqueue_album (next /
|
|
// append only), so "Add to queue" covers the append arm. The offline
|
|
// row swaps on the live all-cached aggregate).
|
|
QbzContextMenu {
|
|
id: albumMenu
|
|
menuWidth: 196
|
|
Repeater {
|
|
model: [
|
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
{ "label": root.toggleState("album", albumHeader.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", albumHeader.isFavorite) ? "heart-filled" : "heart", "action": "favorite" },
|
|
{ "label": root.toggleState("pin", albumHeader.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", albumHeader.isPinned) ? "pin-filled" : "pin", "action": "pin" },
|
|
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
|
|
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
|
|
{ "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" },
|
|
{ "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" },
|
|
// AlbumContextMenu.slint:137-152 — the offline row swaps
|
|
// on the all-cached aggregate (live from row flips).
|
|
{ "label": root.fullyCachedLive ? QbzSession.tr("Refresh offline copy", QbzSession.trRev) : QbzSession.tr("Make available offline", QbzSession.trRev), "icon": root.fullyCachedLive ? "refresh-cw" : "cloud-download", "action": root.fullyCachedLive ? "recache-album" : "cache-album" },
|
|
]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 33
|
|
radius: 5
|
|
color: amiArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
spacing: 8
|
|
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
|
Text {
|
|
height: parent.height
|
|
width: parent.width - 23
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: amiArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
albumMenu.close()
|
|
var a = modelData.action
|
|
if (a === "play") QbzPlayer.playAlbum(albumHeader.id)
|
|
else if (a === "next") QbzPlayer.enqueueAlbum(albumHeader.id, "next")
|
|
else if (a === "queue") QbzPlayer.enqueueAlbum(albumHeader.id, "later")
|
|
else if (a === "favorite") {
|
|
root.setToggleState("album", !root.toggleState("album", albumHeader.isFavorite))
|
|
QbzLibrary.libraryToggleFavorite("album", albumHeader.id)
|
|
} else if (a === "pin") {
|
|
root.setToggleState("pin", !root.toggleState("pin", albumHeader.isPinned))
|
|
QbzLibrary.togglePin("album", albumHeader.id, albumHeader.title, albumHeader.artist, albumHeader.artUrl)
|
|
} else if (a === "playlist") {
|
|
// The loaded view's own track ids (Slint
|
|
// main.rs:12213 collects AlbumState.tracks).
|
|
var ids = []
|
|
var ts = root.album.tracks || []
|
|
for (var i = 0; i < ts.length; i++) {
|
|
if (/^\d+$/.test(ts[i].id || "")) ids.push(ts[i].id)
|
|
}
|
|
if (ids.length > 0) QbzPlaylistPicker.openForTracks(JSON.stringify(ids))
|
|
} else if (a === "mixtape") {
|
|
QbzAlbum.addToMixtape(albumHeader.id)
|
|
} else if (a === "share-qobuz") {
|
|
QbzAlbum.shareQobuzLink(albumHeader.id)
|
|
} else if (a === "share-albumlink") {
|
|
QbzAlbum.shareAlbumLink(albumHeader.id)
|
|
} else if (a === "cache-album") {
|
|
QbzAlbum.albumCacheOffline(albumHeader.id)
|
|
} else if (a === "recache-album") {
|
|
QbzAlbum.albumRefreshOffline(albumHeader.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// AlbumContextMenu.slint:153 — a 1px border-subtle separator, then
|
|
// the album-blacklist toggle (:157-172). The .slint writes it as two
|
|
// `if` arms (Block / Unblock) whose row count is constant; one row
|
|
// with a flipping label is the same single row, and it is the shape
|
|
// ArtistPageView.slint:561-572 uses for the identical toggle. Own
|
|
// Rectangle rather than a sixth Repeater entry because the inline
|
|
// delegate above has no separator arm — `{sep:true}` is CardMenu's
|
|
// vocabulary, not this hand-rolled menu's (ArtistView.qml's
|
|
// overflow menu draws its blacklist row exactly this way).
|
|
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
|
Rectangle {
|
|
id: ablkRow
|
|
width: parent.width
|
|
height: 33
|
|
radius: 5
|
|
// Seeded from the header document, which does NOT carry the
|
|
// field yet (album_qt.rs still has to port album.rs:683's
|
|
// `set_is_album_blocked` seed — spec 03 F13). Read defensively:
|
|
// `undefined` folds to false through toggleState's
|
|
// `fallback === true`, so the row is correct the moment the
|
|
// seed lands and never throws before then.
|
|
readonly property bool blocked: root.toggleState("blocked", albumHeader.isAlbumBlocked)
|
|
color: ablkArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
spacing: 8
|
|
QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
|
Text {
|
|
height: parent.height
|
|
width: parent.width - 23
|
|
text: ablkRow.blocked
|
|
? QbzSession.tr("Unblock album", QbzSession.trRev)
|
|
: QbzSession.tr("Block this album", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: ablkArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
albumMenu.close()
|
|
// Optimistic flip (main.rs:12835), then the mutation;
|
|
// `blacklistChanged` above settles it — or rolls it
|
|
// back on a write failure (main.rs:12859).
|
|
root.setToggleState("blocked", !ablkRow.blocked)
|
|
QbzBlacklist.albumToggle(albumHeader.id, albumHeader.title,
|
|
albumHeader.artist, albumHeader.artUrl)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|