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:
20 files changed
+1431
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 || "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user