Add trickplay scrub-preview thumbnails to the Player scrubber - #87
Merged
Conversation
Dragging the scrubber now shows a small preview thumbnail above the
thumb, sourced from Jellyfin's own server-generated Trickplay tile
sheets (a still every few seconds across the whole runtime, packed
into tile-sheet JPEGs) rather than AetherEngine's playback engine.
An AetherEngine-backed version was tried first (its own cache-backed
scrub-still API) but confirmed dead on a real device: it only serves
stills from segments already decoded into a narrow window near the
current playhead, so every request during an actual scrub-bar drag
(aimed at wherever the user hasn't watched yet) came back nil. That
plumbing has been fully reverted in favor of Trickplay, which is
server-pre-rendered and covers the entire timeline.
- New `TrickplayInfo` DTO on `BaseItemDto.trickplay` (`Fields=Trickplay`),
`ImageURLBuilder.trickplayTileURL(...)`, and `TrickplayMath`/
`TrickplayThumbnailProvider` (pure sheet/tile lookup + fetch-and-crop,
reusing `RemoteImageLoader`'s existing URL-keyed caching).
- `PlayerViewModel` resolves a `TrickplayThumbnailProvider` in `start()`
once the active media source is known; `PlayerControlsOverlay` debounces
(120ms) scrub-thumbnail fetches per drag tick, same cancel-and-replace
shape `SearchViewModel`'s search debounce already uses.
- Two scrubber-bar polish fixes found during manual testing:
- Fixed-width leading/trailing timestamp labels (reserved via an
invisible worst-case reference string) so the scrubber track no
longer resizes when the countdown crosses an hour/minute digit
boundary (e.g. "-1:00:00" -> "-59:59") — this was also destabilizing
the thumbnail bubble's own drag-to-x-offset math.
- The thumbnail bubble is now attached via `.overlay` instead of as a
`ZStack` sibling, so its own taller natural size can no longer
inflate the scrubber track's reported layout height (was visibly
dropping the whole scrubber row on every drag).
- Blank-space taps on the controls overlay now dismiss it immediately
(fading out) instead of waiting for the auto-hide timer, but only in
the middle band between the top button row and the scrubber row —
those two rows stay "protected" so a near-miss tap trying to hit an
actual button/the scrubber can't accidentally close the UI.
Tests: new `TrickplayMathTests`/`TrickplayThumbnailProviderTests`,
`ImageURLBuilderTests` coverage for the new tile URL, and
`PlayerViewModelTests` coverage for `trickplayProvider` resolution
replacing the reverted AetherEngine-based cases. 469 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bing Full review of the trickplay branch surfaced four issues, all fixed here: - TrickplayThumbnailProvider now uses its own dedicated RemoteImageLoader (80MB budget) instead of RemoteImageLoader.shared. A decoded trickplay sheet is ~23MB (RemoteImageLoader.estimatedByteCost costs by decoded size, not file size); a scrub session touching a handful of sheets could fill the entire shared 150MB cache on its own and evict posters/ backdrops the rest of the app depends on for instant redisplay. The dedicated instance is also scoped to and released with the player session instead of lingering under the shared cache's LRU policy. - item(userID:itemID:) now takes an overridable `fields:` param, mirroring items(...)'s existing convention. detailFields is back to its pre-trickplay value; only PlayerViewModel.start() requests `Trickplay` explicitly via the new detailFieldsWithTrickplay constant. Previously every caller of item(userID:itemID:) - including several AssetDetailViewModel polling loops that never touch trickplay data - was requesting it too. - PlayerControlsOverlay now cancels scrubThumbnailTask on .onDisappear. SwiftUI doesn't auto-cancel a plain Task stored in @State the way it does a .task{} modifier's Task, so closing the player mid-drag left a scheduled fetch/crop running to completion against an orphaned view. - requestScrubThumbnail(at:) is now a throttle instead of a debounce. The old cancel-and-restart-on-every-tick debounce could in principle never fire during a sufficiently fast, sustained, continuous drag (a debounce only fires once input goes quiet). It now guarantees a fetch roughly every 120ms during continuous movement - firing immediately if that long has passed since the last fetch, otherwise scheduling exactly one trailing fetch that reads whatever position is latest when it runs. 469 tests passing. Manually verified: posters/backdrops on Home stay cached (no reload flicker) after a scrub session touching multiple trickplay sheets, closing the player mid-drag doesn't crash or warn, and a sustained continuous drag updates the thumbnail steadily rather than only once the finger pauses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Dragging the scrubber now shows a small preview thumbnail above the thumb, sourced from Jellyfin's own server-generated Trickplay tile sheets rather than AetherEngine.
An AetherEngine-backed version was tried first (its own cache-backed scrub-still API) but confirmed dead on a real device: it only serves stills from segments already decoded into a narrow window near the current playhead, so every request during an actual scrub-bar drag came back nil. That plumbing has been fully reverted in favor of Trickplay, which is server-pre-rendered and covers the entire timeline.
Changes
TrickplayInfoDTO onBaseItemDto.trickplay(Fields=Trickplay),ImageURLBuilder.trickplayTileURL(...), andTrickplayMath/TrickplayThumbnailProvider(pure sheet/tile lookup + fetch-and-crop, reusingRemoteImageLoader's existing URL-keyed caching).PlayerViewModelresolves aTrickplayThumbnailProviderinstart()once the active media source is known;PlayerControlsOverlaydebounces (120ms) scrub-thumbnail fetches per drag tick..overlayinstead of as aZStacksibling, so it can no longer inflate the scrubber track's own layout height.Testing
TrickplayMathTests/TrickplayThumbnailProviderTests,ImageURLBuilderTestscoverage for the new tile URL, andPlayerViewModelTestscoverage fortrickplayProviderresolution.xcodebuild test).🤖 Generated with Claude Code