fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library. toggle_follow called unsubscribe_playlist and settled the ownership snapshot and stopped there, so the row only left at the next full refetch. Both surfaces are patched directly now, in both directions: following from a card buys the metadata with one get_playlist rather than re-reading getUserPlaylists, which lags the write. The un-follow collapses a hearted-and-followed playlist to the one row the next load will actually produce instead of dropping it, because the local heart is a library.db flag that survives the unsubscribe — removing every row made it vanish and come back, which is the original complaint wearing the other shoe. The Plex mark on album cards was a hard-drive glyph tinted accent — a blue hard drive — and the three Qobuz kinds drew no badge at all. The badge now ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18 (22 for a download), the purchase arm gold on its amber chip, hard-drive white at 14 for local only. The brand marks are plain Images: routing them through QbzIcon floods every pixel with the tint and flattens a logo to a silhouette. SourceIcon moves to controls/ since it is no longer local-only, and every other surface drawing a hard-drive was audited against its .slint. The local album detail loses the two nav chevrons above the art. They are a Tauri-era leftover from before the app had global navigation. My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the standard cards/AlbumCard is mounted with its overlay. localMode bundled four separate things — action routing, the heart, the pin and Block this album — so a multi-source container had to switch all four off together; it is split, and the catalog affordances now follow the ITEM. On a Qobuz cell the heart works through the shared favourite seam; on a track, playlist, local or Plex cell it is absent rather than dead. Row covers carry two rungs: the list keeps _50, the grid gets _600. Not the _230 that would merely cover a 200px card — the complaint was that the cover reads softer than a normal AlbumCard, and a normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the CDN serves at all (it answers 404). The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite and isPinned when toggled, which destroys the binding that fed them. A recycled delegate therefore showed the previous row's heart and the click added the new album while the glyph said remove. Restored with the Qt.binding idiom this port already uses on TrackCard, PlaylistCard and TrackRow. The details view no longer force-opens every row on entry, and the per-row open state persists. Library > Artists showed placeholders for nearly every portrait. The side panel never reported its artwork window — the one caller of libraryArtworkWindow is LibraryView.reportWindow, and the panel had no report call at all, so no key ever reached the resolver and artMap stayed empty for its rows. The rail now reports through that same path, with the section-header sentinel reportWindow already skips. Note the reference has no portrait fallback either: an artist Qobuz serves no image for is a placeholder on both sides. cc
This commit is contained in:
37 files changed
+2043
-506
No files matched your search
@@ -105,6 +105,10 @@ fn main() {
|
||||
"qml/controls/SettingsButton.qml",
|
||||
"qml/controls/SettingsDivider.qml",
|
||||
"qml/controls/SettingsSpacer.qml",
|
||||
// Moved out of views/local/ on 2026-07-31: the album/track
|
||||
// CARD badges mount it too, and Slint keeps its counterpart in
|
||||
// primitives/ (SourceGlyph.slint).
|
||||
"qml/controls/SourceIcon.qml",
|
||||
"qml/rows/BlacklistRow.qml",
|
||||
"qml/rows/TrackCols.qml",
|
||||
"qml/rows/TrackListHeader.qml",
|
||||
@@ -222,7 +226,6 @@ fn main() {
|
||||
"qml/views/local/LocalTracksTab.qml",
|
||||
"qml/views/local/LocalTreeRail.qml",
|
||||
"qml/views/local/SelectCheck.qml",
|
||||
"qml/views/local/SourceIcon.qml",
|
||||
"qml/views/local/TreeRow.qml",
|
||||
"qml/views/local/VersionPicker.qml",
|
||||
// MyQBZ (Mixtapes / Collections / Artist-Collection builder).
|
||||
@@ -231,7 +234,6 @@ fn main() {
|
||||
"qml/views/myqbz/DiscoBuilderView.qml",
|
||||
"qml/views/myqbz/DiscoCandidateRow.qml",
|
||||
"qml/views/myqbz/MyQbzCard.qml",
|
||||
"qml/views/myqbz/MyQbzDetailCard.qml",
|
||||
"qml/views/myqbz/MyQbzDetailRow.qml",
|
||||
"qml/views/myqbz/MyQbzDetailView.qml",
|
||||
"qml/views/myqbz/MyQbzGridView.qml",
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
// Open album · Play · Play next · Play later · Add to queue ·
|
||||
// Add to/Remove from Library (show-favorite) · Block this album
|
||||
// (source != local && source != plex)
|
||||
// (the first entry's NOUN is overridable per host — see `openLabel`; the last
|
||||
// two are gated on `catalogAffordances`)
|
||||
// …plus whatever the host appends through `extraMenuEntries` (My QBZ's
|
||||
// "Remove from collection" — see that property).
|
||||
// All but the last are live. "Block this album" is gated OFF by
|
||||
// `hasBlacklistSeam` below: the Qt bridge exposes the blacklist COUNTERS
|
||||
// (settings_qt/devtools.rs) but no block-album invokable, and the row used
|
||||
@@ -51,8 +55,21 @@ Rectangle {
|
||||
property string artworkUrl: ""
|
||||
property bool isFavorite: false
|
||||
property bool isPinned: false
|
||||
// Source badge (Library show-local): "local" | "plex" | "" (hidden).
|
||||
// The row's SOURCE word — "local" | "offline" | "plex" (the Local Library
|
||||
// badge triple, src/local_rows.rs `badge_source`) or "qobuz" (the Library
|
||||
// ALL feed, library_qt.rs `FeedItem.source`). "" = a catalog surface that
|
||||
// publishes no source at all.
|
||||
//
|
||||
// Read by TWO consumers, which is why it is NOT the badge's on/off switch:
|
||||
// the badge below, and the "Block this album" menu gate
|
||||
// (AlbumCard.slint:434 keeps the entry off local/plex rows). Hosts that
|
||||
// want the badge hidden set `showSourceBadge`, never `source: ""`.
|
||||
property string source: ""
|
||||
// discover/AlbumCard.slint:79 `show-source-badge` — default OFF; the two
|
||||
// hosts that turn it on are the Library ALL grid (gated on the toolbar's
|
||||
// show-local toggle, FavoritesView.slint:1097) and the Local Library
|
||||
// grid (LocalLibraryView.slint:1267).
|
||||
property bool showSourceBadge: false
|
||||
// Local play count — rendered as the muted "{} plays" line under the
|
||||
// artist, and ONLY there (discover/AlbumCard.slint:508-513). Every
|
||||
// surface but Most Played Albums publishes 0, which is why that page's
|
||||
@@ -65,11 +82,60 @@ Rectangle {
|
||||
// (folder or metadata identity), not a Qobuz catalog id — routing it
|
||||
// through QbzAlbum.openAlbum / QbzPlayer.playAlbum would fire a
|
||||
// catalog fetch for a folder path. In localMode every action is emitted
|
||||
// to the host instead, and the catalog-only affordances (heart, pin,
|
||||
// "Block this album") are hidden. Nothing else about the card changes,
|
||||
// so the two surfaces stay pixel-identical.
|
||||
// to the host instead. Nothing else about the card changes, so the two
|
||||
// surfaces stay pixel-identical.
|
||||
//
|
||||
// ROUTING ONLY, since the My QBZ round (2026-08-01). It used to ALSO hide
|
||||
// the heart, the pin badge and "Block this album" — see
|
||||
// `catalogAffordances` for why that turned out to be two different
|
||||
// questions wearing one flag.
|
||||
property bool localMode: false
|
||||
|
||||
// --- Catalog-only affordances: heart, pin badge, "Block this album" -----
|
||||
//
|
||||
// Split out of `localMode` because a host can need one answer and not the
|
||||
// other, and My QBZ is that host. Its grid routes EVERY action through
|
||||
// `QbzMyQbz` (only it knows how to open a Plex album, a track or a
|
||||
// playlist), so it needs `localMode: true` — but a Qobuz ALBUM cell's
|
||||
// `albumId` IS a catalog album id, and its heart / pin / block are the same
|
||||
// entity every other AlbumCard in the app talks about. The blanket flag
|
||||
// hid the heart on those cells, which is the owner's report ("al overlay le
|
||||
// falta el botón de favorite"): a container that is multi-source PER ITEM
|
||||
// cannot be described by one grid-wide boolean.
|
||||
//
|
||||
// Defaults to `!localMode`, so the two pre-existing hosts (Local Library
|
||||
// grid, Local album view) keep byte-identical behaviour and no caller has
|
||||
// to be updated. A host that wants the split sets it explicitly.
|
||||
//
|
||||
// The heart still goes through `toggleFavorite()` -> the shared
|
||||
// `QbzLibrary.libraryToggleFavorite` seam and still settles on
|
||||
// `libraryFavoriteChanged`, so a heart flipped here and one flipped on the
|
||||
// album page agree. Do NOT let a host paint its own heart on top of the
|
||||
// card — see the `selectMode` note below for the same mistake's other half.
|
||||
property bool catalogAffordances: !root.localMode
|
||||
|
||||
// --- Multi-select mode — discover/AlbumCard.slint:83, :179-197, :207,
|
||||
// :239, :465. NOT an invention of this port and NOT a host concern: the
|
||||
// reference card owns the whole presentation of select mode, and it owns
|
||||
// it because three things have to move together —
|
||||
// * the hover action buttons are HIDDEN (:239) — in select mode the
|
||||
// gesture is "pick this card", and a live play button there plays an
|
||||
// album the user was trying to tick;
|
||||
// * the pin badge is HIDDEN (:207), because the checkbox takes its
|
||||
// corner;
|
||||
// * the checkbox is drawn in THAT corner (top-right, :179-197) and the
|
||||
// card click toggles instead of opening (:169, :465).
|
||||
// A host that paints a tick on top of the card can only do the third.
|
||||
// `views/local/LocalAlbumCollection.qml` used to do exactly that — a third
|
||||
// tick geometry, top-left, with the card's play button still live
|
||||
// underneath it — and it was moved onto these members on 2026-08-01. Both
|
||||
// grid hosts (Local Library, My QBZ) are on the card's select mode now;
|
||||
// there is no host-painted tick left in the tree, and a new one is a
|
||||
// regression, not a shortcut.
|
||||
property bool selectMode: false
|
||||
property bool selected: false
|
||||
signal selectToggled()
|
||||
|
||||
// Album blacklist ("Block this album") — LIVE since QbzBlacklist landed
|
||||
// (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not
|
||||
// inlined, because a host whose `albumId` is not a Qobuz catalog id must
|
||||
@@ -78,6 +144,39 @@ Rectangle {
|
||||
// next reload, there is no live removal.
|
||||
property bool hasBlacklistSeam: true
|
||||
|
||||
// --- Host-supplied extras (additive; every default is the old behaviour)
|
||||
//
|
||||
// 1. `placeholderIcon` — the glyph drawn in the empty artwork well when
|
||||
// `artSource` is "". The Qobuz/Local hosts leave it "" and get the bare
|
||||
// `surface-elevated` square they always had. My QBZ needs it because its
|
||||
// grid is HETEROGENEOUS (album / track / playlist) and the reference
|
||||
// card for that grid draws a type glyph there
|
||||
// (MixtapeDetailView.slint:522-533, 28px, text-muted) — dropping it when
|
||||
// that grid moved onto this card would have been a visual regression.
|
||||
property string placeholderIcon: ""
|
||||
// 2. `extraMenuEntries` / `extraMenuAction` — a host-owned TAIL on the ONE
|
||||
// menu this card already owns (the ⋯ overlay button AND the right press
|
||||
// open the same `albumMenu`). My QBZ has to offer "Remove from
|
||||
// collection", which is a CONTAINER action no album card can know about,
|
||||
// and the alternative in the tree — `views/local/LocalAlbumCollection`
|
||||
// mounting a SECOND CardMenu for the right press — leaves the ⋯ button
|
||||
// showing a different menu than the right click. Entries use CardMenu's
|
||||
// shape ({ label, icon, action, danger } / { sep: true }); an action that
|
||||
// matches one of them is emitted here instead of being interpreted.
|
||||
property var extraMenuEntries: []
|
||||
signal extraMenuAction(string action)
|
||||
// 3. `openLabel` — the NOUN on the first menu entry. "" (default) keeps the
|
||||
// card's own `tr("Open album")`, which is what every catalog surface
|
||||
// wants. My QBZ's grid is heterogeneous: a PLAYLIST cell routes to the
|
||||
// playlist page (`myqbz_detail_qt::open_item`), so "Open album" on it was
|
||||
// simply the wrong word — the action was never wrong, only its label.
|
||||
// A TRACK cell keeps "Open album" on purpose: `open_item` sends
|
||||
// "album" | "track" to the same album page (1:1 with the reference,
|
||||
// `qbz/src/main.rs:6275-6277`), so the noun is accurate there.
|
||||
// It is a LABEL, not a second action: the entry's `action` stays "open"
|
||||
// and still reaches `openRequested()` / `QbzAlbum.openAlbum`.
|
||||
property string openLabel: ""
|
||||
|
||||
signal openRequested()
|
||||
signal playRequested()
|
||||
signal enqueueRequested(string mode)
|
||||
@@ -132,31 +231,60 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// AlbumCard.slint's album-menu, in its order. localMode drops the
|
||||
// catalog-only rows (heart + block); the five navigation/playback rows
|
||||
// are identical and route to the host's signals instead.
|
||||
// AlbumCard.slint's album-menu, in its order. `catalogAffordances: false`
|
||||
// drops the catalog-only rows (heart + block); `localMode` routes the five
|
||||
// navigation/playback rows to the host's signals instead. The two are
|
||||
// independent — see the property notes.
|
||||
function menuModel() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = [
|
||||
{ "label": t("Open album", r), "icon": "library-big", "action": "open" },
|
||||
{ "label": root.openLabel !== "" ? root.openLabel : t("Open album", r),
|
||||
"icon": "library-big", "action": "open" },
|
||||
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
||||
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
||||
// #442 "Play later" — end of the manual block.
|
||||
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
|
||||
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
if (!root.localMode) {
|
||||
if (root.catalogAffordances) {
|
||||
m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
// .slint gates this on a non-local/plex source as well.
|
||||
if (root.hasBlacklistSeam && root.source !== "local" && root.source !== "plex")
|
||||
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
|
||||
}
|
||||
return m
|
||||
// Host tail (see `extraMenuEntries`). `concat` so the host's array is
|
||||
// never mutated — it is usually a binding's return value.
|
||||
var extra = root.extraMenuEntries || []
|
||||
return extra.length > 0 ? m.concat(extra) : m
|
||||
}
|
||||
|
||||
function menuAction(a) {
|
||||
// A host entry is HANDED BACK, never interpreted: the card has no idea
|
||||
// what "remove from this collection" means.
|
||||
var extra = root.extraMenuEntries || []
|
||||
for (var i = 0; i < extra.length; i++) {
|
||||
if (extra[i].sep !== true && extra[i].action === a) {
|
||||
root.extraMenuAction(a)
|
||||
return
|
||||
}
|
||||
}
|
||||
// CATALOG-only rows first, because they are orthogonal to routing: they
|
||||
// are in the menu at all only when `catalogAffordances` is on, which
|
||||
// means `albumId` IS a catalog album id — so they target the catalog
|
||||
// whatever `localMode` says about open/play. Handling them after the
|
||||
// `localMode` early-return (the old shape) made them unreachable for
|
||||
// My QBZ, i.e. a menu row that rendered and did nothing.
|
||||
// `artworkUrl` (the REMOTE url), never `artSource` — the store keeps a
|
||||
// denormalized snapshot and a file:// cache path is dead on any other
|
||||
// machine, the same reason the pin payload uses it.
|
||||
if (a === "favorite") { root.toggleFavorite(); return }
|
||||
if (a === "block") {
|
||||
QbzBlacklist.blockAlbum(root.albumId, root.title, root.artist,
|
||||
root.artworkUrl)
|
||||
return
|
||||
}
|
||||
if (root.localMode) {
|
||||
if (a === "open") root.openRequested()
|
||||
else if (a === "play") root.playRequested()
|
||||
@@ -170,12 +298,6 @@ Rectangle {
|
||||
else if (a === "next") QbzPlayer.enqueueAlbum(root.albumId, "next")
|
||||
else if (a === "later") QbzPlayer.enqueueAlbum(root.albumId, "later")
|
||||
else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "queue")
|
||||
else if (a === "favorite") root.toggleFavorite()
|
||||
// `artworkUrl` (the REMOTE url), never `artSource` — the store keeps a
|
||||
// denormalized snapshot and a file:// cache path is dead on any other
|
||||
// machine, the same reason the pin payload uses it.
|
||||
else if (a === "block") QbzBlacklist.blockAlbum(root.albumId, root.title,
|
||||
root.artist, root.artworkUrl)
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -195,6 +317,19 @@ Rectangle {
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
|
||||
// Empty-well glyph — opt-in, see `placeholderIcon`. Declared
|
||||
// before every hit area so it never takes the pointer (it is an
|
||||
// Image with no MouseArea, but z-order is declaration order and
|
||||
// the scrim must still paint over it).
|
||||
QbzIcon {
|
||||
visible: root.placeholderIcon !== "" && root.artSource === ""
|
||||
anchors.centerIn: parent
|
||||
name: root.placeholderIcon
|
||||
width: 28
|
||||
height: 28
|
||||
tintName: "muted"
|
||||
}
|
||||
|
||||
// Hover scrim.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -246,6 +381,11 @@ Rectangle {
|
||||
albumMenu.openAtCursor(artArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
// .slint:169 — in select mode the card click TOGGLES.
|
||||
if (root.selectMode) {
|
||||
root.selectToggled()
|
||||
return
|
||||
}
|
||||
if (root.localMode)
|
||||
root.openRequested()
|
||||
else
|
||||
@@ -253,13 +393,50 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select checkbox — top-right of the art, select-mode only
|
||||
// (.slint:179-197, number for number): 24px disc at
|
||||
// `width - 24 - 8, 8`, accent when selected else `#00000099`
|
||||
// (Slint #RRGGBBAA -> Qt #AARRGGBB = #99000000), 1.5px border,
|
||||
// accent when selected else `#ffffffcc` -> `#ccffffff`, 120ms
|
||||
// colour animation, and a 14px white check that only shows when
|
||||
// selected. It is the INDICATOR: the card click above is what
|
||||
// toggles, so it carries no MouseArea of its own — exactly like
|
||||
// the .slint, which gives it no TouchArea either.
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: parent.width - width - 8
|
||||
y: 8
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
color: root.selected ? theme.accent : "#99000000"
|
||||
border.width: 1.5
|
||||
border.color: root.selected ? theme.accent : "#ccffffff"
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
QbzIcon {
|
||||
visible: root.selected
|
||||
name: "check"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
// .slint:196 hardcodes #ffffff here, and unlike
|
||||
// SelectCheck's 8px glyph this one sits on a 24px disc —
|
||||
// the contrast argument that made SelectCheck diverge does
|
||||
// not reach this size, so the reference stands.
|
||||
tintName: "white"
|
||||
}
|
||||
}
|
||||
|
||||
// Pin badge — top-right. Hover-revealed like the overlay
|
||||
// buttons (AlbumCard.slint: opacity follows overlay-on even
|
||||
// when pinned — the pinned state reads in the icon swap only:
|
||||
// filled accent pin vs outline). Always-mounted (opacity) so
|
||||
// its hover joins overlayOn.
|
||||
Rectangle {
|
||||
visible: !root.localMode
|
||||
// …and hidden in select mode too (.slint:207 — the checkbox
|
||||
// owns this corner). Pinning writes `album:{albumId}` into the
|
||||
// pinned store, so it needs a catalog id, not a routing mode.
|
||||
visible: root.catalogAffordances && !root.selectMode
|
||||
x: parent.width - width - 8
|
||||
y: 8
|
||||
width: 26
|
||||
@@ -288,13 +465,20 @@ Rectangle {
|
||||
// Hover action buttons — favorite / play / more (y=120, h=44,
|
||||
// centered, spacing 12).
|
||||
CardOverlayRow {
|
||||
// .slint:239 `visible: !root.select-mode` — in select mode the
|
||||
// click toggles selection, so a live play/⋯ row here would act
|
||||
// on a card the user is only trying to tick.
|
||||
visible: !root.selectMode
|
||||
y: 120
|
||||
width: parent.width
|
||||
shown: root.overlayOn
|
||||
|
||||
CardOverlayButton {
|
||||
id: favBtn
|
||||
visible: !root.localMode
|
||||
// ABSENT, not present-and-dead, when `albumId` is not a
|
||||
// catalog album id — the same gate as the menu's heart row
|
||||
// and the pin badge.
|
||||
visible: root.catalogAffordances
|
||||
name: root.isFavorite ? "heart-filled" : "heart"
|
||||
active: root.isFavorite
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -368,25 +552,51 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Source badge (Library show-local): bottom-right of the art.
|
||||
// Always-visible source badge — bottom-right of the art, ON TOP of
|
||||
// the scrim and the hover buttons (declaration order = z-order).
|
||||
// 24x24 rounded SQUARE (ADR-008: not a pill), 6px inset.
|
||||
// discover/AlbumCard.slint:326-354, number for number.
|
||||
//
|
||||
// The glyph goes through controls/SourceIcon.qml, never QbzIcon:
|
||||
// the Qobuz and Plex marks are MULTI-COLOUR and a tint flattens
|
||||
// them to a silhouette. This card used to draw an accent-tinted
|
||||
// `hard-drive` for Plex — a blue hard drive where the design calls
|
||||
// for the Plex mark.
|
||||
Rectangle {
|
||||
visible: root.source === "local" || root.source === "plex"
|
||||
|| root.source === "offline"
|
||||
visible: root.showSourceBadge && root.source !== ""
|
||||
x: parent.width - width - 6
|
||||
y: parent.height - height - 6
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 4
|
||||
color: "#b3000000"
|
||||
QbzIcon {
|
||||
// The Local Library's third source: a Qobuz offline copy
|
||||
// (LocalLibraryView.slint's `show-source-badge` triple).
|
||||
name: root.source === "offline" ? "cloud-download" : "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
// On the #b3000000 badge — dark under every theme.
|
||||
tintName: root.source === "plex" ? "accent" : "white"
|
||||
// .slint:335-338. Slint literals are #RRGGBBAA, Qt's are
|
||||
// #AARRGGBB: #1e1400d9 -> #d91e1400, #000000b3 -> #b3000000,
|
||||
// #eab30880 -> #80eab308.
|
||||
// qobuz_purchase -> §8.7 gold chip (dark-amber translucent
|
||||
// fill + 1px gold border)
|
||||
// qobuz_download -> no chip at all (the wordmark stands
|
||||
// alone at 22px)
|
||||
// everything else -> the near-black chip.
|
||||
color: root.source === "qobuz_purchase" ? "#d91e1400"
|
||||
: (root.source === "offline" ? "transparent" : "#b3000000")
|
||||
border.width: root.source === "qobuz_purchase" ? 1 : 0
|
||||
border.color: "#80eab308"
|
||||
SourceIcon {
|
||||
kind: root.source
|
||||
// .slint:347-349 — one size per arm: plex 16, download 22,
|
||||
// qobuz/purchase 18, local 14. "offline" IS the Qt word for
|
||||
// `qobuz_download` (local_rows.rs `badge_source`).
|
||||
glyphSize: 14
|
||||
plexSize: 16
|
||||
qobuzSize: root.source === "offline" ? 22 : 18
|
||||
// .slint:345-346 — the monochrome glyph is #ffffff here (the
|
||||
// chip is dark under every theme), not the rows' muted.
|
||||
localTint: "white"
|
||||
// The .slint centres with explicit pixel rounding
|
||||
// (:351-352); anchors.centerIn would leave a half pixel on
|
||||
// the odd sizes and blur the mark.
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,6 +631,12 @@ Rectangle {
|
||||
albumMenu.openAtCursor(titleArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
// .slint:465 — the title carries the SAME target
|
||||
// as the artwork, select mode included.
|
||||
if (root.selectMode) {
|
||||
root.selectToggled()
|
||||
return
|
||||
}
|
||||
if (root.localMode)
|
||||
root.openRequested()
|
||||
else
|
||||
|
||||
@@ -29,11 +29,19 @@
|
||||
// SETTLE ASYMMETRY, deliberate and not a hole this file can close: the
|
||||
// OWNED arm settles on `QbzLibrary.libraryFavoriteChanged` (rollback on a
|
||||
// failed write included). The FOREIGN follow arm has no signal at all —
|
||||
// `playlist_qt::set_follow_by_id` logs the error and only calls
|
||||
// `reload_sidebar()`, so a failed subscribe leaves the optimistic tick up
|
||||
// `playlist_qt::set_follow_by_id` logs the error and otherwise settles only
|
||||
// the caches (`follow_settled`: the ownership snapshot, the sidebar tree and
|
||||
// the Library feed), so a FAILED subscribe leaves the optimistic tick up
|
||||
// until the card's document is republished. Closing it needs a Rust-side
|
||||
// settle emit, not a QML change.
|
||||
//
|
||||
// Offline the foreign arm is a WRITE that cannot land (`playlist/subscribe`
|
||||
// is gate-refused), and this card offers it anyway — the detail header now
|
||||
// disables its twin (`views/PlaylistView.qml`, `online`). Not fixed here
|
||||
// because the same control also carries the OWNED heart, which is a local
|
||||
// library.db write and MUST stay live offline; splitting the enablement is a
|
||||
// change to this card's contract, not a one-liner.
|
||||
//
|
||||
// --- Menu inventory vs discover/PlaylistCard.slint ---------------------
|
||||
// Play · Play next · Play later · Add to queue ·
|
||||
// Add to Library (is-owned) | Follow/Unfollow on Qobuz (!is-owned) ·
|
||||
|
||||
@@ -127,22 +127,37 @@ Rectangle {
|
||||
onClicked: function (mouse) { trackMenu.openAtCursor(moreBtn, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
// Source badge (All feed, show-local): bottom-right.
|
||||
// Source badge (All feed, show-local): bottom-right of the art.
|
||||
// 24x24 rounded SQUARE (ADR-008: not a pill), 6px inset —
|
||||
// discover/TrackCard.slint:177-197. Simpler than the album card's:
|
||||
// ONE chip colour (#000000b3 -> Qt #b3000000) and no purchase arm.
|
||||
//
|
||||
// The glyph goes through controls/SourceIcon.qml, never QbzIcon:
|
||||
// the Plex and Qobuz marks are MULTI-COLOUR and a tint flattens
|
||||
// them to a silhouette (this card used to draw an accent-tinted
|
||||
// `hard-drive` for Plex — a blue hard drive).
|
||||
Rectangle {
|
||||
visible: root.showSourceBadge && (root.item.source === "local" || root.item.source === "plex")
|
||||
visible: root.showSourceBadge && (root.item.source || "") !== ""
|
||||
x: parent.width - width - 6
|
||||
y: parent.height - height - 6
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 4
|
||||
color: "#b3000000"
|
||||
QbzIcon {
|
||||
name: "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
// On the #b3000000 badge — dark under every theme.
|
||||
tintName: root.item.source === "plex" ? "accent" : "white"
|
||||
SourceIcon {
|
||||
kind: root.item.source || ""
|
||||
// .slint:191-193 — plex 16, qobuz 18, local 14. "offline"
|
||||
// is the Qt word for a Qobuz download (local_rows.rs
|
||||
// `badge_source`); the .slint's local-library track rows
|
||||
// reach the same arm spelled "qobuz" (local_library.rs:1047).
|
||||
glyphSize: 14
|
||||
plexSize: 16
|
||||
qobuzSize: 18
|
||||
// .slint:190 — #ffffff on the near-black chip.
|
||||
localTint: "white"
|
||||
// Explicit pixel rounding (.slint:194-195).
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
}
|
||||
}
|
||||
CardMenu {
|
||||
|
||||
+44
-22
@@ -1,14 +1,20 @@
|
||||
// Per-source glyph — the QML port of `primitives/SourceGlyph.slint`, used by
|
||||
// the version picker, the MyQBZ collection-detail rows and the Discography
|
||||
// Builder rows.
|
||||
// Per-source glyph — the QML port of `primitives/SourceGlyph.slint`. THE one
|
||||
// place this port draws a source mark: the version picker, the MyQBZ
|
||||
// collection-detail rows, the Discography Builder rows, the Local Library
|
||||
// album/track rows, the Library ALL list rows and the 24x24 card badges of
|
||||
// discover/AlbumCard.slint + discover/TrackCard.slint all mount it.
|
||||
//
|
||||
// Lives in controls/ (the port's "primitives") since 2026-07-31, because the
|
||||
// cards need it too and Slint keeps its counterpart in `primitives/`. Nothing
|
||||
// about it was ever Local-Library-specific.
|
||||
//
|
||||
// ── THE LOAD-BEARING RULE (SourceGlyph.slint:4-8) ─────────────────────────
|
||||
// The full-colour Qobuz and Plex logos render **UNTINTED**; only the
|
||||
// monochrome local hard-drive glyph is tinted, and to MUTED text, never
|
||||
// accent. Slint states the reason out loud: routing the logos through a
|
||||
// tinting primitive "would turn the logos into a flat accent silhouette — the
|
||||
// bug this primitive replaces". `QbzIcon` floods every pixel with its tint, so
|
||||
// the logo arms deliberately do NOT go through it — they are plain `Image`s.
|
||||
// monochrome local hard-drive glyph is tinted. Slint states the reason out
|
||||
// loud: routing the logos through a tinting primitive "would turn the logos
|
||||
// into a flat accent silhouette — the bug this primitive replaces". `QbzIcon`
|
||||
// floods every pixel with its tint, so the logo arms deliberately do NOT go
|
||||
// through it — they are plain `Image`s.
|
||||
//
|
||||
// ── WHY THIS FILE USED TO LIE ─────────────────────────────────────────────
|
||||
// The port shipped with neither logo: `qobuz-logo-filled.svg` and
|
||||
@@ -28,26 +34,39 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// "local" | "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" | "plex"
|
||||
/// (anything else = local). Plain "qobuz" is what the MyQBZ collection
|
||||
/// items carry; `SourceGlyph.slint:23` groups it with the downloads.
|
||||
/// "local" | "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" |
|
||||
/// "plex" (anything else = local).
|
||||
///
|
||||
/// `"offline"` is the Qt port's word for the Slint's `qobuz_download`
|
||||
/// (src/local_rows.rs:223-229 `badge_source`, which folds `qobuz_download`
|
||||
/// AND `qobuz_purchase` into it); plain `"qobuz"` is what the Library ALL
|
||||
/// feed (library_qt.rs `FeedItem.source`) and the MyQBZ collection items
|
||||
/// carry. `SourceGlyph.slint:23` groups all of them with the downloads.
|
||||
property string kind: "local"
|
||||
|
||||
/// 14 = `album/LocalAlbumView.slint:24` (the version picker, the default
|
||||
/// here because those are the call sites that set nothing).
|
||||
/// 15 = `primitives/SourceGlyph.slint:29-30` (every row glyph).
|
||||
/// SourceGlyph gives the three Qobuz kinds 16px to compensate the
|
||||
/// wordmark's proportions (`:29`) — now reproducible, because the wordmark
|
||||
/// is finally the asset being drawn.
|
||||
/// Size of the MONOCHROME local glyph — and the default for the two brand
|
||||
/// marks, because `album/LocalAlbumView.slint:24-26` draws all three kinds
|
||||
/// at a flat 14px (the version picker: the call site that sets nothing).
|
||||
property int glyphSize: 14
|
||||
/// Plex mark size; -1 = `glyphSize`. `SourceGlyph.slint:31` gives Plex the
|
||||
/// same 15px as the local glyph, while `AlbumListRow.slint:319` and both
|
||||
/// card badges give it 16px — hence a knob and not a rule.
|
||||
property int plexSize: -1
|
||||
/// Qobuz mark size; -1 = `glyphSize`. 16 in `SourceGlyph.slint:31` /
|
||||
/// `TrackRow.slint:722` / `AlbumListRow.slint:319`, 18 on the card badges,
|
||||
/// 22 for `qobuz_download` on `AlbumCard.slint:352` — the wordmark's
|
||||
/// proportions need the extra pixels.
|
||||
property int qobuzSize: -1
|
||||
/// Tint of the MONOCHROME local glyph. "secondary" = LocalAlbumView.slint:36;
|
||||
/// "muted" = SourceGlyph.slint:28, whose header calls the
|
||||
/// muted-never-accent rule load-bearing.
|
||||
/// muted-never-accent rule load-bearing; "white" = the card badges
|
||||
/// (AlbumCard.slint:347 / TrackCard.slint:190), which sit on a near-black
|
||||
/// chip.
|
||||
property string localTint: "secondary"
|
||||
|
||||
readonly property bool isQobuz: kind === "qobuz" || kind === "qobuz_download"
|
||||
@@ -55,8 +74,11 @@ Item {
|
||||
readonly property bool isPlex: kind === "plex"
|
||||
readonly property bool isPurchase: kind === "qobuz_purchase"
|
||||
|
||||
// SourceGlyph.slint:31 — the Qobuz marks run one pixel larger.
|
||||
width: root.isQobuz ? root.glyphSize + 2 : root.glyphSize
|
||||
width: root.isPlex
|
||||
? (root.plexSize < 0 ? root.glyphSize : root.plexSize)
|
||||
: (root.isQobuz
|
||||
? (root.qobuzSize < 0 ? root.glyphSize : root.qobuzSize)
|
||||
: root.glyphSize)
|
||||
height: width
|
||||
|
||||
readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/"
|
||||
@@ -70,7 +92,7 @@ Item {
|
||||
: root.brandDir + "qobuz-logo-filled.svg"
|
||||
fillMode: Image.PreserveAspectFit // SourceGlyph's `image-fit: contain`
|
||||
// Decode at the size actually drawn: these are a 512x512 and a
|
||||
// ~1000x1006 source rendered into 15-16px.
|
||||
// ~1000x1006 source rendered into 14-22px.
|
||||
sourceSize.width: root.width
|
||||
sourceSize.height: root.height
|
||||
smooth: true
|
||||
@@ -692,16 +692,30 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Debounced window reporting (180ms, library_all.rs throttle).
|
||||
//
|
||||
// `pendingRows` is the array the band indexes into. It is null for every
|
||||
// body that scrolls `visibleRows` itself (grid, list, albums list,
|
||||
// playlists list) and non-null ONLY for a body whose mounted order is not
|
||||
// `visibleRows` — today that is the Artists sidepanel rail, which sorts
|
||||
// A-Z and interleaves letter headers, so its ListView index N is NOT
|
||||
// `visibleRows[N]`. Reporting the rail against `visibleRows` would name
|
||||
// the wrong artists; reporting nothing (what it did until now) left
|
||||
// `artMap` empty for every row it mounts and every rail avatar fell to
|
||||
// the placeholder.
|
||||
Timer {
|
||||
id: windowDebounce
|
||||
interval: 180
|
||||
onTriggered: root.reportWindow(root.visibleRows, pendingFirst, pendingLast)
|
||||
onTriggered: root.reportWindow(
|
||||
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast)
|
||||
property int pendingFirst: 0
|
||||
property int pendingLast: 0
|
||||
property var pendingRows: null
|
||||
}
|
||||
function queueWindowReport(first, last) {
|
||||
/// `rows` is optional — omit it and the band is read off `visibleRows`.
|
||||
function queueWindowReport(first, last, rows) {
|
||||
windowDebounce.pendingFirst = first
|
||||
windowDebounce.pendingLast = last
|
||||
windowDebounce.pendingRows = rows === undefined ? null : rows
|
||||
windowDebounce.restart()
|
||||
}
|
||||
|
||||
@@ -957,6 +971,7 @@ Rectangle {
|
||||
onContentYChanged: root.gridWindowReport()
|
||||
onModelChanged: root.gridWindowReport()
|
||||
onWidthChanged: root.gridWindowReport()
|
||||
onVisibleChanged: root.gridWindowReport()
|
||||
Component.onCompleted: root.gridWindowReport()
|
||||
|
||||
delegate: FeedGridCell {
|
||||
@@ -984,6 +999,7 @@ Rectangle {
|
||||
model: root.visibleRows
|
||||
onContentYChanged: root.listWindowReport()
|
||||
onModelChanged: root.listWindowReport()
|
||||
onVisibleChanged: root.listWindowReport()
|
||||
Component.onCompleted: root.listWindowReport()
|
||||
|
||||
delegate: Loader {
|
||||
@@ -1091,7 +1107,16 @@ Rectangle {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: content.showPlaylistsList ? root.visibleRows : []
|
||||
|
||||
// Gated on visibility for the same reason gridWindowReport and
|
||||
// listWindowReport are: `artMap` is ONE map, a report PRUNES
|
||||
// outside its band, and this body still fires while hidden —
|
||||
// `model` flips to [] on every tab switch (onModelChanged) and
|
||||
// `anchors.fill` makes every window resize an onHeightChanged.
|
||||
// Ungated, a resize on the Artists sidepanel evicted the rail's
|
||||
// avatars and requested a `visibleRows` band the rail is not
|
||||
// even showing (its order is A-Z, not feed order).
|
||||
function report() {
|
||||
if (!playlistsList.visible) return
|
||||
var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4)
|
||||
var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4
|
||||
root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
@@ -1099,6 +1124,7 @@ Rectangle {
|
||||
onContentYChanged: playlistsList.report()
|
||||
onModelChanged: playlistsList.report()
|
||||
onHeightChanged: playlistsList.report()
|
||||
onVisibleChanged: playlistsList.report()
|
||||
Component.onCompleted: playlistsList.report()
|
||||
|
||||
delegate: PlaylistListRow {
|
||||
@@ -1202,7 +1228,18 @@ Rectangle {
|
||||
// setModel() — see the visibleRows comment. Everything they touch on the
|
||||
// view (width/height/contentY/cellWidth/cellHeight) is a plain qreal on
|
||||
// the private; the array comes from root, never from the view.
|
||||
//
|
||||
// Both early-return while their body is hidden. `artMap` is ONE map and a
|
||||
// report PRUNES everything outside the reported band, so a hidden body
|
||||
// that still fires (the grid's rightMargin changes when `alphaVisible`
|
||||
// flips; the list's `onModelChanged` fires on every tab switch because its
|
||||
// `model` is bound to `visibleRows` unconditionally) would evict the
|
||||
// covers of whichever body is actually on screen — the Artists sidepanel
|
||||
// rail above all, since it is the one body whose band is NOT a
|
||||
// `visibleRows` band. `onVisibleChanged` keeps the gate from swallowing
|
||||
// the first report of a body that becomes visible after its model landed.
|
||||
function gridWindowReport() {
|
||||
if (!grid.visible) return
|
||||
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
|
||||
var firstRow = Math.max(0, Math.floor(grid.contentY / grid.cellHeight) - 1)
|
||||
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 1
|
||||
@@ -1210,6 +1247,7 @@ Rectangle {
|
||||
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
|
||||
}
|
||||
function listWindowReport() {
|
||||
if (!list.visible) return
|
||||
var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
|
||||
var last = Math.ceil((list.contentY + list.height) / 44) + 4
|
||||
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
|
||||
@@ -218,19 +218,16 @@ Rectangle {
|
||||
spacing: 0
|
||||
|
||||
// ---- History navigation ----
|
||||
Row {
|
||||
spacing: 6
|
||||
QbzNavButton {
|
||||
name: "chevron-left"
|
||||
btnEnabled: QbzShell.canBack
|
||||
onClicked: QbzShell.navigateBack()
|
||||
}
|
||||
QbzNavButton {
|
||||
name: "chevron-right"
|
||||
btnEnabled: QbzShell.canForward
|
||||
onClicked: QbzShell.navigateForward()
|
||||
}
|
||||
}
|
||||
// NOTHING here, by design. LocalAlbumView.slint:207 does mount
|
||||
// `NavButtons { }`, but NavButtons.slint:5-8 is a ZERO-SIZE empty
|
||||
// placeholder: "the back / forward / collapse buttons moved to the
|
||||
// app header (HeaderBar, floating right) … the real buttons live
|
||||
// once, in the header". Drawing real chevrons here was a Tauri-era
|
||||
// leftover — the page-level nav from before the app had global
|
||||
// history — and views/AlbumView.qml already gets this right.
|
||||
// The 22px spacer stays: it is the .slint's own
|
||||
// `Rectangle { height: 22px; }` (:211), which follows a zero-high
|
||||
// NavButtons, so the header lands exactly where it does today.
|
||||
Item { width: 1; height: 22 }
|
||||
|
||||
// ---- Album header ----
|
||||
|
||||
@@ -76,6 +76,12 @@ Rectangle {
|
||||
/// only affordances and, for the reorder below, it changes WHICH sort is
|
||||
/// the reorderable one.
|
||||
readonly property bool isLocal: doc.isLocalPlaylist === true
|
||||
/// A Qobuz WRITE can be attempted. `QbzSession.offlineMode` is the shared
|
||||
/// tri-state (0 = online, 1 = no connection, 2 = induced offline — the same
|
||||
/// property `shell/HeaderBar.qml:101` and `controls/QbzOfflinePlaceholder`
|
||||
/// read), and BOTH non-zero arms refuse the request in Rust, so the buttons
|
||||
/// that depend on one must not advertise otherwise.
|
||||
readonly property bool online: QbzSession.offlineMode === 0
|
||||
|
||||
// --- Reorderable? (owner findings 5 + 8) -----------------------------
|
||||
// PlaylistView.slint:1094-1096 states the rule: the reorder affordance is
|
||||
@@ -452,8 +458,17 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistTogglePin()
|
||||
}
|
||||
// Follow / unfollow and Copy are Qobuz WRITES
|
||||
// (`playlist/subscribe`, `playlist/create`): offline they
|
||||
// cannot succeed, and the port let them be clicked anyway —
|
||||
// the request was gate-refused and the only trace was a log
|
||||
// line. They stay visible and go inert (0.4 opacity, no
|
||||
// hover, arrow cursor), which is the reference's read-only
|
||||
// treatment of the offline hearts rather than a control
|
||||
// that vanishes and leaves the row jumping.
|
||||
QbzCircleAction {
|
||||
visible: !root.isOwner
|
||||
btnEnabled: root.online
|
||||
name: (doc.isFollowing === true) ? "check" : "user-plus"
|
||||
active: doc.isFollowing === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -461,6 +476,7 @@ Rectangle {
|
||||
}
|
||||
QbzCircleAction {
|
||||
visible: !root.isOwner && doc.isCopied !== true
|
||||
btnEnabled: root.online
|
||||
name: "copy"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistCopy()
|
||||
|
||||
@@ -49,7 +49,13 @@ Item {
|
||||
// landed in the Home Pinned rail as a permanent grey placeholder,
|
||||
// across restarts.
|
||||
artworkUrl: cell.item.imageUrl || ""
|
||||
// The source word is ALWAYS passed (the "Block this album" menu
|
||||
// gate reads it); the toolbar's show-local toggle only decides
|
||||
// whether the BADGE is drawn — FavoritesView.slint:1097
|
||||
// `show-source-badge: LibraryAllState.show-local`, the same gate
|
||||
// the track card below already uses.
|
||||
source: cell.item.source
|
||||
showSourceBadge: cell.view.showLocal
|
||||
}
|
||||
}
|
||||
// Group separator. These are pseudo-rows injected into the SAME flat model
|
||||
|
||||
@@ -243,16 +243,28 @@ Rectangle {
|
||||
width: 44
|
||||
height: parent.height
|
||||
color: "transparent"
|
||||
QbzIcon {
|
||||
visible: feedRow.item.source === "local" || feedRow.item.source === "plex"
|
||||
name: "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Through controls/SourceIcon.qml, never QbzIcon: the Plex and
|
||||
// Qobuz marks are MULTI-COLOUR and a tint flattens them to a
|
||||
// silhouette. This column used to draw an accent-tinted
|
||||
// `hard-drive` for Plex — a blue hard drive.
|
||||
//
|
||||
// DEVIATION, stated on purpose: the .slint has NO source column in
|
||||
// this list at all (FavoritesView.slint:1767/2154 pass
|
||||
// `show-source: false` to AlbumCollectionView). The numbers below
|
||||
// are therefore its siblings' — AlbumListRow.slint:319, the row
|
||||
// glyph, not the card badge.
|
||||
SourceIcon {
|
||||
visible: (feedRow.item.source || "") !== ""
|
||||
&& feedRow.item.source !== "qobuz"
|
||||
kind: feedRow.item.source || ""
|
||||
glyphSize: 15
|
||||
plexSize: 16
|
||||
qobuzSize: 16
|
||||
// Host is a transparent column over the theme row (NOT an
|
||||
// artwork scrim), so this matches its siblings
|
||||
// local/LocalAlbumRow.qml and local/LocalTrackRow.qml.
|
||||
tintName: feedRow.item.source === "plex" ? "accent" : "muted"
|
||||
localTint: "muted"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
// Col — quality (albums + tracks).
|
||||
|
||||
@@ -96,7 +96,17 @@ Item {
|
||||
// Windowed artwork, same contract as the grid: never read
|
||||
// `list.model` back off the view (views/LibraryView.qml
|
||||
// documents the SIGSEGV), always the array from the host.
|
||||
//
|
||||
// Gated on visibility like the host's own bodies
|
||||
// (LibraryView.gridWindowReport / listWindowReport): `artMap`
|
||||
// is ONE map and a report PRUNES outside its band, while this
|
||||
// body keeps firing hidden — `rows` flips to [] on every tab
|
||||
// switch (onModelChanged) and `anchors.fill` turns any window
|
||||
// resize into onHeightChanged. Ungated it evicted whichever
|
||||
// body is actually on screen, the Artists sidepanel rail above
|
||||
// all, whose band is NOT a `visibleRows` band.
|
||||
function report() {
|
||||
if (!list.visible) return
|
||||
var stride = root.rowH + root.rowGap
|
||||
var first = Math.max(0, Math.floor(list.contentY / stride) - 4)
|
||||
var last = Math.ceil((list.contentY + list.height) / stride) + 4
|
||||
@@ -106,6 +116,7 @@ Item {
|
||||
onContentYChanged: list.report()
|
||||
onModelChanged: list.report()
|
||||
onHeightChanged: list.report()
|
||||
onVisibleChanged: list.report()
|
||||
Component.onCompleted: list.report()
|
||||
|
||||
delegate: AlbumListRow {
|
||||
|
||||
@@ -39,6 +39,25 @@ Item {
|
||||
// shape), instead of a Repeater per section.
|
||||
property var entries: []
|
||||
property var alphaJumps: []
|
||||
// The SAME array, indexed the same way, in the shape LibraryView's
|
||||
// `reportWindow` reads (`kind` / `artKey` / `imageUrl`): rail rows are the
|
||||
// feed row objects themselves, letter headers are the group-header
|
||||
// sentinel that report already knows to skip. It exists because the rail's
|
||||
// order is NOT `visibleRows` (it re-sorts A-Z and interleaves headers), so
|
||||
// index N here is not `visibleRows[N]` and the band has to travel with its
|
||||
// own array — see LibraryView.queueWindowReport's third argument.
|
||||
property var railRows: []
|
||||
readonly property var headerRow: ({ "kind": "group-header", "artKey": "", "imageUrl": "" })
|
||||
/// Held while `entries` and `railRows` are being swapped. Assigning
|
||||
/// `entries` re-evaluates the ListView's `model` binding SYNCHRONOUSLY,
|
||||
/// which emits `onModelChanged` -> `report()` one statement BEFORE
|
||||
/// `railRows` is updated — an index band read off the new entries against
|
||||
/// the old flat array. The trailing `report()` supersedes it in the common
|
||||
/// case (the debounce is a `restart()`), but NOT when the rebuild ends
|
||||
/// empty: `report()` bails on `railRows.length === 0`, leaving the stale
|
||||
/// band queued and pinning covers for artists the rail no longer shows
|
||||
/// (the "search with no matches" path). One flag closes both.
|
||||
property bool _rebuilding: false
|
||||
function rebuild() {
|
||||
var rows = root.view ? root.view.visibleRows : []
|
||||
var artists = []
|
||||
@@ -52,6 +71,7 @@ Item {
|
||||
return av < bv ? -1 : av > bv ? 1 : 0
|
||||
})
|
||||
var out = []
|
||||
var flat = []
|
||||
var jumps = []
|
||||
var last = null
|
||||
for (i = 0; i < artists.length; i++) {
|
||||
@@ -59,18 +79,68 @@ Item {
|
||||
if (key !== last) {
|
||||
jumps.push({ "letter": key, "index": out.length })
|
||||
out.push({ "t": 0, "label": key })
|
||||
flat.push(root.headerRow)
|
||||
last = key
|
||||
}
|
||||
out.push({ "t": 1, "item": artists[i] })
|
||||
flat.push(artists[i])
|
||||
}
|
||||
root._rebuilding = true
|
||||
root.entries = out
|
||||
root.railRows = flat
|
||||
root.alphaJumps = jumps
|
||||
root._rebuilding = false
|
||||
root.report()
|
||||
}
|
||||
Component.onCompleted: root.rebuild()
|
||||
Component.onCompleted: { root.rebuild(); root.reportSoon() }
|
||||
Connections {
|
||||
target: root.view
|
||||
function onVisibleRowsChanged() { root.rebuild() }
|
||||
}
|
||||
|
||||
// ------------------------ windowed avatars ---------------------------
|
||||
// The rail rides the SAME id-keyed artwork window every other Library
|
||||
// body uses — LibraryView.reportWindow -> QbzLibrary.libraryArtworkWindow
|
||||
// -> main.rs library_artwork_window -> libraryArtworkReady -> `artMap`,
|
||||
// which is what the row avatars bind to below. Until this landed the
|
||||
// panel reported nothing at all, so `artMap` only ever held whatever
|
||||
// another tab had left in it and practically every rail avatar drew the
|
||||
// placeholder glyph.
|
||||
//
|
||||
// Only the mounted band is asked for (rule 6): the rail is one row per
|
||||
// followed artist and can run to hundreds.
|
||||
function report() {
|
||||
if (root._rebuilding) return
|
||||
if (!root.view || root.railRows.length === 0) return
|
||||
// Same gate the sibling rail uses (views/local/LocalArtistsTab.qml:309)
|
||||
// and the host's own bodies now use: a hidden surface must not report,
|
||||
// because `artMap` is ONE map and a report PRUNES outside its band.
|
||||
// `contentY`/`height` keep firing while the whole Library view is off
|
||||
// screen (another nav destination), and a report made then evicts the
|
||||
// covers of whatever IS on screen. The `onVisibleChanged` handler on
|
||||
// `railList` re-reports when the rail comes back.
|
||||
if (!railList.visible) return
|
||||
var first = railList.indexAt(4, railList.contentY + 1)
|
||||
var last = railList.indexAt(4, railList.contentY + Math.max(1, railList.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = Math.min(root.railRows.length - 1, first + 12)
|
||||
root.view.queueWindowReport(Math.max(0, first - 4),
|
||||
Math.min(root.railRows.length - 1, last + 4),
|
||||
root.railRows)
|
||||
}
|
||||
/// Report now, then again once the ListView has laid out — `indexAt`
|
||||
/// answers -1 before that, which is the state of a rail that has just been
|
||||
/// built or just been mounted (views/local/LocalArtistsTab.qml:330).
|
||||
Timer {
|
||||
id: reportSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.report()
|
||||
}
|
||||
function reportSoon() {
|
||||
root.report()
|
||||
reportSettle.restart()
|
||||
}
|
||||
// Leaving the mode drops the selection, so re-entering does not paint a
|
||||
// stale artist's discography under a rail with nothing highlighted.
|
||||
Component.onDestruction: QbzLibrary.sidepanelClear()
|
||||
@@ -140,6 +210,16 @@ Item {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
|
||||
// The trigger set views/local/LocalArtistsTab.qml:161-165 already
|
||||
// uses for the same shape of rail: scroll, model swap, viewport
|
||||
// resize, and a settle pass when the pane becomes visible (a
|
||||
// report made against a ListView that has not laid out gets -1
|
||||
// from `indexAt`, and nothing re-fires once it has).
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
onHeightChanged: root.report()
|
||||
onVisibleChanged: if (visible) root.reportSoon()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: railList.width
|
||||
|
||||
@@ -243,10 +243,35 @@ Item {
|
||||
qualityTier: cardCell.modelData.qualityTier
|
||||
artSource: root.view
|
||||
? (root.view.artMap[cardCell.modelData.artKey] || "") : ""
|
||||
source: root.showSource ? cardCell.modelData.source : ""
|
||||
onOpenRequested: root.selectMode
|
||||
? root.toggleSelect(cardCell.modelData.id)
|
||||
: root.openRequested(cardCell.modelData.id)
|
||||
// LocalLibraryView.slint:1267 `show-source-badge`
|
||||
// — the card takes the raw source word and the
|
||||
// BADGE is what the flag switches (blanking
|
||||
// `source` would also silence the card's
|
||||
// source-aware menu gates).
|
||||
source: cardCell.modelData.source
|
||||
showSourceBadge: root.showSource
|
||||
// SELECT MODE IS THE CARD'S (AlbumCard's
|
||||
// `selectMode`/`selected`/`selectToggled`, the
|
||||
// port of discover/AlbumCard.slint:83, :179-197,
|
||||
// :207, :239, :465). This host used to paint its
|
||||
// OWN 22px tick on top of the card at top-left —
|
||||
// a THIRD geometry, and one that could only add
|
||||
// the indicator: the card's hover PLAY button
|
||||
// stayed live underneath it, so hovering a card
|
||||
// you meant to tick and hitting play started the
|
||||
// album. The card hides the whole hover action
|
||||
// row and the pin badge in select mode and draws
|
||||
// the reference's 24px tick in the reference's
|
||||
// corner (top-right); all three move together,
|
||||
// which is exactly what a host overlay cannot
|
||||
// do. Do not re-add one.
|
||||
selectMode: root.selectMode
|
||||
selected: root.selected[cardCell.modelData.id] === true
|
||||
onSelectToggled: root.toggleSelect(cardCell.modelData.id)
|
||||
// Non-select mode only — the card routes a
|
||||
// select-mode click to `selectToggled` and never
|
||||
// emits this while ticking.
|
||||
onOpenRequested: root.openRequested(cardCell.modelData.id)
|
||||
onPlayRequested: root.playRequested(cardCell.modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
root.enqueueRequested(cardCell.modelData.id, m)
|
||||
@@ -287,22 +312,9 @@ Item {
|
||||
cardMenu.openAtCursor(cardRc, mouse.x, mouse.y)
|
||||
}
|
||||
}
|
||||
// Multi-select tick — the collection view's
|
||||
// select-mode overlay (top-left of the artwork).
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: 8
|
||||
y: 8
|
||||
width: 22
|
||||
height: 22
|
||||
radius: 11
|
||||
color: "#b3000000"
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.selected[cardCell.modelData.id] === true
|
||||
onToggled: root.toggleSelect(cardCell.modelData.id)
|
||||
}
|
||||
}
|
||||
// (The multi-select tick that used to sit here is
|
||||
// gone — it is AlbumCard's `selectMode` now; see
|
||||
// the note on the card above.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,21 @@ Rectangle {
|
||||
visible: root.showSource
|
||||
width: visible ? 34 : 0
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
visible: root.item.source === "local" || root.item.source === "plex"
|
||||
|| root.item.source === "offline"
|
||||
name: root.item.source === "offline" ? "cloud-download" : "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
// AlbumListRow.slint:308-322 — through controls/SourceIcon.qml,
|
||||
// never QbzIcon: the Plex and Qobuz marks are MULTI-COLOUR and a
|
||||
// tint flattens them to a silhouette. This row used to draw an
|
||||
// accent-tinted `hard-drive` for Plex (a blue hard drive) and
|
||||
// `cloud-download` for an offline copy.
|
||||
SourceIcon {
|
||||
visible: (root.item.source || "") !== ""
|
||||
kind: root.item.source || ""
|
||||
// .slint:319 — local 15, the marks 16; muted local tint.
|
||||
glyphSize: 15
|
||||
plexSize: 16
|
||||
qobuzSize: 16
|
||||
localTint: "muted"
|
||||
x: 0 // .slint:321
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: root.item.source === "plex" ? "accent" : "muted"
|
||||
}
|
||||
}
|
||||
// Trailing ⋯ overflow (AlbumListRow.slint:360).
|
||||
|
||||
@@ -148,16 +148,25 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Source glyph (local / offline cache / Plex).
|
||||
QbzIcon {
|
||||
visible: root.item.source === "local" || root.item.source === "plex"
|
||||
|| root.item.source === "offline"
|
||||
name: root.item.source === "offline" ? "cloud-download" : "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
x: root.width - 20
|
||||
// Source glyph (local / offline cache / Plex) — TrackRow.slint:709-727.
|
||||
// Through controls/SourceIcon.qml, never QbzIcon: the Plex and Qobuz marks
|
||||
// are MULTI-COLOUR and a tint flattens them to a silhouette. This row used
|
||||
// to draw an accent-tinted `hard-drive` for Plex (a blue hard drive) and
|
||||
// `cloud-download` for an offline copy, where the .slint draws the two
|
||||
// brand marks.
|
||||
SourceIcon {
|
||||
visible: (root.item.source || "") !== ""
|
||||
kind: root.item.source || ""
|
||||
// .slint:722 — local 15, the marks 16. `localTint` muted is the rule
|
||||
// SourceGlyph.slint:6-8 calls load-bearing (never accent).
|
||||
glyphSize: 15
|
||||
plexSize: 16
|
||||
qobuzSize: 16
|
||||
localTint: "muted"
|
||||
// The glyph's CENTRE stays where the 14px icon's was (x = width-20,
|
||||
// centre = width-13) now that the marks are 15-16px wide.
|
||||
x: Math.round(root.width - 13 - width / 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: root.item.source === "plex" ? "accent" : "muted"
|
||||
}
|
||||
|
||||
// Right-click anywhere on the row opens the SAME menu at the pointer
|
||||
|
||||
@@ -38,6 +38,9 @@ Rectangle {
|
||||
SourceIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
kind: root.currentVersion.source || "local"
|
||||
// No size overrides on purpose: LocalAlbumView.slint:24-26 draws
|
||||
// ALL THREE kinds at a flat 14px here, which is SourceIcon's
|
||||
// default (the row glyphs are the ones that grow the marks).
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -430,8 +430,12 @@ Rectangle {
|
||||
SourceIcon {
|
||||
anchors.centerIn: parent
|
||||
kind: root.candidate.source || ""
|
||||
// Row glyph — SourceGlyph.slint:29-30.
|
||||
// Row glyph — SourceGlyph.slint:31: 15px, and the three
|
||||
// Qobuz kinds one pixel larger (the wordmark's
|
||||
// proportions). Explicit since the size rule became a
|
||||
// per-arm knob; it used to be a hardcoded +2.
|
||||
glyphSize: 15
|
||||
qobuzSize: 16
|
||||
localTint: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// MyQbzDetailCard — ONE grid card of the My QBZ detail body, the port of the
|
||||
// local `DetailCard` component in myqbz/MixtapeDetailView.slint:495-594.
|
||||
//
|
||||
// Geometry 1:1 with the .slint: `height = cardW + 8 + 18 + 16 + 16`
|
||||
// (= cardW + 58, :501), radius 8, padding 8, spacing 8, a square artwork of
|
||||
// `cardW - 16` at radius 6, title 14px/medium, subtitle 12px/muted. Selected
|
||||
// = a 2px accent border plus the `surface-hover` fill the hover uses.
|
||||
//
|
||||
// The host GridView owns the column arithmetic (spec 01 §7.4) and hands the
|
||||
// width down as `cardW`, exactly as the .slint passes `card-w` (:498).
|
||||
//
|
||||
// TWO deviations from the .slint, both deliberate:
|
||||
//
|
||||
// * `cardArea` is declared FIRST and pinned `z: -1`. The .slint declares
|
||||
// `card-ta` LAST (:584), i.e. frontmost, which puts it over the artwork's
|
||||
// own play / checkbox TouchAreas — the identical defect
|
||||
// `MixtapeDetailView.slint:203-217` describes and fixes for the ROW but
|
||||
// never fixed here, so the card's hover scrim and its play affordance
|
||||
// cannot fire upstream. Fixed on the way over.
|
||||
// * `rev` is a dependency of every `item` read — the host patches the row
|
||||
// objects IN PLACE and a plain JS object mutation fires no notifier. See
|
||||
// the long note in MyQbzDetailRow.qml; the `>= 0` guard is always true
|
||||
// and exists only to capture the dependency.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
import "../local"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// One `DetailRow` object out of `detailJson.items` (spec 02 §5.2).
|
||||
property var item: ({})
|
||||
/// Card width, computed by the host from the auto-fill arithmetic.
|
||||
property real cardW: 150
|
||||
property bool selectMode: false
|
||||
/// In-place-patch revision (see the header).
|
||||
property int rev: 0
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property int itemPosition: root.rev >= 0 ? (root.item.position || 0) : 0
|
||||
readonly property string itemType: root.rev >= 0 ? (root.item.itemType || "") : ""
|
||||
readonly property string itemSource: root.rev >= 0 ? (root.item.source || "") : ""
|
||||
readonly property string sourceItemId: root.rev >= 0 ? (root.item.sourceItemId || "") : ""
|
||||
readonly property string rowTitle: root.rev >= 0 ? (root.item.title || "") : ""
|
||||
readonly property string rowSubtitle: root.rev >= 0 ? (root.item.subtitle || "") : ""
|
||||
readonly property string artPath: root.rev >= 0 ? (root.item.artPath || "") : ""
|
||||
readonly property bool selected: root.rev >= 0 && root.item.selected === true
|
||||
|
||||
/// track → music, playlist → list-music, else disc (.slint :522-526).
|
||||
readonly property string typeGlyph: root.itemType === "track" ? "music"
|
||||
: (root.itemType === "playlist" ? "list-music" : "disc")
|
||||
|
||||
readonly property bool hovered: cardArea.containsMouse || playArea.containsMouse
|
||||
|
||||
width: root.cardW
|
||||
height: root.cardW + 58
|
||||
radius: 8
|
||||
color: (root.hovered || root.selected) ? theme.surfaceHover : "transparent"
|
||||
border.width: root.selected ? 2 : 0
|
||||
border.color: theme.accent
|
||||
|
||||
// Card-wide target — FIRST + `z: -1` (see the header).
|
||||
MouseArea {
|
||||
id: cardArea
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition)
|
||||
else QbzMyQbz.openItem(root.itemSource, root.itemType, root.sourceItemId)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
x: 8
|
||||
y: 8
|
||||
width: parent.width - 16
|
||||
spacing: 8
|
||||
|
||||
// --- Artwork ------------------------------------------------------
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: width
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
RoundedImage {
|
||||
visible: root.artPath !== ""
|
||||
anchors.fill: parent
|
||||
source: root.artPath
|
||||
radius: 6
|
||||
}
|
||||
QbzIcon {
|
||||
visible: root.artPath === ""
|
||||
anchors.centerIn: parent
|
||||
name: root.typeGlyph
|
||||
width: 28
|
||||
height: 28
|
||||
tintName: "muted"
|
||||
}
|
||||
|
||||
// Select-mode: the checkbox rides a dark disc so it stays legible
|
||||
// over a bright cover (.slint :536-549).
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: 6
|
||||
y: 6
|
||||
width: 22
|
||||
height: 22
|
||||
radius: 11
|
||||
color: root.selected ? "transparent" : "#8c000000"
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.selected
|
||||
onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: the hover play scrim (.slint :550-566).
|
||||
Rectangle {
|
||||
visible: !root.selectMode
|
||||
anchors.fill: parent
|
||||
color: "#73000000"
|
||||
opacity: playArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "play-fill"
|
||||
width: 24
|
||||
height: 24
|
||||
tintName: "white"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: playArea
|
||||
anchors.fill: parent
|
||||
enabled: !root.selectMode
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzMyQbz.playItem(root.sourceItemId)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.rowTitle
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.rowSubtitle
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@
|
||||
// bottom hairline `Theme.alpha-4`, hover/selected fill `surface-hover`.
|
||||
//
|
||||
// Plus a NINTH, leading 24px cell that no reference has: the accordion
|
||||
// chevron (owner-authorised divergence, see its own comment below). It shifts
|
||||
// the eight reference columns 36px right; `titleColWidth` and the host's
|
||||
// `ColHead` row are retuned together.
|
||||
// chevron (owner-authorised divergence, see its own comment below). It is
|
||||
// mounted ONLY in the details arm (`showExpander`, owner 2026-07-31); when it
|
||||
// is, it shifts the eight reference columns 36px right and `titleColWidth`
|
||||
// plus the host's `ColHead` row are retuned together.
|
||||
//
|
||||
// --- TWO THINGS THAT ARE LOAD-BEARING -----------------------------------
|
||||
//
|
||||
@@ -57,6 +58,23 @@ Rectangle {
|
||||
/// without shadowing the delegate's own context `index`.
|
||||
property int ordinal: 0
|
||||
property bool selectMode: false
|
||||
/// Does THIS row carry the accordion chevron cell?
|
||||
///
|
||||
/// The accordion is the owner's feature, but it is scoped to the DETAILS
|
||||
/// arm (the `expanded` segment) — the plain LIST arm never had a use for it
|
||||
/// and the owner asked for it back out of there (2026-07-31). Off, the
|
||||
/// leading 24px cell is `visible: false`, which in a `Row` drops the cell
|
||||
/// AND its 12px gap, so the eight reference columns land exactly where
|
||||
/// `MixtapeDetailView.slint:219-491` puts them again (`titleColWidth`
|
||||
/// below switches back to the reference's 700 with it).
|
||||
///
|
||||
/// The STATE is untouched: `rowOpen` stays the one notion of open and Rust
|
||||
/// stays its only writer. Rust also AND-s `rowOpen` with the details arm
|
||||
/// (`myqbz_detail_qt.rs` `to_item` / `resync_row_open`), so with the chevron
|
||||
/// gone from list mode no row renders open there and nothing is orphaned —
|
||||
/// while the user's open set itself SURVIVES the round trip (it is persisted
|
||||
/// per collection now, `collection_open_rows.json`).
|
||||
property bool showExpander: false
|
||||
/// The host's shared 3-phase clock for `QbzLoadingDots` (ONE timer per
|
||||
/// view — spec 01 §7.5 / §15.5).
|
||||
property int dotPhase: 0
|
||||
@@ -87,8 +105,8 @@ Rectangle {
|
||||
/// nothing to show, so it gets no chevron (Rust decides — `canExpand`).
|
||||
readonly property bool canExpand: root.rev >= 0 && root.item.canExpand === true
|
||||
/// THE accordion state, and the ONLY one: Rust derives it from the shared
|
||||
/// `OPEN_ROWS` set that the `expanded` view-mode segment also writes, so
|
||||
/// this row never needs to know what the view mode is.
|
||||
/// `OPEN_ROWS` set — the chevron's own writes, persisted per collection —
|
||||
/// so this row never needs to know what the view mode is.
|
||||
readonly property bool rowOpen: root.rev >= 0 && root.item.rowOpen === true
|
||||
|
||||
/// track → music, playlist → list-music, else disc (.slint :263-267).
|
||||
@@ -106,14 +124,16 @@ Rectangle {
|
||||
height: 56
|
||||
color: (root.hovered || root.selected) ? theme.surfaceHover : "transparent"
|
||||
|
||||
/// Title column = rowWidth − padding(24) − fixed cells(616) − gaps(96).
|
||||
/// Title column = rowWidth − padding(24) − fixed cells(592) − gaps(84)
|
||||
/// = width − 700, the reference's own arithmetic (:219-491).
|
||||
///
|
||||
/// 736, not the reference's 700: the accordion chevron added a NINTH cell
|
||||
/// (24px) and a ninth 12px gap in front of the ordinal, shifting the
|
||||
/// 8-column template 36px right. `MyQbzDetailView.qml`'s `ColHead` row
|
||||
/// carries the identical 24px leader and the identical `- 736`, and the two
|
||||
/// must be changed together or the header stops lining up with the rows.
|
||||
readonly property int titleColWidth: Math.max(0, root.width - 736)
|
||||
/// 736 while `showExpander`: the accordion chevron adds a NINTH cell (24px)
|
||||
/// and a ninth 12px gap in front of the ordinal, shifting the 8-column
|
||||
/// template 36px right. `MyQbzDetailView.qml`'s `ColHead` row carries the
|
||||
/// identical leader and the identical switch, and the two must be changed
|
||||
/// together or the header stops lining up with the rows.
|
||||
readonly property int titleColWidth: Math.max(0,
|
||||
root.width - (root.showExpander ? 736 : 700))
|
||||
|
||||
// Bottom hairline — `#ffffff0a` in the .slint (:200) == `alpha-4`, taken
|
||||
// from the polarity-baked ramp so it is visible on light themes too. The
|
||||
@@ -154,14 +174,20 @@ Rectangle {
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
// --- Col 0 (24) — THE ACCORDION CHEVRON --------------------------
|
||||
// --- Col 0 (24) — THE ACCORDION CHEVRON, details arm only ---------
|
||||
//
|
||||
// ADDITIVE, and a deliberate divergence from BOTH references
|
||||
// (owner-authorised 2026-07-30): neither `MixtapeDetailView.slint` nor
|
||||
// the Tauri build ever had a per-row accordion — both render inline
|
||||
// tracks only as a whole-list VIEW MODE, explicitly "no chevron". The
|
||||
// owner asked for the accordion anyway. The view mode survives
|
||||
// untouched and now means "open all", through the same per-row state.
|
||||
// untouched but no longer means "open all" (owner 2026-08-01): the arm
|
||||
// opens fully CLOSED and what the user opens here is remembered, per
|
||||
// collection, across view switches and restarts.
|
||||
//
|
||||
// SCOPED to the details arm 2026-07-31 (`showExpander`): the plain LIST
|
||||
// arm shows no chevron and is the reference's 8-column row again. Do
|
||||
// not remove the accordion itself — only this gate is the owner's.
|
||||
//
|
||||
// The MouseArea is INVISIBLE on a non-expandable row, and an invisible
|
||||
// item is not hit-tested — so a track row's chevron cell falls through
|
||||
@@ -170,6 +196,7 @@ Rectangle {
|
||||
// area sits at the default z, above `rowArea`'s `z: -1`, and swallows
|
||||
// the click: the chevron toggles ONLY this row, it never navigates.
|
||||
Item {
|
||||
visible: root.showExpander
|
||||
width: 24
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
@@ -354,9 +381,11 @@ Rectangle {
|
||||
x: 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
kind: root.sourceKind
|
||||
// Row glyph, so SourceGlyph.slint's numbers (15px, muted),
|
||||
// not the version picker's defaults.
|
||||
// Row glyph, so SourceGlyph.slint's numbers (:31 — 15px, the
|
||||
// three Qobuz kinds 16px, muted local tint), not the version
|
||||
// picker's flat-14 defaults.
|
||||
glyphSize: 15
|
||||
qobuzSize: 16
|
||||
localTint: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,22 +17,30 @@
|
||||
// ListView/GridView (spec 01 §15.1, non-negotiable). A recycling view cannot
|
||||
// be content-sized inside an outer Flickable, and `ListView.header` is not
|
||||
// available to the GRID arm either — the grid is hosted in a clipped Item and
|
||||
// given `width: parent.width + 20 - 24` to reproduce Slint's column count
|
||||
// (spec 01 §7.4), which would offset and over-widen a header inside it. The
|
||||
// given a corrected width to reproduce the app album grid's column count
|
||||
// (see the grid arm), which would offset and over-widen a header inside it. The
|
||||
// paddings, the 32px hero→body gap and the 10px toolbar tail are all kept;
|
||||
// the toolbar simply became literally sticky, which is what §7.2 calls it.
|
||||
//
|
||||
// --- PER-ROW COLLAPSIBLE ROWS (owner-authorised divergence) -------------
|
||||
// Every album / playlist row carries a chevron that opens ITS OWN inline track
|
||||
// list (`MyQbzDetailRow.qml`, col 0). NEITHER the .slint NOR the Tauri build
|
||||
// had this — both render inline tracks only as a whole-list view MODE,
|
||||
// explicitly "no chevron". The owner asked for the accordion anyway
|
||||
// In the DETAILS arm every album / playlist row carries a chevron that opens
|
||||
// ITS OWN inline track list (`MyQbzDetailRow.qml`, col 0). NEITHER the .slint
|
||||
// NOR the Tauri build had this — both render inline tracks only as a whole-list
|
||||
// view MODE, explicitly "no chevron". The owner asked for the accordion anyway
|
||||
// (2026-07-30); do not "restore" it toward either reference.
|
||||
// It is ADDITIVE on machinery that already existed: Rust already published
|
||||
// per-row `canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`, so
|
||||
// the accordion added exactly one thing — `rowOpen`, THE single notion of open.
|
||||
// The `expanded` segment writes the same state and now means "open all"; there
|
||||
// is deliberately no second, QML-side definition of "is this row open".
|
||||
// There is deliberately no second, QML-side definition of "is this row open".
|
||||
// DEFAULT + PERSISTENCE, owner 2026-08-01: the details arm opens with every row
|
||||
// CLOSED (the segment no longer force-opens anything) and the rows the user
|
||||
// opens are remembered per collection across view switches and restarts
|
||||
// (`collection_open_rows.json`). Rust gates the published `rowOpen` on the
|
||||
// details arm itself, so the other arms still render no inline block.
|
||||
// SCOPE, owner 2026-07-31: the chevron belongs to the DETAILS arm only — the
|
||||
// plain LIST arm carries no affordance and is the reference's 8-column row
|
||||
// again (`rowExpander` below). The accordion itself stays; only its reach
|
||||
// changed, and `rowOpen` is still the one notion of open.
|
||||
//
|
||||
// --- THE MODEL IS PATCHED, NEVER REPLACED, AND PARSED ONCE --------------
|
||||
// `QbzMyQbz` carries no signals: every Rust-side change arrives as a whole new
|
||||
@@ -51,7 +59,18 @@
|
||||
// a real re-derive (sort / filter / search / another collection), which is
|
||||
// exactly when Slint also rebuilds and the scroll reset is correct.
|
||||
//
|
||||
// Reused, not rebuilt: rows/TrackRow.qml (inline tracks),
|
||||
// --- THE GRID ARM MOUNTS THE STANDARD CARD ------------------------------
|
||||
// `cards/AlbumCard.qml`, not a My QBZ card. The port used to carry
|
||||
// `MyQbzDetailCard.qml`, a 1:1 of the reference's local `DetailCard`
|
||||
// (MixtapeDetailView.slint:495-594); the owner rejected it (2026-07-31,
|
||||
// "quiero el de siempre con su overlay") and it is DELETED. Select mode is the
|
||||
// CARD's (`selectMode`/`selected`/`selectToggled` — the port of the reference
|
||||
// card's own `select-mode`, discover/AlbumCard.slint:83), because it has to
|
||||
// hide the pin and the hover action row as well as draw the tick; only "Remove
|
||||
// from collection" is host knowledge and it rides AlbumCard's
|
||||
// `extraMenuEntries` tail.
|
||||
//
|
||||
// Reused, not rebuilt: rows/TrackRow.qml (inline tracks), cards/AlbumCard,
|
||||
// controls/QualityBadgeFull, controls/CardMenu, controls/QbzContextMenu,
|
||||
// controls/QbzSegToggle, controls/QbzMultiSelectBar, controls/QbzToolButton,
|
||||
// controls/QbzLoadingDots, controls/QbzLineEdit, controls/QbzSelect,
|
||||
@@ -181,6 +200,19 @@ Rectangle {
|
||||
readonly property int filterCount: doc.filterCount || 0
|
||||
readonly property bool hasAnyFilter: doc.hasAnyFilter === true
|
||||
|
||||
/// Does the body carry the per-row accordion chevron?
|
||||
///
|
||||
/// ONLY the DETAILS arm — the `rows-3` segment, the one whose whole point
|
||||
/// is showing each item's tracks (the segment id stays `expanded`, which is
|
||||
/// now only the arm's name: it no longer means "open all"). The owner asked
|
||||
/// for the chevron out of the plain LIST arm (2026-07-31); it is not a
|
||||
/// removal of the accordion, which stays his feature, and `rowOpen` stays
|
||||
/// the ONE notion of open with Rust as its only writer. Rust AND-s the
|
||||
/// published `rowOpen` with this same arm, so list mode cannot render an
|
||||
/// open row and no state is orphaned by hiding the affordance — the user's
|
||||
/// open set survives the switch instead of being cleared by it.
|
||||
readonly property bool rowExpander: root.viewMode === "expanded"
|
||||
|
||||
// --------------------------- the row model -----------------------------
|
||||
property var rows: []
|
||||
property string rowsKey: ""
|
||||
@@ -602,15 +634,19 @@ Rectangle {
|
||||
spacing: 12
|
||||
// The accordion chevron's leading cell (see
|
||||
// MyQbzDetailRow.qml). Unlabelled — a header for a
|
||||
// disclosure control would be noise — but it MUST be
|
||||
// here, at exactly the row's 24px, or the eight
|
||||
// reference columns below drift 36px out of the header.
|
||||
Item { width: 24; height: 1 }
|
||||
// disclosure control would be noise — but while the
|
||||
// details arm mounts it, it MUST be here at exactly the
|
||||
// row's 24px or the eight reference columns below drift
|
||||
// 36px out of the header. `visible: false` drops the
|
||||
// cell AND its 12px gap, exactly like the row's.
|
||||
Item { visible: root.rowExpander; width: 24; height: 1 }
|
||||
ColHead { width: 40; text: "#"; horizontalAlignment: Text.AlignHCenter }
|
||||
ColHead {
|
||||
// Paired with MyQbzDetailRow's `titleColWidth`:
|
||||
// 24 padding + 616 fixed cells + 96 gaps.
|
||||
width: Math.max(0, listHost.width - 736)
|
||||
// 24 padding + 592 fixed cells + 84 gaps = 700,
|
||||
// plus the chevron's 24 + 12 while it is mounted.
|
||||
width: Math.max(0,
|
||||
listHost.width - (root.rowExpander ? 736 : 700))
|
||||
text: root.trs("Item")
|
||||
}
|
||||
ColHead { width: 140; text: root.trs("Type") }
|
||||
@@ -694,11 +730,11 @@ Rectangle {
|
||||
/// two writers (the chevron and the segment) and
|
||||
/// two definitions would disagree the first time a
|
||||
/// row was closed inside expanded mode — so the
|
||||
/// view mode is not read here at all. Rust sets
|
||||
/// `rowOpen` on every expandable row while the mode
|
||||
/// is `expanded` (open all) and clears it on the
|
||||
/// way out; `canExpand` is implied, because a
|
||||
/// non-expandable row is never marked open.
|
||||
/// view mode is not read here at all. Rust publishes
|
||||
/// `rowOpen` only for a row the USER opened while
|
||||
/// the details arm is up; `canExpand` is implied,
|
||||
/// because a non-expandable row is never marked
|
||||
/// open.
|
||||
///
|
||||
/// Through `live`, like every other row read — see
|
||||
/// the note above.
|
||||
@@ -716,6 +752,7 @@ Rectangle {
|
||||
item: rowCell.live
|
||||
ordinal: rowCell.index + 1
|
||||
selectMode: root.selectMode
|
||||
showExpander: root.rowExpander
|
||||
dotPhase: root.dotPhase
|
||||
rev: root.patchRev
|
||||
}
|
||||
@@ -723,23 +760,27 @@ Rectangle {
|
||||
// Inline tracks (.slint :1151-1218): padding
|
||||
// 52 / 12 / 4 / 8, spacing 0.
|
||||
//
|
||||
// 88, not 52: the reference's 52 is `12 padding +
|
||||
// 40 ordinal cell`, i.e. exactly where the row's
|
||||
// artwork column starts. The chevron cell added
|
||||
// `24 + 12` in front of the ordinal, so the same
|
||||
// alignment is 88 and the right inset stays 12
|
||||
// (width - 88 - 12). Keep it tied to the row's
|
||||
// template or the inline block stops hanging under
|
||||
// the title it belongs to.
|
||||
// The reference's 52 is `12 padding + 40 ordinal
|
||||
// cell`, i.e. exactly where the row's artwork
|
||||
// column starts; the chevron cell adds `24 + 12` in
|
||||
// front of the ordinal, so the same alignment is 88
|
||||
// whenever the chevron is mounted. The right inset
|
||||
// stays 12 either way (width - x - 12). Keep it tied
|
||||
// to the row's template — off by one cell and the
|
||||
// inline block stops hanging under its own title.
|
||||
//
|
||||
// Only the details arm can have an open row today
|
||||
// (see `rowExpander`), so the 88 branch is the live
|
||||
// one; the 52 keeps the two definitions honest.
|
||||
Item {
|
||||
visible: rowCell.expanded
|
||||
width: parent.width
|
||||
height: visible ? inlineCol.height + 12 : 0
|
||||
Column {
|
||||
id: inlineCol
|
||||
x: 88
|
||||
x: root.rowExpander ? 88 : 52
|
||||
y: 4
|
||||
width: Math.max(0, parent.width - 100)
|
||||
width: Math.max(0, parent.width - inlineCol.x - 12)
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
@@ -831,33 +872,47 @@ Rectangle {
|
||||
}
|
||||
|
||||
// ======================= BODY: GRID =======================
|
||||
// `auto-fill minmax(150, 1fr)`, gap 20, 12px content inset
|
||||
// (.slint :1090-1108). The clipped host + `width: parent.width +
|
||||
// 20 - 24` reproduces Slint's `floor((W - 24 + gap) / (150 + gap))`
|
||||
// column count, which GridView's own `floor(W / cellWidth)` would
|
||||
// otherwise undercount by one at every exact boundary.
|
||||
// THE STANDARD `cards/AlbumCard.qml`, at its app-wide size, with
|
||||
// its own hover overlay and its own ⋯ / right-click menu.
|
||||
//
|
||||
// This REPLACES the port's `MyQbzDetailCard.qml` (deleted), which
|
||||
// was a faithful 1:1 of the reference's local `DetailCard`
|
||||
// (MixtapeDetailView.slint:495-594): a 150px-ish hand-rolled tile
|
||||
// with no overlay buttons, no quality badge, no source badge and no
|
||||
// menu. The owner ruled against that card (2026-07-31): "quiero el
|
||||
// de siempre con su overlay". So this arm is a DELIBERATE, OWNER-
|
||||
// ORDERED divergence from the .slint, and the .slint's `auto-fill
|
||||
// minmax(150,1fr)` / gap-20 arithmetic went with it.
|
||||
//
|
||||
// Geometry is now the one every other album grid in the app uses
|
||||
// (`views/AlbumCollection.qml:44-46`): a fixed 200x246 card in a
|
||||
// 200x266 cell with a 24px gap. AlbumCard hardcodes its 200px
|
||||
// width, so this grid cannot be fluid — matching the app is both
|
||||
// the correct and the only option.
|
||||
Item {
|
||||
id: gridHost
|
||||
anchors.fill: parent
|
||||
visible: root.viewMode === "grid"
|
||||
clip: true
|
||||
|
||||
readonly property int columns: Math.max(1,
|
||||
Math.floor((width - 24 + 20) / (150 + 20)))
|
||||
// Clamped: the first frame has width 0, and a negative
|
||||
// cellWidth is a silent GridView layout trap.
|
||||
readonly property real cardW: Math.max(1,
|
||||
(width - 24 - 20 * (columns - 1)) / columns)
|
||||
readonly property real cardH: cardW + 58
|
||||
readonly property int cardWidth: 200
|
||||
readonly property int cardHeight: 266
|
||||
readonly property int cardGap: 24
|
||||
|
||||
GridView {
|
||||
id: itemGrid
|
||||
x: 12
|
||||
y: 0
|
||||
width: Math.max(0, gridHost.width + 20 - 24)
|
||||
// The app grid takes `floor((avail + gap) / (card + gap))`
|
||||
// columns over `avail = width - 24` (the 12px content inset
|
||||
// on both sides); GridView takes `floor(width / cellWidth)`.
|
||||
// Since the gap and the two insets are both 24, handing it
|
||||
// the UNINSET width makes the two agree exactly — the same
|
||||
// correction the old 150/20 arm made with `+ 20 - 24`.
|
||||
width: Math.max(0, gridHost.width)
|
||||
height: gridHost.height
|
||||
cellWidth: gridHost.cardW + 20
|
||||
cellHeight: gridHost.cardH + 20
|
||||
cellWidth: gridHost.cardWidth + gridHost.cardGap
|
||||
cellHeight: gridHost.cardHeight + gridHost.cardGap
|
||||
cacheBuffer: 500
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
@@ -865,15 +920,219 @@ Rectangle {
|
||||
bottomMargin: 100
|
||||
model: root.rows
|
||||
|
||||
delegate: MyQbzDetailCard {
|
||||
delegate: Item {
|
||||
id: gcell
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: gridHost.cardWidth
|
||||
height: gridHost.cardHeight
|
||||
|
||||
// Live row, not the delegate model's snapshot — see the
|
||||
// list delegate's `live` note.
|
||||
item: root.patchRev >= 0 ? (root.rows[index] || modelData) : modelData
|
||||
cardW: gridHost.cardW
|
||||
selectMode: root.selectMode
|
||||
rev: root.patchRev
|
||||
// list delegate's `live` note. AlbumCard has no `rev`
|
||||
// property, so EVERY leaf below carries the
|
||||
// `root.patchRev >= 0` guard itself: that is what makes
|
||||
// `patchRev` a captured dependency of the binding, and
|
||||
// an in-place `Object.assign` patch fires no notifier of
|
||||
// its own. Always true; never "simplify" it away.
|
||||
readonly property var live: root.patchRev >= 0
|
||||
? (root.rows[index] || modelData) : modelData
|
||||
readonly property int cPosition: root.patchRev >= 0 ? (gcell.live.position || 0) : 0
|
||||
readonly property string cItemId: root.patchRev >= 0 ? (gcell.live.sourceItemId || "") : ""
|
||||
readonly property string cItemType: root.patchRev >= 0 ? (gcell.live.itemType || "") : ""
|
||||
readonly property string cSource: root.patchRev >= 0 ? (gcell.live.source || "") : ""
|
||||
readonly property string cTitle: root.patchRev >= 0 ? (gcell.live.title || "") : ""
|
||||
readonly property string cSubtitle: root.patchRev >= 0 ? (gcell.live.subtitle || "") : ""
|
||||
readonly property string cYear: root.patchRev >= 0 ? (gcell.live.yearText || "") : ""
|
||||
readonly property string cQuality: root.patchRev >= 0 ? (gcell.live.qualityTier || "") : ""
|
||||
/// The GRID rung of the cover, NOT the list row's.
|
||||
/// `artPath` is the 50px thumbnail every list row draws
|
||||
/// at 40px; this cell's artwork well is 200px, so
|
||||
/// reading `artPath` here upscaled it 4x — the owner's
|
||||
/// "la calidad de la portada es menor que la del
|
||||
/// albumcard". Rust publishes both rungs per row
|
||||
/// (`myqbz_detail_qt::DetailRow::art_url_large`) so the
|
||||
/// list/grid toggle stays instant: re-deriving one rung
|
||||
/// per view mode would republish the document, and a
|
||||
/// republish resets the view's scroll offset.
|
||||
readonly property string cArt: root.patchRev >= 0 ? (gcell.live.artPathLarge || "") : ""
|
||||
/// The REMOTE url of that same rung — AlbumCard's
|
||||
/// `artworkUrl`, i.e. the pin payload and the block
|
||||
/// snapshot, never the `file://` cache path.
|
||||
readonly property string cArtUrl: root.patchRev >= 0 ? (gcell.live.artUrlLarge || "") : ""
|
||||
readonly property string cSourceKind: root.patchRev >= 0 ? (gcell.live.sourceKind || "") : ""
|
||||
readonly property bool cSelected: root.patchRev >= 0 && gcell.live.selected === true
|
||||
readonly property bool cFavorite: root.patchRev >= 0 && gcell.live.isFavorite === true
|
||||
readonly property bool cPinned: root.patchRev >= 0 && gcell.live.isPinned === true
|
||||
/// Is THIS cell's `sourceItemId` a Qobuz catalog album
|
||||
/// id? The per-item gate for the card's catalog-only
|
||||
/// affordances (heart, pin, "Block this album"), and the
|
||||
/// QML twin of `myqbz_detail_qt::is_catalog_album` — the
|
||||
/// two must agree, because Rust seeds `isFavorite` /
|
||||
/// `isPinned` behind the same predicate.
|
||||
///
|
||||
/// The STORED source (`source`, "qobuz" | "local"), not
|
||||
/// the resolved `sourceKind`: the stored word is what
|
||||
/// makes the id a catalog id. And the TYPE, because a
|
||||
/// Qobuz track item carries a track id and a Qobuz
|
||||
/// playlist item a playlist id — hearting either as
|
||||
/// "album" would hit a different entity.
|
||||
readonly property bool cCatalogAlbum: gcell.cSource === "qobuz"
|
||||
&& gcell.cItemType === "album" && gcell.cItemId !== ""
|
||||
/// track → music, playlist → list-music, else disc —
|
||||
/// the reference card's empty-well glyph (.slint
|
||||
/// :522-526), carried over on AlbumCard's opt-in
|
||||
/// `placeholderIcon`.
|
||||
readonly property string cGlyph: gcell.cItemType === "track" ? "music"
|
||||
: (gcell.cItemType === "playlist" ? "list-music" : "disc")
|
||||
|
||||
/// RE-ESTABLISH the two self-mutating bindings whenever
|
||||
/// this delegate changes row.
|
||||
///
|
||||
/// `AlbumCard` writes its OWN `isFavorite` / `isPinned`
|
||||
/// — optimistically in `toggleFavorite()` /
|
||||
/// `togglePin()`, and again from its
|
||||
/// `libraryFavoriteChanged` / `pinChanged` Connections —
|
||||
/// and a QML property assignment DESTROYS the binding
|
||||
/// that fed it. On a non-recycling host that is
|
||||
/// harmless: the delegate dies with its row. This grid
|
||||
/// sets `reuseItems: true`, so the very same AlbumCard
|
||||
/// instance is handed the NEXT row, with a dead
|
||||
/// `isFavorite` binding — it would keep the previous
|
||||
/// album's filled heart, and clicking it would toggle
|
||||
/// the new album the wrong way (glyph says "remove", the
|
||||
/// call adds). Same for the pin badge.
|
||||
///
|
||||
/// This is the idiom the port already uses for exactly
|
||||
/// this hazard on the cards that own an `item`
|
||||
/// (`cards/TrackCard.qml:49`, `cards/PlaylistCard.qml:117`,
|
||||
/// `rows/TrackRow.qml:233`, `views/library/FeedListRow.qml:39`);
|
||||
/// AlbumCard takes flat properties instead of an `item`,
|
||||
/// so the restore belongs to the recycling HOST. Keyed
|
||||
/// on `cItemId`, which is derived from `index` and
|
||||
/// therefore changes on every reuse.
|
||||
onCItemIdChanged: gcell.rebindCardState()
|
||||
function rebindCardState() {
|
||||
// The change signal can fire while the delegate is
|
||||
// still being built, before the child below exists;
|
||||
// at that point nothing has broken a binding yet, so
|
||||
// skipping is the correct answer, not a workaround.
|
||||
if (!gcard)
|
||||
return
|
||||
gcard.isFavorite = Qt.binding(function () { return gcell.cFavorite })
|
||||
gcard.isPinned = Qt.binding(function () { return gcell.cPinned })
|
||||
}
|
||||
|
||||
AlbumCard {
|
||||
id: gcard
|
||||
// localMode = ROUTING: open / play / enqueue go to
|
||||
// the host signals below, because a My QBZ item can
|
||||
// be a Plex or local album, a track or a playlist
|
||||
// and only `QbzMyQbz` knows how to open and play
|
||||
// one. TRUE for every cell, no exceptions.
|
||||
localMode: true
|
||||
// …but the catalog-only affordances are decided PER
|
||||
// ITEM, which is the whole point of the split (see
|
||||
// AlbumCard's `catalogAffordances`). A My QBZ
|
||||
// container is multi-source per ROW: a Qobuz album
|
||||
// cell's id IS a catalog album id and its heart /
|
||||
// pin / block are perfectly valid; a track, a
|
||||
// playlist, a local or a Plex cell's is not, and
|
||||
// there the three are ABSENT rather than dead.
|
||||
catalogAffordances: gcell.cCatalogAlbum
|
||||
// Seeded by Rust behind the SAME predicate, then
|
||||
// settled by the card itself on
|
||||
// `QbzLibrary.libraryFavoriteChanged` / `pinChanged`
|
||||
// — so a heart flipped here, on the album page or
|
||||
// on a Home rail agrees everywhere, and no toggle
|
||||
// republishes this document.
|
||||
isFavorite: gcell.cFavorite
|
||||
isPinned: gcell.cPinned
|
||||
albumId: gcell.cItemId
|
||||
title: gcell.cTitle
|
||||
artist: gcell.cSubtitle
|
||||
// No artist link: the reference grid card's subtitle
|
||||
// is a plain Text (.slint :578-583), and a My QBZ
|
||||
// subtitle can be a Plex/local artist whose id would
|
||||
// route QbzArtist.openArtist into a Qobuz lookup.
|
||||
artistId: ""
|
||||
genre: ""
|
||||
year: gcell.cYear
|
||||
qualityTier: gcell.cQuality
|
||||
artSource: gcell.cArt
|
||||
// The pin payload / block snapshot url (see
|
||||
// `cArtUrl`). Without it a pinned My QBZ album
|
||||
// lands in the Pinned rail as a placeholder.
|
||||
artworkUrl: gcell.cArtUrl
|
||||
// Badge ON: a My QBZ container is multi-source BY
|
||||
// DEFINITION and the sibling LIST arm gives every
|
||||
// row a Source column (col 4, the same
|
||||
// `sourceKind` through the same `SourceIcon`), so
|
||||
// the grid arm hiding it would be the odd one out.
|
||||
// All four kinds `sourceKind` can carry —
|
||||
// qobuz / plex / local / offline — draw a mark;
|
||||
// only "" (nothing resolved yet) draws none, which
|
||||
// is AlbumCard's `source !== ""` gate.
|
||||
source: gcell.cSourceKind
|
||||
showSourceBadge: true
|
||||
placeholderIcon: gcell.cGlyph
|
||||
// The first menu entry's NOUN. `open_item` sends
|
||||
// "album" AND "track" to the album page and only
|
||||
// "playlist" elsewhere (myqbz_detail_qt.rs:1893-1901,
|
||||
// 1:1 with qbz/src/main.rs:6275-6288), so "Open
|
||||
// album" is accurate on an album and on a track and
|
||||
// wrong on exactly one arm. A label, not an action:
|
||||
// the routing below is unchanged.
|
||||
openLabel: gcell.cItemType === "playlist"
|
||||
? root.trs("Open playlist") : ""
|
||||
// The container action the card cannot know about.
|
||||
// Same msgid, same icon and same destructive styling
|
||||
// as the list row's last entry
|
||||
// (MyQbzDetailRow.qml:457-458 == .slint :481-486),
|
||||
// after the same separator. It applies to a
|
||||
// Collection, a Mixtape and an artist Collection
|
||||
// alike: `remove_item` removes by POSITION from
|
||||
// whatever container is open
|
||||
// (myqbz_edit_qt.rs:360-362), so there is no arm
|
||||
// here that could render and no-op.
|
||||
extraMenuEntries: [
|
||||
{ "sep": true },
|
||||
{ "label": QbzSession.tr("Remove from collection", QbzSession.trRev),
|
||||
"icon": "trash-2", "action": "remove", "danger": true },
|
||||
]
|
||||
onExtraMenuAction: function (a) {
|
||||
if (a === "remove") QbzMyQbz.removeItem(gcell.cPosition)
|
||||
}
|
||||
// SELECT MODE is the card's, not this host's
|
||||
// (discover/AlbumCard.slint:83 declares
|
||||
// `select-mode` and :179-239 hides the pin and the
|
||||
// whole hover action row behind it). Overlaying a
|
||||
// tick from here — the shape
|
||||
// `views/local/LocalAlbumCollection.qml` used until
|
||||
// 2026-08-01 — can only add the indicator: it leaves
|
||||
// the hover
|
||||
// PLAY button live under the selection, so hovering
|
||||
// a card you meant to tick and hitting play starts
|
||||
// the album instead. The owner's own instruction
|
||||
// for this round was to extend AlbumCard with the
|
||||
// selection checkbox rather than bolt one on.
|
||||
selectMode: root.selectMode
|
||||
selected: gcell.cSelected
|
||||
onSelectToggled: QbzMyQbz.detailToggleItemSelect(gcell.cPosition)
|
||||
// Card body click, non-select mode only (the card
|
||||
// routes a select-mode click to `selectToggled`).
|
||||
onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
|
||||
gcell.cItemType, gcell.cItemId)
|
||||
onPlayRequested: QbzMyQbz.playItem(gcell.cItemId)
|
||||
// AlbumCard's `next|later|queue` -> the wire's
|
||||
// `play-next|play-later|add-to-queue`
|
||||
// (myqbz_play_qt.rs:84-91), the same mapping the
|
||||
// row menu hands `itemAction`.
|
||||
onEnqueueRequested: function (mode) {
|
||||
QbzMyQbz.itemAction(gcell.cItemId,
|
||||
mode === "next" ? "play-next"
|
||||
: mode === "later" ? "play-later" : "add-to-queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,3 +144,25 @@ pub fn is_favorite_playlist(pid: u64) -> bool {
|
||||
pub fn set_favorite_playlist(pid: u64, favorite: bool) -> bool {
|
||||
with_db(true, |db| db.set_playlist_favorite(pid, favorite)).is_some()
|
||||
}
|
||||
|
||||
/// Has this Qobuz playlist (by its SOURCE id) already been copied into the
|
||||
/// user's library?
|
||||
///
|
||||
/// The reference seeds `PlaylistState.is-copied` from exactly this read when
|
||||
/// the detail opens (`qbz/src/main.rs:4555`), which is what keeps the Copy
|
||||
/// button hidden on the SECOND visit. The port used to carry `is_copied` in
|
||||
/// the session document only, so a restart offered "Copy to your library"
|
||||
/// again on a playlist that was already copied.
|
||||
///
|
||||
/// Read-only (`create: false`): a user with no local flags yet has no db, and
|
||||
/// that is "not copied", not an error.
|
||||
pub fn is_playlist_copied(pid: u64) -> bool {
|
||||
with_db(false, |db| db.is_playlist_copied(pid)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Record that a Qobuz playlist (by its SOURCE id) was copied into the user's
|
||||
/// library. Idempotent server-side of the sqlite (`INSERT OR IGNORE`), so a
|
||||
/// re-copy is a no-op. Returns false when the write did not land.
|
||||
pub fn mark_playlist_copied(pid: u64) -> bool {
|
||||
with_db(true, |db| db.mark_playlist_copied(pid)).is_some()
|
||||
}
|
||||
+165
-12
@@ -419,7 +419,13 @@ fn map_artist(artist: Artist) -> FeedItem {
|
||||
/// > `images`) — the collage source. Same picker as `sidebar_qt::load` and
|
||||
/// `qbz::search::playlist_cover_urls`; these lists are the covers of the
|
||||
/// playlist's MEMBER ALBUMS, never the playlist's own artwork.
|
||||
fn playlist_cover_urls(playlist: &Playlist) -> Vec<String> {
|
||||
///
|
||||
/// `pub(crate)` since the follow seam landed: `playlist_qt::FollowRowMeta`
|
||||
/// synthesizes a Library row from the API model and must pick its covers with
|
||||
/// THIS function, not a second copy of the precedence — two copies of it is
|
||||
/// how the cards ended up showing a member-album sleeve where the playlist
|
||||
/// graphic belongs.
|
||||
pub(crate) fn playlist_cover_urls(playlist: &Playlist) -> Vec<String> {
|
||||
let source = [&playlist.images300, &playlist.images150, &playlist.images]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -445,7 +451,9 @@ fn playlist_cover_urls(playlist: &Playlist) -> Vec<String> {
|
||||
/// fall back to the member-cover collage, exactly the Tauri split between
|
||||
/// `QobuzPlaylistCard` (`image.rectangle`, contain-fitted) and
|
||||
/// `FavoritePlaylistCard` (`PlaylistCollage`).
|
||||
fn playlist_own_image(playlist: &Playlist) -> String {
|
||||
///
|
||||
/// `pub(crate)` for the same reason as [`playlist_cover_urls`].
|
||||
pub(crate) fn playlist_own_image(playlist: &Playlist) -> String {
|
||||
[&playlist.image_rectangle, &playlist.image_rectangle_mini]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -453,22 +461,37 @@ fn playlist_own_image(playlist: &Playlist) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The playlist card's subtitle line: the owner, then the track count.
|
||||
///
|
||||
/// Factored out of [`map_playlist_row`] so the follow / copy seams
|
||||
/// ([`insert_playlist_row`]) can synthesize a row that reads exactly like the
|
||||
/// one the next full load will produce — the alternative was a second copy of
|
||||
/// this format string drifting from this one.
|
||||
pub(crate) fn playlist_subtitle(owner: &str, tracks_count: u32) -> String {
|
||||
let mut subtitle = owner.to_string();
|
||||
if tracks_count > 0 {
|
||||
let tracks_label = qbz_i18n::tf(
|
||||
"{} track",
|
||||
"{} tracks",
|
||||
tracks_count as i64,
|
||||
&[&tracks_count.to_string()],
|
||||
);
|
||||
subtitle = if subtitle.is_empty() {
|
||||
tracks_label
|
||||
} else {
|
||||
format!("{} • {}", subtitle, tracks_label)
|
||||
};
|
||||
}
|
||||
subtitle
|
||||
}
|
||||
|
||||
fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
||||
// The card's single image is the playlist's own Qobuz artwork ONLY.
|
||||
// Falling back to `images300[0]` here is what put a member ALBUM cover
|
||||
// on every playlist card; the mosaic is fed separately via `covers`.
|
||||
let cover_url = playlist_own_image(playlist);
|
||||
let covers = playlist_cover_urls(playlist);
|
||||
let mut subtitle = playlist.owner.name.clone();
|
||||
if playlist.tracks_count > 0 {
|
||||
let count = playlist.tracks_count;
|
||||
let tracks_label = qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]);
|
||||
subtitle = if subtitle.is_empty() {
|
||||
tracks_label
|
||||
} else {
|
||||
format!("{} • {}", subtitle, tracks_label)
|
||||
};
|
||||
}
|
||||
let subtitle = playlist_subtitle(&playlist.owner.name, playlist.tracks_count);
|
||||
let uid = UserDataPaths::load_last_user_id();
|
||||
let owned = uid.map(|uid| uid == playlist.owner.id).unwrap_or(false);
|
||||
FeedItem {
|
||||
@@ -1070,6 +1093,136 @@ pub(crate) fn set_feed_favorite(kind: &str, id: &str, value: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// An UNFOLLOW just landed: drop the playlist's rows from the live Library
|
||||
/// document. Returns whether anything was removed (so the caller only
|
||||
/// republishes when it must).
|
||||
///
|
||||
/// Why the feed is mutated instead of left to the next reload: unfollowing is
|
||||
/// the user asking for the playlist to leave their library, and `load_library`
|
||||
/// only re-runs when the view is (re)entered — so the row sat there, still
|
||||
/// listed, until a manual refresh. That is the owner's report.
|
||||
///
|
||||
/// ALL matching rows, not the first: a foreign playlist that is ALSO hearted
|
||||
/// locally is pushed TWICE by `load_library` (once from the not-owned pass,
|
||||
/// :553-560, once from the hearted-ids pass, :576-585 — both with
|
||||
/// `is_following = true`, so `group` cannot tell them apart), and leaving one
|
||||
/// of them behind would leave the row on screen.
|
||||
///
|
||||
/// HEARTED IS NOT UNFOLLOWED. The local heart is a qbz-only flag in library.db
|
||||
/// and it survives the unsubscribe, so the state the NEXT full load produces
|
||||
/// for a hearted-and-unfollowed playlist is exactly ONE row: `get_user_playlists`
|
||||
/// no longer lists it, the hearted-ids pass falls through to `get_playlist(fid)`
|
||||
/// and maps it with `is_following = false` — a `favorites` row (`load_library`
|
||||
/// :580-584). The reference surfaces the same "favorited but neither owned nor
|
||||
/// subscribed" playlist deliberately (`qbz/src/playlist_manager.rs:212-215`).
|
||||
/// So this collapses to that one row instead of removing it: dropping it would
|
||||
/// make the playlist vanish now and reappear at the next load, which is the
|
||||
/// owner's original complaint wearing the other shoe.
|
||||
///
|
||||
/// `counts.all` follows the rows that actually left. `counts.playlists` is the
|
||||
/// FAVORITES bucket (`load_library`: `playlists_total = pl_favorites.len()` =
|
||||
/// owned ∪ locally hearted) — an unfollow only ever targets a FOREIGN playlist,
|
||||
/// so it was in that bucket iff it is hearted, and in that case the row STAYS.
|
||||
/// Either way the count is unchanged. Clamped: the badge must never go negative.
|
||||
pub(crate) fn remove_playlist_rows(id: &str) -> bool {
|
||||
let hearted = crate::fav_cache_qt::is_favorite("playlist", id);
|
||||
with_library_mut(|d| {
|
||||
// Snapshot one row before the retain — it is the only place the
|
||||
// playlist's title / covers / subtitle still exist without a refetch.
|
||||
let kept = if hearted {
|
||||
d.feed
|
||||
.iter()
|
||||
.find(|i| i.kind == "playlist" && i.id == id)
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let before = d.feed.len();
|
||||
d.feed.retain(|i| !(i.kind == "playlist" && i.id == id));
|
||||
let removed = before - d.feed.len();
|
||||
if removed == 0 {
|
||||
return false;
|
||||
}
|
||||
d.counts.all = (d.counts.all - removed as i64).max(0);
|
||||
if let Some(mut row) = kept {
|
||||
// Re-filed exactly as the next `load_library` will map it.
|
||||
row.group = "favorites".into();
|
||||
row.playlist_following = false;
|
||||
row.playlist_owned = false;
|
||||
d.feed.insert(0, row);
|
||||
d.counts.all += 1;
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A playlist just JOINED the user's library (a follow, or a copy that created
|
||||
/// an owned one): put it into the live Library document so it appears without a
|
||||
/// manual refresh. Returns whether the document changed.
|
||||
///
|
||||
/// The mirror of [`remove_playlist_rows`], and it takes the pieces rather than
|
||||
/// a `&Playlist` because its callers hold a mapped document, not the raw API
|
||||
/// model — re-fetching the playlist just to re-map it would pull its whole
|
||||
/// track list back over the wire for one card. The fields it does fill are
|
||||
/// produced by the SAME helpers `map_playlist_row` uses ([`playlist_subtitle`],
|
||||
/// `fav_cache_qt`, `sidebar_qt::is_pinned`), so the synthesized row and the one
|
||||
/// the next full load produces agree.
|
||||
///
|
||||
/// `following` picks the bucket exactly as `map_playlist_row` does: a followed
|
||||
/// playlist is a `following` row, an owned one a `favorites` row — which is the
|
||||
/// sub-tab the Library's Playlists tab opens on.
|
||||
///
|
||||
/// `counts.playlists` follows only the owned arm, because that is the count
|
||||
/// `load_library` publishes (`pl_favorites.len()`).
|
||||
///
|
||||
/// No-op when the Library was never loaded this session (`LIBRARY` is `None`) —
|
||||
/// there is no document to patch and the eventual load fetches the truth.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn insert_playlist_row(
|
||||
id: &str,
|
||||
title: &str,
|
||||
owner: &str,
|
||||
tracks_count: u32,
|
||||
cover_url: &str,
|
||||
covers: Vec<String>,
|
||||
following: bool,
|
||||
) -> bool {
|
||||
with_library_mut(|d| {
|
||||
if d.feed.iter().any(|i| i.kind == "playlist" && i.id == id) {
|
||||
return false;
|
||||
}
|
||||
let item = FeedItem {
|
||||
is_pinned: crate::sidebar_qt::is_pinned("playlist", id),
|
||||
kind: "playlist".into(),
|
||||
group: if following { "following" } else { "favorites" }.into(),
|
||||
source: "qobuz".into(),
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
subtitle: playlist_subtitle(owner, tracks_count),
|
||||
playlist_own_image: !cover_url.is_empty(),
|
||||
image_url: cover_url.to_string(),
|
||||
covers,
|
||||
is_favorite: crate::fav_cache_qt::is_favorite("playlist", id),
|
||||
playlist_owned: !following,
|
||||
playlist_following: following,
|
||||
// 0.0 = most-recently added, which is what this just made it. The
|
||||
// feed is kept sorted by this proxy, so the row lands at the head
|
||||
// of the All tab exactly like the newest favourite does.
|
||||
added_rank: 0.0,
|
||||
..Default::default()
|
||||
}
|
||||
.keyed();
|
||||
d.feed.insert(0, item);
|
||||
d.counts.all += 1;
|
||||
if !following {
|
||||
d.counts.playlists += 1;
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A pin/unpin just landed: patch the cached feed rows so a later
|
||||
/// serialization of this document does not resurrect the stale flag.
|
||||
///
|
||||
|
||||
@@ -1826,6 +1826,37 @@ pub(crate) fn reload_library() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-publish the Library document from the CACHED feed — no fetch, no db read.
|
||||
/// The `publish_sidebar()` of this domain.
|
||||
///
|
||||
/// Reserved for the mutations that REMOVE or ADD a row, which is the one thing
|
||||
/// the in-place `libraryFavoriteChanged` / `pinChanged` signals cannot express:
|
||||
/// they patch a badge on a row that stays, while an unfollow has to make the
|
||||
/// row leave. Everything else must keep using the signals — see the long note
|
||||
/// on `library_toggle_favorite` about `QQuickItemView::setModel()`.
|
||||
///
|
||||
/// Handing `model:` a new array is exactly what a tab switch already does, and
|
||||
/// the crash that note describes was reading `.model` BACK off the view inside
|
||||
/// the teardown; `LibraryView.visibleRows` no longer does that. What the user
|
||||
/// does pay is the scroll offset, which is why this is not a per-click verb.
|
||||
///
|
||||
/// No-op before the Library has ever loaded (`with_library` -> `None`).
|
||||
pub(crate) fn publish_library_document() {
|
||||
let Some((feed_json, counts_json)) = library_qt::with_library(|d| {
|
||||
(
|
||||
serde_json::to_string(&d.feed).unwrap_or_else(|_| "[]".into()),
|
||||
serde_json::to_string(&d.counts).unwrap_or_else(|_| "{}".into()),
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
library_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_library_json(QString::from(feed_json.as_str()));
|
||||
b.as_mut()
|
||||
.set_library_counts_json(QString::from(counts_json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// VmRSS (KiB) from /proc/self/status — phase-5 RSS-delta measurement.
|
||||
fn log_rss(mark: &str) {
|
||||
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
//! caches are plain statics reachable from the tokio tasks with no GUI hop.
|
||||
//!
|
||||
//! Load order (spec 02 §6.2 `navigate`): `reset()` (clears the three caches +
|
||||
//! the prefs gate, publishes `{loading:true, found:true}`) -> BLOCK fetch ->
|
||||
//! the prefs gate, publishes `{loading:true, found:true}`) -> BLOCK fetch (the
|
||||
//! collection AND its persisted accordion set, one `spawn_blocking`) ->
|
||||
//! `apply()` (header -> hero mosaic -> restore view-prefs -> open the persist
|
||||
//! gate -> FULL_ITEMS -> rebuild) -> ONE artwork pass + ONE republish ->
|
||||
//! `resolve_items` (fire-and-forget, <= 6 concurrent).
|
||||
//! gate -> restore the pruned open set -> FULL_ITEMS -> rebuild) -> ONE artwork
|
||||
//! pass + ONE republish -> `resolve_items` (fire-and-forget, <= 6 concurrent)
|
||||
//! -> `ensure_expanded` (a no-op unless the restored set opened something).
|
||||
//!
|
||||
//! Two divergences from the reference are REPRODUCED on purpose and must not
|
||||
//! be "unified" (spec 02 §6.2 + review items 23/26):
|
||||
@@ -143,6 +145,40 @@ pub struct DetailRow {
|
||||
pub artwork_url: String,
|
||||
#[serde(rename = "artworkPath")]
|
||||
pub artwork_path: String,
|
||||
/// The SECOND cover, for the GRID arm — same row, bigger rung.
|
||||
///
|
||||
/// `artUrl` is the 50px list thumbnail (the reference's `_50` downscale,
|
||||
/// `myqbz_detail.rs:269`), and the grid used to read that same field into a
|
||||
/// 200px `AlbumCard`: a 4x upscale, which is the owner's "la calidad de la
|
||||
/// portada es menor que la del albumcard". The owner's ruling (2026-07-31)
|
||||
/// is "list sí deben ser los de 50, grid 200", i.e. the row carries BOTH
|
||||
/// sizes rather than one being re-derived per view-mode switch — a
|
||||
/// re-derive republishes the document, and a republish hands `model:` a new
|
||||
/// array, which resets the view's scroll offset (see `RowPatch`). Two
|
||||
/// strings per row buy an instant list/grid toggle.
|
||||
///
|
||||
/// See `GRID_ART_TARGET` for why the rung is 600 and not 200.
|
||||
#[serde(rename = "artUrlLarge")]
|
||||
pub art_url_large: String,
|
||||
#[serde(rename = "artPathLarge")]
|
||||
pub art_path_large: String,
|
||||
/// Heart state for a row whose `sourceItemId` IS a Qobuz catalog album id
|
||||
/// (stored source `qobuz` + item type `album`); `false` for everything
|
||||
/// else, which is also everything the grid card hides the heart on.
|
||||
///
|
||||
/// Read through `fav_cache_qt::is_album_favorite`, the same door every
|
||||
/// other card PRODUCER in this port uses (`home_qt`, `library_qt`,
|
||||
/// `album_qt`) — never `library_qt::is_favorite`, which ORs in a linear
|
||||
/// scan of the whole Library feed and is O(rows x feed) on a 200-item
|
||||
/// collection.
|
||||
#[serde(rename = "isFavorite")]
|
||||
pub is_favorite: bool,
|
||||
/// Pin state, same gate and the same producer door as `is_favorite`
|
||||
/// (`sidebar_qt::is_pinned`). Both settle themselves after a click through
|
||||
/// `QbzLibrary.libraryFavoriteChanged` / `pinChanged`, so neither needs a
|
||||
/// `RowPatch` field.
|
||||
#[serde(rename = "isPinned")]
|
||||
pub is_pinned: bool,
|
||||
pub selected: bool,
|
||||
#[serde(rename = "canExpand")]
|
||||
pub can_expand: bool,
|
||||
@@ -152,11 +188,13 @@ pub struct DetailRow {
|
||||
/// (owner-authorised 2026-07-30): neither `MixtapeDetailView.slint` nor the
|
||||
/// Tauri build had a per-row accordion, they render inline tracks only as a
|
||||
/// whole-list VIEW MODE ("no chevron"). The owner asked for the accordion
|
||||
/// anyway; the view mode SURVIVES and now simply means "open all".
|
||||
/// anyway. The view mode SURVIVES but no longer means "open all" (owner,
|
||||
/// 2026-08-01): it only decides whether the arm carries the chevron.
|
||||
///
|
||||
/// It is ONE notion of open, not two: the chevron and the `expanded`
|
||||
/// segment both write `OPEN_ROWS`, and this flag is derived from that set
|
||||
/// in `to_item`. Do not add a second `viewMode === "expanded"` test in QML.
|
||||
/// It is ONE notion of open, not two: `OPEN_ROWS` is the persisted truth,
|
||||
/// written by the chevron alone, and this flag is derived from it in
|
||||
/// `to_item` (AND-ed with the details arm, which is a render gate, not a
|
||||
/// second state). Do not add a `viewMode === "expanded"` test in QML.
|
||||
#[serde(rename = "rowOpen")]
|
||||
pub row_open: bool,
|
||||
#[serde(rename = "tracksLoaded")]
|
||||
@@ -211,6 +249,14 @@ struct RowPatch {
|
||||
artwork_url: Option<String>,
|
||||
#[serde(rename = "artworkPath", skip_serializing_if = "Option::is_none")]
|
||||
artwork_path: Option<String>,
|
||||
/// The GRID rung (`DetailRow::art_url_large`). Travels with the small pair
|
||||
/// on the resolve backfill — the grid delegate reads these two and nothing
|
||||
/// else, so a patch that moved only `artUrl` left the grid on its
|
||||
/// placeholder glyph for the whole session.
|
||||
#[serde(rename = "artUrlLarge", skip_serializing_if = "Option::is_none")]
|
||||
art_url_large: Option<String>,
|
||||
#[serde(rename = "artPathLarge", skip_serializing_if = "Option::is_none")]
|
||||
art_path_large: Option<String>,
|
||||
#[serde(rename = "rowOpen", skip_serializing_if = "Option::is_none")]
|
||||
row_open: Option<bool>,
|
||||
#[serde(rename = "tracksLoaded", skip_serializing_if = "Option::is_none")]
|
||||
@@ -361,12 +407,17 @@ static INLINE_CACHE: LazyLock<Mutex<HashMap<String, Vec<InlineTrackRow>>>> =
|
||||
|
||||
/// THE per-row accordion OPEN set, same key as `INLINE_CACHE`.
|
||||
///
|
||||
/// ONE notion of "is this row open", written by BOTH mechanisms: the chevron
|
||||
/// (`toggle_row_expand`, one key) and the `expanded` view-mode segment
|
||||
/// (`set_view_mode` / `rebuild`, every expandable key = "open all"). Two
|
||||
/// parallel states would disagree the first time a user closed a row inside
|
||||
/// expanded mode. Surviving a re-derive is deliberate: a sort or a search must
|
||||
/// not fold the rows the user opened.
|
||||
/// ONE notion of "is this row open", with exactly ONE writer: the chevron
|
||||
/// (`toggle_row_expand`). The `expanded` segment used to write it too — entering
|
||||
/// the details arm force-opened every expandable row — and the owner asked for
|
||||
/// the opposite (2026-08-01): the view starts CLOSED and remembers. Surviving a
|
||||
/// re-derive is deliberate: a sort or a search must not fold the rows the user
|
||||
/// opened.
|
||||
///
|
||||
/// PERSISTED per collection in `collection_open_rows.json`
|
||||
/// (`myqbz_prefs_qt::{load,save,remove}_open_rows`): seeded in `apply` from the
|
||||
/// pruned stored keys, written by `persist_open_rows` on every chevron flip,
|
||||
/// cleared in-memory by `reset`/`teardown` (which never touch the file).
|
||||
static OPEN_ROWS: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
/// resolveItems results, same key. A MISS is what flags a row
|
||||
@@ -560,6 +611,51 @@ fn year_text(item: &MixtapeCollectionItem) -> String {
|
||||
item.year.map(|y| y.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Cover rung for the GRID arm's `AlbumCard` (200px artwork well).
|
||||
///
|
||||
/// The owner's words are "list sí deben ser los de 50, grid 200" (2026-07-31).
|
||||
/// 200 is not a rung Qobuz serves: the static CDN has a FIXED ladder —
|
||||
/// 50 / 100 / 150 / 230 / 300 / 600 / max / org, which is exactly the token
|
||||
/// list `myqbz_qt::small_qobuz_url` rewrites — and `_200.jpg` answers **404**
|
||||
/// (probed against a live cover on 2026-08-01: 50/150/230/300/600 -> 200,
|
||||
/// 200 -> 404). Emitting the literal 200 would have swapped a low-res cover
|
||||
/// for NO cover.
|
||||
///
|
||||
/// The rung is 600, not the smallest one that covers 200 (which is 230),
|
||||
/// because the owner's actual complaint was "la calidad de la portada es menor
|
||||
/// que la del albumcard" and the instruction was "USAR el AlbumCard normal" —
|
||||
/// and every other `AlbumCard` in the app is fed `album.image.large`, which IS
|
||||
/// `_600.jpg`. 230 would still read softer than its neighbours:
|
||||
/// `theme/RoundedImage.qml` decodes at native size with no `sourceSize`, so a
|
||||
/// 200pt well is 400 device px on a HiDPI screen and 230 upscales ~1.7x. "200"
|
||||
/// was the CARD's size, not a CDN rung.
|
||||
///
|
||||
/// Cost, accepted deliberately: a Qobuz row now caches `_50` (list) and `_600`
|
||||
/// (grid), so a collection's first open fetches both. The owner ruled on
|
||||
/// exactly this trade — "si había un motivo para hacer ligera esa vista en
|
||||
/// Tauri y Slint... ya no más, Qt sí puede con esas vistas" (2026-08-01).
|
||||
const GRID_ART_TARGET: u32 = 600;
|
||||
|
||||
/// Is this item's `source_item_id` a Qobuz CATALOG ALBUM id?
|
||||
///
|
||||
/// THE gate for the grid card's catalog-only affordances (heart, pin, "Block
|
||||
/// this album"). Both halves matter:
|
||||
/// * the stored `source` — `AlbumSource::Qobuz` is what makes the id a
|
||||
/// catalog id at all (a local or Plex item's is a path or a `plex:` key);
|
||||
/// * the item TYPE — a Qobuz TRACK item carries a track id and a Qobuz
|
||||
/// PLAYLIST item a playlist id, and `libraryToggleFavorite("album", …)` on
|
||||
/// either would heart a different entity, or none.
|
||||
///
|
||||
/// The RESOLVED `source_kind` is deliberately not consulted: it can read
|
||||
/// "offline" for a Qobuz download, which is still a catalog album, and "plex"
|
||||
/// for an item stored as Local — the stored source is the authority on what
|
||||
/// the id IS.
|
||||
fn is_catalog_album(item: &MixtapeCollectionItem) -> bool {
|
||||
item.source == AlbumSource::Qobuz
|
||||
&& item.item_type == ItemType::Album
|
||||
&& !item.source_item_id.is_empty()
|
||||
}
|
||||
|
||||
/// Stable per-item cache key. `source_item_id` alone is the logical key;
|
||||
/// pairing it with the source makes a qobuz-vs-local collision impossible.
|
||||
fn cache_key(source: &str, source_item_id: &str) -> String {
|
||||
@@ -618,22 +714,29 @@ fn to_item(
|
||||
inline_cache: &HashMap<String, Vec<InlineTrackRow>>,
|
||||
selected: &HashSet<i32>,
|
||||
open_rows: &HashSet<String>,
|
||||
arm_open: bool,
|
||||
) -> DetailRow {
|
||||
let source = source_str(item.source);
|
||||
// `small_qobuz_url` only rewrites Qobuz CDN `_<size>.jpg` urls; running it
|
||||
// on a local filesystem path (or a Plex `/library/...` path) corrupts it.
|
||||
let mut art_url = item
|
||||
.artwork_url
|
||||
.as_deref()
|
||||
.filter(|u| !u.is_empty())
|
||||
.map(|u| {
|
||||
if item.source == AlbumSource::Qobuz {
|
||||
crate::myqbz_qt::small_qobuz_url(u, 50)
|
||||
} else {
|
||||
u.to_string()
|
||||
//
|
||||
// TWO rungs off the ONE stored url: 50 for the list row, `GRID_ART_TARGET`
|
||||
// for the 200px grid card (see `DetailRow::art_url_large`). The non-Qobuz
|
||||
// guard applies to BOTH — a local path or a Plex `/library/...` path is
|
||||
// passed through raw on both fields, which also means the two are the same
|
||||
// string there and the grid gets whatever the file is.
|
||||
let stored_art = item.artwork_url.as_deref().filter(|u| !u.is_empty());
|
||||
let rung = |target: u32| -> String {
|
||||
match stored_art {
|
||||
Some(u) if item.source == AlbumSource::Qobuz => {
|
||||
crate::myqbz_qt::small_qobuz_url(u, target)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(u) => u.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
};
|
||||
let mut art_url = rung(50);
|
||||
let mut art_url_large = rung(GRID_ART_TARGET);
|
||||
|
||||
let key = cache_key(source, &item.source_item_id);
|
||||
let (source_kind, quality_tier, quality_detail, type_label_text, artist_id, quality_resolving) =
|
||||
@@ -644,6 +747,14 @@ fn to_item(
|
||||
if art_url.is_empty() && !r.artwork_url.is_empty() {
|
||||
art_url = r.artwork_url.clone();
|
||||
}
|
||||
// The backfill is a RESOLVED track's own artwork (a local file
|
||||
// path, or whatever rung the catalog handed back) — it is NOT
|
||||
// re-rung, exactly as the small field is not, so the two stay
|
||||
// the same string and the grid never asks for a size the
|
||||
// backfill source cannot serve.
|
||||
if art_url_large.is_empty() && !r.artwork_url.is_empty() {
|
||||
art_url_large = r.artwork_url.clone();
|
||||
}
|
||||
(
|
||||
r.source_kind.clone(),
|
||||
r.quality_tier.clone(),
|
||||
@@ -697,53 +808,66 @@ fn to_item(
|
||||
art_url,
|
||||
artwork_url: String::new(),
|
||||
artwork_path: String::new(),
|
||||
art_path_large: crate::artwork_qt::cached_path(&art_url_large),
|
||||
art_url_large,
|
||||
// The catalog gate, decided ONCE here: a Qobuz ALBUM item's
|
||||
// `source_item_id` IS the catalog album id, so its heart and its pin
|
||||
// are the same entity every other AlbumCard in the app talks about. A
|
||||
// track, a playlist, a local or a Plex item's is not — those answer
|
||||
// `false` and the grid card hides both affordances rather than drawing
|
||||
// a dead one (`MyQbzDetailView.qml`'s `cCatalogAlbum`, same predicate).
|
||||
is_favorite: is_catalog_album(item)
|
||||
&& crate::fav_cache_qt::is_album_favorite(&item.source_item_id),
|
||||
is_pinned: is_catalog_album(item)
|
||||
&& crate::sidebar_qt::is_pinned("album", &item.source_item_id),
|
||||
selected: selected.contains(&item.position),
|
||||
can_expand,
|
||||
// Re-derives (sort / search / filter) rebuild every row, so the open
|
||||
// state has to be re-read off the set here or the accordion would snap
|
||||
// shut on the first keystroke.
|
||||
row_open: can_expand && open_rows.contains(&key),
|
||||
//
|
||||
// `arm_open` is the ARM gate, not a second notion of open: the chevron
|
||||
// exists in the DETAILS arm only (`MyQbzDetailView.qml` `rowExpander`),
|
||||
// so the list and grid arms must not render an inline block the user has
|
||||
// no affordance to close. The SET is the persisted truth and survives the
|
||||
// round trip untouched — which is what makes an open row still open when
|
||||
// the user comes back to details (and after a restart).
|
||||
row_open: can_expand && arm_open && open_rows.contains(&key),
|
||||
tracks_loaded,
|
||||
expand_loading: false,
|
||||
inline_tracks,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_rows(items: &[MixtapeCollectionItem]) -> Vec<DetailRow> {
|
||||
fn build_rows(items: &[MixtapeCollectionItem], arm_open: bool) -> Vec<DetailRow> {
|
||||
let resolved = RESOLVE_CACHE.lock().unwrap();
|
||||
let inline = INLINE_CACHE.lock().unwrap();
|
||||
let selected = SELECTED.lock().unwrap();
|
||||
let open_rows = OPEN_ROWS.lock().unwrap();
|
||||
items
|
||||
.iter()
|
||||
.map(|it| to_item(it, &resolved, &inline, &selected, &open_rows))
|
||||
.map(|it| to_item(it, &resolved, &inline, &selected, &open_rows, arm_open))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Force every expandable row OPEN, in the document AND in the shared set — the
|
||||
/// `expanded` view-mode's whole meaning. Returns the rows it changed as
|
||||
/// patches, so a caller that is not already republishing can send just those.
|
||||
/// Re-derive every row's `rowOpen` from the shared set for the CURRENT arm,
|
||||
/// in place — no rebuild of the model, no re-derive of the filter chain, so the
|
||||
/// caches, the selection and the scroll position all survive (which is what
|
||||
/// `set_view_mode` promises). The caller publishes.
|
||||
///
|
||||
/// Called from `rebuild` as well as `set_view_mode` because a re-derive can
|
||||
/// REVEAL a row (a filter cleared, a search narrowed then widened) that has
|
||||
/// never been opened: without this it would render collapsed among its open
|
||||
/// neighbours and the segment would be lying.
|
||||
fn open_all_expandable(d: &mut DetailDoc) -> Vec<RowPatch> {
|
||||
let mut open = OPEN_ROWS.lock().unwrap();
|
||||
let mut patches = Vec::new();
|
||||
/// This is the whole of what a view-mode switch does to the accordion now:
|
||||
/// leaving the details arm hides the inline blocks (their affordance is gone),
|
||||
/// coming back restores exactly what the user had open. The SET is never
|
||||
/// touched here — it is the persisted truth.
|
||||
fn resync_row_open(d: &mut DetailDoc) {
|
||||
let arm_open = d.view_mode == "expanded";
|
||||
let open = OPEN_ROWS.lock().unwrap();
|
||||
for row in d.items.iter_mut() {
|
||||
if !row.can_expand {
|
||||
continue;
|
||||
}
|
||||
open.insert(cache_key(&row.source, &row.source_item_id));
|
||||
if !row.row_open {
|
||||
row.row_open = true;
|
||||
let mut patch = RowPatch::of(row);
|
||||
patch.row_open = Some(true);
|
||||
patches.push(patch);
|
||||
}
|
||||
let want = row.can_expand
|
||||
&& arm_open
|
||||
&& open.contains(&cache_key(&row.source, &row.source_item_id));
|
||||
row.row_open = want;
|
||||
}
|
||||
patches
|
||||
}
|
||||
|
||||
/// The source word a RESOLVED track renders as — the glyph kind in
|
||||
@@ -902,15 +1026,12 @@ fn rebuild(d: &mut DetailDoc) {
|
||||
}
|
||||
|
||||
let view = filtered_sorted(d);
|
||||
d.items = build_rows(&view);
|
||||
// `expanded` means OPEN ALL. The rows were just rebuilt off `OPEN_ROWS`,
|
||||
// so this only ever adds the ones the re-derive revealed. (A row closed by
|
||||
// its chevron WHILE in expanded mode therefore re-opens on the next
|
||||
// re-derive — the price of one shared state, and the honest reading of a
|
||||
// segment whose meaning is "all of them".)
|
||||
if d.view_mode == "expanded" {
|
||||
open_all_expandable(d);
|
||||
}
|
||||
// The rows come out of `OPEN_ROWS` — the ONLY thing that opens a row is the
|
||||
// user's chevron. Entering the details arm used to force every expandable
|
||||
// row open here (`open_all_expandable`); the owner asked for the opposite
|
||||
// (2026-08-01): the view starts fully CLOSED and remembers what he opened.
|
||||
let arm_open = d.view_mode == "expanded";
|
||||
d.items = build_rows(&view, arm_open);
|
||||
d.selected_count = SELECTED.lock().unwrap().len() as i32;
|
||||
|
||||
let source_count = i32::from(d.src_qobuz) + i32::from(d.src_plex) + i32::from(d.src_local);
|
||||
@@ -938,19 +1059,21 @@ fn rebuild(d: &mut DetailDoc) {
|
||||
/// out has never been fetched, so without this it appears in expanded mode with
|
||||
/// no inline tracks and no spinner until the user toggles the mode again.
|
||||
/// Idempotent — rows already loaded or loading are skipped, and a row cached in
|
||||
/// `INLINE_CACHE` re-hydrates in `to_item` rather than re-fetching. Deliberately
|
||||
/// NOT called from `apply`: the reference's load path does not auto-expand
|
||||
/// either, even when the restored view-mode is `expanded`.
|
||||
/// `INLINE_CACHE` re-hydrates in `to_item` rather than re-fetching. `load` calls
|
||||
/// it once after `apply` for the same reason, since the RESTORED open set can
|
||||
/// carry rows whose tracks this session has never fetched; that is not the old
|
||||
/// auto-expand, which opened rows the user never opened.
|
||||
pub(crate) fn refresh_view() {
|
||||
with_doc(rebuild);
|
||||
let missing = attach_artwork();
|
||||
publish_current();
|
||||
dispatch_downloads(missing);
|
||||
// Unconditional now that `ensure_expanded` keys off the per-row OPEN state
|
||||
// rather than the view mode: with nothing open it is one pass over the
|
||||
// rows and no fetch, and it covers the accordion in LIST mode too (a row
|
||||
// the user opened, then filtered out and back in, has lost its in-flight
|
||||
// spinner and must be re-fetched).
|
||||
// rather than the view mode: with nothing open it is one pass over the rows
|
||||
// and no fetch. It matters for a row the user opened, then filtered out and
|
||||
// back in — the re-derive rebuilt it from `OPEN_ROWS` as open, but its
|
||||
// in-flight spinner died with the old row object and its tracks must be
|
||||
// re-fetched.
|
||||
ensure_expanded();
|
||||
}
|
||||
|
||||
@@ -1036,15 +1159,27 @@ fn attach_artwork() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
for row in d.items.iter_mut() {
|
||||
if row.art_url.is_empty() || !row.art_path.is_empty() {
|
||||
continue;
|
||||
// BOTH rungs, or the grid asks for a file nothing ever fetches:
|
||||
// `artUrlLarge` is a DIFFERENT url from `artUrl` (`_230` vs `_50`)
|
||||
// and therefore a different disk-cache entry, so a sweep that only
|
||||
// knew about the small one left every grid cell empty.
|
||||
if !row.art_url.is_empty() && row.art_path.is_empty() {
|
||||
let hit = crate::artwork_qt::cached_path(&row.art_url);
|
||||
if hit.is_empty() {
|
||||
let url = row.art_url.clone();
|
||||
push(&url, &mut missing);
|
||||
} else {
|
||||
row.art_path = hit;
|
||||
}
|
||||
}
|
||||
let hit = crate::artwork_qt::cached_path(&row.art_url);
|
||||
if hit.is_empty() {
|
||||
let url = row.art_url.clone();
|
||||
push(&url, &mut missing);
|
||||
} else {
|
||||
row.art_path = hit;
|
||||
if !row.art_url_large.is_empty() && row.art_path_large.is_empty() {
|
||||
let hit = crate::artwork_qt::cached_path(&row.art_url_large);
|
||||
if hit.is_empty() {
|
||||
let url = row.art_url_large.clone();
|
||||
push(&url, &mut missing);
|
||||
} else {
|
||||
row.art_path_large = hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
missing
|
||||
@@ -1079,6 +1214,10 @@ fn refresh_artwork_paths() {
|
||||
if row.art_path.is_empty() {
|
||||
row.art_path = crate::artwork_qt::cached_path(&row.art_url);
|
||||
}
|
||||
// The grid rung is its own cache entry — see `attach_artwork`.
|
||||
if row.art_path_large.is_empty() {
|
||||
row.art_path_large = crate::artwork_qt::cached_path(&row.art_url_large);
|
||||
}
|
||||
}
|
||||
d.clone()
|
||||
});
|
||||
@@ -1133,6 +1272,40 @@ fn persist_prefs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the accordion's OPEN set for the collection on screen.
|
||||
///
|
||||
/// Called from `toggle_row_expand`, which runs on the GUI thread (a QML
|
||||
/// invokable), so the write is pushed onto `spawn_blocking` — every disk helper
|
||||
/// in this port is blocking and none of them may sit on the Qt event loop.
|
||||
///
|
||||
/// The snapshot is taken INSIDE the blocking task, not at call time: two fast
|
||||
/// chevron clicks spawn two tasks that the runtime may run in either order, and
|
||||
/// a task that carried its own older snapshot could then land last and persist a
|
||||
/// stale set. Reading the live set in the task makes both writes converge on
|
||||
/// whatever the current state is. The collection id is re-checked there too — a
|
||||
/// task queued while the user navigated away must not write another
|
||||
/// collection's file.
|
||||
///
|
||||
/// Local UI state end to end: no network verb, no Qobuz session, works offline
|
||||
/// and on a machine that has never logged in (the store degrades to a no-op when
|
||||
/// there is no per-user directory, exactly like the view prefs).
|
||||
fn persist_open_rows() {
|
||||
let collection_id = current_id();
|
||||
if collection_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if current_id() != collection_id {
|
||||
return;
|
||||
}
|
||||
let keys: Vec<String> = OPEN_ROWS.lock().unwrap().iter().cloned().collect();
|
||||
crate::myqbz_prefs_qt::save_open_rows(&collection_id, &keys);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset / apply
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1143,6 +1316,8 @@ fn reset() {
|
||||
FULL_ITEMS.lock().unwrap().clear();
|
||||
INLINE_CACHE.lock().unwrap().clear();
|
||||
RESOLVE_CACHE.lock().unwrap().clear();
|
||||
// In-memory only — the on-disk set for this collection is untouched, and
|
||||
// `apply` seeds this one back from it.
|
||||
OPEN_ROWS.lock().unwrap().clear();
|
||||
SELECTED.lock().unwrap().clear();
|
||||
*LAST_FILTER_KEY.lock().unwrap() = None;
|
||||
@@ -1161,7 +1336,10 @@ fn reset() {
|
||||
|
||||
/// Apply a freshly-loaded collection. The ORDER is load-bearing: the persist
|
||||
/// gate opens AFTER the restore, so the restore itself is not re-persisted.
|
||||
fn apply(c: MixtapeCollection) {
|
||||
///
|
||||
/// `stored_open` is this collection's persisted accordion set, read off disk by
|
||||
/// the caller's `spawn_blocking` (never here — `apply` runs inline).
|
||||
fn apply(c: MixtapeCollection, stored_open: Vec<String>) {
|
||||
with_doc(|d| {
|
||||
let item_count = c.items.len();
|
||||
d.id = c.id.clone();
|
||||
@@ -1191,6 +1369,24 @@ fn apply(c: MixtapeCollection) {
|
||||
restore_prefs(d, &c.id);
|
||||
PREFS_HYDRATED.store(true, Ordering::SeqCst);
|
||||
|
||||
// Restore the accordion, PRUNED to the rows that still exist and can
|
||||
// actually expand. A key whose item was removed from the collection (or
|
||||
// whose type changed) would otherwise sit in the file forever and, worse,
|
||||
// could re-open a DIFFERENT row if that id came back under another type.
|
||||
// The pruned set is what the next chevron click persists, so the file
|
||||
// self-heals without a write on every open.
|
||||
{
|
||||
let live: HashSet<String> = c
|
||||
.items
|
||||
.iter()
|
||||
.filter(|it| matches!(it.item_type, ItemType::Album | ItemType::Playlist))
|
||||
.map(|it| cache_key(source_str(it.source), &it.source_item_id))
|
||||
.collect();
|
||||
let mut open = OPEN_ROWS.lock().unwrap();
|
||||
open.clear();
|
||||
open.extend(stored_open.into_iter().filter(|k| live.contains(k)));
|
||||
}
|
||||
|
||||
*FULL_ITEMS.lock().unwrap() = c.items;
|
||||
rebuild(d);
|
||||
d.loading = false;
|
||||
@@ -1329,6 +1525,21 @@ fn apply_resolved(item: &MixtapeCollectionItem, resolved: ResolvedItem, collecti
|
||||
patch.artwork_url = Some(row.art_url.clone());
|
||||
patch.artwork_path = Some(row.art_path.clone());
|
||||
}
|
||||
// The GRID rung, backfilled from the SAME resolved url and NOT
|
||||
// re-rung (`to_item` does the same) — a resolved track's artwork is
|
||||
// whatever its source hands back, and asking a local file for a
|
||||
// `_230` variant is meaningless. Its own `if`, not the small one's
|
||||
// branch: the two fields are only ever equal by accident and a row
|
||||
// can perfectly well have one filled and the other empty.
|
||||
if row.art_url_large.is_empty() && !resolved.artwork_url.is_empty() {
|
||||
row.art_url_large = resolved.artwork_url.clone();
|
||||
row.art_path_large = crate::artwork_qt::cached_path(&row.art_url_large);
|
||||
if row.art_path_large.is_empty() && stored_art_empty && backfill.is_none() {
|
||||
backfill = Some(row.art_url_large.clone());
|
||||
}
|
||||
patch.art_url_large = Some(row.art_url_large.clone());
|
||||
patch.art_path_large = Some(row.art_path_large.clone());
|
||||
}
|
||||
patches.push(patch);
|
||||
}
|
||||
(patches, backfill)
|
||||
@@ -1414,11 +1625,12 @@ fn full_item_by_source_id(source_item_id: &str) -> Option<MixtapeCollectionItem>
|
||||
/// is instant.
|
||||
///
|
||||
/// The gate is `row_open`, not `can_expand`: with the accordion, "expandable"
|
||||
/// is no longer "will be shown". In expanded mode every expandable row IS open
|
||||
/// (`rebuild` / `set_view_mode`), so the old behaviour is unchanged; in list
|
||||
/// mode this fetches only what the user actually opened, which is what makes
|
||||
/// the chevron lazy. `toggle_row_expand` fetches its own single row directly
|
||||
/// rather than routing through here, so one chevron never sweeps the list.
|
||||
/// is no longer "will be shown". Nothing force-opens rows any more, so this
|
||||
/// fetches only what the USER opened — that is the whole of what makes the
|
||||
/// chevron lazy, and it is why entering the details arm on a 200-item
|
||||
/// collection now costs zero requests. `toggle_row_expand` fetches its own
|
||||
/// single row directly rather than routing through here, so one chevron never
|
||||
/// sweeps the list.
|
||||
///
|
||||
/// DEVIATION (reported): the fan-out is capped at `MAX_RESOLVE_CONCURRENCY`,
|
||||
/// the same cap T18 mandates for `resolve_items`. The reference is uncapped and
|
||||
@@ -1531,15 +1743,25 @@ fn patch_expanded(source_item_id: &str, collection_id: &str, rows: Option<Vec<In
|
||||
/// re-hydrates `inlineTracks` from it, and `tracks_loaded` short-circuits the
|
||||
/// fetch. Closing never drops the cache.
|
||||
///
|
||||
/// It writes the SAME `OPEN_ROWS` set the `expanded` view-mode segment writes,
|
||||
/// which is what keeps "open all" and "open this one" from disagreeing. The
|
||||
/// view mode itself is NOT touched — a chevron in list mode does not flip the
|
||||
/// It is the ONLY writer of `OPEN_ROWS` — the view-mode segment stopped writing
|
||||
/// it (see `set_view_mode`), so "open" means exactly "the user opened this row".
|
||||
/// The view mode itself is NOT touched either: a chevron does not flip the
|
||||
/// segment, and closing every row by hand does not leave the segment stranded.
|
||||
///
|
||||
/// Every flip is PERSISTED (`persist_open_rows`), which is what makes the
|
||||
/// accordion survive a view switch, a re-open and a restart.
|
||||
pub(crate) fn toggle_row_expand(source_item_id: &str) {
|
||||
let mut fetch = false;
|
||||
let (collection_id, patches) = with_doc(|d| {
|
||||
let mut patches: Vec<RowPatch> = Vec::new();
|
||||
let id = d.id.clone();
|
||||
// The affordance lives in the details arm only, and so does the state
|
||||
// it writes: opening a row from anywhere else would render an inline
|
||||
// block with no chevron to close it. The QML never offers the gesture
|
||||
// there — this is the invariant enforced where the state lives.
|
||||
if d.view_mode != "expanded" {
|
||||
return (id, patches);
|
||||
}
|
||||
let Some(row) = d
|
||||
.items
|
||||
.iter_mut()
|
||||
@@ -1572,7 +1794,16 @@ pub(crate) fn toggle_row_expand(source_item_id: &str) {
|
||||
patches.push(patch);
|
||||
(id, patches)
|
||||
});
|
||||
// Exactly one patch is pushed, and only on the path that actually flipped a
|
||||
// row — every early return above (wrong arm, no such row, not expandable)
|
||||
// leaves `patches` empty. So this is the "did the set change" flag, and it
|
||||
// keeps a click that changed nothing from spending a read-modify-write on
|
||||
// the sidecar.
|
||||
let flipped = !patches.is_empty();
|
||||
publish_rows(&collection_id, patches);
|
||||
if flipped {
|
||||
persist_open_rows();
|
||||
}
|
||||
if !fetch || collection_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1645,28 +1876,25 @@ pub(crate) fn reset_filters() {
|
||||
/// item list is unchanged, only the layout switches), so neither do we: mutate,
|
||||
/// persist, PUBLISH. The caches, the selection and the scroll position survive.
|
||||
///
|
||||
/// The segment now drives the SAME per-row open state the chevron does:
|
||||
/// `expanded` = open all, and leaving it = close all. Without the close-all arm
|
||||
/// the list would keep every inline block rendered after switching back and the
|
||||
/// segment would appear to do nothing. Toggling list <-> grid leaves the set
|
||||
/// alone, so a row opened in list mode is still open when you come back from
|
||||
/// the grid.
|
||||
/// The segment does NOT open or close anything any more (owner, 2026-08-01):
|
||||
/// `expanded` used to mean "open all" and leaving it meant "close all, and
|
||||
/// forget". Both are gone. The segment now only decides whether the arm RENDERS
|
||||
/// the accordion — the chevron lives in the details arm alone
|
||||
/// (`MyQbzDetailView.qml` `rowExpander`), so the list and grid arms must not
|
||||
/// show an inline block there is no affordance to close. `resync_row_open`
|
||||
/// re-derives the flag from the untouched set, which is why a row the user
|
||||
/// opened is still open when he comes back to details.
|
||||
pub(crate) fn set_view_mode(mode: &str) {
|
||||
let was_expanded = with_doc(|d| d.view_mode == "expanded");
|
||||
with_doc(|d| {
|
||||
d.view_mode = mode.to_string();
|
||||
if mode == "expanded" {
|
||||
open_all_expandable(d);
|
||||
} else if was_expanded {
|
||||
OPEN_ROWS.lock().unwrap().clear();
|
||||
for row in d.items.iter_mut() {
|
||||
row.row_open = false;
|
||||
}
|
||||
}
|
||||
resync_row_open(d);
|
||||
});
|
||||
persist_prefs();
|
||||
publish_current();
|
||||
if mode == "expanded" {
|
||||
// Restoring the arm can reveal rows whose inline tracks were never
|
||||
// fetched (a restored set on a fresh session). Idempotent, and a no-op
|
||||
// when nothing is open.
|
||||
ensure_expanded();
|
||||
}
|
||||
}
|
||||
@@ -1798,24 +2026,37 @@ pub(crate) async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, id: String)
|
||||
// D11.c GAP: the reference drops items failing the offline availability
|
||||
// rule here (`myqbz.rs:231 retain_available_offline`). This port opens no
|
||||
// offline-cache index, so no item is hidden (spec 02 §1.1).
|
||||
// ONE blocking hop for both reads: the collection (DB) and this
|
||||
// collection's persisted accordion set (a small per-user JSON). Neither may
|
||||
// run on the GUI thread, and pairing them keeps the load at one hop.
|
||||
let fetch_id = id.clone();
|
||||
let collection = tokio::task::spawn_blocking(move || get_collection(&fetch_id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let loaded = tokio::task::spawn_blocking(move || {
|
||||
get_collection(&fetch_id).map(|c| {
|
||||
let open = crate::myqbz_prefs_qt::load_open_rows(&fetch_id);
|
||||
(c, open)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(collection) = collection else {
|
||||
let Some((collection, stored_open)) = loaded else {
|
||||
log::warn!("[qbz-qt] myqbz_detail load({id}): collection not found");
|
||||
apply_not_found();
|
||||
return;
|
||||
};
|
||||
|
||||
apply(collection);
|
||||
apply(collection, stored_open);
|
||||
let missing = attach_artwork();
|
||||
publish_current();
|
||||
dispatch_downloads(missing);
|
||||
|
||||
resolve_items(Arc::clone(runtime));
|
||||
// The restored set can carry rows the user left open: they render open with
|
||||
// an empty body until something fetches their tracks. This is NOT the old
|
||||
// auto-expand — with nothing restored (the default, and every first open) it
|
||||
// is one pass over the rows and no fetch at all.
|
||||
ensure_expanded();
|
||||
}
|
||||
|
||||
/// Grid card click: push the route, then load.
|
||||
@@ -2061,16 +2302,53 @@ mod tests {
|
||||
open.insert(cache_key("qobuz", &album.source_item_id));
|
||||
open.insert(cache_key("qobuz", &track.source_item_id));
|
||||
|
||||
let album_row = to_item(&album, &resolved, &inline, &selected, &open);
|
||||
let album_row = to_item(&album, &resolved, &inline, &selected, &open, true);
|
||||
assert!(album_row.can_expand);
|
||||
assert!(album_row.row_open);
|
||||
|
||||
let track_row = to_item(&track, &resolved, &inline, &selected, &open);
|
||||
let track_row = to_item(&track, &resolved, &inline, &selected, &open, true);
|
||||
assert!(!track_row.can_expand);
|
||||
assert!(!track_row.row_open);
|
||||
|
||||
let closed: HashSet<String> = HashSet::new();
|
||||
assert!(!to_item(&album, &resolved, &inline, &selected, &closed).row_open);
|
||||
assert!(!to_item(&album, &resolved, &inline, &selected, &closed, true).row_open);
|
||||
}
|
||||
|
||||
/// The ARM gate: outside the details arm no row renders open, and the SET
|
||||
/// is not consulted destructively — the very same set still opens the row
|
||||
/// when the arm comes back. This is what makes a view switch lossless while
|
||||
/// keeping the list and grid arms free of inline blocks they cannot close.
|
||||
#[test]
|
||||
fn row_open_is_gated_by_the_details_arm_without_losing_the_set() {
|
||||
let resolved = HashMap::new();
|
||||
let inline = HashMap::new();
|
||||
let selected = HashSet::new();
|
||||
let album = item(0, "A", ItemType::Album, AlbumSource::Qobuz);
|
||||
let mut open = HashSet::new();
|
||||
open.insert(cache_key("qobuz", &album.source_item_id));
|
||||
|
||||
assert!(!to_item(&album, &resolved, &inline, &selected, &open, false).row_open);
|
||||
assert!(to_item(&album, &resolved, &inline, &selected, &open, true).row_open);
|
||||
assert!(open.contains(&cache_key("qobuz", &album.source_item_id)));
|
||||
}
|
||||
|
||||
/// A fresh collection starts CLOSED: nothing but the user's own chevron puts
|
||||
/// a key in the set, so an empty set means every row renders collapsed even
|
||||
/// in the details arm.
|
||||
#[test]
|
||||
fn a_collection_with_no_stored_state_starts_fully_closed() {
|
||||
let resolved = HashMap::new();
|
||||
let inline = HashMap::new();
|
||||
let selected = HashSet::new();
|
||||
let open: HashSet<String> = HashSet::new();
|
||||
for (i, kind) in [ItemType::Album, ItemType::Playlist, ItemType::Track]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let it = item(i as i32, "X", kind, AlbumSource::Qobuz);
|
||||
let row = to_item(&it, &resolved, &inline, &selected, &open, true);
|
||||
assert!(!row.row_open, "{kind:?} row must start closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A patch carries the join key plus ONLY what changed — the point of the
|
||||
|
||||
@@ -337,6 +337,11 @@ pub(crate) fn confirm_delete() {
|
||||
Ok(()) => {
|
||||
close();
|
||||
crate::myqbz_prefs_qt::remove_view_prefs(&id);
|
||||
// Same lifetime as the view prefs: the collection is gone, so
|
||||
// its accordion state goes with it. (Ids are v4 UUIDs, so a
|
||||
// re-created collection could not inherit it either way — this
|
||||
// keeps the sidecar from accumulating dead entries.)
|
||||
crate::myqbz_prefs_qt::remove_open_rows(&id);
|
||||
crate::nav_qt::back();
|
||||
crate::myqbz_qt::reload_grids();
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
//! ```text
|
||||
//! <data_dir>/qbz/users/<uid>/myqbz_branding.json { label, icon_path }
|
||||
//! <data_dir>/qbz/users/<uid>/collection_view_prefs.json { "<id>": { …7 fields… } }
|
||||
//! <data_dir>/qbz/users/<uid>/collection_open_rows.json { "<id>": ["src|itemId", …] }
|
||||
//! ```
|
||||
//!
|
||||
//! Both documents are SHARED with the shipping Slint app, so:
|
||||
//! The first two documents are SHARED with the shipping Slint app (the third is
|
||||
//! Qt-only — see below), so:
|
||||
//! - the persisted keys stay snake_case and EXACTLY as Slint spells them — a
|
||||
//! rename would silently drop the user's stored prefs (spec 02 §12 Q11);
|
||||
//! - every write goes through `settings_qt::read_json_object` +
|
||||
@@ -34,6 +36,16 @@
|
||||
//! - persist on change: `save_view_prefs(id, &prefs)`;
|
||||
//! - clear on delete: `remove_view_prefs(id)`.
|
||||
//!
|
||||
//! The OPEN-ROWS sidecar (`collection_open_rows.json`) has the SAME three-call
|
||||
//! lifecycle and lives here for the same reason the view prefs do — but in its
|
||||
//! OWN file, not as an eighth key of `collection_view_prefs.json`, because that
|
||||
//! document is co-owned with the shipping Slint build: `myqbz_view_prefs.rs`
|
||||
//! deserializes it into `HashMap<String, Prefs>` and `write_all` re-serializes
|
||||
//! THAT map, so any key this port added inside a collection's object would be
|
||||
//! dropped the next time the user touched the same collection in the Slint app.
|
||||
//! The accordion is a Qt-only owner feature (neither reference has one), so its
|
||||
//! state has no Slint counterpart and must not ride in a shared document.
|
||||
//!
|
||||
//! The hydration gate (spec 02 §7 T15 — no persist until `apply()` has restored
|
||||
//! the stored prefs, or an early setter clobbers them) lives in
|
||||
//! `myqbz_detail_qt`, which is the state machine that opens and closes it
|
||||
@@ -55,6 +67,7 @@ pub(crate) const DEFAULT_LABEL: &str = "My QBZ";
|
||||
|
||||
const BRANDING_FILE: &str = "myqbz_branding.json";
|
||||
const VIEW_PREFS_FILE: &str = "collection_view_prefs.json";
|
||||
const OPEN_ROWS_FILE: &str = "collection_open_rows.json";
|
||||
|
||||
/// The per-user directory, bound at session activation. `None` before login —
|
||||
/// both stores then degrade to defaults (there is no pre-login MyQBZ surface).
|
||||
@@ -82,6 +95,10 @@ fn view_prefs_path() -> Option<PathBuf> {
|
||||
Some(user_dir()?.join(VIEW_PREFS_FILE))
|
||||
}
|
||||
|
||||
fn open_rows_path() -> Option<PathBuf> {
|
||||
Some(user_dir()?.join(OPEN_ROWS_FILE))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branding store
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -376,6 +393,128 @@ pub(crate) fn remove_view_prefs(id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-collection ACCORDION open rows
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `{ "<collection-id>": ["<source>|<source_item_id>", …] }` — the keys are
|
||||
// `myqbz_detail_qt::cache_key`, the same string that keys `INLINE_CACHE`,
|
||||
// `RESOLVE_CACHE` and the in-memory `OPEN_ROWS` set. Storing the KEY rather
|
||||
// than the row position is what survives a reorder: position 3 is a different
|
||||
// album after a drag, `qobuz|12345` is not.
|
||||
//
|
||||
// Lifetime: created on the first chevron click, replaced wholesale on every
|
||||
// later one, and dropped by `remove_open_rows` when the collection is deleted.
|
||||
// A stale key (an item removed from the collection) is pruned on the next open
|
||||
// by `myqbz_detail_qt::apply`, and the pruned set is what the next click
|
||||
// persists — the file self-heals rather than growing forever.
|
||||
|
||||
/// The whole `{ collection-id -> [row key] }` map. A missing / unreadable /
|
||||
/// unparseable file degrades to an empty map, exactly like the view prefs.
|
||||
fn read_open_rows() -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(path) = open_rows_path() else {
|
||||
return serde_json::Map::new();
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
},
|
||||
Err(_) => serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ONE collection's stored entry -> its open-row keys.
|
||||
///
|
||||
/// Anything that is not an array of non-empty strings (an older shape, a hand
|
||||
/// edit, a null) degrades to "nothing was open" rather than resurrecting
|
||||
/// garbage as open rows. Split out of `load_open_rows` so the unit test can
|
||||
/// exercise THIS function instead of a copy of it — the store itself needs a
|
||||
/// bound user directory and is not reachable from a test.
|
||||
fn parse_open_rows_entry(entry: Option<&serde_json::Value>) -> Vec<String> {
|
||||
match entry {
|
||||
Some(serde_json::Value::Array(keys)) => keys
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored open-row keys for `id` (empty when none are stored, the id is
|
||||
/// empty, or the stored entry is malformed).
|
||||
///
|
||||
/// BLOCKING (one small file read) — call it from `spawn_blocking`, never on the
|
||||
/// GUI thread. `myqbz_detail_qt::load` reads it inside the same
|
||||
/// `spawn_blocking` that fetches the collection.
|
||||
pub(crate) fn load_open_rows(id: &str) -> Vec<String> {
|
||||
if id.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
parse_open_rows_entry(read_open_rows().get(id))
|
||||
}
|
||||
|
||||
/// Persist the open-row keys for `id`. An EMPTY set removes the entry instead
|
||||
/// of writing `[]` — closing every row must not leave the user's file carrying
|
||||
/// one key per collection ever visited.
|
||||
///
|
||||
/// BLOCKING — same rule as `load_open_rows`.
|
||||
pub(crate) fn save_open_rows(id: &str, keys: &[String]) {
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = open_rows_path() else {
|
||||
log::warn!("[qbz-qt] collection open-rows: no active user, not saving");
|
||||
return;
|
||||
};
|
||||
let Some(mut doc) = crate::settings_qt::read_json_object(&path) else {
|
||||
return;
|
||||
};
|
||||
if keys.is_empty() {
|
||||
if doc.remove(id).is_none() {
|
||||
return; // nothing stored and nothing to store — skip the write.
|
||||
}
|
||||
} else {
|
||||
// Sorted so an unordered `HashSet` drain cannot rewrite the same
|
||||
// logical document with a different byte order on every click.
|
||||
let mut keys: Vec<&str> = keys.iter().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
doc.insert(
|
||||
id.to_string(),
|
||||
serde_json::Value::Array(
|
||||
keys.into_iter()
|
||||
.map(|k| serde_json::Value::String(k.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
crate::settings_qt::write_json_object_atomic(&path, &doc);
|
||||
}
|
||||
|
||||
/// Drop the orphaned entry after a collection is deleted, alongside
|
||||
/// `remove_view_prefs`. No-op when absent.
|
||||
///
|
||||
/// Collection ids are v4 UUIDs (`qbz_mixtape::repo` line 33), so a re-created
|
||||
/// collection cannot inherit a deleted one's key even without this — it exists
|
||||
/// so the file does not accumulate entries for collections that no longer are.
|
||||
pub(crate) fn remove_open_rows(id: &str) {
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = open_rows_path() else {
|
||||
return;
|
||||
};
|
||||
let Some(mut doc) = crate::settings_qt::read_json_object(&path) else {
|
||||
return;
|
||||
};
|
||||
if doc.remove(id).is_some() {
|
||||
crate::settings_qt::write_json_object_atomic(&path, &doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session binding
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -482,4 +621,31 @@ mod tests {
|
||||
save_view_prefs("", &Prefs::default());
|
||||
remove_view_prefs("");
|
||||
}
|
||||
|
||||
/// Same guard on the open-rows sidecar. No user dir is bound in tests, so
|
||||
/// what is pinned is that the id guard comes first on all three paths.
|
||||
#[test]
|
||||
fn empty_id_is_a_no_op_on_every_open_rows_path() {
|
||||
assert!(load_open_rows("").is_empty());
|
||||
save_open_rows("", &["qobuz|1".to_string()]);
|
||||
remove_open_rows("");
|
||||
}
|
||||
|
||||
/// The stored entry is a plain array of `cache_key` strings; anything else
|
||||
/// (an older shape, a hand edit) degrades to "nothing was open" rather than
|
||||
/// resurrecting garbage as open rows. Calls the PRODUCTION parser — the
|
||||
/// store around it needs a bound user directory, which a unit test has not
|
||||
/// got, so the parse is the seam that gets pinned.
|
||||
#[test]
|
||||
fn open_rows_entry_parses_only_a_string_array() {
|
||||
let doc: serde_json::Value = serde_json::from_str(
|
||||
r#"{"a":["qobuz|1","local|/x",""],"b":{"nope":true},"c":["qobuz|2",7],"d":null}"#,
|
||||
)
|
||||
.expect("parses");
|
||||
assert_eq!(parse_open_rows_entry(doc.get("a")), vec!["qobuz|1", "local|/x"]);
|
||||
assert!(parse_open_rows_entry(doc.get("b")).is_empty());
|
||||
assert_eq!(parse_open_rows_entry(doc.get("c")), vec!["qobuz|2"]);
|
||||
assert!(parse_open_rows_entry(doc.get("d")).is_empty());
|
||||
assert!(parse_open_rows_entry(doc.get("missing")).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -865,7 +865,9 @@ fn reconcile_sidebar_after_import(
|
||||
// Optimistic insert NOW (single-playlist imports only — multi-part imports
|
||||
// get their per-part names from the API once it catches up).
|
||||
if single {
|
||||
crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count);
|
||||
// No covers to give: the import created the playlist seconds ago and
|
||||
// nothing has resolved its collage yet (the next real load fills it).
|
||||
crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count, &[]);
|
||||
crate::publish_sidebar();
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
@@ -888,7 +890,7 @@ fn reconcile_sidebar_after_import(
|
||||
// wiped out of, and the row would vanish for up to 15 s. Idempotent
|
||||
// by id, and evaluated AFTER `present` so it cannot fake it.
|
||||
if !present && single {
|
||||
crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count);
|
||||
crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count, &[]);
|
||||
}
|
||||
if present || attempt >= MAX_ATTEMPTS {
|
||||
if !present {
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
//! `(u64, bool, i32)` rows — same behavior, simpler backend for the POC).
|
||||
//! - Custom cover art (set/clear via library.db), multi-select + bulk bar,
|
||||
//! Suggested Songs, offline download of the playlist, share: not ported.
|
||||
//! - is_copied persists only for the session (the Slint's mark_playlist_copied
|
||||
//! local-db write is skipped).
|
||||
//! The copy path therefore also skips the reference's
|
||||
//! `update_playlist_artwork` write (main.rs:2132) — it would be state
|
||||
//! nothing in this port can read.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -296,6 +297,109 @@ fn mark_following(playlist_id: u64, following: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a synthesized sidebar / Library row needs about a playlist the
|
||||
/// user just followed. `None` = the caller could not obtain it, which downgrades
|
||||
/// the follow arm to an authoritative refetch.
|
||||
pub(crate) struct FollowRowMeta {
|
||||
pub name: String,
|
||||
pub owner: String,
|
||||
pub tracks_count: u32,
|
||||
pub cover_url: String,
|
||||
pub covers: Vec<String>,
|
||||
}
|
||||
|
||||
impl FollowRowMeta {
|
||||
/// Build from the API model — for the CARD seam, which holds an id and
|
||||
/// nothing else.
|
||||
///
|
||||
/// The two artwork fields go through `library_qt`'s own pickers rather than
|
||||
/// re-deriving `image_rectangle` / `images300` here: `cover_url` is the
|
||||
/// playlist's OWN editorial graphic and `covers` are its MEMBER-ALBUM
|
||||
/// sleeves, and conflating the two is the exact bug that put an album
|
||||
/// sleeve on every playlist card (`library_qt::map_playlist_row`'s note).
|
||||
/// A second copy of that precedence would drift from this one.
|
||||
fn from_playlist(playlist: &Playlist) -> Self {
|
||||
Self {
|
||||
name: playlist.name.clone(),
|
||||
owner: playlist.owner.name.clone(),
|
||||
tracks_count: playlist.tracks_count,
|
||||
cover_url: crate::library_qt::playlist_own_image(playlist),
|
||||
covers: crate::library_qt::playlist_cover_urls(playlist),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// THE settle point for a follow / unfollow, wherever the click came from —
|
||||
/// the header toggle on the open detail and the card-overlay `set_follow_by_id`
|
||||
/// both end here, the way every heart ends at `crate::emit_library_favorite`.
|
||||
///
|
||||
/// Three surfaces have to agree, and before this they did not:
|
||||
///
|
||||
/// 1. the ownership snapshot (`mark_following`) — the only one the port
|
||||
/// updated, which is why the glyph settled and nothing else did;
|
||||
/// 2. the SIDEBAR, whose rows are `get_user_playlists()` — a followed playlist
|
||||
/// is in that list, so unfollowing must take the row out;
|
||||
/// 3. the LIBRARY feed, where the same playlist is a `following` row.
|
||||
///
|
||||
/// The unfollow arm patches the two caches and republishes them instead of
|
||||
/// re-fetching. That is not an optimisation, it is the fix: Qobuz's
|
||||
/// `playlist/getUserPlaylists` lags a write (the reference documents the same
|
||||
/// lag on the create side and answers it with a bounded retry,
|
||||
/// `qbz/src/main.rs:4884`), so the `crate::reload_sidebar()` this seam used to
|
||||
/// fire re-read the STALE list and put the row it had just removed straight
|
||||
/// back. The next natural load reconciles.
|
||||
///
|
||||
/// The follow arm inserts optimistically. Both seams have the metadata — the
|
||||
/// header takes it from the open document, the card seam fetches it once
|
||||
/// (`FollowRowMeta::from_playlist`) — so `None` here means only that the fetch
|
||||
/// FAILED, and it falls back to `crate::reload_sidebar()`, which is what this
|
||||
/// seam used to do unconditionally.
|
||||
///
|
||||
/// Reference note, stated because this is deliberately NOT 1:1: the Slint's
|
||||
/// `playlist_set_follow_by_id` (`qbz/src/main.rs:2189-2191`) flips the chip on
|
||||
/// every visible card and then calls `crate::playback::refresh_sidebar(true)` —
|
||||
/// which is the QUEUE panel (`qbz/src/playback.rs:42`), not the playlist tree.
|
||||
/// It never touches the playlist sidebar or the Favorites playlist models on a
|
||||
/// follow change, so the reference has the same defect the owner reported here.
|
||||
/// Ported behaviour would keep the bug; the owner asked for the row to leave
|
||||
/// both surfaces.
|
||||
fn follow_settled(playlist_id: u64, following: bool, meta: Option<FollowRowMeta>) {
|
||||
mark_following(playlist_id, following);
|
||||
let id = playlist_id.to_string();
|
||||
if following {
|
||||
let Some(meta) = meta else {
|
||||
// No metadata to synthesize from: take the round trip.
|
||||
crate::reload_sidebar();
|
||||
return;
|
||||
};
|
||||
crate::sidebar_qt::insert_qobuz_entry(
|
||||
playlist_id,
|
||||
&meta.name,
|
||||
meta.tracks_count,
|
||||
&meta.covers,
|
||||
);
|
||||
crate::publish_sidebar();
|
||||
if crate::library_qt::insert_playlist_row(
|
||||
&id,
|
||||
&meta.name,
|
||||
&meta.owner,
|
||||
meta.tracks_count,
|
||||
&meta.cover_url,
|
||||
meta.covers,
|
||||
true,
|
||||
) {
|
||||
crate::publish_library_document();
|
||||
}
|
||||
} else {
|
||||
if crate::sidebar_qt::remove_qobuz_entry(playlist_id) {
|
||||
crate::publish_sidebar();
|
||||
}
|
||||
if crate::library_qt::remove_playlist_rows(&id) {
|
||||
crate::publish_library_document();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(doc: &PlaylistDoc) {
|
||||
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
|
||||
crate::ui(move |mut b| {
|
||||
@@ -626,6 +730,12 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
})
|
||||
.unwrap_or(false));
|
||||
let pinned = crate::sidebar_qt::is_pinned("playlist", &playlist_id.to_string());
|
||||
// "Copy to your library" hides once this playlist HAS been copied. The
|
||||
// authority is `library.db`'s `copied_playlists` (reference: main.rs:4555
|
||||
// seeds `PlaylistState.is-copied` from the identical read) — the port used
|
||||
// to keep the flag in the session document only, so a restart offered the
|
||||
// copy again on a playlist that already had one.
|
||||
let is_copied = !is_owner && crate::library_db_qt::is_playlist_copied(playlist_id);
|
||||
|
||||
// Custom drag order (applied when sort == custom).
|
||||
let sort_state = with_doc(|d| (d.sort_field.clone(), d.sort_asc)).unwrap_or(("default".into(), true));
|
||||
@@ -649,6 +759,7 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
doc.is_owner = is_owner;
|
||||
doc.is_favorite = is_favorite;
|
||||
doc.is_following = is_following;
|
||||
doc.is_copied = is_copied;
|
||||
doc.pinned = pinned;
|
||||
doc.loading = false;
|
||||
let (field, asc) = sort_state;
|
||||
@@ -872,11 +983,24 @@ fn revert_favorite(id: &str, value: bool) {
|
||||
/// Follow / unfollow a foreign playlist (subscribe API; optimistic flip,
|
||||
/// revert on error — main.rs playlist_set_follow_by_id).
|
||||
pub async fn toggle_follow(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let Some((pid, follow)) = with_doc(|d| {
|
||||
// The open document is the ONE place in the app that already holds
|
||||
// everything a synthesized row needs, so the metadata is snapshotted in the
|
||||
// same lock hop as the optimistic flip.
|
||||
let Some((pid, follow, meta)) = with_doc(|d| {
|
||||
d.is_following = !d.is_following;
|
||||
let doc = d.clone();
|
||||
publish(&doc);
|
||||
(d.id.parse::<u64>().ok(), d.is_following)
|
||||
(
|
||||
d.id.parse::<u64>().ok(),
|
||||
d.is_following,
|
||||
FollowRowMeta {
|
||||
name: d.name.clone(),
|
||||
owner: d.owner.clone(),
|
||||
tracks_count: d.track_count.max(0) as u32,
|
||||
cover_url: d.cover_url.clone(),
|
||||
covers: d.covers.clone(),
|
||||
},
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
@@ -894,9 +1018,9 @@ pub async fn toggle_follow(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
publish(&doc);
|
||||
});
|
||||
} else {
|
||||
// Settle the ownership snapshot so every card built from here on
|
||||
// draws the check (or the user-plus) that matches what just happened.
|
||||
mark_following(pid, follow);
|
||||
// Settle the ownership snapshot AND the two surfaces the playlist
|
||||
// lives on (sidebar + Library feed) — see `follow_settled`.
|
||||
follow_settled(pid, follow, Some(meta));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,37 +1045,85 @@ pub fn toggle_pin() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Copy to your library" — clone a foreign playlist: create + add all
|
||||
/// track ids (main.rs copy_playlist; the local mark_playlist_copied db
|
||||
/// write is a POC-NOTE — is_copied is session-only).
|
||||
/// "Copy to your library" — clone a foreign playlist into an OWNED one:
|
||||
/// create + add all track ids, then record the copy (`main.rs:2080`
|
||||
/// `playlist_copy_by_id`).
|
||||
///
|
||||
/// It does NOT unfollow the source, and it is not meant to: the reference's
|
||||
/// copy path contains no `unsubscribe_playlist` anywhere
|
||||
/// (`qbz/src/main.rs:2080-2150`), and `mark_playlist_copied` exists purely to
|
||||
/// hide the Copy button on a second visit (`qbz/src/main.rs:4555`). Following
|
||||
/// and copying are independent — after a copy the user has BOTH the followed
|
||||
/// original and their own editable clone.
|
||||
///
|
||||
/// Three gaps against that reference are closed here, all of them things the
|
||||
/// user can see:
|
||||
///
|
||||
/// * the ATTRIBUTION line the reference appends to the copied description
|
||||
/// (verbatim, and untranslated there too — it is a plain `format!`);
|
||||
/// * the `mark_playlist_copied` write, so the Copy button stays hidden across
|
||||
/// restarts instead of offering to copy the same playlist a second time
|
||||
/// (the module POC-NOTE said session-only; `library.db` already has the
|
||||
/// `copied_playlists` table and `qbz-library` already has both accessors,
|
||||
/// so no schema touch was needed);
|
||||
/// * the toasts — success, and the two failure arms that used to log and
|
||||
/// leave the user staring at an unchanged screen.
|
||||
///
|
||||
/// The new playlist is inserted into the sidebar + Library caches rather than
|
||||
/// re-fetched: `crate::reload_sidebar()` (what this used to call) re-reads
|
||||
/// `playlist/getUserPlaylists`, which lags a create by seconds — the same lag
|
||||
/// the importer's bounded retry exists for — so the copy the user just made
|
||||
/// was simply not there. See `follow_settled` for the same reasoning.
|
||||
///
|
||||
/// NOT ported: the reference also copies the source's artwork into the new
|
||||
/// playlist's `library.db` custom-artwork row (`update_playlist_artwork`,
|
||||
/// `main.rs:2132`). This port renders no custom playlist artwork at all (the
|
||||
/// module POC-NOTE), so the write would be unreadable state; porting it means
|
||||
/// porting the custom-cover feature, not one line here.
|
||||
pub async fn copy_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let Some((pid, name, description, track_ids)) = with_doc(|d| {
|
||||
let Some((pid, name, description, owner, track_ids, covers)) = with_doc(|d| {
|
||||
(
|
||||
d.id.parse::<u64>().ok(),
|
||||
d.name.clone(),
|
||||
d.description.clone(),
|
||||
d.owner.clone(),
|
||||
d.tracks
|
||||
.iter()
|
||||
.filter_map(|t| t.id.parse::<u64>().ok())
|
||||
.collect::<Vec<u64>>(),
|
||||
// The member-album collage, NOT `cover_url`: that is the SOURCE's
|
||||
// editorial `image_rectangle`, which the copy does not inherit
|
||||
// (this port does not write custom artwork — see the doc comment).
|
||||
// The copy holds the same tracks, so the same collage is what its
|
||||
// own next load will produce.
|
||||
d.covers.clone(),
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(pid) = pid else { return };
|
||||
let new_description = if description.is_empty() {
|
||||
None
|
||||
if track_ids.is_empty() {
|
||||
log::warn!("[qbz-qt] copy playlist {pid}: no tracks to copy");
|
||||
crate::toast_qt::error(qbz_i18n::t("Playlist has no tracks to copy"));
|
||||
return;
|
||||
}
|
||||
// main.rs:2109-2119, verbatim: the attribution is appended to the source
|
||||
// description, or becomes the whole description when there is none.
|
||||
let attribution = format!("\n\n---\nOriginally curated by {owner} on Qobuz");
|
||||
let new_description = if description.trim().is_empty() {
|
||||
attribution.trim_start().to_string()
|
||||
} else {
|
||||
Some(description.as_str())
|
||||
format!("{description}{attribution}")
|
||||
};
|
||||
let new_playlist = match runtime
|
||||
.core()
|
||||
.create_playlist(&name, new_description, false)
|
||||
.create_playlist(&name, Some(new_description.as_str()), false)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] copy playlist {pid}: create failed: {e}");
|
||||
crate::toast_qt::error(qbz_i18n::t("Failed to copy playlist"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -962,13 +1134,44 @@ pub async fn copy_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
{
|
||||
log::error!("[qbz-qt] copy playlist {pid}: add tracks failed: {e}");
|
||||
}
|
||||
with_doc(|d| {
|
||||
// Persist the copy against the SOURCE id (idempotent). rusqlite is
|
||||
// blocking, so it goes off the async path like every other db write here.
|
||||
let _ = tokio::task::spawn_blocking(move || crate::library_db_qt::mark_playlist_copied(pid)).await;
|
||||
// Only the page that asked for the copy gets the flag: the user may have
|
||||
// navigated away while the create was in flight (reference `is_open`).
|
||||
let source_id = pid.to_string();
|
||||
let patched = with_doc(|d| {
|
||||
if d.id != source_id {
|
||||
return None;
|
||||
}
|
||||
d.is_copied = true;
|
||||
let doc = d.clone();
|
||||
publish(&doc);
|
||||
Some(d.clone())
|
||||
});
|
||||
if let Some(Some(doc)) = patched {
|
||||
publish(&doc);
|
||||
}
|
||||
log::info!("[qbz-qt] playlist {pid} copied to library as {}", new_playlist.id);
|
||||
crate::reload_sidebar();
|
||||
crate::toast_qt::success(qbz_i18n::t("Copied to your library"));
|
||||
|
||||
// The copy is OWNED, so it joins the sidebar and the Library's favorites
|
||||
// bucket. `tracks_count` comes from what was just sent, not from the
|
||||
// create response — that response describes an empty playlist.
|
||||
let new_id = new_playlist.id;
|
||||
let copied_count = track_ids.len() as u32;
|
||||
let new_owner = new_playlist.owner.name.clone();
|
||||
crate::sidebar_qt::insert_qobuz_entry(new_id, &name, copied_count, &covers);
|
||||
crate::publish_sidebar();
|
||||
if crate::library_qt::insert_playlist_row(
|
||||
&new_id.to_string(),
|
||||
&name,
|
||||
&new_owner,
|
||||
copied_count,
|
||||
"",
|
||||
covers,
|
||||
false,
|
||||
) {
|
||||
crate::publish_library_document();
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the playlist whose detail page is currently loaded, if any.
|
||||
@@ -1333,10 +1536,36 @@ pub async fn set_follow_by_id(
|
||||
if let Err(e) = res {
|
||||
log::error!("[qbz-qt] playlist {playlist_id} follow={follow} failed: {e}");
|
||||
} else {
|
||||
// Keep the ownership snapshot in step: `reload_sidebar` will refresh
|
||||
// it from the server anyway, but not before the next card is built.
|
||||
mark_following(playlist_id, follow);
|
||||
crate::reload_sidebar();
|
||||
// Same settle point as the header toggle. The UNFOLLOW arm needs no
|
||||
// metadata — it only removes rows.
|
||||
//
|
||||
// The FOLLOW arm buys it with ONE `get_playlist`, which is what the
|
||||
// reference's copy path pays for the same reason (`qbz/src/main.rs:2087`).
|
||||
// What that replaces is the `crate::reload_sidebar()` this seam used to
|
||||
// fire, which was wrong twice over: it re-read `playlist/getUserPlaylists`,
|
||||
// a list that LAGS the subscribe, so the row it was fetched for could
|
||||
// still be missing; and it never touched the Library feed at all, while
|
||||
// the Library loads exactly ONCE per session (`main.rs::load_library_once`)
|
||||
// — so a playlist followed from a Discover / Search card stayed out of My
|
||||
// Library for the rest of the session unless the user hit the toolbar's
|
||||
// manual refresh. That is the owner's finding seen from the follow side.
|
||||
//
|
||||
// A failed metadata fetch degrades to `None`, which is the refetch this
|
||||
// seam used to do unconditionally — never worse than before.
|
||||
let meta = if follow {
|
||||
match runtime.core().get_playlist(playlist_id).await {
|
||||
Ok(playlist) => Some(FollowRowMeta::from_playlist(&playlist)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[qbz-qt] playlist {playlist_id} follow: row metadata fetch failed: {e}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
follow_settled(playlist_id, follow, meta);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -620,15 +620,20 @@ pub fn folder_name(folder_id: &str) -> Option<String> {
|
||||
/// The playlist importer creates playlists through the API, and the
|
||||
/// user-playlists endpoint lags the write by seconds: without this the newly
|
||||
/// imported playlist is simply absent from the tree until the bounded-retry
|
||||
/// reload catches up, and the user's only evidence it exists is a toast. The
|
||||
/// row carries no covers (the collage fills on the next real load) and is a
|
||||
/// no-op when the id is already cached, so the retry loop can call it again
|
||||
/// reload catches up, and the user's only evidence it exists is a toast. It is
|
||||
/// a no-op when the id is already cached, so the retry loop can call it again
|
||||
/// after a failed reload without duplicating.
|
||||
///
|
||||
/// `covers` is the 2x2 micro-collage. The importer has none to give and passes
|
||||
/// `&[]` (the collage fills on the next real load, which is what the reference
|
||||
/// does unconditionally); the FOLLOW seam does have them — the open playlist
|
||||
/// document already resolved the same urls — and passing them keeps the row
|
||||
/// from appearing as a blank tile for the rest of the session.
|
||||
///
|
||||
/// Cache-only, like every accessor in this block: the caller publishes
|
||||
/// (`crate::publish_sidebar()`), which keeps this correct offline and free of a
|
||||
/// round trip.
|
||||
pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32) {
|
||||
pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32, covers: &[String]) {
|
||||
let mut guard = CACHE.lock().unwrap();
|
||||
// `None` = the sidebar has never loaded this session. The reference's cache
|
||||
// is not an Option and always has somewhere to insert; synthesising an
|
||||
@@ -644,12 +649,37 @@ pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32) {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
tracks_count,
|
||||
cover_urls: Vec::new(),
|
||||
cover_urls: covers.iter().take(4).cloned().collect(),
|
||||
position: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop a Qobuz playlist row from the sidebar cache. Returns whether anything
|
||||
/// was actually removed, so the caller can skip a pointless republish.
|
||||
///
|
||||
/// The twin of [`insert_qobuz_entry`], and it exists for the same reason the
|
||||
/// insert does — Qobuz's `playlist/getUserPlaylists` lags a write. UNFOLLOWING
|
||||
/// a playlist removes it from that list server-side, but a `reload_sidebar()`
|
||||
/// fired immediately after the unsubscribe re-reads the STALE list and puts the
|
||||
/// row straight back: the owner's report is literally "I unfollow it and it is
|
||||
/// still in the sidebar". Patching the cache and republishing from it is the
|
||||
/// D10 refresh — instant, correct offline, no round trip — and the next natural
|
||||
/// load reconciles.
|
||||
///
|
||||
/// Folder membership, hidden flag and custom position are deliberately left
|
||||
/// alone: they are per-playlist SETTINGS in library.db, and a re-follow should
|
||||
/// find the playlist where the user filed it.
|
||||
pub fn remove_qobuz_entry(id: u64) -> bool {
|
||||
let mut guard = CACHE.lock().unwrap();
|
||||
let Some(data) = guard.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let before = data.playlists.len();
|
||||
data.playlists.retain(|p| p.id != id);
|
||||
before != data.playlists.len()
|
||||
}
|
||||
|
||||
/// Patch a playlist's folder membership in the sidebar cache — `""` = root.
|
||||
///
|
||||
/// The Playlist Manager's move-to-folder calls this and then
|
||||
|
||||
@@ -4940,3 +4940,8 @@ msgstr "Playlist konnte nicht gelöscht werden"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Playlist konnte nicht erstellt werden"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Playlist öffnen"
|
||||
@@ -736,3 +736,8 @@ msgstr "Failed to delete playlist"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Couldn't create the playlist"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Open playlist"
|
||||
@@ -4948,3 +4948,8 @@ msgstr "No se pudo eliminar la playlist"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "No se pudo crear la playlist"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Abrir playlist"
|
||||
@@ -4940,3 +4940,8 @@ msgstr "Échec de la suppression de la playlist"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Impossible de créer la playlist"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Ouvrir la playlist"
|
||||
@@ -4925,3 +4925,8 @@ msgstr "プレイリストの削除に失敗しました"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "プレイリストを作成できませんでした"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "プレイリストを開く"
|
||||
@@ -4930,3 +4930,8 @@ msgstr "Verwijderen van afspeellijst mislukt"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Kon de afspeellijst niet aanmaken"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Afspeellijst openen"
|
||||
@@ -4940,3 +4940,8 @@ msgstr "Falha ao excluir a playlist"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Não foi possível criar a playlist"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Abrir playlist"
|
||||
@@ -4957,3 +4957,8 @@ msgstr "Не удалось удалить плейлист"
|
||||
|
||||
msgid "Couldn't create the playlist"
|
||||
msgstr "Не удалось создать плейлист"
|
||||
|
||||
# My QBZ grid card — the heterogeneous grid mounts the standard AlbumCard,
|
||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||
msgid "Open playlist"
|
||||
msgstr "Открыть плейлист"
|
||||
Reference in new issue
Block a user