fix: live TV time-shift controls and timeline

This commit is contained in:
edde746
2026-04-02 02:29:04 +02:00
parent 339a767304
commit c57cbfb2f6
5 changed files with 84 additions and 101 deletions
+1 -11
View File
@@ -178,7 +178,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Live TV time-shift
CaptureBuffer? _captureBuffer;
int? _programBeginsAt;
int? _programEndsAt;
double _streamStartEpoch = 0;
bool _isAtLiveEdge = true;
String? _transcodeSessionId;
@@ -1046,9 +1045,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_liveDurationMs = tuneResult.metadata.duration;
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_programEndsAt = tuneResult.beginsAt != null && tuneResult.metadata.duration != null
? tuneResult.beginsAt! + tuneResult.metadata.duration! ~/ 1000
: null;
_transcodeSessionId = PlexClient.generateSessionIdentifier();
// Show "Watch from Start" dialog when an existing capture session has >60s of history.
@@ -2269,8 +2265,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final client = widget.liveClient;
if (client == null) return;
if (mounted) setState(() => _hasFirstFrame.value = false);
final streamPath = await client.buildLiveStreamPath(
sessionPath: _liveSessionPath!,
sessionIdentifier: _liveSessionIdentifier!,
@@ -2356,9 +2350,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Reset time-shift state for new channel
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_programEndsAt = tuneResult.beginsAt != null && tuneResult.metadata.duration != null
? tuneResult.beginsAt! + tuneResult.metadata.duration! ~/ 1000
: null;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
@@ -2822,8 +2813,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
liveChannelName: _liveChannelName,
captureBuffer: _captureBuffer,
isAtLiveEdge: _isAtLiveEdge,
programBeginsAt: _programBeginsAt,
programEndsAt: _programEndsAt,
streamStartEpoch: _streamStartEpoch,
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
onLiveSeek: _captureBuffer != null ? _seekLivePosition : null,
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
@@ -68,8 +68,7 @@ class DesktopVideoControls extends StatefulWidget {
// Live TV time-shift
final CaptureBuffer? captureBuffer;
final bool isAtLiveEdge;
final int? programBeginsAt;
final int? programEndsAt;
final double streamStartEpoch;
final int? currentPositionEpoch;
final ValueChanged<int>? onLiveSeek;
final VoidCallback? onJumpToLive;
@@ -125,8 +124,7 @@ class DesktopVideoControls extends StatefulWidget {
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
@@ -157,6 +155,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
late final FocusNode _skipForwardFocusNode;
late final FocusNode _nextChapterFocusNode;
late final FocusNode _nextItemFocusNode;
late final FocusNode _goToLiveFocusNode;
late final FocusNode _timelineFocusNode;
// Focus node for volume control
@@ -194,6 +193,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
_skipForwardFocusNode = FocusNode(debugLabel: 'SkipForward');
_nextChapterFocusNode = FocusNode(debugLabel: 'NextChapter');
_nextItemFocusNode = FocusNode(debugLabel: 'NextItem');
_goToLiveFocusNode = FocusNode(debugLabel: 'GoToLive');
_timelineFocusNode = FocusNode(debugLabel: 'Timeline');
_volumeFocusNode = FocusNode(debugLabel: 'Volume');
@@ -208,6 +208,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
_skipForwardFocusNode,
_nextChapterFocusNode,
_nextItemFocusNode,
_goToLiveFocusNode,
];
}
@@ -220,6 +221,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
_skipForwardFocusNode.dispose();
_nextChapterFocusNode.dispose();
_nextItemFocusNode.dispose();
_goToLiveFocusNode.dispose();
_timelineFocusNode.dispose();
_volumeFocusNode.dispose();
for (final node in _trackControlFocusNodes) {
@@ -481,28 +483,34 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Ignore seeking if user cannot control
if (!_canControl) return KeyEventResult.handled;
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
// Track direction change - reset if direction changes
if (_seekDirection != key) {
_seekDirection = key;
_seekRepeatCount = 0;
}
// Increment repeat count on KeyRepeatEvent
if (event is KeyRepeatEvent) {
_seekRepeatCount++;
}
final isForward = key == LogicalKeyboardKey.arrowRight;
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0;
// Live TV: epoch-based seeking via onLiveSeek
if (_isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
final stepSeconds = (10.0 * effectiveMultiplier).clamp(1, 300).round();
final targetEpoch = widget.currentPositionEpoch! + (isForward ? stepSeconds : -stepSeconds);
widget.onLiveSeek!(targetEpoch);
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
// Base step: 0.5% of duration, minimum 500ms, maximum 15s
final baseStepMs = (duration.inMilliseconds * 0.005).clamp(500, 15000).toInt();
// Apply progressive multiplier only on repeat events (initial press uses 1x)
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0;
final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 60000).toInt();
final step = Duration(milliseconds: stepMs);
final isForward = key == LogicalKeyboardKey.arrowRight;
final newPosition = isForward ? position + step : position - step;
// Clamp to valid range
@@ -622,7 +630,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onBack: widget.onBack,
),
),
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
if (_isLive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -632,23 +640,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
),
),
] else if (_isLive && widget.captureBuffer != null && !widget.isAtLiveEdge) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: widget.onJumpToLive,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: const BorderRadius.all(Radius.circular(4)),
border: Border.all(color: Colors.white24),
),
child: Text(
t.liveTv.goToLive,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12),
),
),
),
],
],
),
@@ -664,15 +655,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
child: Column(
children: [
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
if (_isLive && widget.captureBuffer != null && widget.currentPositionEpoch != null) ...[
if (_isLive && widget.captureBuffer != null) ...[
LiveTimelineBar(
player: widget.player,
captureBuffer: widget.captureBuffer!,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch!,
streamStartEpoch: widget.streamStartEpoch,
isAtLiveEdge: widget.isAtLiveEdge,
onSeekEnd: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
horizontalLayout: true,
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
@@ -787,6 +776,17 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
),
),
],
// Go to Live button (only when time-shifted behind live edge)
if (_isLive && widget.captureBuffer != null && !widget.isAtLiveEdge && widget.onJumpToLive != null) ...[
_buildFocusableButton(
focusNode: _goToLiveFocusNode,
index: 7,
icon: Symbols.stream_rounded,
onPressed: _canControl ? widget.onJumpToLive : null,
semanticLabel: t.liveTv.goToLive,
tooltip: t.liveTv.goToLive,
),
],
if (!_isLive) ...[
// Next chapter
StreamBuilder<Duration>(
@@ -61,11 +61,8 @@ class MobileVideoControls extends StatefulWidget {
// Live TV time-shift
final CaptureBuffer? captureBuffer;
final bool isAtLiveEdge;
final int? programBeginsAt;
final int? programEndsAt;
final int? currentPositionEpoch;
final double streamStartEpoch;
final ValueChanged<int>? onLiveSeek;
final VoidCallback? onJumpToLive;
/// Server ID for chapter thumbnails in the content strip
final String? serverId;
@@ -106,11 +103,8 @@ class MobileVideoControls extends StatefulWidget {
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.currentPositionEpoch,
this.streamStartEpoch = 0,
this.onLiveSeek,
this.onJumpToLive,
this.serverId,
this.showQueueTab = false,
this.onQueueItemSelected,
@@ -383,18 +377,16 @@ class _MobileVideoControlsState extends State<MobileVideoControls>
Widget _buildBottomBar(BuildContext _) {
if (widget.isLive) {
if (widget.captureBuffer != null && widget.currentPositionEpoch != null) {
if (widget.captureBuffer != null) {
// Live TV with time-shift: show seekable timeline
return FirstFrameGuard(
hasFirstFrame: widget.hasFirstFrame,
builder: (context) => LiveTimelineBar(
player: widget.player,
captureBuffer: widget.captureBuffer!,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch!,
streamStartEpoch: widget.streamStartEpoch,
isAtLiveEdge: widget.isAtLiveEdge,
onSeekEnd: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
horizontalLayout: false,
enabled: widget.canControl,
),
+7 -17
View File
@@ -88,8 +88,7 @@ Widget plexVideoControlsBuilder(
String? liveChannelName,
CaptureBuffer? captureBuffer,
bool isAtLiveEdge = true,
int? programBeginsAt,
int? programEndsAt,
double streamStartEpoch = 0,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
VoidCallback? onJumpToLive,
@@ -124,8 +123,7 @@ Widget plexVideoControlsBuilder(
liveChannelName: liveChannelName,
captureBuffer: captureBuffer,
isAtLiveEdge: isAtLiveEdge,
programBeginsAt: programBeginsAt,
programEndsAt: programEndsAt,
streamStartEpoch: streamStartEpoch,
currentPositionEpoch: currentPositionEpoch,
onLiveSeek: onLiveSeek,
onJumpToLive: onJumpToLive,
@@ -189,11 +187,8 @@ class PlexVideoControls extends StatefulWidget {
/// Whether playback is at the live edge
final bool isAtLiveEdge;
/// Program start epoch seconds (from EPG)
final int? programBeginsAt;
/// Program end epoch seconds (from EPG)
final int? programEndsAt;
/// Epoch seconds corresponding to player position 0 (for live TV)
final double streamStartEpoch;
/// Current playback position as absolute epoch seconds (for live TV)
final int? currentPositionEpoch;
@@ -239,8 +234,7 @@ class PlexVideoControls extends StatefulWidget {
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
@@ -2141,11 +2135,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch,
streamStartEpoch: widget.streamStartEpoch,
onLiveSeek: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive
@@ -2294,8 +2285,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
streamStartEpoch: widget.streamStartEpoch,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
@@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../models/livetv_capture_buffer.dart';
import '../../../mpv/mpv.dart';
import '../../../focus/focusable_wrapper.dart';
/// Timeline bar for live TV time-shift.
///
/// Shows the full program range (beginsAt..endsAt) with the seekable capture
/// buffer highlighted. The user can drag within the buffer region to seek.
/// Listens to the player's position stream and computes the absolute epoch
/// position from [streamStartEpoch] + player position. The slider range
/// covers the capture buffer's seekable window.
class LiveTimelineBar extends StatefulWidget {
final Player player;
final CaptureBuffer captureBuffer;
final int? programBeginsAt;
final int? programEndsAt;
final int currentPositionEpoch;
final double streamStartEpoch;
final bool isAtLiveEdge;
final ValueChanged<int>? onSeekEnd;
final VoidCallback? onJumpToLive;
final bool horizontalLayout;
final FocusNode? focusNode;
final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent;
@@ -24,13 +24,11 @@ class LiveTimelineBar extends StatefulWidget {
const LiveTimelineBar({
super.key,
required this.player,
required this.captureBuffer,
this.programBeginsAt,
this.programEndsAt,
required this.currentPositionEpoch,
required this.streamStartEpoch,
this.isAtLiveEdge = true,
this.onSeekEnd,
this.onJumpToLive,
this.horizontalLayout = true,
this.focusNode,
this.onKeyEvent,
@@ -49,7 +47,11 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
int get _rangeStart => widget.captureBuffer.seekableStartEpoch;
int get _rangeEnd => widget.captureBuffer.seekableEndEpoch;
int get _displayPosition => _isDragging ? _dragPositionEpoch : widget.currentPositionEpoch;
int _currentEpoch(Duration playerPosition) =>
(widget.streamStartEpoch + playerPosition.inSeconds).round();
int _displayPosition(Duration playerPosition) =>
_isDragging ? _dragPositionEpoch : _currentEpoch(playerPosition);
String _formatEpochTime(int epochSeconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000);
@@ -58,7 +60,7 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
double _epochToFraction(int epoch) {
final range = _rangeEnd - _rangeStart;
if (range <= 0) return 0;
if (range <= 0) return 1.0; // No range yet → show at live edge (right)
return ((epoch - _rangeStart) / range).clamp(0.0, 1.0);
}
@@ -69,36 +71,45 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
@override
Widget build(BuildContext context) {
if (widget.horizontalLayout) {
return _buildHorizontalLayout();
}
return _buildVerticalLayout();
return StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnapshot) {
final position = posSnapshot.data ?? Duration.zero;
final displayPos = _displayPosition(position);
if (widget.horizontalLayout) {
return _buildHorizontalLayout(displayPos);
}
return _buildVerticalLayout(displayPos);
},
);
}
Widget _buildHorizontalLayout() {
Widget _buildHorizontalLayout(int displayPos) {
return Row(
children: [
Text(
_formatEpochTime(_displayPosition),
_formatEpochTime(displayPos),
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(width: 8),
Expanded(child: _buildSlider()),
Expanded(child: _buildSlider(displayPos)),
],
);
}
Widget _buildVerticalLayout() {
Widget _buildVerticalLayout(int displayPos) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildSlider(),
_buildSlider(displayPos),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
_formatEpochTime(_displayPosition),
_formatEpochTime(displayPos),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
@@ -107,8 +118,8 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
);
}
Widget _buildSlider() {
final positionFraction = _epochToFraction(_displayPosition);
Widget _buildSlider(int displayPos) {
final positionFraction = _epochToFraction(displayPos);
return FocusableWrapper(
focusNode: widget.focusNode,
@@ -142,7 +153,7 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
void _onDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPositionEpoch = widget.currentPositionEpoch;
_dragPositionEpoch = _currentEpoch(widget.player.state.position);
});
}