From edb285a279e01954b51e58ea961c460032ec0322 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:49 +0100 Subject: [PATCH] feat: cache BIF file for instant preview thumbnails close #565 --- lib/services/bif_thumbnail_service.dart | 111 ++++++++++++++++++ lib/services/plex_client.dart | 20 ++-- .../desktop_video_controls.dart | 8 +- .../video_controls/mobile_video_controls.dart | 10 +- .../video_controls/video_controls.dart | 15 +-- .../widgets/timeline_slider.dart | 32 ++--- .../widgets/video_timeline_bar.dart | 10 +- 7 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 lib/services/bif_thumbnail_service.dart diff --git a/lib/services/bif_thumbnail_service.dart b/lib/services/bif_thumbnail_service.dart new file mode 100644 index 00000000..cc616f3e --- /dev/null +++ b/lib/services/bif_thumbnail_service.dart @@ -0,0 +1,111 @@ +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'plex_client.dart'; +import '../utils/app_logger.dart'; + +/// A single BIF thumbnail entry: timestamp in milliseconds + JPEG bytes. +typedef BifEntry = ({int timestampMs, Uint8List imageBytes}); + +/// Parse raw BIF file bytes into a list of thumbnail entries. +/// +/// BIF format: +/// - 0..7 : magic bytes (0x89 "BIF" 0x0D 0x0A 0x1A 0x0A) +/// - 8..11 : version (uint32 LE) +/// - 12..15 : image count (uint32 LE) +/// - 16..19 : timestamp multiplier (uint32 LE, ms per unit; 0 = 1000) +/// - 20..63 : reserved +/// - 64.. : index table — (imageCount + 1) entries of 8 bytes each: +/// [timestamp (uint32 LE), offset (uint32 LE)] +/// The last entry is a sentinel (timestamp = 0xFFFFFFFF). +/// +/// Top-level function so it can be passed to [Isolate.run]. +List _parseBifBytes(Uint8List bytes) { + if (bytes.length < 64) return []; + + final data = ByteData.sublistView(bytes); + + // Validate magic: 0x89 B I F 0x0D 0x0A 0x1A 0x0A + const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A]; + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic[i]) return []; + } + + final imageCount = data.getUint32(12, Endian.little); + var timestampMultiplier = data.getUint32(16, Endian.little); + if (timestampMultiplier == 0) timestampMultiplier = 1000; + + // Index table starts at byte 64; each entry is 8 bytes. + // There are (imageCount + 1) entries (last is sentinel). + final indexTableSize = (imageCount + 1) * 8; + if (bytes.length < 64 + indexTableSize) return []; + + final entries = []; + for (var i = 0; i < imageCount; i++) { + final entryOffset = 64 + i * 8; + final timestamp = data.getUint32(entryOffset, Endian.little); + final imgOffset = data.getUint32(entryOffset + 4, Endian.little); + + // Next entry's offset gives us the end of this image's data. + final nextEntryOffset = 64 + (i + 1) * 8; + final nextImgOffset = data.getUint32(nextEntryOffset + 4, Endian.little); + + if (nextImgOffset <= imgOffset || nextImgOffset > bytes.length) continue; + + entries.add(( + timestampMs: timestamp * timestampMultiplier, + imageBytes: Uint8List.sublistView(bytes, imgOffset, nextImgOffset), + )); + } + + return entries; +} + +/// Caches a full BIF file in memory and serves thumbnails by timestamp. +class BifThumbnailService { + List? _entries; + + /// Download and parse the BIF file for [partId]. + /// Returns silently on failure (thumbnails simply won't be available). + Future load(PlexClient client, int partId) async { + _entries = null; + try { + final bytes = await client.downloadBifFile(partId); + if (bytes == null || bytes.isEmpty) return; + _entries = await Isolate.run(() => _parseBifBytes(bytes)); + } catch (e) { + appLogger.w('BIF download/parse failed', error: e); + } + } + + /// Whether thumbnails have been loaded successfully. + bool get isAvailable => _entries != null && _entries!.isNotEmpty; + + /// Return the JPEG bytes for the thumbnail nearest to [time]. + /// Uses binary search for O(log n) lookup. + Uint8List? getThumbnail(Duration time) { + final entries = _entries; + if (entries == null || entries.isEmpty) return null; + + final ms = time.inMilliseconds; + + // Binary search for the largest timestamp <= ms. + var lo = 0; + var hi = entries.length - 1; + while (lo < hi) { + final mid = (lo + hi + 1) ~/ 2; // bias right + if (entries[mid].timestampMs <= ms) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return entries[lo].imageBytes; + } + + /// Release cached data. + void dispose() { + _entries = null; + } +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7aac83c8..6d3d4705 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui' show VoidCallback; import 'package:dio/dio.dart'; @@ -918,17 +919,20 @@ class PlexClient { return '${config.baseUrl}/$path'.withPlexToken(config.token); } - /// Check whether thumbnail previews are available for a given part. - /// Returns true if the server responds with 200 to the first thumbnail. - Future checkThumbnailsAvailable(int partId) async { + /// Download the full BIF (Base Index Frames) file for a given part. + /// Returns the raw bytes, or null on failure. + Future downloadBifFile(int partId) async { try { - final response = await _dio.get( - '/library/parts/$partId/indexes/sd/0', - options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 5)), + final response = await _dio.get>( + '/library/parts/$partId/indexes/sd', + options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 30)), ); - return response.statusCode == 200; + if (response.statusCode == 200 && response.data != null) { + return Uint8List.fromList(response.data!); + } + return null; } catch (_) { - return false; + return null; } } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 74880b43..a74fc2b2 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -81,8 +81,8 @@ class DesktopVideoControls extends StatefulWidget { final ShaderService? shaderService; final VoidCallback? onShaderChanged; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; @@ -146,7 +146,7 @@ class DesktopVideoControls extends StatefulWidget { this.hasFirstFrame, this.shaderService, this.onShaderChanged, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.isAmbientLightingEnabled = false, @@ -486,7 +486,7 @@ class DesktopVideoControlsState extends State { onKeyEvent: _handleTimelineKeyEvent, onFocusChange: _onFocusChange, enabled: canInteract, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, ), const SizedBox(height: 4), ], diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index a98e6fff..2973f989 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -43,8 +45,8 @@ class MobileVideoControls extends StatelessWidget { /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; @@ -73,7 +75,7 @@ class MobileVideoControls extends StatelessWidget { this.onSeekToNextChapter, this.canControl = true, this.hasFirstFrame, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, }); @@ -225,7 +227,7 @@ class MobileVideoControls extends StatelessWidget { horizontalLayout: false, enabled: canControl, showFinishTime: true, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, ), ), ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d4a0623f..b5853d7b 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,5 +1,6 @@ import 'dart:async' show StreamSubscription, Timer; import 'dart:io' show Platform; +import 'dart:typed_data'; import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent; import 'package:flutter/material.dart'; @@ -77,7 +78,7 @@ Widget plexVideoControlsBuilder( ValueNotifier? controlsVisible, ShaderService? shaderService, VoidCallback? onShaderChanged, - String Function(Duration time)? thumbnailUrlBuilder, + Uint8List? Function(Duration time)? thumbnailDataBuilder, bool isLive = false, String? liveChannelName, bool isAmbientLightingEnabled = false, @@ -103,7 +104,7 @@ Widget plexVideoControlsBuilder( controlsVisible: controlsVisible, shaderService: shaderService, onShaderChanged: onShaderChanged, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, isLive: isLive, liveChannelName: liveChannelName, isAmbientLightingEnabled: isAmbientLightingEnabled, @@ -148,8 +149,8 @@ class PlexVideoControls extends StatefulWidget { /// Called when shader preset changes final VoidCallback? onShaderChanged; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream (disables seek, progress, etc.) final bool isLive; @@ -184,7 +185,7 @@ class PlexVideoControls extends StatefulWidget { this.controlsVisible, this.shaderService, this.onShaderChanged, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.isAmbientLightingEnabled = false, @@ -1853,7 +1854,7 @@ class _PlexVideoControlsState extends State with WindowListen onSeekToNextChapter: _seekToNextChapter, canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, isLive: widget.isLive, liveChannelName: widget.liveChannelName, ), @@ -1977,7 +1978,7 @@ class _PlexVideoControlsState extends State with WindowListen onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, isLive: widget.isLive, liveChannelName: widget.liveChannelName, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index ec251858..c2362809 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -1,6 +1,6 @@ import 'dart:async'; +import 'dart:typed_data'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../models/plex_media_info.dart'; import '../../../mpv/models.dart'; @@ -9,7 +9,6 @@ import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; import '../painters/buffer_range_painter.dart'; import '../painters/chapter_marker_painter.dart'; -import '../../plex_optimized_image.dart' show blurArtwork; /// Timeline slider with chapter markers for video playback /// @@ -36,8 +35,8 @@ class TimelineSlider extends StatefulWidget { /// Whether the slider is enabled for interaction. final bool enabled; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; const TimelineSlider({ super.key, @@ -52,7 +51,7 @@ class TimelineSlider extends StatefulWidget { this.onKeyEvent, this.onFocusChange, this.enabled = true, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, }); @override @@ -78,7 +77,7 @@ class _TimelineSliderState extends State { // Detect user-initiated seeks. A normal playback will advance the timeline // a very short amount. But a bigger jump indicates that the user changed position. // For now we will check half a second, but this can probably be made higher. - if (widget.thumbnailUrlBuilder != null && _dragValue == null) { + if (widget.thumbnailDataBuilder != null && _dragValue == null) { final delta = (widget.position.inMilliseconds - oldWidget.position.inMilliseconds).abs(); if (delta > 500) { _showKeySeekThumbnail = true; @@ -101,12 +100,8 @@ class _TimelineSliderState extends State { } Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { - // Snap to the nearest 5-second interval since Plex's thumbnails are generated every 5 seconds. - // Round here so the URL is consistent for widget-level cache hits rather than a new URL for each timestamp. - final roundedMs = (time.inMilliseconds / 5000).round() * 5000; - final roundedTime = Duration(milliseconds: roundedMs); - final thumbnailUrl = widget.thumbnailUrlBuilder?.call(roundedTime); - final hasThumbnail = thumbnailUrl != null; + final thumbnailData = widget.thumbnailDataBuilder?.call(time); + final hasThumbnail = thumbnailData != null; final tooltipWidth = hasThumbnail ? _thumbWidth : 64.0; final timestampOffset = 16.0; @@ -131,13 +126,12 @@ class _TimelineSliderState extends State { boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8, spreadRadius: 1)], ), clipBehavior: Clip.antiAlias, - child: blurArtwork(CachedNetworkImage( - imageUrl: thumbnailUrl, + child: Image.memory( + thumbnailData, fit: BoxFit.cover, - fadeInDuration: Duration.zero, - placeholder: (_, _) => const SizedBox.shrink(), // Show nothing for placeholder - errorWidget: (_, _, _) => const SizedBox.shrink(), // Show nothing for errors - )), + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), ), if (hasThumbnail) const SizedBox(height: 4), Container( @@ -189,7 +183,7 @@ class _TimelineSliderState extends State { final fraction = ((_mousePosition! - _sliderPadding) / trackWidth).clamp(0.0, 1.0); final time = Duration(milliseconds: (fraction * durationMs).round()); tooltip = _buildTooltip(sliderWidth, _mousePosition!, time); - } else if (_showKeySeekThumbnail && widget.thumbnailUrlBuilder != null) { + } else if (_showKeySeekThumbnail && widget.thumbnailDataBuilder != null) { // Show tooltip at current playback position when user is actively seeking via d-pad/keyboard // Note that this has the lowest priority, so if the user hovers, that will show instead final fraction = (widget.position.inMilliseconds / durationMs).clamp(0.0, 1.0); diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 1447a189..64e17bd1 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; @@ -36,8 +38,8 @@ class VideoTimelineBar extends StatelessWidget { /// Whether to show the estimated finish time next to the remaining timestamp (mobile). final bool showFinishTime; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; const VideoTimelineBar({ super.key, @@ -52,7 +54,7 @@ class VideoTimelineBar extends StatelessWidget { this.onFocusChange, this.enabled = true, this.showFinishTime = false, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, }); @override @@ -144,7 +146,7 @@ class VideoTimelineBar extends StatelessWidget { onKeyEvent: onKeyEvent, onFocusChange: onFocusChange, enabled: enabled, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, ); } }