Add support for video thumbnail previews

This commit is contained in:
Micah Morrison
2026-02-07 14:14:19 -05:00
parent 101a55552e
commit d76f1ed562
6 changed files with 104 additions and 15 deletions
+22
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false); // Track if navigating away (for black overlay)
@@ -861,8 +871,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
controlsVisible: _controlsVisible,
shaderService: _shaderService,
onShaderChanged: () => setState(() {}),
thumbnailUrlBuilder: _hasThumbnails ? (Duration time) => _buildThumbnailUrl(context, time)! : null,
),
);
},
@@ -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<DesktopVideoControls> {
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: canInteract,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
),
const SizedBox(height: 4),
// Row 2: Playback controls and options
@@ -41,6 +41,9 @@ class MobileVideoControls extends StatelessWidget {
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? 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,
),
),
);
@@ -72,6 +72,7 @@ Widget plexVideoControlsBuilder(
ValueNotifier<bool>? 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<PlexVideoControls> with WindowListen
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
),
)
: Listener(
@@ -1744,6 +1751,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
hasFirstFrame: widget.hasFirstFrame,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
),
),
),
@@ -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<TimelineSlider> {
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()],
),
),
),
),
],
),
),
);
@@ -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,
);
}
}