// TrackRow — THE track list row (primitives/TrackRow.slint, POC arm // subset), consolidated in phase 22 from FOUR copies (AlbumView // .AlbumTrackRow, PlaylistView.PlTrackRow, LibraryView.TrackListRow, // SearchView.SearchTrackRow). 50px, radius 8: number→play cell (pause // swap + accent pill when it is the now-playing row — Slint-universal), // optional 36px art cell, title+explicit / artist column, optional 220px // album link, duration, quality (BARE QualityBadgeFull — the tier label over // the exact bit-depth / sample-rate line), optional // heart, optional download slot (offline column — glyph stub or reserved // spacer until the offline cache lands, POC-NOTE), ⋯ CardMenu. // // item contract: { id, title, artist, artistId, album, albumId, number?, // duration, qualityTier, qualityDetail, explicit, isFavorite, artPath? } // (plus playlistTrackId for the remove-from-playlist arm, and the OPTIONAL // `source` / `unavailable` a mixed-source producer carries — the Add to // playlist gate below is the one arm that reads them, plus the OPTIONAL // `qobuzUnavailable`, which is a DIFFERENT thing from `unavailable` and is // documented where it is read). `qualityDetail` // is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer // does not carry it yet the badge degrades to the tier label alone rather // than to a blank cell. `item.isFavorite` SEEDS the row's own `favorite` // property and is never written back — see the heart block below for why a // plain-JS-object field cannot be the live state. // // Arms: showArtwork / showAlbum / showFavorite / showDownload / // downloadGlyph / showMenu / zebra / artistLink / clickPlays / // menuShowLater / menuShowGoTo / menuShowFavorite / menuShowRemove. // (`qualityStyle` is RETIRED — kept declared, inert, see below.) // Signals: playRequested() (per-site play: album-scoped, playlist-scoped // or plain), enqueueRequested(mode) ("next"|"later"|"queue"), // removeRequested(), bodyDragStarted(index) (fired BEFORE the shared // dragStart — the #589 reorder pre-hook), mixtapeRequested() (the host // builds the MyQBZ AddItem payload), goToRequested(kind) (only when // `routeGoToExternally` is set), playlistAddRequested() (only when // `routePlaylistAddExternally` is set — the local-mode "Add to playlist"), // replaceRequested() (only when `routeReplaceExternally` is set — the // "Find available version" repair for a track Qobuz pulled). // Favorite toggling, Go-to-artist/album, Share and Track info are // identical on every site — handled internally. The row BODY is the drag // source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT // press opens the very same menu the ⋯ button does. // // --- Menu inventory vs primitives/TrackContextMenu.slint ---------------- // The .slint menu is FLAT (no separators) in this order: Play now · Play // next · Play later · Add to queue · Create QBZ radio · Create Qobuz radio · // Add to library · Add to mixtape · Add to playlist · Remove from // playlist(danger) · Share Qobuz link · Share Song.link · Make available // offline | Refresh + Remove offline copy(danger) · Go to album · Go to // artist · Track info. Everything the Qt bridge has a seam for is here, in // that order; the rest is gated OFF by the `has*Seam` constants below // rather than rendered as a row that silently does nothing. // // Deliberately NOT consolidated here (Slint-distinct): QueuePanel.QueueRow // (QueueItem data + queue-op menu + press-and-hold reorder) and // LibraryView.FeedListRow (mixed-kind feed row — inline in Slint too). // // --- COLUMN GEOMETRY LIVES IN rows/TrackCols.qml ------------------------ // Not in this file. rows/TrackListHeader.qml reads the same object, so the // header of a list and the rows in it cannot disagree. If you change a // column width, change it THERE — a literal typed back into this file is // the exact defect the owner reported (the header laid out only the // labelled columns, so every label sat right of the column it named). import QtQuick // Qt's own attached ToolTip, for the pulled-track glyphs ONLY. The shell's // shared bubble (controls/QbzTooltip.qml) is reached by naming the AppShell // instance, which a recycled delegate cannot do — the same trade // controls/QbzMultiSelectBar.qml and views/AlbumView.qml already made. import QtQuick.Controls import com.blitzfc.qbz import "../controls" import "../immersive" import "../shell" import "../theme" Rectangle { id: root property var item: ({}) property int number: 0 property bool showArtwork: false property bool showAlbum: false property bool showFavorite: true property bool showDownload: false property bool downloadGlyph: false property bool showMenu: true /// The row is EPHEMERAL — a disc, a disc image or an ad-hoc folder. /// /// It has no `library.db` row and no catalog id, so every entry that /// writes a reference meant to outlive the session (favourite, playlist, /// mixtape, offline cache, share) has nothing to point at once the disc /// comes out. The QUEUE block stays, because a queue is exactly as /// temporary as the session is. /// /// Entries are ABSENT rather than rendered-and-inert — the same rule the /// `has*Seam` gates in `menuModel` already follow. property bool queueOnly: false property bool zebra: false property bool artistLink: false property bool clickPlays: true /// RETIRED and INERT. primitives/TrackRow.slint has exactly ONE quality /// form (the bare QualityBadgeFull below) — there was never an icon/text /// split to arm. Kept declared ONLY so the two existing call sites that /// still assign it (AlbumView.qml `"text"`, local/LocalTrackRow.qml /// `"icon"`) keep loading; assigning a non-existent property is a hard /// QML instantiation error. Both assignments are listed for removal. property string qualityStyle: "icon" property bool menuShowLater: true property bool menuShowGoTo: true property bool menuShowFavorite: true property bool menuShowRemove: false /// Reorder arm (primitives/TrackRow.slint:87 `show-reorder`): a leading /// 22px gutter with an up/down chevron stack. ON = this list is in an /// order the user OWNS and can rewrite (the playlist detail under custom /// sort; a LOCAL playlist under its natural order, whose repo `position` /// IS that order). Default off, so no existing call site grows a gutter. /// /// It is also the gate for `preventStealing` on the row-body drag — see /// the DRAG vs. DRAG-TO-SCROLL block at the bottom of this file. property bool showReorder: false /// Ends of the list: the reference dims nothing, but a chevron that /// renders and no-ops is the defect class this round is fixing, so the /// first row's ↑ and the last row's ↓ are drawn disabled instead. property bool canMoveUp: true property bool canMoveDown: true // Drag source arm (additive; default = the existing behaviour). The // Local Library rows set this false: their `item.id` is a local DB row // id, and the sidebar drop handler forwards whatever it receives to // `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a // playlist would silently add an unrelated catalog track. property bool draggable: true /// Multi-select arm (primitives/TrackRow.slint:58 `multi-select-mode`): /// the leading play/number cell becomes a selection checkbox, the WHOLE /// row body is a selection target, and the row's own gestures (play, /// double-click, drag, right-click menu) stand down. Default off, so every /// existing call site is untouched. property bool selectMode: false property bool checked: false /// `modifiers` is the mouse event's, straight through — Shift is what /// turns a click into a range (controls/SelectionModel.qml). Hosts that /// do not care can keep a no-argument handler; QML allows it. signal toggleSelect(int modifiers) // Per-row artwork placeholder (see the 36px cell below). The host view // owns the phase clock so one timer drives every row. property bool artPending: false property bool skelPhase: false property int artSettleMs: 0 // A ListView reuse-pool item stays alive and keeps receiving signals. // Hosts that enable reuse flip this attached-state twin from pooled / // reused handlers so an offstage playing row cannot keep pulsing and an // old track cannot consume cache/favourite settle signals. property bool recycleActive: true // Catalog-backed row? `draggable` is ALREADY the "item.id is a Qobuz // catalog track id" predicate (the Local Library / ephemeral rows set it // false because their id is a local DB row id — see the note above), so // the two entries that hit the Qobuz catalog by id ride it instead of // asking every host to pass a new arm. readonly property bool catalogRow: root.draggable property bool menuShowShare: root.catalogRow property bool menuShowTrackInfo: root.catalogRow // --- Seams the Qt bridge does NOT have yet (menu-parity round) ------- // Each maps to a TrackContextMenu.slint entry. OFF = the row is not // built at all, never rendered-and-inert. Flip the constant AND fill the // matching `menuAction` branch when the invokable lands; the entry then // appears in its .slint-correct slot. Why each is missing: // radio no radio invokable on any Qt bridge object // songlink needs the ISRC -> Deezer -> Odesli round-trip (backend) // offlineCache no per-track cache bridge (the download cell is a stub) readonly property bool hasRadioSeam: false readonly property bool hasSonglinkSeam: false // Live since the offline port: catalog (Qobuz) rows only — local/Plex // rows have no offline tier to download into (TrackContextMenu.slint // gates on the same qobuz-actions rule). readonly property bool hasOfflineCacheSeam: root.catalogRow && !root.localSourceRow // --- Offline cache status (TrackRow.slint:639-702) -------------------- // 0 none · 1 queued · 2 downloading · 3 ready · 4 failed. Seeded from // the document row; live updates arrive on QbzShell's // trackCacheStatusChanged and are adopted only for THIS row's id. property int cacheStatus: root.item.cacheStatus !== undefined ? root.item.cacheStatus : 0 function setCacheStatus(status) { root.cacheStatus = status } Connections { target: QbzShell enabled: root.recycleActive && root.hasOfflineCacheSeam function onTrackCacheStatusChanged(trackId, status, progress) { if (trackId === (root.item.id || "")) root.cacheStatus = status } } // --- PULLED FROM THE QOBUZ CATALOGUE (contract §5.2) ------------------ // `item.qobuzUnavailable` is the API saying `streamable: false` for THIS // track. The producer stamps it from `qbz_models::Track::is_streamable()` // — never from an absent key (that is exactly what the contract's §3.1 // `Option` exists to keep apart), never from a 403, never from a // network error. A producer that does not carry the field yet leaves it // `undefined`, which reads as "available" here: absence is not a claim. // // It is a NEW field and deliberately NOT `item.unavailable`, which this // file already reads a few lines below. That one is a BROKEN LOCAL REF (a // Plex key the cache does not know, a stored id outside the catalog // range). The two look alike and behave oppositely: a broken ref stays // fully SELECTABLE so the user can delete it and can never heal on its // own, while a pulled Qobuz track is inert and CAN be repaired by the // replacement search. One flag for both would collapse two treatments into // whichever was written last. readonly property bool pulled: root.item.qobuzUnavailable === true /// THE DEAD ROW — dim, inert, alert glyph. /// /// `cacheStatus === 3` (downloaded) is excluded on purpose: a pulled track /// the user already downloaded STILL PLAYS. `qbz-core/src/core.rs:716` /// resolves the offline tier before any network call and before any /// availability question, so the file on disk is served regardless of what /// the catalogue says. Treating it as dead would take away a song the user /// owns — a worse bug than the one this treatment fixes, and it would bite /// hardest in offline mode. It gets `pulledCached` below instead. readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3 /// Pulled from the catalogue, but playable from the downloaded copy. Keeps /// its play cell, its heart and its click; gains only an honest badge, /// because the user still needs to know the track is gone from Qobuz — /// the moment they clear the download it dies. readonly property bool pulledCached: root.pulled && root.cacheStatus === 3 /// "Find available version" — HOST-routed, the `routeGoToExternally` /// idiom. Only the host knows which playlist is open, whether the user /// OWNS it (Qobuz refuses a write to anyone else's) and that the row is a /// catalog membership rather than a local ref. Default off, so no other /// surface grows the entry. property bool routeReplaceExternally: false signal replaceRequested() readonly property bool hasReplaceSeam: root.pulled && root.routeReplaceExternally // --- Add to playlist ------------------------------------------------- // LIVE since QbzPlaylistPicker landed. It is NOT a flat constant, because // the entry's correctness depends on WHICH ID SPACE this row's `item.id` // lives in — the one thing the picker's whole design exists to keep // unmistakable (playlist_picker_qt.rs's four-case matrix): // // catalogRow `item.id` is a Qobuz catalog id -> openForTrack. // localSourceRow the row carries its own source word and it names // a NON-CATALOG origin -> `item.id` is a library.db // row id or a namespaced synthetic id (Plex, // Jellyfin, Subsonic). Sending it to the Qobuz arm // adds an UNRELATED track, so the entry is only // offered when the HOST takes the routing // (`routePlaylistAddExternally`, below), and is // ABSENT otherwise rather than wrong. // item.unavailable a stored ref that cannot resolve at all // (playlist_qt.rs PlaylistTrackRow.unavailable); // there is no track to copy anywhere. // // Ephemeral rows never reach this decision: their surface sets // `queueOnly: true`, so `menuModel` returns after the transport block. // An ALLOWLIST of non-catalog words, not `!== "qobuz"`: "offline" is an // offline-CACHED Qobuz row whose id IS a catalog id, so inverting the test // would strip Add-to-playlist from the rows that legitimately have it. // The Subsonic brand spellings fold here for the same reason they fold in // `SourceIcon.isSubsonic` — a row stamped "navidrome" is not a Qobuz row. readonly property bool localSourceRow: { var s = root.item.source return s === "local" || s === "plex" || s === "jellyfin" || s === "subsonic" || s === "navidrome" || s === "gonic" || s === "airsonic" || s === "astiga" } /// Hand "Add to playlist" to the HOST (the `routeGoToExternally` idiom): /// the host answers `playlistAddRequested()` with the LOCAL-mode call its /// surface can make. The local playlist detail is the one that needs it — /// only Rust can turn a detail row's display id into a source-aware ref /// (`local_playlist_qt::local_picker_ref_for_row`). property bool routePlaylistAddExternally: false signal playlistAddRequested() /// 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)) // LIVE since the MyQBZ domain landed: QbzMyQbzAdd.open() takes a JSON // array of AddItem, so the row only has to ASK — it does not know its own // 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. /// 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) signal removeRequested() signal bodyDragStarted(int index) /// Reorder gutter (`showReorder`). The HOST answers — only it knows the /// row's id and which playlist is open (TrackRow.slint routes the same /// pair through `media-action("track", id, "move-up"/"move-down")`). signal moveUpRequested() signal moveDownRequested() /// "Add to mixtape" — the HOST answers, because only the host knows the /// row's `itemType` / `source` (a MyQBZ AddItem needs both) and building /// the array here would hardcode `("track", "qobuz")` for every surface. signal mixtapeRequested() // --- Go-to routing: internal by default, host-routed on request ------- // The go-to actions (menu AND the artist-column link) navigate through // QbzArtist.openArtist / QbzAlbum.openAlbum here, which is right on every // catalog surface. It is WRONG where the row is a child of another entity // whose host must resolve the target — MyQBZ's expanded detail rows, where // Slint routes both paths through the SAME shared handler // (`media-action("artist", id, "open")`, TrackRow.slint:530). Flipping // `routeGoToExternally` hands both sites to the host as // `goToRequested(kind)`; it defaults false, so no existing call site moves. property bool routeGoToExternally: false signal goToRequested(string kind) // "artist" | "album" // --- THE heart state (a real QML property, never `item.isFavorite`) --- // The row used to flip its glyph with `root.item.isFavorite = !…`. That is // a write into a plain JS object and it does nothing at all here, twice // over — both halves measured under qml6 6.11.1 in this exact delegate // shape (`Repeater { model: JSON.parse(...) }` + `required property var // modelData` + `item: modelData`): // // 1. QML does not re-evaluate a binding when a plain JS object's // property is mutated — there is no notifier to fire. The glyph // binding kept its old value on the same tick AND on the next. // 2. `item: modelData` does not even alias the row: assigning a `var` // property from `modelData` yields an object that compares `!==` to // both `modelData` and the source array element (Qt round-trips it // through QVariant). So the write did not reach the model either — // not the delegate's `modelData`, not the host's array. // // A DECLARED property has a notifier, so the optimistic flip repaints // immediately. The binding below keeps the document authoritative: it is // re-established whenever the host hands over a new row object, so a // republished document (or a page re-open) overrides a stale local flip. property bool favorite: root.item.isFavorite === true onItemChanged: { // Both properties are writable live state. A signal/click breaks their // seed binding, so a recycled delegate must explicitly re-establish // both bindings when ListView hands it a different model row. root.favorite = Qt.binding(function () { return root.item.isFavorite === true }) root.cacheStatus = Qt.binding(function () { return root.item.cacheStatus !== undefined ? root.item.cacheStatus : 0 }) if (rowMenuLoader.item) rowMenuLoader.item.close() } /// The heart click and the menu's "Add to / Remove from Library" row — /// ONE implementation, because they used to diverge silently. function toggleFavorite() { root.favorite = !root.favorite QbzLibrary.libraryToggleFavorite("track", root.item.id) } // Settle. `libraryFavoriteChanged` (key `{kind}:{id}`, emitted by // main.rs::emit_library_favorite) carries the value the WRITE produced: // the flipped one when it landed, the UNCHANGED one when it failed. This // is the favourite twin of the pin fan-out the cards already have // (cards/AlbumCard.qml) and it is what rolls a 404'd un-favorite back — // and what keeps the OTHER surfaces showing this same track honest // (the album page's row, the queue, a search result) without anybody // republishing a document. Connections { target: QbzLibrary enabled: root.recycleActive function onLibraryFavoriteChanged(key, value) { var tid = (root.item && root.item.id !== undefined) ? root.item.id : "" if (tid !== "" && key === "track:" + tid) root.favorite = value } } QbzTheme { id: theme } // --- Polarity-baked tokens for the play cell (#638) ------------------- // `theme.alphaTier(pct)` IS Slint's `Theme.alpha-N`: one ramp per theme, // white-based on dark, black-based on light. The `.length` guard covers // the pre-publish frame QbzTheme documents (its baked fallback carries no // ramp) — without it the disc would render fill-less AND ring-less, which // is the exact failure this block exists to prevent. The literals are the // Slint dark values and their light-polarity mirrors. readonly property color playCellFill: theme.alphaTiers.length > 0 ? theme.alphaTier(10) : (theme.isDark ? "#1affffff" : "#1a000000") readonly property color playCellRing: theme.alphaTiers.length > 0 ? theme.alphaTier(15) : (theme.isDark ? "#26ffffff" : "#26000000") /// Slint's `Theme.text-primary` icon tint, runtime-tinted (QbzIcon.qml / /// src/icon_tint_qt.rs) so it is the live token under all 36 themes. Every /// host it lands on is a THEME surface — the play cell's polarity-baked /// alpha disc, and `surface-elevated` for the row's trailing controls. readonly property string playGlyphTint: "textPrimary" /// TrackRow.slint:123-125 — the row hover is `Theme.alpha-8`, not /// surface-hover, and the .slint says why: "Hover uses the polarity-baked /// alpha ramp so the hover state is visible on light themes too". The /// zebra stripe stays the literal, per the same comment ("Zebra kept /// as-is to avoid a visible dark-theme stripe shift"). readonly property color rowHoverBg: theme.alphaTiers.length > 0 ? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000") width: parent ? parent.width : 0 height: 50 radius: 8 // Hover fill is OFF on a dead row (views/purchases/PurchaseListRow.qml // does the same on its unavailable rows): a row that lights up under the // pointer reads as clickable, and this one is not. The zebra stripe stays // — it is structure, not affordance. color: (hovered && !root.pulledDead) ? rowHoverBg : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent") // `playArea` is in here for the reason TrackPlayCell.slint:76-79 spells // out: the cell owns a hover-enabled area of its own, and "Slint's // has-hover does not propagate to ancestor TouchAreas" — Qt's does not // either, so without this the row (and the play circle with it) would go // un-hovered exactly while the pointer sits on the play button. // The reorder chevrons are in the same set for the same reason: they are // hover-enabled areas ON the row, and the row must not un-hover under // them. Their ids resolve whether or not the gutter is `visible` — the // objects always exist (no Loader / no `if`), only their painting is off. readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse || playArea.containsMouse || chevUp.hovered || chevDown.hovered // THIS row is the now-playing one. Two ids are compared, not one, and the // second is not redundancy: an OFFLINE row's queue id is the QOBUZ CATALOG // id (the offline tier is keyed on it) while every Local Library list // draws that row with its `library.db` id, so the first test alone can // never match a downloaded track on the very view it was played from — the // pill and the eq bars stayed dark there. `npLocalTrackId` is published // for Offline rows ONLY (playback_qt::refresh_now_playing says why), so // the second test is inert for every other source and cannot light a row // by colliding with an id from another space. Mirrors the reference's // TrackRow.slint:116-118 / TrackPlayCell.slint:85-87. readonly property bool isActive: (item.id || "") !== "" && (QbzPlayer.npTrackId === (item.id || "") || (QbzPlayer.npLocalTrackId !== "" && QbzPlayer.npLocalTrackId === (item.id || ""))) // The animated now-playing indicator pref (AppearanceState // .play-indicator-animation; the toggle lives in AppearanceSettings). // Read by parsing the settings document — the AlbumView headerGradient // precedent; the settings snapshot republishes on every settings write, // which is the only time this re-evaluates. readonly property bool playIndicatorAnim: { var raw = QbzBridge.settingsJson if (raw && raw.length > 2) { try { return JSON.parse(raw).playIndicatorAnimation === true } catch (e) { /* fall through */ } } return false } // The animated eq bars carry the playing state in the play cell when the // pref is ON (TrackPlayCell.slint:99-100 `show-bars`): only while this // row's track is actually PLAYING, at rest (hover reveals the pause // affordance instead), and never in select mode. The bars tick on the // shared shell pulse, so they cost ZERO extra presents (EqualizerBars // rides QbzShell.pulseMs) — that is what makes the indicator affordable // here, where a display-rate animation was not. readonly property bool showBars: root.playIndicatorAnim && root.recycleActive && root.isActive && QbzPlayer.npPlaying && !root.hovered && !root.selectMode // --- Column geometry: rows/TrackCols.qml, NOT literals --------------- // Every width, the padding and the gap come from the one file that // rows/TrackListHeader.qml also reads, so the header above a list of // these rows lines up with them by construction. The title column's // arithmetic lives there too (`titleWidth`) — the header asks the same // function the same question and gets the same answer. TrackCols { id: cols } /// One chevron of the reorder gutter. Declared at the file's top level /// (an inline component must be — the same rule TrackListHeader's /// `ColLabel` follows), not inside the Row. /// /// It reads NOTHING off an outer id: `width` is set by the two call /// sites, which are in the outer scope and can reach `cols` there. An /// inline component that reaches for an enclosing id is legal only under /// the default (unbound) ComponentBehavior, and this file does not /// declare the pragma either way — so it simply does not depend on it. component ReorderChevron: Item { id: chev property string glyph: "chevron-up" property bool armed: true /// Read by the row's own `hovered` (see it): a hover-enabled MouseArea /// on top BLOCKS hover to the areas below it, so without republishing /// it here the row un-hovers the moment the pointer enters the gutter /// — the same defect this file already documents for the play cell. readonly property bool hovered: chevArea.containsMouse signal activated() height: 18 QbzIcon { name: chev.glyph width: 14 height: 14 anchors.centerIn: parent opacity: chev.armed ? 1.0 : 0.35 tintName: (chev.armed && chevArea.containsMouse) ? "textPrimary" : "muted" } MouseArea { id: chevArea anchors.fill: parent // ENABLED even when the chevron is not armed, and the click is // swallowed instead. A DISABLED MouseArea does not accept the // press at all, so it fell through to the row body underneath // (`trArea`, z:-1) — and this surface has `clickPlays: true`, so // clicking the greyed-out ↑ on the first row STARTED PLAYBACK. // The reference has no disabled state at all (its chevrons are // always armed and the Rust arm no-ops at the ends), so a dead // click there is genuinely dead; ours has to make it dead too. hoverEnabled: true cursorShape: chev.armed ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { if (chev.armed) chev.activated() } } } readonly property int titleColWidth: cols.titleWidth(root.width, root.showArtwork, root.showAlbum, root.showFavorite, root.showDownload, root.showMenu, root.showReorder) // Static now-playing mark: 3px accent pill on the left edge. With the // animation pref ON the eq bars in the play cell carry the state instead // (TrackRow.slint:127-137 — the pill is the pref-OFF arm). Rectangle { visible: root.isActive && !root.playIndicatorAnim x: 2 y: 7 width: 3 height: parent.height - 14 radius: 1.5 color: theme.accent } Row { anchors.fill: parent anchors.leftMargin: cols.padH anchors.rightMargin: cols.padH spacing: cols.gap // THE DIM (contract §5.2). On the Row, not on `root`: the row's menu // and its now-playing edge mark are siblings of this item, and neither // should fade. 0.5 is views/purchases/PurchaseListRow.qml's value — // the only precedent for this state in the tree, so the two screens // agree. opacity: root.pulledDead ? 0.5 : 1.0 // --- Reorder gutter (primitives/TrackRow.slint:270-311) ---------- // The LEADING cell on a reorderable list: a 22px column with an // up/down chevron stack, 18px each. It is the affordance a user who // cannot (or does not want to) drag reorders with — the reference // ships it and the port had only the drag, which is half of owner // finding 8. // // Reuse note: the Playlist Manager's equivalent is // views/playlistmanager/PmGridCard.qml's 22px header (chevron-up · // grip-vertical · chevron-down through PmActionButton). That one is // a GRID card with 140px of width to spend; this is a 50px table row // whose gutter is 22px wide, so the same three-across header does not // fit and PmActionButton's 22px disc would leave no room for the // second chevron. The geometry here is the reference row's own. Item { visible: root.showReorder width: cols.colReorder height: parent.height Column { anchors.verticalCenter: parent.verticalCenter spacing: 0 ReorderChevron { id: chevUp width: cols.colReorder glyph: "chevron-up" armed: root.canMoveUp onActivated: root.moveUpRequested() } ReorderChevron { id: chevDown width: cols.colReorder glyph: "chevron-down" armed: root.canMoveDown onActivated: root.moveDownRequested() } } } // Number cell — the number-variant of primitives/TrackPlayCell.slint // (:172-232), the play affordance shared by every track row. // // --- LIGHT-THEME LEGIBILITY (Slint 2.0.2 / #638, "Track-row play // --- glyph … legible on light themes") --------------------------- // TrackPlayCell.slint:201-207 states the defect and the fix in its // own words: "Circular backing so the play/pause glyph is legible on // EVERY theme (the old bare #ffffff glyph vanished on light themes — // white-on-white, no scrim like the artwork variant has). Fill/border/ // shadow use the polarity-baked alpha ramp (black-alpha on light, // white-alpha on dark) and the glyph uses text-primary." // // The port had the pre-fix shape: a hardcoded `#3dffffff` disc, no // ring, and the "primary" icon bake — which is a literal #ffffff, not // text-primary. Both halves are now theme-correct: theme.alphaTier() // is white-based on dark themes and black-based on light (qbz-theme // colors.rs:125-129, republished per theme by theme_qt.rs), and // `playGlyphTint` is the real text-primary token, runtime-tinted. // // Numbers off the .slint: 24px disc, 1.5px ring ("a 1px stroked circle // aliases badly in femtovg", :215), 16px glyph. // The accent-ring arm (:208-229) is the playing row: transparent fill, // accent circumference, accent glyph. // POC-NOTE: the .slint also drops a 6px `card-shadow` under the disc. // Qt's DropShadow is a Qt5Compat graphical effect and renders NOTHING // on this port's software path (the finding that killed ColorOverlay // in QbzIcon), so the shadow is dropped rather than faked; the ring // carries the separation on its own. Item { id: playCell width: cols.colNumber height: 40 anchors.verticalCenter: parent.verticalCenter // `show-overlay: row-hovered || is-active` (TrackPlayCell.slint // :93) — the playing row keeps the affordance at rest, which is // what the accent ring is FOR. The port showed it on hover only, // so the ring never appeared. // A dead row never offers the play disc: the affordance IS the // claim that clicking does something. readonly property bool showOverlay: (root.hovered || root.isActive) && !root.pulledDead // `accent-ring` (:108): the playing row's static form — only with // the animation pref OFF; with it ON the eq bars carry the state. readonly property bool accentRing: !root.playIndicatorAnim && root.isActive && QbzPlayer.npPlaying // `show-bars` (TrackPlayCell.slint:184-187): the animated eq bars // replace the disc at rest on the playing row. Pulse-driven, so // they ride the shell's frame instead of owning one. EqualizerBars { visible: root.showBars active: root.recycleActive && root.isActive && QbzPlayer.npPlaying anchors.centerIn: parent } // Selection checkbox — TrackRow.slint:392-419: a 13px disc, accent // when selected, muted/text-primary ring when not. It REPLACES the // number and the play disc (both gate on !selectMode below), so // the leading column keeps its width and nothing else shifts. Rectangle { visible: root.selectMode anchors.centerIn: parent width: 14 height: 14 radius: 7 color: root.checked ? theme.accent : "transparent" border.width: root.checked ? 0 : 1.5 border.color: selArea.containsMouse ? theme.textPrimary : theme.textMuted Behavior on color { ColorAnimation { duration: 120 } } QbzIcon { visible: root.checked name: "check" width: 8 height: 8 anchors.centerIn: parent // The glyph sits ON the accent fill — the same selector // controls/QbzCheckbox.qml routes through, not a literal // #ffffff (which is under 2.6:1 on 16 of the 35 palettes). tintName: theme.accentGlyphTint } MouseArea { id: selArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.toggleSelect(Qt.NoModifier) } } Text { visible: !playCell.showOverlay && !root.selectMode && !root.pulledDead anchors.centerIn: parent text: root.number color: theme.textMuted font.pixelSize: 13 } // PULLED FROM THE CATALOGUE — the alert glyph takes the track // number's slot, so the leading column still says "what this row // is" and nothing reflows. // // `circle-alert`, NOT `triangle-alert` (contract §A F11): the // download column of THIS row already draws `triangle-alert` for // `cacheStatus === 4` (a failed cache), and two identical glyphs // meaning two different things on one 50px row is a defect, not a // style. `circle-alert` is also what the Tauri frontend used for // exactly this state (TrackRow.svelte's CircleAlert), so the // meaning is not being invented here. Both glyphs exist in every // tint dir (qml/assets/icons/*/circle-alert.svg). // // A Loader, so a normal row instantiates NOTHING — not the glyph, // not the MouseArea, and not the attached ToolTip, which is a // QObject per item the moment it is referenced. A 16K-row list // must not pay for a state a handful of rows are ever in. Loader { anchors.centerIn: parent width: 20 height: 20 active: root.pulledDead && !root.selectMode visible: active sourceComponent: Item { QbzIcon { anchors.centerIn: parent name: "circle-alert" width: 15 height: 15 tintName: "favorite" } MouseArea { anchors.fill: parent hoverEnabled: true // No cursorShape override: an Arrow over an inert row // is the honest signal, and PurchaseListRow.qml does // the same on its unavailable rows. ToolTip.visible: containsMouse ToolTip.delay: 300 // REUSED msgid — already translated in all 7 // translated locales (es:3658); it is the same // sentence the reactive skip path toasts. ToolTip.text: QbzSession.tr( "This track is no longer available", QbzSession.trRev) } } } Rectangle { visible: playCell.showOverlay && !root.showBars && !root.selectMode anchors.centerIn: parent width: 24 height: 24 radius: 12 color: playCell.accentRing ? "transparent" : root.playCellFill border.width: 1.5 border.color: playCell.accentRing ? theme.accent : root.playCellRing QbzIcon { anchors.centerIn: parent // `show-pause` (:101-104): PAUSE only while the playing // row is hovered — at rest the playing row shows PLAY, // "an affordance, not a state badge: the accent ring + // the row's edge mark carry the state". name: playCell.accentRing && root.hovered ? "pause" : "play-fill" width: 16 height: 16 tintName: playCell.accentRing ? "accent" : root.playGlyphTint } } // TrackPlayCell.slint:234-243 — the cell is its OWN click target: // a non-current cell plays the track, the current cell toggles // play/pause. Without it the circle was a control that rendered // and did nothing on the album view (clickPlays:false there, so // the press fell through to the row body, which only reacts to a // double-click). Declared LAST so it sits ABOVE the glyph, and it // is above the row-body area regardless (that one is pinned z:-1). MouseArea { id: playArea anchors.fill: parent // Declared BEFORE the checkbox in z-order terms? No — the // checkbox is declared FIRST and this fills the same cell, so // it would swallow the checkbox's press. Disabling it in // select mode is what hands the cell over (the play // affordance is invisible there anyway). // // The cell is its OWN click target (TrackPlayCell.slint // :234-243), so disabling the row body is not enough: without // the `pulledDead` term, clicking the alert glyph would start // a play that cannot succeed. enabled: !root.selectMode && !root.pulledDead hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (root.isActive) QbzPlayer.togglePlay() else root.playRequested() } } } // 36px artwork cell (showArtwork arm). Rectangle { visible: root.showArtwork width: cols.colArt height: cols.colArt anchors.verticalCenter: parent.verticalCenter radius: 4 color: theme.surfaceElevated // No clip: RoundedImage confines itself on both of its arms, and a // rectangular scissor never produced this radius anyway. Same // per-row batch-root cost as the quality cell above. RoundedImage { anchors.fill: parent source: root.item.artPath || "" radius: 4 } // Per-item cover placeholder — clears when THIS row's cover lands, // which is what makes a long list read as progressive instead of // filling in one lump. Host views drive the three properties; // default-off, so no existing call site changes. QbzSkeleton { variant: "art" anchors.fill: parent blockRadius: 4 visible: root.artPending phase: root.skelPhase settleMs: root.artSettleMs } } // Title (+ explicit) / artist. Column { width: root.titleColWidth anchors.verticalCenter: parent.verticalCenter spacing: 2 Row { spacing: 6 Text { text: root.item.title || "" // Muted, not just dimmed: the Row's 0.5 opacity fades the // whole cell uniformly, and the TITLE is the part that has // to stop reading as live content. color: root.pulledDead ? theme.textMuted : theme.textPrimary font.pixelSize: 14 font.weight: theme.weightMedium elide: Text.ElideRight width: Math.min(implicitWidth, parent.parent.width - (root.item.explicit ? 22 : 0) - (root.pulledCached ? 22 : 0)) } Rectangle { visible: root.item.explicit === true width: 16 height: 16 radius: 3 anchors.verticalCenter: parent.verticalCenter color: theme.surfaceElevated Text { anchors.centerIn: parent text: "E" color: theme.textMuted font.pixelSize: 10 font.weight: theme.weightSemibold } } // CACHED BUT PULLED (contract §5.3, finding F5). Qobuz removed // the track, but the user DOWNLOADED it and `qbz-core` resolves // the offline tier before any availability question, so it // still plays perfectly. This row is therefore NOT the dead // row — it keeps its play cell, its heart and its click, and // all it gains is an honest badge, because the user still needs // to know it is gone from the catalogue: the moment they clear // the download, it dies. // // `cloud-off`, which reads as "not in the cloud any more" and // collides with nothing on this row (the download column shows // `circle-check-big` for the copy that makes this state // possible). Loader for the same per-row cost reason as the // dead glyph above. Loader { anchors.verticalCenter: parent.verticalCenter width: 16 height: 16 active: root.pulledCached visible: active sourceComponent: Item { QbzIcon { anchors.centerIn: parent name: "cloud-off" width: 14 height: 14 tintName: "muted" } MouseArea { anchors.fill: parent hoverEnabled: true ToolTip.visible: containsMouse ToolTip.delay: 300 // NEW msgid — the one string this state needs and // the catalogs do not carry (the i18n lane adds it // to all eight). Not foldable into "This track is // no longer available": that sentence would be a // lie on a row that is playing right now. ToolTip.text: QbzSession.tr( "No longer on Qobuz — playing your downloaded copy", QbzSession.trRev) } } } } Text { width: parent.width visible: (root.item.artist || "") !== "" text: root.item.artist || "" color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse ? theme.textPrimary : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight MouseArea { id: artistLinkArea anchors.fill: parent enabled: root.artistLink && !!root.item.artistId hoverEnabled: true cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor // Same routing as the menu's "Go to artist" — Slint drives // both through one handler, so gating only the menu would // leave the LINK navigating to the track's own artist. onClicked: { if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) } } } } // Album (link) column (showAlbum arm). Text { visible: root.showAlbum width: cols.colAlbum anchors.verticalCenter: parent.verticalCenter text: root.item.album || "" color: albumArea.containsMouse ? theme.accent : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight MouseArea { id: albumArea anchors.fill: parent hoverEnabled: true cursorShape: root.item.albumId ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: if (root.item.albumId) QbzAlbum.openAlbum(root.item.albumId) } } // Duration. Text { width: cols.colDuration anchors.verticalCenter: parent.verticalCenter text: root.item.duration || "" color: theme.textMuted font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter } // Quality (92px) — the BARE badge, 1:1 with primitives/TrackRow.slint // 578-592: a 92px cell, `alignment: center` on both axes, holding a // QualityBadgeFull with `show-icon: false` + `bare: true`. That is the // tier label ("CD"/"HI-RES"/"MP3"/"LOSSLESS") stacked over the exact // bit-depth / sample-rate line, with no chip background or border, so // it blends into the row instead of reading as a contained badge. // The .slint has ONE form here — no icon variant, no bare-text // variant — which is why `qualityStyle` above is inert. // // The cell keeps its FIXED 92px. That number is a term of // `cellsRight`, which is what sizes the title column, so a wider badge // MUST NOT widen the cell or the title would be pushed into the // trailing controls. The badge is centred and the cell clips; at 8/9px // the longest real detail ("24-bit / 352.8 kHz") measures ~80px, so // the clip is a guard, never the normal path. // // Cheaper than what it replaces, too: QualityMini resolves to // QualityBadge, which carries a ToolTip popup and a hover MouseArea // per row; this one is two Texts. Item { width: cols.colQuality height: parent.height // No `clip: true`: a clip is an unconditional batch root // (qsgbatchrenderer visitClipNode), so one here cost a draw call // PER VISIBLE ROW and severed merging on both sides of it. The // badge bounds itself instead — real details measure ~80px in a // 92px cell, so the elide never fires. QualityBadgeFull { anchors.centerIn: parent tier: root.item.qualityTier || "" detail: root.item.qualityDetail || "" showIcon: false bare: true maxTextWidth: cols.colQuality } } // Favorite (showFavorite arm). Rectangle { visible: root.showFavorite width: cols.colFavorite height: cols.colFavorite radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter color: favArea.containsMouse ? theme.surfaceElevated : "transparent" QbzIcon { // The SLOT stays (the cell keeps its `visible` arm) and only // the glyph goes. Hiding the whole cell would collapse it out // of the Row and shift every column left of it — and // rows/TrackListHeader.qml lays out from the same TrackCols // object, so the header would no longer name the columns under // it. Suppressing the affordance without moving the grid is // what the reference did too (Tauri PlaylistDetailView // :2712-2713). visible: !root.pulledDead anchors.centerIn: parent name: root.favorite ? "heart-filled" : "heart" width: 16 height: 16 // TrackRow.slint:619 — hover raises the glyph to // Theme.text-primary, NOT to white (same #638 hole). tintName: root.favorite ? "favorite" : (favArea.containsMouse ? root.playGlyphTint : "muted") } MouseArea { id: favArea anchors.fill: parent // TrackRow.slint:527/558/623/688 — the in-row controls stand // down in select mode so their click reaches the row body. // Without it, aiming at a row and hitting the heart // un-favourites a track the user meant to tick. A dead row's // heart is not drawn at all (above), so the area under it must // not stay live either. enabled: !root.selectMode && !root.pulledDead hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.toggleFavorite() } } // Download slot (showDownload arm) — the offline-cache column, // TrackRow.slint:639-702: cloud when absent, spinner while queued / // downloading, accent check when ready, red alert on failure. Click // map: ready -> remove, anything else -> (re)download. The slot is // reserved wherever showDownload is on so the grid stays aligned; // the glyph itself also shows when a surface that hides the column // has a row with a live copy (`cacheStatus > 0`). Item { visible: root.showDownload || (root.hasOfflineCacheSeam && root.cacheStatus > 0) width: cols.colDownload height: cols.colDownload anchors.verticalCenter: parent.verticalCenter QbzIcon { // A pulled track has no file url to fetch, so the download // affordance could only ever fail — the slot keeps its width // (see the favourite cell for why) and loses its glyph. visible: root.cacheStatus === 0 && !root.pulledDead anchors.centerIn: parent name: "cloud-download" width: 16 height: 16 tintName: dlArea.containsMouse ? root.playGlyphTint : "muted" } QbzSpinner { visible: root.cacheStatus === 1 || root.cacheStatus === 2 anchors.centerIn: parent size: 16 } QbzIcon { visible: root.cacheStatus === 3 anchors.centerIn: parent name: "circle-check-big" width: 16 height: 16 tintName: "accent" } QbzIcon { // Never alongside the pulled-track alert in the number cell: // one row, one alert. visible: root.cacheStatus === 4 && !root.pulledDead anchors.centerIn: parent name: "triangle-alert" width: 16 height: 16 tintName: "favorite" } MouseArea { id: dlArea anchors.fill: parent enabled: root.hasOfflineCacheSeam && !root.selectMode && !root.pulledDead hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (root.cacheStatus === 3) QbzPlayer.uncacheTrack(root.item.id) else QbzPlayer.cacheTrack(root.item.id) } } } // ⋯ menu (showMenu arm). Rectangle { visible: root.showMenu width: cols.colMenu height: cols.colMenu radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter color: moreArea.containsMouse ? theme.surfaceElevated : "transparent" QbzIcon { anchors.centerIn: parent name: "ellipsis" width: 16 height: 16 // TrackRow.slint:750 — Theme.text-primary on hover. tintName: moreArea.containsMouse ? root.playGlyphTint : "muted" } MouseArea { id: moreArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: function (mouse) { root.openRowMenu(moreArea, mouse.x, mouse.y) } } } } // LAZY. This is a QtQuick.Controls Popup with a Repeater over its // entries, and it was built EAGERLY for every row — so a long list // constructed one whole popup per visible row, and rebuilt them all // on every scroll step, to show a menu only ever opened on click. // Same lazy-Loader idiom this file already uses for the clipboard // helper and the info modal. Loader { id: rowMenuLoader active: false sourceComponent: CardMenu { menuWidth: 224 entries: root.menuModel() onPicked: function (a) { root.menuAction(a) } } } /// Build the popup on first use, then open it. Called by `openRowMenu` /// below, which owns the empty-menu guard. function openRowMenuLazy(anchor, x, y) { rowMenuLoader.active = true rowMenuLoader.item.openAtCursor(anchor, x, y) } // 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 root.openRowMenuLazy(anchor, x, y) } function menuModel() { var t = QbzSession.tr var r = QbzSession.trRev var m = [] // A DEAD row does not get transport entries. D5 keeps an unavailable // track out of the queue at the seam, so "Play next" here would render // and do nothing — the rendered-and-inert defect the `has*Seam` // constants above already refuse ("OFF = the row is not built at all"). // The row keeps its menu because the menu is the ONLY way to reach the // repair, which is exactly why the row must stay right-clickable. if (!root.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" }) // #442 "Play later" — the end of the MANUAL block (after everything // already queued by hand, before the source resumes). if (root.menuShowLater) m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" }) m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }) } // An ephemeral row stops here: everything below writes something that // is supposed to survive the session, and there will be nothing left // to resolve. if (root.queueOnly) return m // THE REPAIR. First entry on a dead row, because it is the only reason // that row's menu is worth opening at all. Host-routed — see // `routeReplaceExternally`. if (root.hasReplaceSeam) m.push({ "label": t("Find available version", r), "icon": "refresh-cw", "action": "find-replacement" }) if (root.hasRadioSeam) { m.push({ "label": t("Create QBZ radio", r), "icon": "radio", "action": "radio-qbz" }) m.push({ "label": t("Create Qobuz radio", r), "icon": "radio", "action": "radio-qobuz" }) } // The menu and the heart cell agree: neither offers the favourite on a // dead row. if (root.menuShowFavorite && !root.pulledDead) m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r), "icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" }) if (root.hasMixtapeSeam) m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" }) if (root.hasPlaylistAddSeam) m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" }) if (root.menuShowRemove) m.push({ "label": t("Remove from playlist", r), "icon": "trash-2", "action": "remove", "danger": true }) // 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 && !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 // destroy its only copy with a refresh. if (root.hasOfflineCacheSeam && !root.pulledDead) { // TrackContextMenu.slint:174-199 — the offline block tracks the // row's live status: not-cached rows get the download arm, ready // rows get refresh + remove. if (root.cacheStatus === 3) { // A REFRESH on a pulled track would re-fetch a stream url that // no longer exists and can only end with the copy gone — the // one copy that is still playing. Remove stays: the user may // legitimately want the disk space, and it is their call. if (!root.pulledCached) m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" }) m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true }) } else { m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" }) } } if (root.menuShowGoTo && root.item.albumId) 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" }) // 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 } function menuAction(a) { if (a === "play") root.playRequested() else if (a === "next") root.enqueueRequested("next") else if (a === "later") root.enqueueRequested("later") else if (a === "queue") root.enqueueRequested("queue") else if (a === "go-artist") { if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) } else if (a === "go-album") { if (root.routeGoToExternally) root.goToRequested("album") else QbzAlbum.openAlbum(root.item.albumId) } else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() // Add to playlist. The internal arm is the CATALOG one and nothing // else can reach it: `hasPlaylistAddSeam` only builds the entry for a // `catalogRow` that is not a local/plex row, or when the host has // taken the routing. Belt and braces on the dispatch too, so a host // that sets `routePlaylistAddExternally` and forgets the handler // cannot fall through into the Qobuz call with a local id. else if (a === "add-to-playlist") { if (root.routePlaylistAddExternally) root.playlistAddRequested() else if (root.catalogRow && !root.localSourceRow) QbzPlaylistPicker.openForTrack(root.item.id) } // "Find available version" — the HOST answers: only it knows the // playlist id, whether the user owns it, and that this row is a // catalog membership. `hasReplaceSeam` already gates the entry on the // host having taken the routing, so there is no internal arm to fall // through to. else if (a === "find-replacement") root.replaceRequested() else if (a === "remove") root.removeRequested() else if (a === "cache") QbzPlayer.cacheTrack(root.item.id) else if (a === "uncache") QbzPlayer.uncacheTrack(root.item.id) else if (a === "recache") QbzPlayer.recacheTrack(root.item.id) else if (a === "share-qobuz") root.copyToClipboard( "https://open.qobuz.com/track/" + (root.item.id || "")) else if (a === "track-info") root.openTrackInfo() } // --- Share (share.rs::qobuz_track_url + copy_to_clipboard) ----------- // The .slint arm copies the link and raises a toast; the Qt port has no // toast seam yet (GLUE NEEDED), so the copy is silent — but it IS a real // clipboard write. QtQuick exposes no Clipboard type; TextEdit.copy() is // the supported route, kept in an INACTIVE Loader so a 16K-row list does // not carry one TextEdit per row. Loader { id: clipLoader active: false sourceComponent: TextEdit { visible: false } } function copyToClipboard(text) { if (!text || text === "") return clipLoader.active = true clipLoader.item.text = text clipLoader.item.selectAll() clipLoader.item.copy() clipLoader.active = false } // --- Track info (QbzAlbum.openTrackInfo + shell/TrackInfoModal) ------ // Same lazy-Loader reason: the modal is a full Popup tree and a list row // must not instantiate one per delegate. Activated on demand, torn down // on close (Qt.callLater so the Popup is not destroyed mid-signal). Loader { id: trackInfoLoader active: false sourceComponent: TrackInfoModal { } } Connections { target: trackInfoLoader.item ignoreUnknownSignals: true function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) } } function openTrackInfo() { if (!root.item.id) return trackInfoLoader.active = true trackInfoLoader.item.openFor(root.item.id) } // Shared drag (the row BODY is the source — TrackRow.slint): press-drag // >6px starts it (bodyDragStarted fires FIRST, the #589 pre-hook); // release plays (clickPlays) or ignores (album view: double-click). property bool dragging: false property point downPos: Qt.point(0, 0) // --- DRAG vs. DRAG-TO-SCROLL (owner finding 8) ----------------------- // The reference does not solve this because it does not HAVE it: Slint's // ListView is not mouse-drag-scrollable, so its row-body drag has no // competitor. Qt's is — every one of these rows lives in a Flickable, and // a Flickable filters its children's mouse events and takes the grab once // the press travels past `QStyleHints.startDragDistance` (~10px). Our // drag arms at 6px, so BOTH fire: the ghost appears, the list then takes // the grab, this area gets `canceled` and never `released`, `dragging` // stays true and the ghost never ends. // // The resolution is the one this port ALREADY made for the same collision // in shell/QueuePanel.qml:479-491 — `preventStealing` scoped to the rows // that are actually reorderable, described there as "the Qt equivalent of // QueueSidebar.slint's `interactive: false` on the queue Flickable, but // without that side effect: Qt's Flickable also drops WHEEL events when // it is non-interactive, so wheel + scrollbar keep working, and only a // press that starts ON a reorderable row is denied to the flick." Same // trade here, same shape, so the two reorderable lists in the app behave // identically: while a list is reorderable it is not drag-scrollable, and // the wheel and the scrollbar carry scrolling. // // Scoped to `showReorder` on purpose: on every OTHER surface the row-body // drag is the add-to-a-sidebar-playlist gesture, which shipped with the // >6px arm and drag-scroll both live, and re-timing it is not this // round's job. `onCanceled` below is the universal half — it costs no // gesture change and it is what stops a stolen drag from stranding the // ghost anywhere. // // Rejected: constraining the drag AXIS (a reorder drag is vertical, i.e. // the same axis as the scroll — it separates nothing), and moving the // drag source onto a grip handle (see the gutter above: 22 x 50 leaves no // room beside the two chevrons, and the chevrons already are the non-drag // affordance a grip would duplicate). /// Release / cancel teardown — ONE implementation, because the two used /// to be able to disagree about whether the shared drag had ended. function endBodyDrag() { if (root.dragging) { QbzShell.dragEnd() root.dragging = false return true } return false } // Called by a recycling host before this object enters ListView's pool. // Popups are window-overlay children, so merely hiding the delegate would // leave them open and tied to the wrong track after reuse. function releaseForReuse() { root.endBodyDrag() if (rowMenuLoader.item) rowMenuLoader.item.close() if (trackInfoLoader.item) trackInfoLoader.item.close() Qt.callLater(function () { if (!root.recycleActive) { rowMenuLoader.active = false trackInfoLoader.active = false } }) } MouseArea { id: trArea anchors.fill: parent // BEHIND the row's own controls. Declaration order is z-order in QML, // and this fills the whole row from the LAST declaration — so it sat on // top of the heart / cloud / menu buttons and swallowed their presses. // propagateComposedEvents does not save them: `pressed` is not a // composed event, so only the row's own double-click still worked. z: -1 hoverEnabled: true propagateComposedEvents: true // Right press opens the SAME menu as ⋯ (rowMenu), at the pointer. acceptedButtons: Qt.LeftButton | Qt.RightButton // An Arrow over a dead row, whatever the surface's click policy — the // pointer must not promise a play that is blocked below. cursorShape: (!root.pulledDead && (root.clickPlays || root.selectMode)) ? Qt.PointingHandCursor : Qt.ArrowCursor // Keep the grab so the enclosing Flickable cannot turn our drag into a // scroll — see the block above. // // TWO arms, because the two gestures need opposite trades: // // reorder rows — held from the PRESS. A reorder drag is vertical, // i.e. the same axis as the scroll, so there is nothing to // discriminate on and the list gives up drag-scrolling. // every other row — held from the moment OUR drag arms (`dragging`). // Until then the press belongs to the Flickable, so vertical // drag-to-scroll still works; once armed the grab is ours and the // pointer can leave the row. // // That second arm is the AlbumView bug (owner, 2026-08-10: "el evento // dragging comienza pero muere cuando sale del track row"). Off a // reorderable list this was plain `false`: our drag armed at 6px, the // Flickable stole the grab at `startDragDistance` (~10px) a moment // later, `onCanceled` fired, and the drag died at roughly the row // boundary — never reaching the sidebar. Arming at 6px and claiming // the grab there wins the race by construction, because 6 < 10. preventStealing: (root.showReorder && root.draggable && !root.selectMode) || root.dragging onPressed: function (mouse) { if (mouse.button === Qt.RightButton) { // TrackRow.slint:201 — the row menu is suppressed in // multi-select mode (a right-click toggles nothing there). if (root.showMenu && !root.selectMode) root.openRowMenu(trArea, mouse.x, mouse.y) mouse.accepted = true return } root.downPos = Qt.point(mouse.x, mouse.y) } onPositionChanged: function (mouse) { // Only a LEFT press drags — a right press is the context gesture. // Dragging a row onto a sidebar playlist is off in select mode // (TrackRow.slint keeps the whole body as a selection target). // A dead row is not a drag source either: dropping it on a sidebar // playlist would add a track that cannot play, which is precisely // the state this treatment exists to stop spreading. if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable || root.selectMode || root.pulledDead) return const g = mapToItem(null, mouse.x, mouse.y) const dx = mouse.x - root.downPos.x const dy = mouse.y - root.downPos.y // ARMING, and it is axis-aware off a reorderable list. // // A reorder drag is VERTICAL — the same axis as the scroll — so // there is nothing to discriminate on and that arm stays "6px in // any direction" (the list has already given up drag-scrolling // via `preventStealing`, see below). // // Everywhere else the gesture is "carry this row to the sidebar // (or the queue)", which is HORIZONTAL, while drag-to-scroll is // vertical. So they separate cleanly, and both survive: a // vertical press is never claimed and the Flickable scrolls it; a // horizontal one arms here and takes the grab. // // The reference's own note rejects an axis constraint — but it // rejects it FOR REORDER, where it "separates nothing". Here it // separates everything, which is why this arm exists and that one // does not. const armed = root.showReorder ? (Math.abs(dx) > 6 || Math.abs(dy) > 6) : (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy)) if (!root.dragging && armed) { root.dragging = true root.bodyDragStarted(root.number) QbzShell.dragStart(root.item.id, root.item.title || "", (root.item.artist || "") + " · " + (root.item.album || ""), g.x, g.y) } if (root.dragging) QbzShell.dragMove(g.x, g.y) } onReleased: function (mouse) { if (mouse.button === Qt.RightButton) return const wasDragging = root.endBodyDrag() // In select mode the WHOLE row body is a selection target // (TrackRow.slint:174) — never a play. if (root.selectMode) { root.toggleSelect(mouse.modifiers) mouse.accepted = true return } // THE CLICK IS BLOCKED UP FRONT (D4): no request, no toast, no // error — the row already SAYS it is dead, so clicking it must be // a no-op rather than a failure the user has to read. The right // press never reaches here (it returns above), so the context menu // — the only route to the replacement action — still opens. // // AFTER the select-mode arm, not before it: in select mode a click // means "tick this row", not "play it", so blocking it there would // buy nothing (no play is issued either way) while leaving the row // half-selectable — its checkbox has its own live MouseArea in the // leading cell, and the bulk bar's "Select all" reaches it // regardless. A row the user cannot tick by clicking but CAN tick // by hitting the 14px disc is the worse of the two states. if (root.pulledDead) { mouse.accepted = true return } if (wasDragging) { mouse.accepted = true } else if (root.clickPlays) { root.playRequested() } else { mouse.accepted = false } } // Grab lost (the Flickable stole it, the delegate was recycled, the // window lost focus). Without this the shared drag stayed "active" // with no way to end it and the ghost never disappeared. onCanceled: root.endBodyDrag() onDoubleClicked: function (mouse) { // ":156 — In select mode a double-click just selects (via // `clicked`); no play." The single-click release above has already // toggled twice by then, which lands back where it started, so the // double-click must not add a third. if (root.selectMode) return // The album view plays on DOUBLE-click (clickPlays:false there), // so the single-click block above is not enough on its own. if (root.pulledDead) return if (mouse.button === Qt.LeftButton) root.playRequested() } } }