feat: Implement dynamic subtitle positioning based on menu height

This commit is contained in:
Kirill Boychenko 2025-07-28 03:30:26 +02:00
parent 1fdab92f1f
commit 2884a5fadc
4 changed files with 217 additions and 44 deletions

View file

@ -43,6 +43,7 @@ class DesktopControls extends ConsumerStatefulWidget {
class _DesktopControlsState extends ConsumerState<DesktopControls> {
// 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<DesktopControls> {
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());

View file

@ -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));
}
}

View file

@ -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<ConsumerStatefulWidget> 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<String> subtitle = widget.controller.player.state.subtitle;
late List<String> subtitle;
String _cachedSubtitleText = '';
List<String>? _lastSubtitleList;
StreamSubscription<List<String>>? 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<String>.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,
);
}

View file

@ -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));
});
});
}