From 317aa653e58f95c4124bb80dc8a2d804e6c65fba Mon Sep 17 00:00:00 2001 From: Alex Rans Date: Fri, 24 Jul 2026 15:17:16 +0200 Subject: [PATCH 1/2] hotkey for subtitle offset --- .../settings/video_player_settings.dart | 8 ++ .../settings/video_player_settings.g.dart | 2 + .../video_player_settings_provider.dart | 2 + ...ideo_player_subtitle_offset_indicator.dart | 79 +++++++++++++++++++ .../video_player/video_player_controls.dart | 14 ++++ lib/wrappers/media_control_wrapper.dart | 2 + lib/wrappers/players/base_player.dart | 1 + lib/wrappers/players/lib_mpv.dart | 10 +++ 8 files changed, 118 insertions(+) create mode 100644 lib/screens/video_player/components/video_player_subtitle_offset_indicator.dart diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 466ab647c..68f6759e8 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -36,6 +36,8 @@ enum VideoHotKeys { takeScreenshot, takeScreenshotClean, toggleSubtitles, + subtitleOffsetBackward, + subtitleOffsetForward, exit; const VideoHotKeys(); @@ -63,6 +65,8 @@ enum VideoHotKeys { VideoHotKeys.takeScreenshot => context.localized.takeScreenshot, VideoHotKeys.takeScreenshotClean => context.localized.takeScreenshotClean, VideoHotKeys.toggleSubtitles => context.localized.toggleSubtitles, + VideoHotKeys.subtitleOffsetBackward => 'Subtitle backward', + VideoHotKeys.subtitleOffsetForward => 'Subtitle forward', VideoHotKeys.exit => context.localized.exit, }; } @@ -312,6 +316,10 @@ Map get _defaultVideoHotKeys => { VideoHotKeys.takeScreenshotClean => KeyCombination(key: LogicalKeyboardKey.keyG, modifier: LogicalKeyboardKey.controlLeft), VideoHotKeys.toggleSubtitles => KeyCombination(key: LogicalKeyboardKey.keyT), + VideoHotKeys.subtitleOffsetBackward => + KeyCombination(key: LogicalKeyboardKey.arrowLeft, modifier: LogicalKeyboardKey.controlLeft), + VideoHotKeys.subtitleOffsetForward => + KeyCombination(key: LogicalKeyboardKey.arrowRight, modifier: LogicalKeyboardKey.controlLeft), VideoHotKeys.exit => KeyCombination(key: LogicalKeyboardKey.escape), }, }; diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index ea3c322c1..56e20e528 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -169,6 +169,8 @@ const _$VideoHotKeysEnumMap = { VideoHotKeys.takeScreenshot: 'takeScreenshot', VideoHotKeys.takeScreenshotClean: 'takeScreenshotClean', VideoHotKeys.toggleSubtitles: 'toggleSubtitles', + VideoHotKeys.subtitleOffsetBackward: 'subtitleOffsetBackward', + VideoHotKeys.subtitleOffsetForward: 'subtitleOffsetForward', VideoHotKeys.exit: 'exit', }; diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index 529f5c5ba..e8585e44e 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -19,6 +19,8 @@ final videoPlayerSettingsProvider = return VideoPlayerSettingsProviderNotifier(ref); }); +final subtitleTimingOffsetProvider = StateProvider((ref) => Duration.zero); + final playbackRateProvider = StateProvider((ref) => 1.0); class VideoPlayerSettingsProviderNotifier extends StateNotifier { diff --git a/lib/screens/video_player/components/video_player_subtitle_offset_indicator.dart b/lib/screens/video_player/components/video_player_subtitle_offset_indicator.dart new file mode 100644 index 000000000..72c39e22a --- /dev/null +++ b/lib/screens/video_player/components/video_player_subtitle_offset_indicator.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; + +class VideoPlayerSubtitleOffsetIndicator extends ConsumerStatefulWidget { + const VideoPlayerSubtitleOffsetIndicator({super.key}); + + @override + ConsumerState createState() => _VideoPlayerSubtitleOffsetIndicatorState(); +} + +class _VideoPlayerSubtitleOffsetIndicatorState extends ConsumerState { + late Duration currentOffset = ref.read(subtitleTimingOffsetProvider); + + bool showIndicator = false; + late final timer = RestartableTimer(const Duration(seconds: 1), () { + setState(() { + showIndicator = false; + }); + }); + + @override + void dispose() { + showIndicator = false; + timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + subtitleTimingOffsetProvider, + (previous, next) { + if (previous == next) return; + setState(() { + showIndicator = true; + currentOffset = next; + }); + timer.reset(); + }, + ); + + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: showIndicator ? 1 : 0, + child: Center( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + const Icon(IconsaxPlusLinear.textalign_left), + Text(_subtitleDelayLabel(currentOffset)), + ], + ), + ), + ), + ), + ), + ); + } +} + +String _subtitleDelayLabel(Duration offset) { + final absMilliseconds = offset.inMilliseconds.abs(); + final sign = offset.inMilliseconds >= 0 ? '+' : '-'; + return 'Subtitle $sign${absMilliseconds}ms'; +} diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index a119adb7a..c144b5f5b 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -31,6 +31,7 @@ import 'package:fladder/screens/video_player/components/video_player_quality_con import 'package:fladder/screens/video_player/components/video_player_screenshot_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_seek_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_speed_indicator.dart'; +import 'package:fladder/screens/video_player/components/video_player_subtitle_offset_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_volume_indicator.dart'; import 'package:fladder/screens/video_player/components/video_progress_bar.dart'; import 'package:fladder/screens/video_player/components/video_volume_slider.dart'; @@ -182,6 +183,7 @@ class _DesktopControlsState extends ConsumerState { ), VideoPlayerSeekIndicator(controller: _seekController), const VideoPlayerVolumeIndicator(), + const VideoPlayerSubtitleOffsetIndicator(), const VideoPlayerBrightnessIndicator(), const VideoPlayerSpeedIndicator(), const VideoPlayerScreenshotIndicator(), @@ -1000,6 +1002,8 @@ class _DesktopControlsState extends ConsumerState { final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments)); final position = ref.read(mediaPlaybackProvider).position; final playing = ref.read(mediaPlaybackProvider.select((value) => value.playing)); + final subtitlesEnabled = ref.read(playBackModel)?.mediaStreams?.defaultSubStreamIndex != null && + ref.read(playBackModel)!.mediaStreams!.defaultSubStreamIndex != -1; MediaSegment? segment = mediaSegments?.atPosition(position); @@ -1064,6 +1068,16 @@ class _DesktopControlsState extends ConsumerState { case VideoHotKeys.toggleSubtitles: _toggleSubtitles(); return true; + case VideoHotKeys.subtitleOffsetBackward: + if (!subtitlesEnabled) return false; + ref.read(subtitleTimingOffsetProvider.notifier).update((state) => state + const Duration(milliseconds: -250)); + ref.read(videoPlayerProvider).shiftSubtitleOffset(const Duration(milliseconds: -250)); + return true; + case VideoHotKeys.subtitleOffsetForward: + if (!subtitlesEnabled) return false; + ref.read(subtitleTimingOffsetProvider.notifier).update((state) => state + const Duration(milliseconds: 250)); + ref.read(videoPlayerProvider).shiftSubtitleOffset(const Duration(milliseconds: 250)); + return true; case VideoHotKeys.seekForwardInstant: final seekForwardSeconds = ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30)); diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 2300e3afb..fb3ce1c3f 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -553,6 +553,8 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async => await _player?.setSubtitleTrack(model, playbackModel) ?? -1; + Future shiftSubtitleOffset(Duration offset) async => _player?.shiftSubtitleOffset(offset); + Future setVolume(double volume) async => _player?.setVolume(volume); @override diff --git a/lib/wrappers/players/base_player.dart b/lib/wrappers/players/base_player.dart index 4adf59a10..836bc9e7c 100644 --- a/lib/wrappers/players/base_player.dart +++ b/lib/wrappers/players/base_player.dart @@ -39,6 +39,7 @@ abstract class BasePlayer { Future takeScreenshot(); Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel); Future setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel); + Future shiftSubtitleOffset(Duration offset) async {} void applySubtitleSettings(SubtitleSettingsModel settings) {} Uri? isValidUrl(String input) { diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index 2399c06d6..89136a6f1 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -27,6 +27,7 @@ class LibMPV extends BasePlayer { mpv.Player? _player; VideoController? _controller; String _currentSubtitleCodec = ''; + Duration _subtitleOffset = Duration.zero; final StreamController _stateController = StreamController.broadcast(); @override @@ -526,6 +527,15 @@ class LibMPV extends BasePlayer { ) : null; + @override + Future shiftSubtitleOffset(Duration offset) async { + if (_player?.platform is! mpv.NativePlayer) return; + + _subtitleOffset += offset; + final nativePlayer = _player!.platform as dynamic; + await nativePlayer.setProperty('sub-delay', (_subtitleOffset.inMilliseconds / 1000).toStringAsFixed(3)); + } + @override Future setVolume(double volume) async { _fadeTimer?.cancel(); From b42dcb47ef45dfef1f0edd1bf2c02d5bb686e02e Mon Sep 17 00:00:00 2001 From: Alex Rans Date: Fri, 24 Jul 2026 15:36:47 +0200 Subject: [PATCH 2/2] Formatting --- lib/screens/video_player/video_player_controls.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index c144b5f5b..4fff69947 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -1003,7 +1003,7 @@ class _DesktopControlsState extends ConsumerState { final position = ref.read(mediaPlaybackProvider).position; final playing = ref.read(mediaPlaybackProvider.select((value) => value.playing)); final subtitlesEnabled = ref.read(playBackModel)?.mediaStreams?.defaultSubStreamIndex != null && - ref.read(playBackModel)!.mediaStreams!.defaultSubStreamIndex != -1; + ref.read(playBackModel)!.mediaStreams!.defaultSubStreamIndex != -1; MediaSegment? segment = mediaSegments?.atPosition(position);