From d76f1ed562be701627293790164767ee6cf959e7 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Sat, 7 Feb 2026 14:14:19 -0500 Subject: [PATCH] Add support for video thumbnail previews --- lib/screens/video_player_screen.dart | 22 ++++++ .../desktop_video_controls.dart | 5 ++ .../video_controls/mobile_video_controls.dart | 5 ++ .../video_controls/video_controls.dart | 8 ++ .../widgets/timeline_slider.dart | 74 +++++++++++++++---- .../widgets/video_timeline_bar.dart | 5 ++ 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0aedab59..f12659b3 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -42,6 +42,7 @@ import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; import '../focus/focusable_wrapper.dart'; @@ -101,6 +102,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; bool _isHandlingBack = false; + bool _hasThumbnails = false; // Auto-play next episode Timer? _autoPlayTimer; @@ -134,6 +136,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin return context.getClientForServer(widget.metadata.serverId!); } + String? _buildThumbnailUrl(BuildContext context, Duration time) { + final partId = _currentMediaInfo?.partId; + if (partId == null || widget.isOffline) return null; + final client = _getClientForMetadata(context); + return '${client.config.baseUrl}/library/parts/$partId/indexes/sd/${time.inMilliseconds}' + .withPlexToken(client.config.token); + } + final ValueNotifier _isBuffering = ValueNotifier(false); // Track if video is currently buffering final ValueNotifier _hasFirstFrame = ValueNotifier(false); // Track if first video frame has rendered final ValueNotifier _isExiting = ValueNotifier(false); // Track if navigating away (for black overlay) @@ -861,8 +871,19 @@ class VideoPlayerScreenState extends State with WidgetsBindin setState(() { _availableVersions = result.availableVersions.cast(); _currentMediaInfo = result.mediaInfo; + _hasThumbnails = false; }); + // Check whether any thumbnails exist by requesting the first one + if (_currentMediaInfo?.partId != null && !widget.isOffline) { + final url = _buildThumbnailUrl(context, Duration.zero); + if (url != null) { + HttpClient().getUrl(Uri.parse(url)).then((req) => req.close()).then((res) { + if (mounted) setState(() => _hasThumbnails = res.statusCode == 200); + }).catchError((_) {}); + } + } + // Initialize video PIP and filter manager with player and available versions if (player != null) { _videoFilterManager = VideoFilterManager( @@ -1867,6 +1888,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin controlsVisible: _controlsVisible, shaderService: _shaderService, onShaderChanged: () => setState(() {}), + thumbnailUrlBuilder: _hasThumbnails ? (Duration time) => _buildThumbnailUrl(context, time)! : null, ), ); }, diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 0888871c..8052fbbc 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -80,6 +80,9 @@ 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; + const DesktopVideoControls({ super.key, required this.player, @@ -123,6 +126,7 @@ class DesktopVideoControls extends StatefulWidget { this.hasFirstFrame, this.shaderService, this.onShaderChanged, + this.thumbnailUrlBuilder, }); @override @@ -435,6 +439,7 @@ class DesktopVideoControlsState extends State { onKeyEvent: _handleTimelineKeyEvent, onFocusChange: _onFocusChange, enabled: canInteract, + thumbnailUrlBuilder: widget.thumbnailUrlBuilder, ), const SizedBox(height: 4), // Row 2: Playback controls and options diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 8d4b8f4a..1fbebe2f 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -41,6 +41,9 @@ 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; + const MobileVideoControls({ super.key, required this.player, @@ -60,6 +63,7 @@ class MobileVideoControls extends StatelessWidget { this.onPrevious, this.canControl = true, this.hasFirstFrame, + this.thumbnailUrlBuilder, }); @override @@ -167,6 +171,7 @@ class MobileVideoControls extends StatelessWidget { horizontalLayout: false, enabled: canControl, showFinishTime: true, + thumbnailUrlBuilder: thumbnailUrlBuilder, ), ), ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index bc8b7774..9c1befd6 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -72,6 +72,7 @@ Widget plexVideoControlsBuilder( ValueNotifier? controlsVisible, ShaderService? shaderService, VoidCallback? onShaderChanged, + String Function(Duration time)? thumbnailUrlBuilder, }) { return PlexVideoControls( player: player, @@ -93,6 +94,7 @@ Widget plexVideoControlsBuilder( controlsVisible: controlsVisible, shaderService: shaderService, onShaderChanged: onShaderChanged, + thumbnailUrlBuilder: thumbnailUrlBuilder, ); } @@ -133,6 +135,9 @@ 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; + const PlexVideoControls({ super.key, required this.player, @@ -154,6 +159,7 @@ class PlexVideoControls extends StatefulWidget { this.controlsVisible, this.shaderService, this.onShaderChanged, + this.thumbnailUrlBuilder, }); @override @@ -1688,6 +1694,7 @@ class _PlexVideoControlsState extends State with WindowListen onPrevious: widget.onPrevious, canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, + thumbnailUrlBuilder: widget.thumbnailUrlBuilder, ), ) : Listener( @@ -1744,6 +1751,7 @@ class _PlexVideoControlsState extends State with WindowListen hasFirstFrame: widget.hasFirstFrame, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, + thumbnailUrlBuilder: widget.thumbnailUrlBuilder, ), ), ), diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 56ba69e6..22ea835c 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../models/plex_media_info.dart'; import '../../../i18n/strings.g.dart'; @@ -29,6 +30,9 @@ 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; + const TimelineSlider({ super.key, required this.position, @@ -41,6 +45,7 @@ class TimelineSlider extends StatefulWidget { this.onKeyEvent, this.onFocusChange, this.enabled = true, + this.thumbnailUrlBuilder, }); @override @@ -53,29 +58,68 @@ class _TimelineSliderState extends State { static const _sliderPadding = 24.0; + static const _thumbWidth = 160.0; + static const _thumbHeight = 90.0; + Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { - final tooltipWidth = 64.0; + // 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 tooltipWidth = hasThumbnail ? _thumbWidth : 64.0; + final timestampOffset = 16.0; + final tooltipTop = hasThumbnail ? -(_thumbHeight + timestampOffset) : -timestampOffset; // Center tooltip on cursor, clamped so it stays within the slider bounds final left = (pixelX - tooltipWidth / 2).clamp(0.0, sliderWidth - tooltipWidth); return Positioned( left: left, - top: -16, + top: tooltipTop, child: IgnorePointer( - child: Container( - width: tooltipWidth, - height: 26, - alignment: Alignment.center, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(4)), - child: Text( - formatDurationTimestamp(time), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - height: 1.0, - fontFeatures: [FontFeature.tabularFigures()], + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasThumbnail) + Container( + width: _thumbWidth, + height: _thumbHeight, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(6), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8, spreadRadius: 1)], + ), + clipBehavior: Clip.antiAlias, + child: CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + placeholder: (_, __) => const SizedBox.shrink(), // Show nothing for placeholder + errorWidget: (_, __, ___) => const SizedBox.shrink(), // Show nothing for errors + ), + ), + if (hasThumbnail) const SizedBox(height: 4), + Container( + width: 64.0, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + formatDurationTimestamp(time), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.0, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), ), - ), + ], ), ), ); diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 0269adcb..2e8b31ba 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -36,6 +36,9 @@ 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; + const VideoTimelineBar({ super.key, required this.player, @@ -49,6 +52,7 @@ class VideoTimelineBar extends StatelessWidget { this.onFocusChange, this.enabled = true, this.showFinishTime = false, + this.thumbnailUrlBuilder, }); @override @@ -134,6 +138,7 @@ class VideoTimelineBar extends StatelessWidget { onKeyEvent: onKeyEvent, onFocusChange: onFocusChange, enabled: enabled, + thumbnailUrlBuilder: thumbnailUrlBuilder, ); } }