From 60ba1b157056c479f4aceae94e4d8c4ccf1fe956 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:00:22 +0100 Subject: [PATCH] feat: video fill modes --- lib/screens/video_player_screen.dart | 92 +++++++++++++++++++++++----- lib/widgets/plex_video_controls.dart | 44 +++++++++++++ 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 07981813..5b3fec94 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -53,6 +53,10 @@ class _VideoPlayerScreenState extends State { StreamSubscription? _logSubscription; StreamSubscription? _errorSubscription; + // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) + int _boxFitMode = 0; + bool _isPinching = false; // Track if a pinch gesture is occurring + @override void initState() { super.initState(); @@ -287,6 +291,38 @@ class _VideoPlayerScreenState extends State { } } + /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) + void _cycleBoxFitMode() { + setState(() { + _boxFitMode = (_boxFitMode + 1) % 3; + }); + final modes = ['contain (letterbox)', 'cover (fill screen)', 'fill (stretch)']; + appLogger.d('BoxFit mode: ${modes[_boxFitMode]}'); + } + + /// Toggle between contain and cover modes only (for pinch gesture) + void _toggleContainCover() { + setState(() { + _boxFitMode = _boxFitMode == 0 ? 1 : 0; // Toggle between 0 and 1 + }); + final modes = ['contain (letterbox)', 'cover (fill screen)']; + appLogger.d('BoxFit mode toggled: ${modes[_boxFitMode]}'); + } + + /// Get current BoxFit based on mode + BoxFit get _getCurrentBoxFit { + switch (_boxFitMode) { + case 0: + return BoxFit.contain; + case 1: + return BoxFit.cover; + case 2: + return BoxFit.fill; + default: + return BoxFit.contain; + } + } + @override void dispose() { // Stop progress tracking @@ -1022,22 +1058,47 @@ class _VideoPlayerScreenState extends State { }, child: Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - // Video player - Center( - child: Video( - controller: controller!, - controls: (state) => plexVideoControlsBuilder( - player!, - widget.metadata, - onNext: _nextEpisode != null ? _playNext : null, - onPrevious: _previousEpisode != null ? _playPrevious : null, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, + body: GestureDetector( + behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls + onScaleStart: (details) { + // Initialize pinch gesture tracking (mobile only) + if (!PlatformDetector.isMobile(context)) return; + _isPinching = false; + }, + onScaleUpdate: (details) { + // Track if this is a pinch gesture (2+ fingers) on mobile + if (!PlatformDetector.isMobile(context)) return; + if (details.pointerCount >= 2) { + _isPinching = true; + } + }, + onScaleEnd: (details) { + // Only toggle if we detected a pinch gesture on mobile + if (!PlatformDetector.isMobile(context)) return; + if (_isPinching) { + _toggleContainCover(); + _isPinching = false; + } + }, + child: Stack( + children: [ + // Video player + Center( + child: Video( + controller: controller!, + fit: _getCurrentBoxFit, + controls: (state) => plexVideoControlsBuilder( + player!, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null ? _playPrevious : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: _boxFitMode, + onCycleBoxFitMode: _cycleBoxFitMode, + ), ), ), - ), // Play Next Dialog if (_showPlayNextDialog && _nextEpisode != null) Positioned.fill( @@ -1149,7 +1210,8 @@ class _VideoPlayerScreenState extends State { ), ), ), - ], + ], + ), ), ), ); diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index 8e6eb030..c99ca4c7 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -26,6 +26,8 @@ Widget plexVideoControlsBuilder( VoidCallback? onPrevious, List? availableVersions, int? selectedMediaIndex, + int boxFitMode = 0, + VoidCallback? onCycleBoxFitMode, }) { return PlexVideoControls( player: player, @@ -34,6 +36,8 @@ Widget plexVideoControlsBuilder( onPrevious: onPrevious, availableVersions: availableVersions ?? [], selectedMediaIndex: selectedMediaIndex ?? 0, + boxFitMode: boxFitMode, + onCycleBoxFitMode: onCycleBoxFitMode, ); } @@ -44,6 +48,8 @@ class PlexVideoControls extends StatefulWidget { final VoidCallback? onPrevious; final List availableVersions; final int selectedMediaIndex; + final int boxFitMode; + final VoidCallback? onCycleBoxFitMode; const PlexVideoControls({ super.key, @@ -53,6 +59,8 @@ class PlexVideoControls extends StatefulWidget { this.onPrevious, this.availableVersions = const [], this.selectedMediaIndex = 0, + this.boxFitMode = 0, + this.onCycleBoxFitMode, }); @override @@ -271,6 +279,32 @@ class _PlexVideoControlsState extends State return subtitles.isNotEmpty; } + IconData _getBoxFitIcon(int mode) { + switch (mode) { + case 0: + return Icons.crop_free; // contain (letterbox) + case 1: + return Icons.crop; // cover (fill screen) + case 2: + return Icons.fit_screen; // fill (stretch) + default: + return Icons.crop_free; + } + } + + String _getBoxFitTooltip(int mode) { + switch (mode) { + case 0: + return 'Letterbox'; + case 1: + return 'Fill screen'; + case 2: + return 'Stretch'; + default: + return 'Letterbox'; + } + } + Widget _buildTrackAndChapterControls() { return StreamBuilder( stream: widget.player.stream.tracks, @@ -304,6 +338,16 @@ class _PlexVideoControlsState extends State icon: const Icon(Icons.video_file, color: Colors.white), onPressed: _showVersionBottomSheet, ), + // BoxFit mode cycle button + if (widget.onCycleBoxFitMode != null) + IconButton( + icon: Icon( + _getBoxFitIcon(widget.boxFitMode), + color: Colors.white, + ), + tooltip: _getBoxFitTooltip(widget.boxFitMode), + onPressed: widget.onCycleBoxFitMode, + ), ], ); },