Skip to content

Commit 2bd7c72

Browse files
committed
perf(trickplay): retain sprite textures and prefetch directional previews
1 parent 4fbd3a1 commit 2bd7c72

34 files changed

Lines changed: 1684 additions & 246 deletions

‎CMakeLists.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ set(SPOOL_SHELL_SOURCES
206206
src/app/ArtworkPrefetcher.h
207207
src/app/ArtworkService.cpp
208208
src/app/ArtworkService.h
209+
src/app/TrickplayDecoder.cpp
210+
src/app/TrickplayDecoder.h
211+
src/app/TrickplayService.cpp
212+
src/app/TrickplayService.h
209213
src/app/BrowseSessionController.cpp
210214
src/app/BrowseSessionController.h
211215
src/app/ContentModelController.cpp
@@ -789,6 +793,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
789793
group-playback-policy tests/app/GroupPlaybackPolicyTest.cpp
790794
update-manifest tests/app/UpdateManifestTest.cpp
791795
artwork-url tests/app/ArtworkUrlTest.cpp
796+
trickplay tests/app/TrickplayTest.cpp
792797
)
793798
set(SPOOL_TESTS_cache
794799
database-manager tests/cache/DatabaseManagerTest.cpp

‎README.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,48 @@ being mistaken for good performance.
186186
Provider form layouts are precompiled in the host. Settings creates only visible
187187
rows and opens its native folder chooser on demand; initial viewport readiness is
188188
checked at the end of the current event-loop turn, with timed retries only when
189-
delegates are still missing. Playback no longer creates hidden image objects for
190-
every trickplay sheet: previews load the sheet needed by the current seek position.
189+
delegates are still missing. Playback previews prime the resume-position
190+
texture on a dedicated network/decode path, independent of poster queues.
191+
Sprite sheets up to 50 MB decoded RGB32 (12.5 megapixels) stay resident:
192+
moving between tiles changes the displayed crop without another image load,
193+
CPU crop, or texture upload. Larger JPEG sheets use codec-clipped frames.
194+
Whole BIF sequences use their native timestamp indexes, not an assumed
195+
uniform interval. Qt's existing JPEG plugin supplies its libjpeg/libjpeg-turbo
196+
decoder; no duplicate JPEG dependency is needed.
197+
198+
The server-free native fixture smoke exercises real JPEG sprite and BIF pixels,
199+
malformed BIF boundaries, resident-sheet crops, the 50 MB boundary, directional
200+
prefetch, and stale-session/late-request cancellation:
201+
202+
```sh
203+
nix develop .#native -c cmake --build build/linux-dev/app --target app-tests providers-tests
204+
nix develop .#native -c env SPOOL_TRICKPLAY_SMOKE_OUTPUT=/tmp/spool-preview-frame.png \
205+
build/linux-dev/app/app-tests trickplay
206+
nix develop .#native -c ctest --test-dir build/linux-dev/app \
207+
-R '^(trickplay|artwork-authentication|playback-timeline)$' --output-on-failure
208+
```
209+
210+
The smoke prints cold response time, 24 explicit warm image-provider response
211+
times, full-sheet decode time, and texture byte counts. Normal hover within a
212+
resident sheet does not issue those additional image-provider requests.
213+
These measurements exclude actual GPU transfer and presentation duration.
214+
Each local/remote session caps retained decoded textures at 50,000,000 bytes
215+
and encoded sheets (or one BIF sequence) at 32 MiB. The decoded budget is not
216+
a total RAM/VRAM limit: decoder workspace, active image delivery and GPU
217+
textures add to it. Sequences over 32 MiB are rejected.
218+
219+
Hover image selection consumes the latest input once per rendered frame;
220+
the timestamp and preview position update immediately. Prefetch follows hover
221+
direction to one neighbouring sheet or at most two BIF frames. Foreground
222+
requests preempt speculative work; a neighbouring sheet is decoded only when
223+
it fits beside the current retained textures, otherwise only its encoded bytes
224+
are prefetched. Qt image caching is disabled for previews so historical sheets
225+
do not accumulate in its image cache. Switching sessions cancels outstanding
226+
delivery and drops caches.
227+
Remote resource revisions also change when a target switches preview URL or
228+
credentials for the same item. Retired revisions cannot reuse cached frames;
229+
unchanged resources keep stable identities across state polling.
230+
191231

192232

193233
## Android development

‎docs/providers.md‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,19 @@ Advanced navigation/text commands remain provider-owned operations; their thin
332332
picker adapters configure the shared, precompiled `ProviderRemoteControls` surface.
333333

334334
Protected playback previews use account-scoped `spool-artwork://` resources,
335-
resolved by `ArtworkService` on the GUI thread. Local previews use the owning
336-
account's playback headers, not the currently active player's credentials.
337-
Remote descriptors may supply `headers`; SourceHub retains them privately and
338-
exposes only an opaque numeric-index template to QML. Remote previews do not
339-
require starting local playback. Requests stay on the approved origin, reject
340-
foreign-origin redirects, disable cookies and bypass Qt's URL-only disk cache.
341-
The memory cache is partitioned by account, resolved URL and credential hash;
342-
removed accounts cannot resolve old resources, even from memory.
335+
resolved privately by `SourceHub`. `TrickplayService` loads local and remote
336+
sprite sheets or whole BIF sequences on a dedicated worker. Sheets fitting
337+
the 50,000,000-byte decoded RGB32 budget expose stable sheet `image://` URLs
338+
and per-thumbnail crop offsets; larger JPEGs and BIF use single-frame URLs.
339+
Local descriptors use the owning account's playback headers; remote descriptors
340+
may supply their account/device `headers`.
341+
Remote previews do not require starting local playback. Requests stay on the
342+
approved origin, reject foreign-origin redirects, disable cookies and bypass
343+
Qt's URL-only disk cache. Encoded and decoded texture caches are bounded and
344+
session-isolated; removed accounts cannot resolve old resources, even from memory.
345+
Directional prefetch is limited to one neighbouring sheet or two BIF frames
346+
and yields to foreground requests. Both preview surfaces consume the latest
347+
selection once per rendered frame and clip resident sheets to the selected tile.
343348

344349
These adapters do not implement Plex watch-together or native SpoolLink peer
345350
enhancements. Protocol/loopback verification does not imply live-device support
108 Bytes
Binary file not shown.
-93 Bytes
Binary file not shown.
414 Bytes
Binary file not shown.

‎providers/lock.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@
55
"id": "spool.jellyfin",
66
"version": "0.2.8",
77
"archive": "bundled/spool.jellyfin-0.2.8.tar.zst",
8-
"sha256": "d64e4efbea49e757361a8b5a2d56701768489d3b78bc990aad57687f87bfd65f"
8+
"sha256": "17913a9c887edd29c5a886d5fc9af80d7659e1559c8e06aa86a8ad88aa2e4d08"
99
},
1010
{
1111
"id": "spool.emby",
1212
"version": "0.1.4",
1313
"archive": "bundled/spool.emby-0.1.4.tar.zst",
14-
"sha256": "5dec32cd62dafdd4a461e40bb0faa24b51c07ff251d36c85b301cf2740fe13ac"
14+
"sha256": "437db9ab69491c8f3b93627afa7e475a6c6cef95d0c134faa580df9237f3e6fb"
1515
},
1616
{
1717
"id": "spool.plex",
1818
"version": "0.1.5",
1919
"archive": "bundled/spool.plex-0.1.5.tar.zst",
20-
"sha256": "5e426dd3d888d75bfd00a12d37cbab7ab11ab56f02fbd684f4f876ed3ba8c80d"
20+
"sha256": "10ffbe71e182177bf07035c3c025ccf14872bacff31c03653c76a4e6dcf166fa"
2121
}
2222
]
2323
}

‎qml/pages/PlayerTrickplayPreview.qml‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,56 @@ Item {
66
id: root
77

88
required property var overlay
9+
readonly property var previewPlayer: overlay.hasPlayer ? overlay.player : null
910
readonly property bool localHovering: overlay.timelineHovering && overlay.controlsReason !== "remote"
10-
readonly property bool active: overlay.hasPlayer && overlay.controlsVisible && overlay.player.trickplayAvailable && (
11-
overlay.scrubbing || overlay.remoteScrubbing || localHovering
12-
|| overlay.previewing)
11+
readonly property bool active: previewPlayer !== null && overlay.controlsVisible
12+
&& previewPlayer.trickplayAvailable && (overlay.scrubbing || overlay.remoteScrubbing
13+
|| localHovering || overlay.previewing)
1314
readonly property double previewSeconds: overlay.scrubbing || overlay.remoteScrubbing ? overlay.scrubSeconds :
1415
localHovering
1516
? overlay.timelineHoverSeconds :
1617
overlay.positionSeconds()
17-
readonly property var trickplayData: active ? overlay.player.trickplayForSeconds(previewSeconds) : ({})
18+
property var trickplayData: ({})
19+
property bool selectionPending: active
1820
readonly property bool ready: trickplayData && trickplayData.available === true
1921
readonly property real scaleFactor: overlay.uiScale * 1.4
2022
readonly property real previewRatio: overlay.hasPlayer && overlay.player.durationSeconds > 0 ? Math.max(0, Math.min(
2123
1, previewSeconds
2224
/ overlay.player.durationSeconds)) :
2325
0
2426

27+
onActiveChanged: {
28+
selectionPending = active
29+
if (!active)
30+
trickplayData = ({})
31+
}
32+
onPreviewSecondsChanged: if (active)
33+
selectionPending = true
34+
onPreviewPlayerChanged: {
35+
trickplayData = ({})
36+
selectionPending = active
37+
}
38+
39+
Connections {
40+
target: root.previewPlayer
41+
ignoreUnknownSignals: true
42+
function onTrickplayChanged() {
43+
if (!root.active)
44+
root.trickplayData = ({})
45+
root.selectionPending = root.active
46+
}
47+
}
48+
49+
// Keep the scheduler outside the image-ready container so a pending
50+
// asynchronous image cannot prevent the first selection from running.
51+
FrameAnimation {
52+
running: root.active && root.selectionPending
53+
onTriggered: {
54+
root.selectionPending = false
55+
root.trickplayData = root.previewPlayer.trickplayForSeconds(root.previewSeconds)
56+
}
57+
}
58+
2559
function dp(value) {
2660
return overlay.dp(value)
2761
}
@@ -38,10 +72,11 @@ Item {
3872
anchors.bottom: parent.bottom
3973
anchors.bottomMargin: dp(200)
4074
height: ready ? Math.round((trickplayData.height || 0) * scaleFactor) + dp(32) : 0
41-
visible: ready
75+
visible: active
4276
z: 22
4377

4478
Item {
79+
visible: root.ready && previewImage.status === Image.Ready
4580
readonly property real imageWidth: root.ready ? root.trickplayData.width * root.scaleFactor : 0
4681
readonly property real imageHeight: root.ready ? root.trickplayData.height * root.scaleFactor : 0
4782
x: Math.max(root.dp(52), Math.min(parent.width - imageWidth - root.dp(52), root.previewRatio * parent.width
@@ -53,20 +88,21 @@ Item {
5388
id: frame
5489
width: parent.imageWidth
5590
height: parent.imageHeight
56-
color: "black"
91+
color: "transparent"
5792
border.color: Theme.borderStrong
5893
border.width: 1
5994
radius: Theme.radiusLarge
6095
clip: true
6196

6297
Image {
98+
id: previewImage
6399
source: root.ready ? root.artworkSource(root.trickplayData.url) : ""
64100
x: root.ready ? root.trickplayData.offsetX * root.scaleFactor : 0
65101
y: root.ready ? root.trickplayData.offsetY * root.scaleFactor : 0
66102
width: root.ready ? root.trickplayData.sheetWidth * root.scaleFactor : 0
67103
height: root.ready ? root.trickplayData.sheetHeight * root.scaleFactor : 0
68104
fillMode: Image.Stretch
69-
cache: true
105+
cache: false
70106
asynchronous: true
71107
}
72108
}

‎qml/pages/RemoteControlPage.qml‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,30 @@ FocusScope {
3535
onFocusedIndexChanged: if (focusedIndex >= 0)
3636
lastFocusedIndex = focusedIndex
3737
readonly property var preview: snapshot.preview || null
38-
readonly property int previewFrame: preview ? Math.min(preview.count - 1, Math.max(0, Math.floor(editedValue * 1000
39-
/ preview.intervalMs))) :
40-
0
41-
readonly property int previewSheet: preview ? Math.floor(previewFrame / (preview.columns * preview.rows)) : 0
42-
readonly property int previewTile: preview ? previewFrame % (preview.columns * preview.rows) : 0
38+
readonly property bool previewActive: visible && !customContext && editingKey === "seek" && preview !== null
39+
&& remote.trickplayAvailable
40+
property var previewFrame: ({})
41+
property bool previewSelectionPending: previewActive
42+
onPreviewActiveChanged: {
43+
previewSelectionPending = previewActive
44+
if (!previewActive)
45+
previewFrame = ({})
46+
}
47+
onEditedValueChanged: if (previewActive)
48+
previewSelectionPending = true
49+
onRemoteChanged: {
50+
previewFrame = ({})
51+
previewSelectionPending = previewActive
52+
}
53+
54+
// Selection belongs to the page, not the image-ready item below.
55+
FrameAnimation {
56+
running: root.previewActive && root.previewSelectionPending
57+
onTriggered: {
58+
root.previewSelectionPending = false
59+
root.previewFrame = root.remote.trickplayForSeconds(root.editedValue)
60+
}
61+
}
4362

4463
function supports(action) {
4564
return attached && commands.indexOf(action) >= 0
@@ -237,6 +256,12 @@ FocusScope {
237256
}
238257
Connections {
239258
target: root.remote
259+
ignoreUnknownSignals: true
260+
function onTrickplayChanged() {
261+
if (!root.previewActive)
262+
root.previewFrame = ({})
263+
root.previewSelectionPending = root.previewActive
264+
}
240265
function onSelectionChanged() {
241266
root.editingKey = ""
242267
root.customContext = null
@@ -462,20 +487,29 @@ FocusScope {
462487
color: Theme.textSecondary
463488
wrapMode: Text.WordWrap
464489
}
465-
Image {
466-
visible: root.editingKey === "seek" && root.preview !== null
490+
Item {
491+
id: seekPreview
492+
readonly property bool ready: root.previewFrame.available === true
493+
readonly property real imageScale: ready && root.previewFrame.width > 0 ? width / root.previewFrame.width :
494+
0
495+
496+
visible: ready && seekPreviewImage.status === Image.Ready
467497
Layout.alignment: Qt.AlignHCenter
468498
Layout.preferredWidth: Metrics.scaled(240)
469-
Layout.preferredHeight: root.preview ? width * root.preview.height / root.preview.width : 0
470-
source: visible ? "image://artwork/" + encodeURIComponent(root.preview.urlTemplate.replace("{index}", String(
471-
root.previewSheet))) :
472-
""
473-
sourceClipRect: root.preview ? Qt.rect((root.previewTile % root.preview.columns) * root.preview.width,
474-
Math.floor(root.previewTile / root.preview.columns)
475-
* root.preview.height, root.preview.width, root.preview.height) :
476-
Qt.rect(0, 0, 0, 0)
477-
fillMode: Image.PreserveAspectFit
478-
asynchronous: true
499+
Layout.preferredHeight: ready ? root.previewFrame.height * imageScale : 0
500+
clip: true
501+
502+
Image {
503+
id: seekPreviewImage
504+
source: seekPreview.ready ? root.previewFrame.url : ""
505+
x: seekPreview.ready ? root.previewFrame.offsetX * seekPreview.imageScale : 0
506+
y: seekPreview.ready ? root.previewFrame.offsetY * seekPreview.imageScale : 0
507+
width: seekPreview.ready ? root.previewFrame.sheetWidth * seekPreview.imageScale : 0
508+
height: seekPreview.ready ? root.previewFrame.sheetHeight * seekPreview.imageScale : 0
509+
fillMode: Image.Stretch
510+
cache: false
511+
asynchronous: true
512+
}
479513
}
480514
ListView {
481515
id: list

‎sdk/README.md‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ media rows; home cards and details request the image from that owner.
162162
Do not attach a parent's tag to a child without its owner ID. Series posters
163163
and album covers retain their existing `seriesId`/`albumId` ownership.
164164

165+
## Seek previews
166+
167+
Return `resolve().trickplay` for the selected media variant, not a global URL
168+
template in `describe()`. Sprite sheets use
169+
`{width, height, columns, rows, count, intervalMs, urlTemplate}`; the absolute
170+
HTTP(S) template has one `{index}` substitution. BIF sequences use
171+
`{format: "bif", url}` with optional `width`/`height`. Pass the whole sequence
172+
URL, not individual JPEGs: C++ parses its timestamp/offset index and decodes
173+
the selected frame. Missing server-generated previews mean omit `trickplay`.
174+
175+
Both formats use the account's `resolve().headers`; keep tokens out of URLs.
176+
The native loader prefetches the resume preview, caches bounded preview data
177+
separately from posters, and uploads only the requested frame. While loading
178+
or on failure, the player shows no preview frame or black placeholder.
179+
165180
## Screens
166181

167182
A screen is mounted with a `provider` property (`ScreenContext` in `provider.d.ts`) and may
@@ -283,14 +298,13 @@ is verified against count/order and occurrence IDs, with read-back before
283298
reconciliation after uncertain mutations; fixture success is not a live-server
284299
compatibility guarantee.
285300

286-
Protected `spool.remote-targets` preview descriptors may include an optional
287-
`headers` map alongside `urlTemplate`. Use the same account/device authorization
288-
as media requests; never put account tokens in preview query strings. The host
289-
keeps headers out of QML, validates the approved HTTP(S) origin and numeric
290-
`{index}` substitution, and loads only requested sheets through its isolated
291-
artwork pipeline. Local trickplay sheets use `resolve().headers` from their own
292-
account. Authenticated sheets bypass the URL-only disk cache and cookies, reject
293-
foreign-origin redirects, and reuse only their credential-partitioned memory cache.
301+
Protected `spool.remote-targets` state uses the same sheet/BIF descriptor in
302+
`preview`, with an optional `headers` map. Use the same account/device
303+
authorization as media requests; never put tokens in preview query strings.
304+
The host keeps headers out of QML, validates the approved HTTP(S) origin and
305+
numeric `{index}` substitution, and decodes previews through the dedicated
306+
native loader. Authenticated previews bypass URL-only disk caching and cookies,
307+
reject foreign-origin redirects, and keep cached data isolated by session.
294308

295309
### Native preferences and application data
296310

0 commit comments

Comments
 (0)