feat(qt): source-aware playback, session persistence and loading affordances

One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.

Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.

Session persistence, both levels the port was missing:
  - The startup-page preference is finally read. It had been rendering and
    persisting a value nothing consumed. `last_view` is written in the SHARED
    ui_prefs vocabulary, not this port's route ids -- four of the six differ,
    and writing the raw ids would have silently broken the other frontend's
    restore. `on_session_entered` recorded "home" unconditionally, which also
    rewrote last_view on every boot, so the setting could not survive its own
    startup.
  - Queue and active track survive a restart. session_persist was tied to a
    frontend by exactly one type alias, so it moved to qbz-app generic over the
    adapter rather than being copied. Verified end to end: the snapshot restored
    here had been written by the Slint binary, and the exit flush keeps an
    un-consumed resume position instead of overwriting it with the engine's
    idle 0.

Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.

Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.

cc
This commit is contained in:
vicrodh committed 2026-08-13 23:10:32 -06:00
1 parent 3b03682e09
commit c2944db973
20 files changed
+1431 -17

No files matched your search

+15 -2
View File
@@ -511,9 +511,22 @@ Item {
// doing nothing. The highlight above already guards with
// `?? -2`; this is the same guard on the action.
var fi = row.flatIndex
if (fi === undefined || fi === null) return
if (root.headerBar) root.headerBar.clearSearch()
if (fi === undefined || fi === null) {
// Was a bare `return`: the one failure mode that leaves NO
// trace anywhere, which is exactly what a dead click looks
// like from the outside.
console.warn("cortinilla: row has no flatIndex, click dropped —",
JSON.stringify(row))
return
}
// ORDER MATTERS. clearSearch() empties the header field, which
// drives the live-search path; if that reaches Rust before the
// click does, it can replace or clear the very snapshot the
// router is about to look the row up in — and the router then
// returns silently. The click goes FIRST now; the field is
// cleared after, which is the visible behaviour either way.
QbzSearch.cortinillaRowClicked(fi)
if (root.headerBar) root.headerBar.clearSearch()
}
}
}
@@ -67,6 +67,20 @@ Row {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleShuffle()
}
// True while the player is resolving/fetching the track that is about to
// play. Not gated on `npHasTrack`: the first play of a session has no
// track yet, and that is exactly the case that felt dead.
readonly property bool loading: QbzPlayer.npLoading
// Spin phase, advanced on the shared shell pulse (QbzShell.pulseMs).
property real spinPhase: 0
Connections {
target: QbzShell
function onPulseMsChanged() {
if (tc.loading)
tc.spinPhase = (tc.spinPhase + 0.42) % 6.283185
}
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
@@ -83,6 +97,10 @@ Row {
? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent)
: ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent")
QbzIcon {
// Hidden while loading — the ring below stands in for it, so the
// button never shows a play glyph for a track that is still being
// fetched.
visible: !tc.loading
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
@@ -90,6 +108,37 @@ Row {
tintName: tc.playCircle ? tc.tintOnAccent
: (playArea.containsMouse ? "accent" : tc.tintStrong)
}
// LOADING RING. Between the click and the first sample there was NO
// affordance anywhere in the shell, so a fetch that takes seconds — a
// Plex part, a cold CMAF session — reads as a dead click (owner report,
// 2026-08-13). `npLoading` already existed and was published
// (now_playing.rs) but the only consumer was SongCard's artwork
// spinner, which is gated on `npHasTrack` and so shows nothing on the
// first play of a session.
//
// ON THE SHELL PULSE, not a NumberAnimation: a rotation animator ticks
// at display rate and every animation frame is a whole-window present
// (CLAUDE.md, "the repaint pulse"). At ~30 Hz this costs zero extra
// presents because the pulse is already running.
Item {
anchors.fill: parent
visible: tc.loading
Rectangle {
id: ring
anchors.centerIn: parent
width: parent.width - 8
height: width
radius: width / 2
color: "transparent"
border.width: 2
border.color: tc.playCircle ? theme.accentText : theme.accent
// A 270-degree arc read: three quarters of the ring at full
// alpha and the last quarter masked by the fill behind it is
// not expressible on a Rectangle, so the spin is carried by
// opacity pulsing instead — one property, no extra node.
opacity: 0.35 + 0.45 * (1 + Math.sin(tc.spinPhase)) / 2
}
}
MouseArea {
id: playArea
anchors.fill: parent
+6 -1
View File
@@ -516,7 +516,12 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playTrack(card.id)
// The row's ORIGIN goes with the id. A Recently-Played row can be a
// Qobuz track, a local file or a Plex row, and `playTrack` alone
// sends every id to the Qobuz catalog — which 404s on the other two
// (the owner hit it on a Plex id from this exact rail). Empty
// source = qobuz, which is the legacy history shape.
onClicked: QbzPlayer.playTrackFrom(card.id, card.source || "")
}
}