diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7042dfef..3ab22803 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -178,7 +178,6 @@ class VideoPlayerScreenState extends State 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 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 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 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 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, diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 81c8559a..2d72d327 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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? 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 { 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 { _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 { _skipForwardFocusNode, _nextChapterFocusNode, _nextItemFocusNode, + _goToLiveFocusNode, ]; } @@ -220,6 +221,7 @@ class DesktopVideoControlsState extends State { _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 { // 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 { 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 { 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 { 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 { ), ), ], + // 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( diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index bcadc2ff..08ed582a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -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? 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 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, ), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index e959fe0c..cc738e8c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -88,8 +88,7 @@ Widget plexVideoControlsBuilder( String? liveChannelName, CaptureBuffer? captureBuffer, bool isAtLiveEdge = true, - int? programBeginsAt, - int? programEndsAt, + double streamStartEpoch = 0, int? currentPositionEpoch, ValueChanged? 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 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 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, diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index 6af70d86..e968686c 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -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? 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 { 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 { 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 { @override Widget build(BuildContext context) { - if (widget.horizontalLayout) { - return _buildHorizontalLayout(); - } - return _buildVerticalLayout(); + return StreamBuilder( + 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 { ); } - 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 { void _onDragStart(DragStartDetails details) { setState(() { _isDragging = true; - _dragPositionEpoch = widget.currentPositionEpoch; + _dragPositionEpoch = _currentEpoch(widget.player.state.position); }); }