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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user