@@ -41,6 +41,8 @@ import '../utils/language_codes.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../watch_together/providers/watch_together_provider.dart';
|
||||
|
||||
@@ -99,6 +101,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
int _autoPlayCountdown = 5;
|
||||
bool _completionTriggered = false;
|
||||
|
||||
// Play Next dialog focus nodes (for TV D-pad navigation)
|
||||
late final FocusNode _playNextCancelFocusNode;
|
||||
late final FocusNode _playNextConfirmFocusNode;
|
||||
|
||||
// App lifecycle state tracking
|
||||
bool _wasPlayingBeforeInactive = false;
|
||||
|
||||
@@ -128,6 +134,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_activeRatingKey = widget.metadata.ratingKey;
|
||||
_activeMediaIndex = widget.selectedMediaIndex;
|
||||
|
||||
// Initialize Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
|
||||
|
||||
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
|
||||
if (widget.preferredAudioTrack != null) {
|
||||
appLogger.d(
|
||||
@@ -1062,6 +1072,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Cancel auto-play timer
|
||||
_autoPlayTimer?.cancel();
|
||||
|
||||
// Dispose Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode.dispose();
|
||||
_playNextConfirmFocusNode.dispose();
|
||||
|
||||
// Clear media controls and dispose manager
|
||||
_mediaControlsManager?.clear();
|
||||
_mediaControlsManager?.dispose();
|
||||
@@ -1136,6 +1150,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (completed && _nextEpisode != null && !_showPlayNextDialog && !_completionTriggered) {
|
||||
_completionTriggered = true;
|
||||
|
||||
// Capture keyboard mode before async gap
|
||||
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
final settings = await SettingsService.getInstance();
|
||||
final autoPlayEnabled = settings.getAutoPlayNextEpisode();
|
||||
|
||||
@@ -1144,6 +1161,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_autoPlayCountdown = autoPlayEnabled ? 5 : -1;
|
||||
});
|
||||
|
||||
// Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode)
|
||||
if (isKeyboardMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_playNextConfirmFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (autoPlayEnabled) {
|
||||
_startAutoPlayTimer();
|
||||
}
|
||||
@@ -1641,6 +1667,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
onBack: _handleBackButton,
|
||||
canControl: canControl,
|
||||
hasFirstFrame: _hasFirstFrame,
|
||||
playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1731,35 +1758,79 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancelAutoPlay,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: FocusableWrapper(
|
||||
focusNode: _playNextCancelFocusNode,
|
||||
onSelect: _cancelAutoPlay,
|
||||
useBackgroundFocus: true,
|
||||
autoScroll: false,
|
||||
borderRadius: 20,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
// RIGHT arrow moves focus to Play Next button
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
_playNextConfirmFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Trap focus - consume UP/DOWN to prevent escape
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp ||
|
||||
event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancelAutoPlay,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text(t.dialog.cancel),
|
||||
),
|
||||
child: Text(t.dialog.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _playNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_autoPlayCountdown > 0) ...[
|
||||
Text('$_autoPlayCountdown'),
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
||||
] else
|
||||
Text(t.videoControls.playNext),
|
||||
],
|
||||
child: FocusableWrapper(
|
||||
focusNode: _playNextConfirmFocusNode,
|
||||
onSelect: _playNext,
|
||||
useBackgroundFocus: true,
|
||||
autoScroll: false,
|
||||
borderRadius: 20,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
// LEFT arrow moves focus to Cancel button
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
_playNextCancelFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Trap focus - consume UP/DOWN to prevent escape
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp ||
|
||||
event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: FilledButton(
|
||||
onPressed: _playNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_autoPlayCountdown > 0) ...[
|
||||
Text('$_autoPlayCountdown'),
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
||||
] else
|
||||
Text(t.videoControls.playNext),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../services/plex_api_cache.dart';
|
||||
@@ -61,6 +62,7 @@ Widget plexVideoControlsBuilder(
|
||||
VoidCallback? onBack,
|
||||
bool canControl = true,
|
||||
ValueNotifier<bool>? hasFirstFrame,
|
||||
FocusNode? playNextFocusNode,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
@@ -78,6 +80,7 @@ Widget plexVideoControlsBuilder(
|
||||
onBack: onBack,
|
||||
canControl: canControl,
|
||||
hasFirstFrame: hasFirstFrame,
|
||||
playNextFocusNode: playNextFocusNode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +109,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
/// Notifier for whether first video frame has rendered (shows loading state when false).
|
||||
final ValueNotifier<bool>? hasFirstFrame;
|
||||
|
||||
/// Optional focus node for Play Next dialog button (for TV navigation from timeline)
|
||||
final FocusNode? playNextFocusNode;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -123,6 +129,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onBack,
|
||||
this.canControl = true,
|
||||
this.hasFirstFrame,
|
||||
this.playNextFocusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -185,6 +192,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
bool _showPerformanceOverlay = false;
|
||||
// Long-press 2x speed state
|
||||
bool _isLongPressing = false;
|
||||
// Skip marker button focus node (for TV D-pad navigation)
|
||||
late final FocusNode _skipMarkerFocusNode;
|
||||
double? _rateBeforeLongPress;
|
||||
bool _showSpeedIndicator = false;
|
||||
|
||||
@@ -196,6 +205,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
|
||||
_seekThrottle = throttle(
|
||||
(Duration pos) => widget.player.seek(pos),
|
||||
const Duration(milliseconds: 200),
|
||||
@@ -271,6 +281,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Start auto-skip timer for new marker
|
||||
if (foundMarker != null) {
|
||||
_startAutoSkipTimer(foundMarker);
|
||||
|
||||
// Auto-focus skip button on TV when marker appears (only in keyboard/TV mode, if controls hidden)
|
||||
if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_showControls) {
|
||||
_skipMarkerFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_cancelAutoSkipTimer();
|
||||
}
|
||||
@@ -447,6 +466,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_playingSubscription?.cancel();
|
||||
_completedSubscription?.cancel();
|
||||
_focusNode.dispose();
|
||||
_skipMarkerFocusNode.dispose();
|
||||
// Restore original rate if long-press was active when disposed
|
||||
if (_isLongPressing && _rateBeforeLongPress != null) {
|
||||
widget.player.setRate(_rateBeforeLongPress!);
|
||||
@@ -1219,7 +1239,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
/// Hide controls when navigating up from timeline (keyboard mode)
|
||||
/// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls
|
||||
void _hideControlsFromKeyboard() {
|
||||
// If skip marker button is visible, focus it instead of hiding controls
|
||||
if (_currentMarker != null) {
|
||||
_skipMarkerFocusNode.requestFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
// If Play Next dialog is visible (focus node provided), focus it instead of hiding controls
|
||||
if (widget.playNextFocusNode != null) {
|
||||
widget.playNextFocusNode!.requestFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_showControls) {
|
||||
setState(() {
|
||||
_showControls = false;
|
||||
@@ -1300,8 +1333,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause
|
||||
if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) {
|
||||
if (!_showControls) {
|
||||
final isHorizontal = key == LogicalKeyboardKey.arrowLeft ||
|
||||
key == LogicalKeyboardKey.arrowRight;
|
||||
final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight;
|
||||
if (isHorizontal) {
|
||||
_showControlsWithTimelineFocus();
|
||||
} else {
|
||||
@@ -1620,65 +1652,84 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
: baseButtonText;
|
||||
final IconData buttonIcon = showNextEpisode ? Symbols.skip_next_rounded : Symbols.fast_forward_rounded;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (isAutoSkipActive) {
|
||||
_cancelAutoSkipTimer();
|
||||
}
|
||||
// Always perform the skip action when tapped
|
||||
_performAutoSkip();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Progress indicator overlay
|
||||
if (isAutoSkipActive && shouldShowAutoSkip)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
return FocusableWrapper(
|
||||
focusNode: _skipMarkerFocusNode,
|
||||
onSelect: () {
|
||||
if (isAutoSkipActive) {
|
||||
_cancelAutoSkipTimer();
|
||||
}
|
||||
_performAutoSkip();
|
||||
},
|
||||
borderRadius: tokens(context).radiusSm,
|
||||
useBackgroundFocus: true,
|
||||
autoScroll: false,
|
||||
onKeyEvent: (node, event) {
|
||||
// DOWN arrow returns focus to play/pause button
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (isAutoSkipActive) {
|
||||
_cancelAutoSkipTimer();
|
||||
}
|
||||
_performAutoSkip();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: (_autoSkipProgress * 100).round(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: ((1.0 - _autoSkipProgress) * 100).round(),
|
||||
child: Container(decoration: const BoxDecoration(color: Colors.transparent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Progress indicator overlay
|
||||
if (isAutoSkipActive && shouldShowAutoSkip)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: (_autoSkipProgress * 100).round(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: ((1.0 - _autoSkipProgress) * 100).round(),
|
||||
child: Container(decoration: const BoxDecoration(color: Colors.transparent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user