diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index ed0593a..ed8f1ef 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -43,6 +43,7 @@ class DesktopControls extends ConsumerStatefulWidget { class _DesktopControlsState extends ConsumerState { // Add GlobalKey to measure bottom controls height final GlobalKey _bottomControlsKey = GlobalKey(); + double? _cachedMenuHeight; late RestartableTimer timer = RestartableTimer( const Duration(seconds: 5), @@ -111,14 +112,32 @@ class _DesktopControlsState extends ConsumerState { timer.reset(); } + // Use PostFrameCallback to measure height after layout + void _measureMenuHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final RenderBox? renderBox = _bottomControlsKey.currentContext?.findRenderObject() as RenderBox?; + final newHeight = renderBox?.size.height; + + if (newHeight != _cachedMenuHeight && newHeight != null) { + setState(() { + _cachedMenuHeight = newHeight; + }); + } + }); + } + // Method to get actual menu height double? getBottomControlsHeight() { - final RenderBox? renderBox = _bottomControlsKey.currentContext?.findRenderObject() as RenderBox?; - return renderBox?.size.height; + return _cachedMenuHeight; } @override Widget build(BuildContext context) { + // Trigger measurement after each build to ensure accurate height + _measureMenuHeight(); + final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments)); final player = ref.watch(videoPlayerProvider); final subtitleWidget = player.subtitleWidget(showOverlay, menuHeight: getBottomControlsHeight()); diff --git a/lib/util/subtitle_position_calculator.dart b/lib/util/subtitle_position_calculator.dart new file mode 100644 index 0000000..f6e054b --- /dev/null +++ b/lib/util/subtitle_position_calculator.dart @@ -0,0 +1,65 @@ +import 'dart:math' as math; +import 'package:fladder/models/settings/subtitle_settings_model.dart'; + +/// Utility class for calculating subtitle positioning based on menu overlay state +/// Provides utilities for calculating the optimal vertical position of subtitles +/// based on user settings and the visibility or size of the player menu overlay. +class SubtitlePositionCalculator { + // Configuration constants + static const double _fallbackMenuHeightPercentage = 0.15; // 15% fallback + static const double _dynamicSubtitlePadding = + -0.03; // -3% padding when we have accurate menu height so the subtitles are closer to the menu + static const double _fallbackSubtitlePadding = 0.01; // 1% padding for conservative fallback positioning + static const double _maxSubtitleOffset = 0.85; // Max 85% up from bottom + + /// Calculate subtitle offset using actual menu height when available + /// + /// Returns the optimal subtitle offset (0.0 to 1.0) where: + /// - 0.0 = bottom of screen + /// - 1.0 = top of screen + /// + /// Parameters: + /// - [settings]: User's subtitle settings containing preferred vertical offset + /// - [showOverlay]: Whether the player menu overlay is currently visible + /// - [screenHeight]: Height of the screen in pixels + /// - [menuHeight]: Optional actual height of the menu in pixels + static double calculateOffset({ + required SubtitleSettingsModel settings, + required bool showOverlay, + required double screenHeight, + double? menuHeight, + }) { + if (!showOverlay) { + return settings.verticalOffset; + } + + double menuHeightPercentage; + double subtitlePadding; + + if (menuHeight != null && screenHeight > 0) { + // Convert menu height from pixels to screen percentage + menuHeightPercentage = menuHeight / screenHeight; + // Use negative padding since we have accurate measurement - can position closer + subtitlePadding = _dynamicSubtitlePadding; + } else { + // Fallback to static percentage when measurement unavailable + menuHeightPercentage = _fallbackMenuHeightPercentage; + // Use positive padding for safety since we're estimating + subtitlePadding = _fallbackSubtitlePadding; + } + + // Calculate the minimum safe position (menu height + appropriate padding) + final minSafeOffset = menuHeightPercentage + subtitlePadding; + + // If subtitles are already positioned above the safe area, leave them alone + // but still apply maximum bounds checking + if (settings.verticalOffset >= minSafeOffset) { + return math.min(settings.verticalOffset, _maxSubtitleOffset); + } + + // Position subtitles just above the menu with bounds checking + // Defensive: math.max(0.0, ...) ensures the offset is never negative, + // which could happen if future changes allow negative menuHeight or padding. + return math.max(0.0, math.min(minSafeOffset, _maxSubtitleOffset)); + } +} diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index 759af30..4ed69c9 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -14,6 +13,7 @@ import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/settings/subtitle_settings_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; +import 'package:fladder/util/subtitle_position_calculator.dart'; import 'package:fladder/wrappers/players/base_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; @@ -207,25 +207,25 @@ class _VideoSubtitles extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _VideoSubtitlesState(); + _VideoSubtitlesState createState() => _VideoSubtitlesState(); } class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> { - // Keep fallback constants for when dynamic height isn't available - static const double _fallbackMenuHeightPercentage = 0.15; // 15% fallback - static const double _subtitlePadding = 0.005; // 0.5% padding above menu - static const double _maxSubtitleOffset = 0.85; // Max 85% up from bottom - - late List subtitle = widget.controller.player.state.subtitle; + late List subtitle; + String _cachedSubtitleText = ''; + List? _lastSubtitleList; StreamSubscription>? subscription; @override void initState() { super.initState(); // Move to very start as per best practices + subtitle = widget.controller.player.state.subtitle; subscription = widget.controller.player.stream.subtitle.listen((value) { if (mounted) { setState(() { subtitle = value; + // Invalidate cache when subtitle changes + _lastSubtitleList = null; }); } }); @@ -237,53 +237,38 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> { super.dispose(); } - /// Calculate subtitle offset using actual menu height when available - double _calculateSubtitleOffset(SubtitleSettingsModel settings) { - if (!widget.showOverlay) { - return settings.verticalOffset; - } - - final screenHeight = MediaQuery.of(context).size.height; - double menuHeightPercentage; - - if (widget.menuHeight != null && screenHeight > 0) { - // Convert menu height to percentage (without extra padding here) - menuHeightPercentage = widget.menuHeight! / screenHeight; - } else { - // Fallback to static percentage - menuHeightPercentage = _fallbackMenuHeightPercentage; - } - - // Calculate the minimum safe position (menu height + small padding) - final minSafeOffset = menuHeightPercentage + _subtitlePadding; - - // If subtitles are already positioned above the safe area, leave them alone - if (settings.verticalOffset >= minSafeOffset) { - return settings.verticalOffset; - } - - // Instead of replacing user offset, use the minimum safe position - // This ensures subtitles are just above the menu, not way up high - return math.max(minSafeOffset, math.min(settings.verticalOffset, _maxSubtitleOffset)); - } - @override Widget build(BuildContext context) { final settings = ref.watch(subtitleSettingsProvider); final padding = MediaQuery.of(context).padding; - // Process subtitle text - final text = subtitle.where((line) => line.trim().isNotEmpty).map((line) => line.trim()).join('\n'); + // Cache processed subtitle text to avoid unnecessary computation + if (!const ListEquality().equals(subtitle, _lastSubtitleList)) { + _cachedSubtitleText = subtitle.where((line) => line.trim().isNotEmpty).map((line) => line.trim()).join('\n'); + _lastSubtitleList = List.from(subtitle); + } + final text = _cachedSubtitleText; + + // Extract libass enabled check for clarity + final bool isLibassEnabled = widget.controller.player.platform?.configuration.libass ?? false; // Early return for cases where subtitles shouldn't be rendered - if ((widget.controller.player.platform?.configuration.libass ?? false) || text.isEmpty) { + if (isLibassEnabled || text.isEmpty) { return const SizedBox.shrink(); } + // Use the utility for offset calculation + final offset = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: widget.showOverlay, + screenHeight: MediaQuery.of(context).size.height, + menuHeight: widget.menuHeight, + ); + return SubtitleText( subModel: settings, padding: padding, - offset: _calculateSubtitleOffset(settings), + offset: offset, text: text, ); } diff --git a/test/util/subtitle_position_calculator_test.dart b/test/util/subtitle_position_calculator_test.dart new file mode 100644 index 0000000..8197d23 --- /dev/null +++ b/test/util/subtitle_position_calculator_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fladder/models/settings/subtitle_settings_model.dart'; +import 'package:fladder/util/subtitle_position_calculator.dart'; + +void main() { + group('SubtitlePositionCalculator', () { + test('returns original offset when overlay is hidden', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.2); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: false, + screenHeight: 800, + menuHeight: 120, + ); + + expect(result, equals(0.2)); + }); + + test('uses dynamic menu height when available', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.1); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 800, + menuHeight: 120, // 120/800 = 0.15 (15%) + ); + + // Should position at menu height + dynamic padding = (120/800) + (-0.03) = 0.12 + expect(result, closeTo(0.12, 0.001)); + }); + + test('uses fallback when menu height unavailable', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.1); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 800, + menuHeight: null, + ); + + // Should use fallback: 0.15 + 0.01 = 0.16 + expect(result, equals(0.16)); + }); + + test('preserves user offset when already above menu', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.3); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 800, + menuHeight: 120, + ); + + // Should keep original 0.3 since it's above menu area (0.12) + expect(result, equals(0.3)); + }); + + test('clamps to maximum offset', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.95); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 800, + menuHeight: 600, // Large menu that would push subtitles too high + ); + + // Should clamp to max 0.85 + expect(result, equals(0.85)); + }); + + test('handles zero screen height gracefully', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.1); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 0, + menuHeight: 120, + ); + + // Should use fallback when screen height is invalid + expect(result, equals(0.16)); + }); + + test('clamps to minimum offset', () { + const settings = SubtitleSettingsModel(verticalOffset: 0.05); + + final result = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: true, + screenHeight: 800, + menuHeight: 120, + ); + + // Should not go below 0.0 + expect(result, greaterThanOrEqualTo(0.0)); + }); + }); +}