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