fix: Move calculation logic to lib_mpv subtitles

This commit is contained in:
PartyDonut 2025-07-28 21:32:37 +02:00
parent c446210e6a
commit 5fac088e2d
8 changed files with 42 additions and 63 deletions

View file

@ -216,7 +216,8 @@ class SubtitleText extends ConsumerWidget {
child: Stack( child: Stack(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
children: [ children: [
Positioned( AnimatedPositioned(
duration: const Duration(milliseconds: 125),
bottom: position, bottom: position,
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight),
@ -234,7 +235,8 @@ class SubtitleText extends ConsumerWidget {
), ),
), ),
), ),
Positioned( AnimatedPositioned(
duration: const Duration(milliseconds: 125),
bottom: position, bottom: position,
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight),

View file

@ -41,9 +41,7 @@ class DesktopControls extends ConsumerStatefulWidget {
} }
class _DesktopControlsState extends ConsumerState<DesktopControls> { class _DesktopControlsState extends ConsumerState<DesktopControls> {
// 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),
@ -112,41 +110,11 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
timer.reset(); timer.reset();
} }
// Height measurement logic remains here for architectural reasons:
// 1. The video controls widget owns and renders the bottom menu UI elements
// 2. Only this widget has direct access to the menu's RenderBox for accurate measurement
// 3. Subtitle widgets are separate components that shouldn't know about control UI structure
// 4. Different players (LibMPV, MDK) can receive the same measurement without duplicating logic
// 5. Clean separation: controls handle UI measurement, players handle subtitle positioning
// 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() {
return _cachedMenuHeight;
}
@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, controlsKey: _bottomControlsKey);
return InputHandler( return InputHandler(
autoFocus: false, autoFocus: false,
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored,

View file

@ -64,7 +64,7 @@ class LibMDK extends BasePlayer {
null; null;
@override @override
Widget? subtitles(bool showOverlay, {double? menuHeight}) => null; Widget? subtitles(bool showOverlay, {GlobalKey? menuKey}) => null;
@override @override
Future<void> setVolume(double volume) async {} Future<void> setVolume(double volume) async {}

View file

@ -1,12 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:fladder/models/settings/subtitle_settings_model.dart'; import 'package:fladder/models/settings/subtitle_settings_model.dart';
class SubtitlePositionCalculator { class SubtitlePositionCalculator {
static const double _fallbackMenuHeightPercentage = 0.15; static const double _fallbackMenuHeightPercentage = 0.15;
static const double _dynamicSubtitlePadding =
0.00; // Currently unused (0%). Reserved for future implementation of a user-adjustable slider to control subtitle positioning
// relative to the player menu
static const double _fallbackSubtitlePadding = 0.01; // 1% padding for conservative fallback positioning
static const double _maxSubtitleOffset = 0.85; static const double _maxSubtitleOffset = 0.85;
static double calculateOffset({ static double calculateOffset({
@ -20,17 +17,14 @@ class SubtitlePositionCalculator {
} }
double menuHeightPercentage; double menuHeightPercentage;
double subtitlePadding;
if (menuHeight != null && screenHeight > 0) { if (menuHeight != null && screenHeight > 0) {
menuHeightPercentage = menuHeight / screenHeight; menuHeightPercentage = menuHeight / screenHeight;
subtitlePadding = _dynamicSubtitlePadding;
} else { } else {
menuHeightPercentage = _fallbackMenuHeightPercentage; menuHeightPercentage = _fallbackMenuHeightPercentage;
subtitlePadding = _fallbackSubtitlePadding;
} }
final minSafeOffset = menuHeightPercentage + subtitlePadding; final minSafeOffset = menuHeightPercentage;
if (settings.verticalOffset >= minSafeOffset) { if (settings.verticalOffset >= minSafeOffset) {
return math.min(settings.verticalOffset, _maxSubtitleOffset); return math.min(settings.verticalOffset, _maxSubtitleOffset);

View file

@ -39,8 +39,8 @@ class MediaControlsWrapper extends BaseAudioHandler {
Stream<PlayerState>? get stateStream => _player?.stateStream; Stream<PlayerState>? get stateStream => _player?.stateStream;
PlayerState? get lastState => _player?.lastState; PlayerState? get lastState => _player?.lastState;
Widget? subtitleWidget(bool showOverlay, {double? menuHeight}) => Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) =>
_player?.subtitles(showOverlay, menuHeight: menuHeight); _player?.subtitles(showOverlay, menuKey: controlsKey);
Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit); Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit);
final Ref ref; final Ref ref;

View file

@ -20,7 +20,7 @@ abstract class BasePlayer {
); );
Widget? subtitles( Widget? subtitles(
bool showOverlay, { bool showOverlay, {
double? menuHeight, GlobalKey? menuKey,
}); });
Future<void> dispose(); Future<void> dispose();
Future<void> open(String url, bool play); Future<void> open(String url, bool play);

View file

@ -191,7 +191,7 @@ class LibMDK extends BasePlayer {
); );
@override @override
Widget? subtitles(bool showOverlay, {double? menuHeight}) => null; Widget? subtitles(bool showOverlay, {GlobalKey? menuKey}) => null;
@override @override
Future<void> setVolume(double volume) async => _controller?.setVolume(volume / 100); Future<void> setVolume(double volume) async => _controller?.setVolume(volume / 100);

View file

@ -168,13 +168,13 @@ class LibMPV extends BasePlayer {
@override @override
Widget? subtitles( Widget? subtitles(
bool showOverlay, { bool showOverlay, {
double? menuHeight, // Passed from video_player_controls.dart which owns the menu UI GlobalKey? menuKey,
}) => }) =>
_controller != null _controller != null
? _VideoSubtitles( ? _VideoSubtitles(
controller: _controller!, controller: _controller!,
showOverlay: showOverlay, showOverlay: showOverlay,
menuHeight: menuHeight, // Forward the measured height for accurate positioning menuKey: menuKey,
) )
: null; : null;
@ -198,12 +198,12 @@ class LibMPV extends BasePlayer {
class _VideoSubtitles extends ConsumerStatefulWidget { class _VideoSubtitles extends ConsumerStatefulWidget {
final VideoController controller; final VideoController controller;
final bool showOverlay; final bool showOverlay;
final double? menuHeight; // Accurate measurement from controls, null triggers fallback positioning final GlobalKey? menuKey;
const _VideoSubtitles({ const _VideoSubtitles({
required this.controller, required this.controller,
this.showOverlay = false, this.showOverlay = false,
this.menuHeight, // Receives pre-measured height rather than measuring internally this.menuKey,
}); });
@override @override
@ -216,15 +216,16 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
List<String>? _lastSubtitleList; List<String>? _lastSubtitleList;
StreamSubscription<List<String>>? subscription; StreamSubscription<List<String>>? subscription;
double? _cachedMenuHeight;
@override @override
void initState() { void initState() {
super.initState(); // Move to very start as per best practices super.initState();
subtitle = widget.controller.player.state.subtitle; 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; _lastSubtitleList = null;
}); });
} }
@ -239,30 +240,29 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(subtitleSettingsProvider); _measureMenuHeight();
final padding = MediaQuery.of(context).padding;
final settings = ref.watch(subtitleSettingsProvider);
final padding = MediaQuery.paddingOf(context);
// Cache processed subtitle text to avoid unnecessary computation
if (!const ListEquality().equals(subtitle, _lastSubtitleList)) { if (!const ListEquality().equals(subtitle, _lastSubtitleList)) {
_cachedSubtitleText = subtitle.where((line) => line.trim().isNotEmpty).map((line) => line.trim()).join('\n');
_lastSubtitleList = List<String>.from(subtitle); _lastSubtitleList = List<String>.from(subtitle);
_cachedSubtitleText = subtitle.where((line) => line.trim().isNotEmpty).map((line) => line.trim()).join('\n');
} }
final text = _cachedSubtitleText; final text = _cachedSubtitleText;
// Extract libass enabled check for clarity
final bool isLibassEnabled = widget.controller.player.platform?.configuration.libass ?? false; final bool isLibassEnabled = widget.controller.player.platform?.configuration.libass ?? false;
// Early return for cases where subtitles shouldn't be rendered
if (isLibassEnabled || text.isEmpty) { if (isLibassEnabled || text.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Use the utility for offset calculation with passed menuHeight
final offset = SubtitlePositionCalculator.calculateOffset( final offset = SubtitlePositionCalculator.calculateOffset(
settings: settings, settings: settings,
showOverlay: widget.showOverlay, showOverlay: widget.showOverlay,
screenHeight: MediaQuery.of(context).size.height, screenHeight: MediaQuery.sizeOf(context).height,
menuHeight: widget.menuHeight, menuHeight: _cachedMenuHeight,
); );
return SubtitleText( return SubtitleText(
@ -272,4 +272,19 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
text: text, text: text,
); );
} }
void _measureMenuHeight() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || widget.menuKey == null) return;
final RenderBox? renderBox = widget.menuKey?.currentContext?.findRenderObject() as RenderBox?;
final newHeight = renderBox?.size.height;
if (newHeight != _cachedMenuHeight && newHeight != null) {
setState(() {
_cachedMenuHeight = newHeight;
});
}
});
}
} }