fix(qt): a pulled track's menu offers nothing that leads nowhere

Smoke found the row rendering correctly and the menu still offering four
entries that cannot work on it.

Add to mixtape and Add to playlist are refused, in both row implementations.
The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local
ref for the same reason -- this is the same idea with a different cause. Gated
on `pulled` rather than `pulledDead`, so a downloaded copy is refused too:
what a playlist or mixtape stores is the Qobuz catalog id, which is dead on
every other device, every other surface and for anyone the list is shared with.
The local copy playing here does not make the row safe to seed elsewhere.

Both share links go too. Qobuz's page for a withdrawn track is gone and
Song.link resolves through it, so each one rendered and led nowhere. Track info
as well: it needs the catalog record the pull took away.

That empties the menu on a surface that does not offer the repair -- an album
page, search results -- so the row menu no longer opens when its model comes
back empty. An empty popup reads as a broken control rather than as "there is
nothing to do with this row". The ArtistView fork keeps its go-to entries and
can never reach that state.

cc
This commit is contained in:
vicrodh committed 2026-08-17 15:37:12 -06:00
1 parent b2c74c4140
commit e779b6ec1e
2 files changed
+50 -11

No files matched your search

+38 -7
View File
@@ -245,7 +245,16 @@ Rectangle {
/// (`local_playlist_qt::local_picker_ref_for_row`).
property bool routePlaylistAddExternally: false
signal playlistAddRequested()
property bool hasPlaylistAddSeam: root.item.unavailable === true
/// OFF for a ref that cannot resolve (`unavailable`) AND for a track Qobuz
/// pulled (`pulled`) — both would SPREAD a row that cannot play.
///
/// `pulled`, not `pulledDead`: a downloaded copy plays for THIS user on
/// THIS machine, but what gets stored in the playlist is the Qobuz catalog
/// id, which is dead for every other surface, every other device and
/// everyone the playlist is shared with. Seeding that is the one thing
/// worth refusing outright — the user can still repair the row where it
/// already lives.
property bool hasPlaylistAddSeam: (root.item.unavailable === true || root.pulled)
? false
: (root.routePlaylistAddExternally
|| (root.catalogRow && !root.localSourceRow))
@@ -254,7 +263,10 @@ Rectangle {
// itemType/source, which is why the payload is built by the host (see
// `mixtapeRequested` below). Not `readonly`: a host whose ids are not
// Qobuz/local mixtape-addressable can turn it back off.
property bool hasMixtapeSeam: true
/// Same rule as `hasPlaylistAddSeam`: a mixtape stores the catalog id too,
/// so a pulled track added to one is a row that resolves to nothing the
/// next time the collection is played.
property bool hasMixtapeSeam: !root.pulled
signal playRequested()
signal enqueueRequested(string mode)
@@ -1039,7 +1051,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openRowMenu(moreArea, mouse.x, mouse.y) }
}
}
}
@@ -1052,6 +1064,19 @@ Rectangle {
// TrackContextMenu.slint, in its order. Every row here reaches a live
// seam; the seamless ones are gated off above.
/// Open the row menu, unless it would come up EMPTY.
///
/// A pulled track refuses transport, favourite, playlist/mixtape add, both
/// share links, track info and the cache actions, so on a surface that does
/// not offer the repair — an album page, search results — nothing is left.
/// An empty popup is worse than no popup: it reads as a broken control
/// rather than as "there is nothing to do with this row".
function openRowMenu(anchor, x, y) {
if (root.menuModel().length === 0)
return
rowMenu.openAtCursor(anchor, x, y)
}
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
@@ -1093,9 +1118,13 @@ Rectangle {
if (root.menuShowRemove)
m.push({ "label": t("Remove from playlist", r), "icon": "trash-2",
"action": "remove", "danger": true })
if (root.menuShowShare)
// Neither share link survives a pull: the Qobuz page for a withdrawn
// track is gone, and Song.link resolves THROUGH it. Both rendered and
// led nowhere, which is the same rendered-and-inert defect the
// transport entries above are refused for.
if (root.menuShowShare && !root.pulled)
m.push({ "label": t("Share Qobuz link", r), "icon": "link", "action": "share-qobuz" })
if (root.hasSonglinkSeam)
if (root.hasSonglinkSeam && !root.pulled)
m.push({ "label": t("Share Song.link", r), "icon": "link", "action": "share-songlink" })
// The dead row offers no cache action at all (there is no stream url
// left to fetch), and the cached-but-pulled row must not be able to
@@ -1120,7 +1149,9 @@ Rectangle {
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
if (root.menuShowGoTo && root.item.artistId)
m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
if (root.menuShowTrackInfo)
// Track info needs the catalog record the pull took away, so the modal
// opens on nothing.
if (root.menuShowTrackInfo && !root.pulled)
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
return m
}
@@ -1302,7 +1333,7 @@ Rectangle {
// TrackRow.slint:201 — the row menu is suppressed in
// multi-select mode (a right-click toggles nothing there).
if (root.showMenu && !root.selectMode)
rowMenu.openAtCursor(trArea, mouse.x, mouse.y)
root.openRowMenu(trArea, mouse.x, mouse.y)
mouse.accepted = true
return
}
+12 -4
View File
@@ -1478,8 +1478,8 @@ Rectangle {
// "Play next" here would render and no-op — the
// rendered-and-inert defect this menu's own header refuses
// ("ABSENT, not dead"). The row keeps its menu because the
// rest of it (add to playlist / mixtape, go-to) still works and
// because a right-clickable dead row is what §5.2 asks for.
// rest of it (go-to) still works and because a right-clickable
// dead row is what §5.2 asks for.
if (!popRow.pulledDead) {
m.push({ "label": t("Play now", r), "icon": "play-fill", "action": "play" })
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
@@ -1488,8 +1488,16 @@ Rectangle {
m.push({ "label": fav ? t("Remove from Library", r) : t("Add to Library", r),
"icon": fav ? "heart-filled" : "heart", "action": "favorite" })
}
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
// A pulled track is refused BOTH containers, and on `pulled`
// rather than `pulledDead`: a downloaded copy plays for this
// user on this machine, but what a playlist or a mixtape stores
// is the Qobuz catalog id, which resolves to nothing everywhere
// else. Seeding a dead row into a collection is the one thing
// worth refusing outright.
if (!popRow.pulled) {
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
}
// The offline block goes with the transport, and for the same
// reason: a pulled track has no stream url left, so the
// download arm would fail and a REFRESH would destroy the only