mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-09 07:28:14 -07:00
feat: Implement dynamic subtitle positioning based on menu height
This commit is contained in:
parent
1fdab92f1f
commit
2884a5fadc
4 changed files with 217 additions and 44 deletions
|
|
@ -43,6 +43,7 @@ class DesktopControls extends ConsumerStatefulWidget {
|
||||||
class _DesktopControlsState extends ConsumerState<DesktopControls> {
|
class _DesktopControlsState extends ConsumerState<DesktopControls> {
|
||||||
// Add GlobalKey to measure bottom controls height
|
// Add GlobalKey to measure bottom controls height
|
||||||
final GlobalKey _bottomControlsKey = GlobalKey();
|
final GlobalKey _bottomControlsKey = GlobalKey();
|
||||||
|
double? _cachedMenuHeight;
|
||||||
|
|
||||||
late RestartableTimer timer = RestartableTimer(
|
late RestartableTimer timer = RestartableTimer(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 5),
|
||||||
|
|
@ -111,14 +112,32 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
|
||||||
timer.reset();
|
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
|
// Method to get actual menu height
|
||||||
double? getBottomControlsHeight() {
|
double? getBottomControlsHeight() {
|
||||||
final RenderBox? renderBox = _bottomControlsKey.currentContext?.findRenderObject() as RenderBox?;
|
return _cachedMenuHeight;
|
||||||
return renderBox?.size.height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Trigger measurement after each build to ensure accurate height
|
||||||
|
_measureMenuHeight();
|
||||||
|
|
||||||
final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments));
|
final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments));
|
||||||
final player = ref.watch(videoPlayerProvider);
|
final player = ref.watch(videoPlayerProvider);
|
||||||
final subtitleWidget = player.subtitleWidget(showOverlay, menuHeight: getBottomControlsHeight());
|
final subtitleWidget = player.subtitleWidget(showOverlay, menuHeight: getBottomControlsHeight());
|
||||||
|
|
|
||||||
65
lib/util/subtitle_position_calculator.dart
Normal file
65
lib/util/subtitle_position_calculator.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.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/subtitle_settings_model.dart';
|
||||||
import 'package:fladder/models/settings/video_player_settings.dart';
|
import 'package:fladder/models/settings/video_player_settings.dart';
|
||||||
import 'package:fladder/providers/settings/subtitle_settings_provider.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/base_player.dart';
|
||||||
import 'package:fladder/wrappers/players/player_states.dart';
|
import 'package:fladder/wrappers/players/player_states.dart';
|
||||||
|
|
||||||
|
|
@ -207,25 +207,25 @@ class _VideoSubtitles extends ConsumerStatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ConsumerStatefulWidget> createState() => _VideoSubtitlesState();
|
_VideoSubtitlesState createState() => _VideoSubtitlesState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
|
class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
|
||||||
// Keep fallback constants for when dynamic height isn't available
|
late List<String> subtitle;
|
||||||
static const double _fallbackMenuHeightPercentage = 0.15; // 15% fallback
|
String _cachedSubtitleText = '';
|
||||||
static const double _subtitlePadding = 0.005; // 0.5% padding above menu
|
List<String>? _lastSubtitleList;
|
||||||
static const double _maxSubtitleOffset = 0.85; // Max 85% up from bottom
|
|
||||||
|
|
||||||
late List<String> subtitle = widget.controller.player.state.subtitle;
|
|
||||||
StreamSubscription<List<String>>? subscription;
|
StreamSubscription<List<String>>? subscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState(); // Move to very start as per best practices
|
super.initState(); // Move to very start as per best practices
|
||||||
|
subtitle = widget.controller.player.state.subtitle;
|
||||||
subscription = widget.controller.player.stream.subtitle.listen((value) {
|
subscription = widget.controller.player.stream.subtitle.listen((value) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
subtitle = value;
|
subtitle = value;
|
||||||
|
// Invalidate cache when subtitle changes
|
||||||
|
_lastSubtitleList = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -237,53 +237,38 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(subtitleSettingsProvider);
|
final settings = ref.watch(subtitleSettingsProvider);
|
||||||
final padding = MediaQuery.of(context).padding;
|
final padding = MediaQuery.of(context).padding;
|
||||||
|
|
||||||
// Process subtitle text
|
// Cache processed subtitle text to avoid unnecessary computation
|
||||||
final text = subtitle.where((line) => line.trim().isNotEmpty).map((line) => line.trim()).join('\n');
|
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
|
// 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();
|
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(
|
return SubtitleText(
|
||||||
subModel: settings,
|
subModel: settings,
|
||||||
padding: padding,
|
padding: padding,
|
||||||
offset: _calculateSubtitleOffset(settings),
|
offset: offset,
|
||||||
text: text,
|
text: text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
test/util/subtitle_position_calculator_test.dart
Normal file
104
test/util/subtitle_position_calculator_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue