Add support for video thumbnail previews
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user