fix: hide controls in PiP mode

This commit is contained in:
edde746
2026-01-02 01:01:24 +01:00
parent e3c1cba600
commit c53018b53b
5 changed files with 522 additions and 495 deletions
+4 -1
View File
@@ -29,6 +29,7 @@ import 'services/data_aggregation_service.dart';
import 'services/in_app_review_service.dart';
import 'services/server_registry.dart';
import 'services/download_manager_service.dart';
import 'services/pip_service.dart';
import 'services/download_storage_service.dart';
import 'services/plex_api_cache.dart';
import 'database/app_database.dart';
@@ -59,9 +60,11 @@ void main() async {
futures.add(windowManager.ensureInitialized());
}
// Initialize TV detection for Android
// Initialize TV detection and PiP service for Android
if (Platform.isAndroid) {
futures.add(TvDetectionService.getInstance().then((_) {}));
// Initialize PiP service to listen for PiP state changes
PipService();
}
// Configure macOS window with custom titlebar (depends on window manager)
+147 -134
View File
@@ -30,6 +30,7 @@ import '../services/settings_service.dart';
import '../services/track_selection_service.dart';
import '../services/video_filter_manager.dart';
import '../services/video_pip_manager.dart';
import '../services/pip_service.dart';
import '../providers/user_profile_provider.dart';
import '../utils/app_logger.dart';
import '../utils/orientation_helper.dart';
@@ -727,9 +728,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoFilterManager!.updateVideoFilter();
// PIP Manager
_videoPIPManager = VideoPIPManager(
player: player!
);
_videoPIPManager = VideoPIPManager(player: player!);
}
// Add external subtitles to the player
@@ -1558,142 +1557,156 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
},
),
),
// Netflix-style auto-play overlay
if (_showPlayNextDialog && _nextEpisode != null)
Positioned(
right: 24,
bottom: 100,
child: Container(
width: 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Consumer<PlaybackStateProvider>(
builder: (context, playbackState, child) {
final isShuffleActive = playbackState.isShuffleActive;
return Row(
children: [
Text(
'Next Episode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
if (isShuffleActive) ...[
const SizedBox(width: 4),
AppIcon(
Symbols.shuffle_rounded,
fill: 1,
size: 12,
color: Colors.white.withValues(alpha: 0.7),
),
],
],
);
},
),
const SizedBox(height: 4),
if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null)
Text(
'S${_nextEpisode!.parentIndex} E${_nextEpisode!.index} · ${_nextEpisode!.title}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
else
Text(
_nextEpisode!.title,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 12),
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: 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,
// Netflix-style auto-play overlay (hidden in PiP mode)
ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip || !_showPlayNextDialog || _nextEpisode == null) {
return const SizedBox.shrink();
}
return Positioned(
right: 24,
bottom: 100,
child: Container(
width: 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$_autoPlayCountdown'),
const SizedBox(width: 4),
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
Consumer<PlaybackStateProvider>(
builder: (context, playbackState, child) {
final isShuffleActive = playbackState.isShuffleActive;
return Row(
children: [
Text(
'Next Episode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
if (isShuffleActive) ...[
const SizedBox(width: 4),
AppIcon(
Symbols.shuffle_rounded,
fill: 1,
size: 12,
color: Colors.white.withValues(alpha: 0.7),
),
],
],
);
},
),
const SizedBox(height: 4),
if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null)
Text(
'S${_nextEpisode!.parentIndex} E${_nextEpisode!.index} · ${_nextEpisode!.title}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
else
Text(
_nextEpisode!.title,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
),
],
),
),
),
// Buffering indicator (also shows during initial load, but not when exiting)
ValueListenableBuilder<bool>(
valueListenable: _isBuffering,
builder: (context, isBuffering, child) {
return ValueListenableBuilder<bool>(
valueListenable: _hasFirstFrame,
builder: (context, hasFrame, child) {
// Don't show spinner when exiting (just black overlay)
// Show spinner when (buffering OR loading) AND NOT exiting
if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink();
return Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
],
),
),
const SizedBox(height: 12),
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: 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: [
Text('$_autoPlayCountdown'),
const SizedBox(width: 4),
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
],
),
),
),
],
),
],
),
),
);
},
),
// Buffering indicator (also shows during initial load, but not when exiting)
// Hidden in PiP mode
ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: _isBuffering,
builder: (context, isBuffering, child) {
return ValueListenableBuilder<bool>(
valueListenable: _hasFirstFrame,
builder: (context, hasFrame, child) {
// Don't show spinner when exiting (just black overlay)
// Show spinner when (buffering OR loading) AND NOT exiting
if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink();
return Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
),
),
);
},
);
},
);
+26 -11
View File
@@ -1,20 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class PipService {
static const MethodChannel _channel =
MethodChannel('app.plezy/pip');
static const MethodChannel _channel = MethodChannel('app.plezy/pip');
// Singleton instance
static final PipService _instance = PipService._internal();
factory PipService() => _instance;
PipService._internal() {
// Listen for callbacks from native Android
_channel.setMethodCallHandler(_handleMethodCall);
}
/// ValueNotifier for PiP state - widgets can listen to this
final ValueNotifier<bool> isPipActive = ValueNotifier<bool>(false);
Future<dynamic> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'onPipChanged':
final isInPip = call.arguments as bool;
isPipActive.value = isInPip;
break;
}
}
static Future<bool> isSupported() async {
return await _channel.invokeMethod<bool>('isSupported') ?? false;
}
static Future<void> enter({
int? width,
int? height,
}) async {
await _channel.invokeMethod('enter', {
'width': width,
'height': height,
});
static Future<void> enter({int? width, int? height}) async {
await _channel.invokeMethod('enter', {'width': width, 'height': height});
}
}
}
+8 -11
View File
@@ -1,28 +1,25 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../mpv/mpv.dart';
import '../services/pip_service.dart';
/// Manages video Picture-in-Picture mode
class VideoPIPManager {
final Player player;
bool isPIPactive = false;
VideoPIPManager({required this.player});
Size? _playerSize;
Size? get playerSize => _playerSize;
/// Access PiP state from the service
ValueNotifier<bool> get isPipActive => PipService().isPipActive;
/// Toggle native PiP
Future<void> togglePIP() async {
final supported = await PipService.isSupported();
if (!supported) return;
await PipService.enter(
width: _playerSize?.width.toInt(),
height: _playerSize?.height.toInt(),
);
await PipService.enter(width: _playerSize?.width.toInt(), height: _playerSize?.height.toInt());
}
}
}
+337 -338
View File
@@ -193,6 +193,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// PiP support
bool _isPipSupported = false;
final PipService _pipService = PipService();
@override
void initState() {
@@ -845,8 +846,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Debounce: ignore taps within 200ms of last skip action
// This prevents double-taps from counting as two separate skips
if (_lastSkipActionTime != null &&
now.difference(_lastSkipActionTime!).inMilliseconds < 200) {
if (_lastSkipActionTime != null && now.difference(_lastSkipActionTime!).inMilliseconds < 200) {
return;
}
@@ -1068,20 +1068,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(
Symbols.fast_forward_rounded,
fill: 1,
color: Colors.white,
size: 16,
),
AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16),
const SizedBox(width: 4),
const Text(
'2x',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
@@ -1249,347 +1240,355 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Use desktop controls for desktop platforms AND Android TV
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
return Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (node, event) {
// Only handle KeyDown and KeyRepeat events
if (!event.isActionable) {
return KeyEventResult.ignored;
}
// Reset hide timer on any keyboard/controller input when controls are visible
if (_showControls) {
_restartHideTimerIfPlaying();
}
final key = event.logicalKey;
final isPlayPauseKey = _isPlayPauseKey(event);
// Always consume play/pause keys to prevent propagation to background routes
// On TV/mobile, handle play/pause here; on desktop, the global handler does it
if (isPlayPauseKey) {
if (_videoPlayerNavigationEnabled || isMobile) {
if (_isPlayPauseActivation(event)) {
widget.player.playOrPause();
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
// Hide ALL controls when in PiP mode (Android only)
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
if (isInPip) return const SizedBox.shrink();
return Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (node, event) {
// Only handle KeyDown and KeyRepeat events
if (!event.isActionable) {
return KeyEventResult.ignored;
}
}
return KeyEventResult.handled;
}
// Handle Back/Escape: show controls if hidden, navigate back if visible
if (_isBackKey(key)) {
if (!_showControls) {
_showControlsWithFocus();
return KeyEventResult.handled;
}
// Controls visible - navigate back
Navigator.of(context).pop(true);
return KeyEventResult.handled;
}
// Reset hide timer on any keyboard/controller input when controls are visible
if (_showControls) {
_restartHideTimerIfPlaying();
}
// Handle Select/Enter when controls are hidden: pause and show controls
// Only intercept if this Focus node itself has primary focus (not a descendant)
if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) {
widget.player.playOrPause();
_showControlsWithFocus();
return KeyEventResult.handled;
}
final key = event.logicalKey;
final isPlayPauseKey = _isPlayPauseKey(event);
// On desktop, show controls and focus play/pause on directional input
// Only handle navigation if video player navigation is enabled
if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) {
// If controls are hidden, show them and focus play/pause
if (!_showControls) {
_showControlsWithFocus();
return KeyEventResult.handled;
}
// If controls are shown, let the event propagate to the focused control
// The DesktopVideoControls will handle navigation
return KeyEventResult.ignored;
}
// Always consume play/pause keys to prevent propagation to background routes
// On TV/mobile, handle play/pause here; on desktop, the global handler does it
if (isPlayPauseKey) {
if (_videoPlayerNavigationEnabled || isMobile) {
if (_isPlayPauseActivation(event)) {
widget.player.playOrPause();
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
}
}
return KeyEventResult.handled;
}
// Pass other events to the keyboard shortcuts service
if (_keyboardService == null) return KeyEventResult.ignored;
// Handle Back/Escape: show controls if hidden, navigate back if visible
if (_isBackKey(key)) {
if (!_showControls) {
_showControlsWithFocus();
return KeyEventResult.handled;
}
// Controls visible - navigate back
Navigator.of(context).pop(true);
return KeyEventResult.handled;
}
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
_toggleFullscreen,
_toggleSubtitles,
_nextAudioTrack,
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
);
return result;
},
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerHover: (_) => _showControlsFromPointerActivity(),
child: MouseRegion(
cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => _showControlsFromPointerActivity(),
child: Stack(
children: [
// Invisible tap detector that always covers the full area
// Also handles long-press for 2x speed
Positioned.fill(
child: GestureDetector(
onTap: _toggleControls,
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Middle area double-tap detector for fullscreen (desktop only)
// Only covers the clear video area (20% to 80% vertically)
if (!isMobile)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final topExclude = height * 0.20; // Top 20%
final bottomExclude = height * 0.20; // Bottom 20%
// Handle Select/Enter when controls are hidden: pause and show controls
// Only intercept if this Focus node itself has primary focus (not a descendant)
if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) {
widget.player.playOrPause();
_showControlsWithFocus();
return KeyEventResult.handled;
}
return Stack(
children: [
Positioned(
top: topExclude,
left: 0,
right: 0,
bottom: bottomExclude,
child: GestureDetector(
onTap: _toggleControls,
onDoubleTap: _toggleFullscreen,
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
),
],
);
},
// On desktop, show controls and focus play/pause on directional input
// Only handle navigation if video player navigation is enabled
if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) {
// If controls are hidden, show them and focus play/pause
if (!_showControls) {
_showControlsWithFocus();
return KeyEventResult.handled;
}
// If controls are shown, let the event propagate to the focused control
// The DesktopVideoControls will handle navigation
return KeyEventResult.ignored;
}
// Pass other events to the keyboard shortcuts service
if (_keyboardService == null) return KeyEventResult.ignored;
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
_toggleFullscreen,
_toggleSubtitles,
_nextAudioTrack,
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
);
return result;
},
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerHover: (_) => _showControlsFromPointerActivity(),
child: MouseRegion(
cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => _showControlsFromPointerActivity(),
child: Stack(
children: [
// Invisible tap detector that always covers the full area
// Also handles long-press for 2x speed
Positioned.fill(
child: GestureDetector(
onTap: _toggleControls,
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
),
// Mobile double-tap zones for skip forward/backward
if (isMobile)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final width = constraints.maxWidth;
final topExclude = height * 0.15; // Exclude top 15% (top bar)
final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider)
final leftZoneWidth = width * 0.35; // Left 35%
// Middle area double-tap detector for fullscreen (desktop only)
// Only covers the clear video area (20% to 80% vertically)
if (!isMobile)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final topExclude = height * 0.20; // Top 20%
final bottomExclude = height * 0.20; // Bottom 20%
return Stack(
children: [
// Left zone - skip backward (custom double-tap detection)
Positioned(
left: 0,
top: topExclude,
bottom: bottomExclude,
width: leftZoneWidth,
child: GestureDetector(
onTap: () => _handleTapInSkipZone(isForward: false),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Right zone - skip forward (custom double-tap detection)
Positioned(
right: 0,
top: topExclude,
bottom: bottomExclude,
width: leftZoneWidth,
child: GestureDetector(
onTap: () => _handleTapInSkipZone(isForward: true),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
],
);
},
),
),
// Custom controls overlay - use AnimatedOpacity to keep widget tree alive
// On Linux, use Offstage after fade completes to fully hide
// Positioned AFTER double-tap zones so controls receive taps first
Positioned.fill(
child: Offstage(
offstage: Platform.isLinux && _controlsFullyHidden,
child: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapUp: (details) => _handleControlsOverlayTap(details, constraints),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.deferToChild,
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
builder: (context, hasFrame, child) {
return Container(
decoration: BoxDecoration(
// Use solid black when loading, gradient when loaded
color: hasFrame ? null : Colors.black,
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: null,
),
child: child,
);
},
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartHideTimerIfPlaying(),
child: MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
trackChapterControls: _buildTrackChapterControlsWidget(),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onSeekCompleted: widget.onSeekCompleted,
onPlayPause: () {}, // Not used, handled internally
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onBack: widget.onBack,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
)
: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartHideTimerIfPlaying(),
child: DesktopVideoControls(
key: _desktopControlsKey,
player: widget.player,
metadata: widget.metadata,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
onSeekToPreviousChapter: _seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter,
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
getReplayIcon: getReplayIcon,
getForwardIcon: getForwardIcon,
onFocusActivity: _restartHideTimerIfPlaying,
onHideControls: _hideControlsFromKeyboard,
// Track chapter controls data
availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: widget.boxFitMode,
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
onLoadSeekTimes: () async {
if (mounted) {
await _loadSeekTimes();
}
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
),
return Stack(
children: [
Positioned(
top: topExclude,
left: 0,
right: 0,
bottom: bottomExclude,
child: GestureDetector(
onTap: _toggleControls,
onDoubleTap: _toggleFullscreen,
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
),
);
},
],
);
},
),
),
// Mobile double-tap zones for skip forward/backward
if (isMobile)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight;
final width = constraints.maxWidth;
final topExclude = height * 0.15; // Exclude top 15% (top bar)
final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider)
final leftZoneWidth = width * 0.35; // Left 35%
return Stack(
children: [
// Left zone - skip backward (custom double-tap detection)
Positioned(
left: 0,
top: topExclude,
bottom: bottomExclude,
width: leftZoneWidth,
child: GestureDetector(
onTap: () => _handleTapInSkipZone(isForward: false),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Right zone - skip forward (custom double-tap detection)
Positioned(
right: 0,
top: topExclude,
bottom: bottomExclude,
width: leftZoneWidth,
child: GestureDetector(
onTap: () => _handleTapInSkipZone(isForward: true),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
],
);
},
),
),
// Custom controls overlay - use AnimatedOpacity to keep widget tree alive
// On Linux, use Offstage after fade completes to fully hide
// Positioned AFTER double-tap zones so controls receive taps first
Positioned.fill(
child: Offstage(
offstage: Platform.isLinux && _controlsFullyHidden,
child: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapUp: (details) => _handleControlsOverlayTap(details, constraints),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.deferToChild,
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? ValueNotifier(true),
builder: (context, hasFrame, child) {
return Container(
decoration: BoxDecoration(
// Use solid black when loading, gradient when loaded
color: hasFrame ? null : Colors.black,
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: null,
),
child: child,
);
},
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartHideTimerIfPlaying(),
child: MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
trackChapterControls: _buildTrackChapterControlsWidget(),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onSeekCompleted: widget.onSeekCompleted,
onPlayPause: () {}, // Not used, handled internally
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onBack: widget.onBack,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
)
: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartHideTimerIfPlaying(),
child: DesktopVideoControls(
key: _desktopControlsKey,
player: widget.player,
metadata: widget.metadata,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
seekTimeSmall: _seekTimeSmall,
onSeekToPreviousChapter: _seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter,
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
getReplayIcon: getReplayIcon,
getForwardIcon: getForwardIcon,
onFocusActivity: _restartHideTimerIfPlaying,
onHideControls: _hideControlsFromKeyboard,
// Track chapter controls data
availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: widget.boxFitMode,
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && Platform.isAndroid)
? widget.onTogglePIPMode
: null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
onLoadSeekTimes: () async {
if (mounted) {
await _loadSeekTimes();
}
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
),
),
),
);
},
),
),
),
),
),
),
),
),
// Visual feedback overlay for double-tap
if (isMobile && _showDoubleTapFeedback)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _doubleTapFeedbackOpacity,
duration: tokens(context).slow,
child: _buildDoubleTapFeedback(),
// Visual feedback overlay for double-tap
if (isMobile && _showDoubleTapFeedback)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _doubleTapFeedbackOpacity,
duration: tokens(context).slow,
child: _buildDoubleTapFeedback(),
),
),
),
),
),
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator)
Positioned.fill(
child: IgnorePointer(
child: _buildSpeedIndicator(),
),
),
// Skip intro/credits button
if (_currentMarker != null)
Positioned(
right: 24,
bottom: isMobile ? 80 : 115,
child: AnimatedOpacity(opacity: 1.0, duration: tokens(context).slow, child: _buildSkipMarkerButton()),
),
// Performance overlay (top-left)
if (_showPerformanceOverlay)
Positioned(
top: isMobile ? 60 : 16,
left: 16,
child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)),
),
],
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
// Skip intro/credits button
if (_currentMarker != null)
Positioned(
right: 24,
bottom: isMobile ? 80 : 115,
child: AnimatedOpacity(
opacity: 1.0,
duration: tokens(context).slow,
child: _buildSkipMarkerButton(),
),
),
// Performance overlay (top-left)
if (_showPerformanceOverlay)
Positioned(
top: isMobile ? 60 : 16,
left: 16,
child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)),
),
],
),
),
),
),
),
);
},
);
}