614 lines
22 KiB
Dart
614 lines
22 KiB
Dart
part of '../video_controls.dart';
|
|
|
|
extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|
static const Duration _touchTapSuppressionPadding = Duration(milliseconds: 80);
|
|
|
|
void _onRateChanged(double newRate) {
|
|
if (!mounted) return;
|
|
if (_isLongPressing) return;
|
|
if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) {
|
|
return;
|
|
}
|
|
final prev = _lastReportedRate;
|
|
if (prev != null && (prev - newRate).abs() < 0.005) return;
|
|
_lastReportedRate = newRate;
|
|
final icon = newRate >= 1.0 ? Symbols.fast_forward_rounded : Symbols.slow_motion_video_rounded;
|
|
widget.toastController.show(icon, formatPlaybackRate(newRate));
|
|
}
|
|
|
|
void _seekToPreviousChapter() => unawaited(_seekToChapter(forward: false));
|
|
|
|
void _seekToNextChapter() => unawaited(_seekToChapter(forward: true));
|
|
|
|
Future<void> _seekByTime({required bool forward}) async {
|
|
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
|
|
await _seekByOffset(delta);
|
|
}
|
|
|
|
Future<void> _seekToChapter({required bool forward}) async {
|
|
if (_chapters.isEmpty) {
|
|
// No chapters - seek by configured amount
|
|
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
|
|
await _seekByOffset(delta);
|
|
return;
|
|
}
|
|
|
|
final targetIndex = MediaChapter.seekTargetIndex(widget.player.state.position, _chapters, forward: forward);
|
|
if (targetIndex != null) {
|
|
await _seekToPosition(_chapters[targetIndex].startTime);
|
|
} else if (!forward) {
|
|
await _seekToPosition(Duration.zero);
|
|
}
|
|
}
|
|
|
|
Future<void> _seekToPosition(Duration position, {bool notifyCompletion = true}) async {
|
|
final clamped = clampSeekPosition(widget.player, position);
|
|
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
|
if (notifyCompletion && mounted) {
|
|
widget.onSeekCompleted?.call(clamped);
|
|
}
|
|
}
|
|
|
|
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
|
|
// Route relative live-TV skips through the parent accumulator, which
|
|
// coalesces a rapid burst into a single transcode re-open and computes the
|
|
// target from a stable base rather than the laggy live epoch (#1253).
|
|
if (widget.isLive && widget.onLiveSeekBy != null) {
|
|
widget.onLiveSeekBy!(delta.inSeconds);
|
|
return;
|
|
}
|
|
final target = widget.player.state.position + delta;
|
|
final clamped = clampSeekPosition(widget.player, target);
|
|
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
|
if (notifyCompletion && mounted) {
|
|
widget.onSeekCompleted?.call(clamped);
|
|
}
|
|
}
|
|
|
|
Future<void> _seekToTimelinePosition(Duration position) {
|
|
final clamped = clampSeekPosition(widget.player, position);
|
|
final seekFuture = _seekToPosition(clamped, notifyCompletion: false);
|
|
_lastDispatchedTimelineSeek = clamped;
|
|
_lastDispatchedTimelineSeekFuture = seekFuture;
|
|
return seekFuture;
|
|
}
|
|
|
|
Future<void> _playOrPause() async {
|
|
if (!widget.canControl) return;
|
|
if (!widget.player.state.playing && _rewindOnResume > 0) {
|
|
final target = widget.player.state.position - Duration(seconds: _rewindOnResume);
|
|
final clamped = clampSeekPosition(widget.player, target);
|
|
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
|
}
|
|
await (widget.onPlayPauseRequested ?? widget.player.playOrPause)();
|
|
}
|
|
|
|
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms.
|
|
void _throttledSeek(Duration position) {
|
|
_seekThrottle([position]);
|
|
}
|
|
|
|
/// Finalizes the seek when user stops scrubbing the timeline
|
|
void _finalizeSeek(Duration position) {
|
|
_seekThrottle.cancel();
|
|
final clamped = clampSeekPosition(widget.player, position);
|
|
|
|
// The dedup state is per-gesture: it exists so a drag release doesn't
|
|
// re-issue the seek its own throttle just dispatched. Clear it before
|
|
// deciding, or a later gesture (e.g. a coalesced key-seek flush) landing
|
|
// on the same position would be silently dropped.
|
|
final lastDispatched = _lastDispatchedTimelineSeek;
|
|
final seekFuture = _lastDispatchedTimelineSeekFuture;
|
|
_lastDispatchedTimelineSeek = null;
|
|
_lastDispatchedTimelineSeekFuture = null;
|
|
|
|
if (shouldSkipDuplicateTimelineSeek(lastDispatchedSeek: lastDispatched, finalSeek: clamped)) {
|
|
if (seekFuture == null) {
|
|
widget.onSeekCompleted?.call(clamped);
|
|
return;
|
|
}
|
|
unawaited(
|
|
seekFuture.then<void>((_) {
|
|
if (!mounted) return;
|
|
widget.onSeekCompleted?.call(clamped);
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
unawaited(_seekToPosition(clamped));
|
|
}
|
|
|
|
void _holdTimelineScrub() {
|
|
widget.chromeController.hold(PlayerChromeHold.scrub);
|
|
}
|
|
|
|
void _releaseTimelineScrub() {
|
|
widget.chromeController.release(PlayerChromeHold.scrub);
|
|
}
|
|
|
|
bool get _isTouchTapSuppressed {
|
|
final until = _suppressTouchTapUntil;
|
|
if (until == null) return false;
|
|
if (DateTime.now().isAfter(until)) {
|
|
_suppressTouchTapUntil = null;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _suppressTouchTaps() {
|
|
_singleTapTimer?.cancel();
|
|
_singleTapTimer = null;
|
|
_suppressTouchTapUntil = DateTime.now().add(kDoubleTapTimeout + _touchTapSuppressionPadding);
|
|
}
|
|
|
|
void _handleTouchPointerDown(PointerDownEvent event) {
|
|
if (event.kind != PointerDeviceKind.touch) return;
|
|
_twoFingerDoubleTapTracker.pointerDown(event.pointer, event.position);
|
|
if (_twoFingerDoubleTapTracker.isChordActive) {
|
|
_suppressTouchTaps();
|
|
_cancelEdgeAdjustmentGesture();
|
|
return;
|
|
}
|
|
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
|
_handleEdgeAdjustmentEvent(
|
|
_edgeAdjustmentGesturesAllowed && hit != null
|
|
? _edgeAdjustmentTracker.pointerDown(event.pointer, hit.position, hit.size)
|
|
: const MobileEdgeAdjustmentEvent.none(),
|
|
);
|
|
}
|
|
|
|
void _handleTouchPointerMove(PointerMoveEvent event) {
|
|
if (event.kind != PointerDeviceKind.touch) return;
|
|
_twoFingerDoubleTapTracker.pointerMove(event.pointer, event.position);
|
|
if (_twoFingerDoubleTapTracker.isChordActive) {
|
|
_suppressTouchTaps();
|
|
_cancelEdgeAdjustmentGesture();
|
|
return;
|
|
}
|
|
if (!_edgeAdjustmentGesturesAllowed) {
|
|
_cancelEdgeAdjustmentGesture();
|
|
return;
|
|
}
|
|
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
|
if (hit == null) {
|
|
_cancelEdgeAdjustmentGesture();
|
|
return;
|
|
}
|
|
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerMove(event.pointer, hit.position));
|
|
}
|
|
|
|
void _handleTouchPointerUp(PointerUpEvent event) {
|
|
if (event.kind != PointerDeviceKind.touch) return;
|
|
final isResetGesture = _twoFingerDoubleTapTracker.pointerUp(event.pointer, event.position);
|
|
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
|
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerUp(event.pointer, hit?.position ?? event.localPosition));
|
|
if (_isTouchTapSuppressed || isResetGesture) _suppressTouchTaps();
|
|
if (isResetGesture) widget.onResetVideoZoom?.call();
|
|
}
|
|
|
|
void _handleTouchPointerCancel(PointerCancelEvent event) {
|
|
if (event.kind != PointerDeviceKind.touch) return;
|
|
_twoFingerDoubleTapTracker.pointerCancel(event.pointer);
|
|
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerCancel(event.pointer));
|
|
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
|
|
}
|
|
|
|
bool get _edgeAdjustmentGesturesAllowed {
|
|
return PlatformDetector.isMobile(context) &&
|
|
!PlatformDetector.isTV() &&
|
|
!_isScreenLocked &&
|
|
!_pipService.isPipActive.value &&
|
|
!widget.chromeController.contentStripVisible;
|
|
}
|
|
|
|
({Offset position, Size size})? _edgeAdjustmentSurfaceHit(Offset globalPosition) {
|
|
final renderObject = context.findRenderObject();
|
|
if (renderObject is! RenderBox) return null;
|
|
return (position: renderObject.globalToLocal(globalPosition), size: renderObject.size);
|
|
}
|
|
|
|
bool _isGlobalPositionInEdgeAdjustmentZone(Offset globalPosition) {
|
|
final hit = _edgeAdjustmentSurfaceHit(globalPosition);
|
|
if (hit == null) return false;
|
|
return mobileEdgeAdjustmentZoneForPosition(position: hit.position, size: hit.size) != null;
|
|
}
|
|
|
|
void _refreshDeviceAdjustmentValues() {
|
|
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.left));
|
|
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.right));
|
|
}
|
|
|
|
Future<double?> _readEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
|
|
++_edgeAdjustmentBaselineGeneration;
|
|
_edgeAdjustmentBaselineSide = side;
|
|
final read = side == MobileEdgeAdjustmentSide.left
|
|
? _deviceAdjustmentService.getBrightness()
|
|
: _deviceAdjustmentService.getMediaVolume();
|
|
final future = read.then((value) {
|
|
if (mounted) _cacheEdgeAdjustmentValue(side, value);
|
|
return value;
|
|
});
|
|
_edgeAdjustmentBaselineFuture = future;
|
|
return future;
|
|
}
|
|
|
|
void _cacheEdgeAdjustmentValue(MobileEdgeAdjustmentSide side, double? value) {
|
|
if (value == null) return;
|
|
if (side == MobileEdgeAdjustmentSide.left) {
|
|
_lastKnownBrightness = value;
|
|
} else {
|
|
_lastKnownMediaVolume = value;
|
|
}
|
|
}
|
|
|
|
void _handleEdgeAdjustmentEvent(MobileEdgeAdjustmentEvent event) {
|
|
final side = event.side;
|
|
switch (event.type) {
|
|
case MobileEdgeAdjustmentEventType.none:
|
|
return;
|
|
case MobileEdgeAdjustmentEventType.candidate:
|
|
if (side != null) unawaited(_readEdgeAdjustmentValue(side));
|
|
return;
|
|
case MobileEdgeAdjustmentEventType.activated:
|
|
if (side != null) _beginEdgeAdjustment(side, event.deltaFraction);
|
|
return;
|
|
case MobileEdgeAdjustmentEventType.update:
|
|
if (side != null) {
|
|
if (_pendingEdgeAdjustmentSide == side) {
|
|
_pendingEdgeAdjustmentDelta = event.deltaFraction;
|
|
} else if (_edgeAdjustmentWasActive) {
|
|
_updateEdgeAdjustment(side, event.deltaFraction);
|
|
}
|
|
}
|
|
return;
|
|
case MobileEdgeAdjustmentEventType.ended:
|
|
if (_pendingEdgeAdjustmentSide != null) {
|
|
_clearPendingEdgeAdjustment();
|
|
_finishEdgeAdjustment(suppressTap: false);
|
|
} else {
|
|
if (side != null && _edgeAdjustmentWasActive) {
|
|
_updateEdgeAdjustment(side, event.deltaFraction, forceWrite: true);
|
|
}
|
|
_finishEdgeAdjustment(suppressTap: _edgeAdjustmentWasActive);
|
|
}
|
|
return;
|
|
case MobileEdgeAdjustmentEventType.cancelled:
|
|
_clearPendingEdgeAdjustment();
|
|
_finishEdgeAdjustment(suppressTap: event.wasActive || _edgeAdjustmentWasActive);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void _beginEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction) {
|
|
final startValue = _currentEdgeAdjustmentValue(side);
|
|
if (startValue != null) {
|
|
_startEdgeAdjustment(side, deltaFraction, startValue: startValue);
|
|
return;
|
|
}
|
|
|
|
_suppressTouchTaps();
|
|
if (_isLongPressing) _handleLongPressCancel();
|
|
|
|
final Future<double?>? future;
|
|
final int generation;
|
|
if (_edgeAdjustmentBaselineSide == side && _edgeAdjustmentBaselineFuture != null) {
|
|
future = _edgeAdjustmentBaselineFuture;
|
|
generation = _edgeAdjustmentBaselineGeneration;
|
|
} else {
|
|
future = _readEdgeAdjustmentValue(side);
|
|
generation = _edgeAdjustmentBaselineGeneration;
|
|
}
|
|
_pendingEdgeAdjustmentSide = side;
|
|
_pendingEdgeAdjustmentDelta = deltaFraction;
|
|
_pendingEdgeAdjustmentGeneration = generation;
|
|
unawaited(_resolvePendingEdgeAdjustment(side, generation, future));
|
|
}
|
|
|
|
Future<void> _resolvePendingEdgeAdjustment(
|
|
MobileEdgeAdjustmentSide side,
|
|
int generation,
|
|
Future<double?>? future,
|
|
) async {
|
|
final value = await (future ?? _readEdgeAdjustmentValue(side)).timeout(
|
|
const Duration(milliseconds: 300),
|
|
onTimeout: () => null,
|
|
);
|
|
if (!mounted) return;
|
|
if (_pendingEdgeAdjustmentSide != side || _pendingEdgeAdjustmentGeneration != generation) {
|
|
return;
|
|
}
|
|
|
|
final latestDelta = _pendingEdgeAdjustmentDelta;
|
|
_clearPendingEdgeAdjustment();
|
|
_cacheEdgeAdjustmentValue(side, value);
|
|
final startValue = _currentEdgeAdjustmentValue(side);
|
|
if (startValue == null) {
|
|
_finishEdgeAdjustment(suppressTap: false);
|
|
return;
|
|
}
|
|
_startEdgeAdjustment(side, latestDelta, startValue: startValue);
|
|
}
|
|
|
|
void _clearPendingEdgeAdjustment() {
|
|
_pendingEdgeAdjustmentSide = null;
|
|
_pendingEdgeAdjustmentDelta = 0.0;
|
|
_pendingEdgeAdjustmentGeneration = null;
|
|
}
|
|
|
|
void _startEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction, {required double startValue}) {
|
|
_suppressTouchTaps();
|
|
if (_isLongPressing) _handleLongPressCancel();
|
|
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
|
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
|
_edgeAdjustmentWasActive = true;
|
|
_edgeAdjustmentStartValue = startValue;
|
|
_lastEdgeAdjustmentWriteAt = null;
|
|
_lastEdgeAdjustmentWriteValue = null;
|
|
widget.chromeController.cancelAutoHide();
|
|
_updateEdgeAdjustment(side, deltaFraction, forceWrite: true);
|
|
}
|
|
|
|
void _updateEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction, {bool forceWrite = false}) {
|
|
final startValue = _edgeAdjustmentStartValue ?? _currentEdgeAdjustmentValue(side);
|
|
if (startValue == null) return;
|
|
final value = (startValue + deltaFraction).clamp(0.0, 1.0).toDouble();
|
|
final indicator = _edgeAdjustmentIndicator.value;
|
|
if (!indicator.visible || indicator.side != side || indicator.value != value) {
|
|
_edgeAdjustmentIndicator.value = (visible: true, side: side, value: value);
|
|
}
|
|
_writeEdgeAdjustment(side, value, force: forceWrite);
|
|
}
|
|
|
|
double? _currentEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
|
|
return switch (side) {
|
|
MobileEdgeAdjustmentSide.left => _lastKnownBrightness,
|
|
MobileEdgeAdjustmentSide.right => _lastKnownMediaVolume,
|
|
};
|
|
}
|
|
|
|
void _writeEdgeAdjustment(MobileEdgeAdjustmentSide side, double value, {required bool force}) {
|
|
final now = DateTime.now();
|
|
final lastWriteAt = _lastEdgeAdjustmentWriteAt;
|
|
final lastValue = _lastEdgeAdjustmentWriteValue;
|
|
final valueChanged = lastValue == null || (value - lastValue).abs() >= 0.01;
|
|
final intervalElapsed = lastWriteAt == null || now.difference(lastWriteAt) >= const Duration(milliseconds: 45);
|
|
if (!force && (!valueChanged || !intervalElapsed)) return;
|
|
|
|
_lastEdgeAdjustmentWriteAt = now;
|
|
_lastEdgeAdjustmentWriteValue = value;
|
|
if (side == MobileEdgeAdjustmentSide.left) {
|
|
_lastKnownBrightness = value;
|
|
unawaited(_deviceAdjustmentService.setBrightness(value));
|
|
} else {
|
|
_lastKnownMediaVolume = value;
|
|
unawaited(_deviceAdjustmentService.setMediaVolume(value));
|
|
}
|
|
}
|
|
|
|
void _finishEdgeAdjustment({required bool suppressTap}) {
|
|
if (suppressTap) _suppressTouchTaps();
|
|
_edgeAdjustmentWasActive = false;
|
|
_edgeAdjustmentStartValue = null;
|
|
_lastEdgeAdjustmentWriteAt = null;
|
|
_lastEdgeAdjustmentWriteValue = null;
|
|
_restartHideTimerForCurrentPlaybackState();
|
|
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
|
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
|
if (_edgeAdjustmentIndicator.value.side == null) return;
|
|
_edgeAdjustmentIndicatorHideTimer = Timer(const Duration(milliseconds: 700), () {
|
|
if (!mounted) return;
|
|
final current = _edgeAdjustmentIndicator.value;
|
|
_edgeAdjustmentIndicator.value = (visible: false, side: current.side, value: current.value);
|
|
_edgeAdjustmentIndicatorClearTimer = Timer(const Duration(milliseconds: 220), () {
|
|
if (!mounted) return;
|
|
_edgeAdjustmentIndicator.value = (visible: false, side: null, value: _edgeAdjustmentIndicator.value.value);
|
|
});
|
|
});
|
|
}
|
|
|
|
void _cancelEdgeAdjustmentGesture() {
|
|
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.cancel());
|
|
}
|
|
|
|
/// Timing-based double-click detection: avoids `onDoubleTap`'s ~300 ms
|
|
/// tap-resolution delay and the arena competition it introduces.
|
|
void _handleOuterTap() {
|
|
if (PlatformDetector.isMobile(context) && _isTouchTapSuppressed) return;
|
|
|
|
if (widget.canControl && _clickVideoTogglesPlayback) {
|
|
_playOrPause();
|
|
} else {
|
|
_toggleControls();
|
|
}
|
|
|
|
if (PlatformDetector.isMobile(context)) return;
|
|
|
|
final now = DateTime.now();
|
|
if (_lastSkipTapTime != null && now.difference(_lastSkipTapTime!) < kDoubleTapTimeout) {
|
|
_lastSkipTapTime = null;
|
|
_toggleFullscreen();
|
|
return;
|
|
}
|
|
_lastSkipTapTime = now;
|
|
}
|
|
|
|
/// Handle tap in skip zone with custom double-tap detection
|
|
void _handleTapInSkipZone({required bool isForward}) {
|
|
if (_isTouchTapSuppressed) return;
|
|
|
|
// Cancel any pending single-tap action
|
|
_singleTapTimer?.cancel();
|
|
_singleTapTimer = null;
|
|
|
|
// While the skip pill is visible, every tap in the same-direction zone
|
|
// stacks another skip immediately — repeat skips cost one tap, not a
|
|
// fresh double-tap. A tap in the opposite zone falls through to pairing.
|
|
if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) {
|
|
_handleStackingSkip(isForward: isForward);
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
final isDoubleTap =
|
|
_lastSkipTapTime != null &&
|
|
now.difference(_lastSkipTapTime!) < kDoubleTapTimeout &&
|
|
_lastSkipTapWasForward == isForward;
|
|
|
|
// Skip ONLY on detected double-tap (no single-tap-to-add behavior)
|
|
if (isDoubleTap) {
|
|
_lastSkipTapTime = null;
|
|
_handleDoubleTapSkip(isForward: isForward);
|
|
} else {
|
|
// First tap - record timestamp and start timer for single-tap action
|
|
_lastSkipTapTime = now;
|
|
_lastSkipTapWasForward = isForward;
|
|
|
|
// If no second tap within the double-tap window, treat as single tap
|
|
// to toggle controls
|
|
_singleTapTimer = Timer(kDoubleTapTimeout, () {
|
|
if (mounted) {
|
|
_toggleControls();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
Size _sizeOf(BuildContext context) {
|
|
final renderObject = context.findRenderObject();
|
|
return renderObject is RenderBox ? renderObject.size : Size.zero;
|
|
}
|
|
|
|
/// Handle stacking skip - add to accumulated skip when feedback is active.
|
|
void _handleStackingSkip({required bool isForward}) {
|
|
if (!widget.canControl) return;
|
|
|
|
_accumulatedSkipSeconds += _seekTimeSmall;
|
|
_showSkipFeedback(isForward: isForward);
|
|
|
|
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
|
unawaited(_seekByOffset(delta));
|
|
}
|
|
|
|
void _handleDoubleTapSkip({required bool isForward}) {
|
|
if (!widget.canControl) return;
|
|
|
|
_accumulatedSkipSeconds = _seekTimeSmall;
|
|
_showSkipFeedback(isForward: isForward);
|
|
|
|
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
|
unawaited(_seekByOffset(delta));
|
|
}
|
|
|
|
/// Show animated visual feedback for skip gesture
|
|
void _showSkipFeedback({required bool isForward}) {
|
|
// Cancel BOTH timers: a skip landing during the fade-out window must not
|
|
// leave the old hide timer pending, or it kills the fresh pill and zeroes
|
|
// the accumulated count mid-display.
|
|
_feedbackTimer?.cancel();
|
|
_feedbackHideTimer?.cancel();
|
|
|
|
_setControlsState(() {
|
|
_lastDoubleTapWasForward = isForward;
|
|
_showDoubleTapFeedback = true;
|
|
_doubleTapFeedbackOpacity = 1.0;
|
|
});
|
|
|
|
// Capture duration before timer to avoid context access in callback
|
|
final slowDuration = tokens(context).slow;
|
|
|
|
// Fade out after delay (1200ms gives time to see value and continue tapping)
|
|
_feedbackTimer = Timer(const Duration(milliseconds: 1200), () {
|
|
if (mounted) {
|
|
_setControlsState(() {
|
|
_doubleTapFeedbackOpacity = 0.0;
|
|
});
|
|
|
|
_feedbackHideTimer = Timer(slowDuration, () {
|
|
if (mounted) {
|
|
_setControlsState(() {
|
|
_showDoubleTapFeedback = false;
|
|
_accumulatedSkipSeconds = 0; // Reset when feedback hides
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Handle tap on controls overlay - route to skip zones or toggle controls
|
|
void _handleControlsOverlayTap(TapUpDetails details, Size size) {
|
|
final isMobile = PlatformDetector.isMobile(context);
|
|
|
|
if (!isMobile) {
|
|
final DateTime now = DateTime.now();
|
|
|
|
// Always perform the single-click behavior immediately
|
|
if (widget.canControl && _clickVideoTogglesPlayback) {
|
|
_playOrPause();
|
|
} else {
|
|
_toggleControls();
|
|
}
|
|
|
|
final bool isDoubleClick = _lastSkipTapTime != null && now.difference(_lastSkipTapTime!) < kDoubleTapTimeout;
|
|
|
|
if (isDoubleClick) {
|
|
_lastSkipTapTime = null;
|
|
|
|
_toggleFullscreen();
|
|
|
|
return;
|
|
}
|
|
|
|
// Record this click as a candidate for double-click detection
|
|
_lastSkipTapTime = now;
|
|
return;
|
|
}
|
|
|
|
if (_isTouchTapSuppressed) return;
|
|
|
|
final skipZone = mobileSkipZoneForTap(position: details.localPosition, size: size);
|
|
if (skipZone != null) {
|
|
_handleTapInSkipZone(isForward: skipZone);
|
|
return;
|
|
}
|
|
|
|
// Not in skip zone, toggle controls
|
|
_toggleControls();
|
|
}
|
|
|
|
/// Handle long-press start - activate 2x speed
|
|
void _handleLongPressStart() {
|
|
if (!widget.canControl || widget.isLive) return;
|
|
|
|
_setControlsState(() {
|
|
_isLongPressing = true;
|
|
_rateBeforeLongPress = widget.player.state.rate;
|
|
_showSpeedIndicator = true;
|
|
});
|
|
widget.player.setRate(2.0);
|
|
}
|
|
|
|
/// Handle long-press end - restore original speed
|
|
void _handleLongPressEnd() {
|
|
if (!_isLongPressing) return;
|
|
// Swallow the rate-restore emission so the stream-driven toast doesn't
|
|
// flash as the rate snaps back to the prior value.
|
|
_suppressRateToastUntil = DateTime.now().add(const Duration(milliseconds: 250));
|
|
widget.player.setRate(_rateBeforeLongPress ?? 1.0);
|
|
_setControlsState(() {
|
|
_isLongPressing = false;
|
|
_rateBeforeLongPress = null;
|
|
_showSpeedIndicator = false;
|
|
});
|
|
}
|
|
|
|
void _handleLongPressCancel() => _handleLongPressEnd();
|
|
|
|
/// Build the visual indicator for long-press 2x speed.
|
|
/// Manual (persistent for duration of press) — separate from the stream-driven
|
|
/// toast so it stays visible for the full long-press rather than auto-hiding.
|
|
Widget _buildSpeedIndicator() => const PlayerToastIndicator(icon: Symbols.fast_forward_rounded, text: '2x');
|
|
}
|