feat(qt): give the ephemeral pane an album header, a shuffle and zebra rows

The pane opened on a bare title, a track count and the folder PATH — a
filesystem readout for something the user is about to listen to. For the
common case this pane IS an album page: one folder, one album, one cover.

It now reads like one. The cover is 224px, the size LocalAlbumHeader uses, so
an opened folder and an indexed album never present the same record at two
different scales; the cell collapses entirely when the session has no art
rather than reserving an empty square. The second line trades the path for the
total playing time, formatted by the same `total_duration` helper every other
Local Library surface uses. Play gains a Shuffle beside it, with the same
`(shuffle)` shape `play_album` has, and the Clear stays — it is the only way
to close a session, and with it the only way to make the tab go away.

The per-block header now draws only for MULTI-album sessions. Adding the big
header made it a stutter for single-album ones: the same cover, the same
title and the same count, twice on one screen.

Track rows get `zebra: true`. `TrackRow` already had the property; this pane
was simply the surface that never set it.

The shuffle's generator was replaced before it shipped. The first version was
a bare xorshift with a comment claiming a folder shuffle "is not a place that
needs a good generator" — measured over eight consecutive shuffles of a
ten-track album, one track opened FOUR of them. It is splitmix64 now, seeded
with a counter as well as the clock so two shuffles inside one clock tick
cannot repeat, and there is a test asserting no position is pinned over 2000
runs. Shuffle quality is something the listener sees directly.

cc
This commit is contained in:
vicrodh committed 2026-08-20 22:23:56 -06:00
1 parent 45c686fab0
commit 9666ef129e
1 file changed
+100 -18
@@ -23,6 +23,14 @@ Item {
readonly property var eph: view ? view.ephemeral : null
readonly property var albums: eph && eph.albums ? eph.albums : []
/// Art for the 224px header cell: the FIRST block's key. For the common
/// one-album session that is simply "the cover"; for a multi-album folder
/// it is a representative, and each block keeps drawing its own 56px art
/// below. Empty when the session has no art at all, which collapses the
/// cell instead of reserving an empty square.
readonly property string headerArtKey:
albums.length > 0 && albums[0].artKey ? albums[0].artKey : ""
// Same rule as LocalFolderDetail: the host reports this pane's covers as
// one window when the document changes, so re-opening the pane on an
// unchanged document (leaving and returning to the Folders tab) needs its
@@ -57,14 +65,62 @@ Item {
width: parent.width - 64
spacing: 14
// ---- Header: name + count · path, Play all, Clear ----
// ---- Header: cover + name / count · duration, and the actions ----
//
// Shaped after LocalAlbumHeader, because for the common case this
// pane IS an album page: one folder, one album, one cover. The art
// is 224px there (`LocalAlbumHeader.qml:45`) and 224 here, so an
// opened folder and an indexed album do not present the same record
// at two different sizes.
//
// The cover cell collapses to zero width when the session has no
// art at all, rather than reserving a 224px hole — a CUE rip with
// no cover.jpg would otherwise open on a mostly empty header.
Row {
width: parent.width
spacing: 12
spacing: root.headerArtKey === "" ? 0 : 20
readonly property int actionsW: 3 * 40 + 2 * 12
Rectangle {
id: heroArt
visible: root.headerArtKey !== ""
width: visible ? 224 : 0
height: visible ? 224 : 0
radius: 8
color: theme.surfaceHover
clip: true
QbzIcon {
name: "disc-3"
width: 64
height: 64
anchors.centerIn: parent
tintName: "muted"
}
RoundedImage {
id: heroImg
anchors.fill: parent
source: root.view ? root.view.artPathOf(root.headerArtKey) : ""
radius: 8
}
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 8
pending: root.view ? root.view.artWanted(root.headerArtKey) : false
coverReady: heroImg.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {
width: parent.width - 2 * 40 - 2 * 12
width: parent.width - heroArt.width
- (root.headerArtKey === "" ? 0 : 20)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
spacing: 8
Text {
width: parent.width
text: root.eph ? (root.eph.name || "") : ""
@@ -73,26 +129,43 @@ Item {
font.weight: theme.weightBold
elide: Text.ElideRight
}
// "10 tracks · 42 min". The PATH used to live here and no
// longer does: the medium's name is already the line above
// and the tab, and the duration is what a listener actually
// wants before pressing play.
Text {
width: parent.width
text: (root.eph ? (root.eph.trackCount || 0) : 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev) + " · "
+ (root.eph ? (root.eph.path || "") : "")
text: {
var n = root.eph ? (root.eph.trackCount || 0) : 0
var d = root.eph ? (root.eph.totalDuration || "") : ""
var t = n + " " + QbzSession.tr("tracks", QbzSession.trRev)
return d === "" ? t : t + " · " + d
}
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
QbzCircleAction {
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ephemeralPlayAll()
}
QbzCircleAction {
name: "x"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ephemeralClear()
Row {
spacing: 12
topPadding: 4
QbzCircleAction {
name: "play-fill"
primary: true
onClicked: QbzLocal.ephemeralPlayAll(false)
}
QbzCircleAction {
name: "shuffle"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ephemeralPlayAll(true)
}
// Kept: this is the ONLY way to close the session, and
// with it the only way to make the tab go away.
QbzCircleAction {
name: "x"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ephemeralClear()
}
}
}
}
@@ -133,7 +206,15 @@ Item {
width: page.width
spacing: 6
// The per-block header is for MULTI-album sessions,
// where it is the only thing separating one record
// from the next. A single-album session already has
// all of it — cover, title, artist, count — in the
// 224px header above, and drawing it twice made the
// pane read like a page with a stutter.
Row {
visible: root.eph && root.eph.multiAlbum === true
height: visible ? implicitHeight : 0
width: parent.width
spacing: 12
Rectangle {
@@ -244,6 +325,7 @@ Item {
showDownload: false
showMenu: false
draggable: false
zebra: true
onPlayRequested: QbzLocal.ephemeralPlayTrack(modelData.id)
}
}