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
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user