feat: subtitle delay config

This commit is contained in:
edde746
2025-11-05 14:43:05 +01:00
parent 4888d125e4
commit c15bc6d4c2
5 changed files with 261 additions and 105 deletions
+10
View File
@@ -140,6 +140,16 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
);
}
// Apply subtitle sync offset
final subtitleSyncOffset = settingsService.getSubtitleSyncOffset();
if (subtitleSyncOffset != 0) {
final offsetSeconds = subtitleSyncOffset / 1000.0;
await (player!.platform as dynamic).setProperty(
'sub-delay',
offsetSeconds.toString(),
);
}
// Notify that player is ready
if (mounted) {
setState(() {
+12
View File
@@ -28,6 +28,7 @@ class SettingsService {
static const String _keyShowHeroSection = 'show_hero_section';
static const String _keySleepTimerDuration = 'sleep_timer_duration';
static const String _keyAudioSyncOffset = 'audio_sync_offset';
static const String _keySubtitleSyncOffset = 'subtitle_sync_offset';
static SettingsService? _instance;
late SharedPreferences _prefs;
@@ -189,6 +190,16 @@ class SettingsService {
return _prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset)
}
// Subtitle Sync Offset (in milliseconds)
Future<void> setSubtitleSyncOffset(int milliseconds) async {
await _prefs.setInt(_keySubtitleSyncOffset, milliseconds);
}
int getSubtitleSyncOffset() {
return _prefs.getInt(_keySubtitleSyncOffset) ??
0; // Default: 0ms (no offset)
}
// Keyboard Shortcuts (Legacy String-based)
Map<String, String> getDefaultKeyboardShortcuts() {
return {
@@ -704,6 +715,7 @@ class SettingsService {
_prefs.remove(_keyMediaVersionPreferences),
_prefs.remove(_keySleepTimerDuration),
_prefs.remove(_keyAudioSyncOffset),
_prefs.remove(_keySubtitleSyncOffset),
]);
}
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../widgets/sync_offset_control.dart';
enum _SettingsView { menu, speed, sleep, audioSync }
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync }
/// Unified settings sheet for playback adjustments with in-sheet navigation
class VideoSettingsSheet extends StatefulWidget {
final Player player;
final int audioSyncOffset;
final int subtitleSyncOffset;
const VideoSettingsSheet({
super.key,
required this.player,
required this.audioSyncOffset,
required this.subtitleSyncOffset,
});
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
@@ -27,14 +30,22 @@ class VideoSettingsSheet extends StatefulWidget {
);
}
static void show(BuildContext context, Player player, int audioSyncOffset) {
showModalBottomSheet(
static Future<void> show(
BuildContext context,
Player player,
int audioSyncOffset,
int subtitleSyncOffset,
) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: getBottomSheetConstraints(context),
builder: (context) =>
VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset),
builder: (context) => VideoSettingsSheet(
player: player,
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
),
);
}
@@ -45,13 +56,13 @@ class VideoSettingsSheet extends StatefulWidget {
class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsView _currentView = _SettingsView.menu;
late int _audioSyncOffset;
late double _currentAudioOffset;
late int _subtitleSyncOffset;
@override
void initState() {
super.initState();
_audioSyncOffset = widget.audioSyncOffset;
_currentAudioOffset = widget.audioSyncOffset.toDouble();
_subtitleSyncOffset = widget.subtitleSyncOffset;
}
void _navigateTo(_SettingsView view) {
@@ -76,6 +87,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return 'Sleep Timer';
case _SettingsView.audioSync:
return 'Audio Sync';
case _SettingsView.subtitleSync:
return 'Subtitle Sync';
}
}
@@ -89,6 +102,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return Icons.bedtime;
case _SettingsView.audioSync:
return Icons.sync;
case _SettingsView.subtitleSync:
return Icons.subtitles;
}
}
@@ -136,7 +151,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
final sleepTimer = SleepTimerService();
final isIconActive =
_currentView == _SettingsView.menu &&
(sleepTimer.isActive || _audioSyncOffset != 0);
(sleepTimer.isActive ||
_audioSyncOffset != 0 ||
_subtitleSyncOffset != 0);
return Padding(
padding: const EdgeInsets.all(16),
@@ -261,6 +278,35 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
),
onTap: () => _navigateTo(_SettingsView.audioSync),
),
// Subtitle Sync
ListTile(
leading: Icon(
Icons.subtitles,
color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70,
),
title: const Text(
'Subtitle Sync',
style: TextStyle(color: Colors.white),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatAudioSync(_subtitleSyncOffset),
style: TextStyle(
color: _subtitleSyncOffset != 0
? Colors.amber
: Colors.white70,
fontSize: 14,
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right, color: Colors.white70),
],
),
onTap: () => _navigateTo(_SettingsView.subtitleSync),
),
],
);
}
@@ -421,98 +467,34 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
}
Widget _buildAudioSyncView() {
void applyOffset(double offsetMs) async {
final offsetSeconds = offsetMs / 1000.0;
await (widget.player.platform as dynamic).setProperty(
'audio-delay',
offsetSeconds.toString(),
);
return SyncOffsetControl(
player: widget.player,
propertyName: 'audio-delay',
initialOffset: _audioSyncOffset,
labelText: 'Audio',
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offset);
setState(() {
_audioSyncOffset = offset;
});
},
);
}
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offsetMs.round());
setState(() {
_audioSyncOffset = offsetMs.round();
});
}
void resetOffset() {
setState(() {
_currentAudioOffset = 0;
});
applyOffset(0);
}
String formatOffset(double offsetMs) {
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs.round()}ms';
}
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Current offset display
Text(
formatOffset(_currentAudioOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_currentAudioOffset > 0
? 'Audio plays later'
: _currentAudioOffset < 0
? 'Audio plays earlier'
: 'No offset',
style: const TextStyle(color: Colors.white70, fontSize: 16),
),
const SizedBox(height: 48),
// Slider
Row(
children: [
const Text('-2s', style: TextStyle(color: Colors.white70)),
Expanded(
child: Slider(
value: _currentAudioOffset,
min: -2000,
max: 2000,
divisions: 80,
activeColor: Colors.blue,
inactiveColor: Colors.white24,
onChanged: (value) {
setState(() {
_currentAudioOffset = value;
});
},
onChangeEnd: (value) {
applyOffset(value);
},
),
),
const Text('+2s', style: TextStyle(color: Colors.white70)),
],
),
const SizedBox(height: 24),
// Reset button
ElevatedButton.icon(
onPressed: _currentAudioOffset != 0 ? resetOffset : null,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset to 0ms'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[850],
disabledForegroundColor: Colors.white38,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
Widget _buildSubtitleSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'sub-delay',
initialOffset: _subtitleSyncOffset,
labelText: 'Subtitles',
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setSubtitleSyncOffset(offset);
setState(() {
_subtitleSyncOffset = offset;
});
},
);
}
@@ -536,6 +518,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildSleepView();
case _SettingsView.audioSync:
return _buildAudioSyncView();
case _SettingsView.subtitleSync:
return _buildSubtitleSyncView();
}
}(),
),
+11 -6
View File
@@ -83,6 +83,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
KeyboardShortcutsService? _keyboardService;
int _seekTimeSmall = 10; // Default, loaded from settings
int _audioSyncOffset = 0; // Default, loaded from settings
int _subtitleSyncOffset = 0; // Default, loaded from settings
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;
@@ -115,6 +116,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
setState(() {
_seekTimeSmall = settingsService.getSeekTimeSmall();
_audioSyncOffset = settingsService.getAudioSyncOffset();
_subtitleSyncOffset = settingsService.getSubtitleSyncOffset();
});
}
}
@@ -319,27 +321,30 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Unified settings button (speed, sleep timer, audio sync)
// Unified settings button (speed, sleep timer, audio sync, subtitle sync)
ListenableBuilder(
listenable: SleepTimerService(),
builder: (context, _) {
final sleepTimer = SleepTimerService();
final isActive = sleepTimer.isActive || _audioSyncOffset != 0;
final isActive =
sleepTimer.isActive ||
_audioSyncOffset != 0 ||
_subtitleSyncOffset != 0;
return IconButton(
icon: Icon(
Icons.tune,
color: isActive ? Colors.amber : Colors.white,
),
onPressed: () async {
VideoSettingsSheet.show(
await VideoSettingsSheet.show(
context,
widget.player,
_audioSyncOffset,
_subtitleSyncOffset,
);
// Reload offset after sheet closes (in case it changed)
await Future.delayed(const Duration(milliseconds: 300));
// Sheet is now closed, reload immediately
if (mounted) {
_loadSeekTimes();
await _loadSeekTimes();
}
},
);
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
/// Reusable widget for adjusting sync offsets (audio or subtitle)
class SyncOffsetControl extends StatefulWidget {
final Player player;
final String propertyName; // 'audio-delay' or 'sub-delay'
final int initialOffset;
final String labelText; // 'Audio' or 'Subtitles'
final Future<void> Function(int offset) onOffsetChanged;
const SyncOffsetControl({
super.key,
required this.player,
required this.propertyName,
required this.initialOffset,
required this.labelText,
required this.onOffsetChanged,
});
@override
State<SyncOffsetControl> createState() => _SyncOffsetControlState();
}
class _SyncOffsetControlState extends State<SyncOffsetControl> {
late double _currentOffset;
@override
void initState() {
super.initState();
_currentOffset = widget.initialOffset.toDouble();
}
@override
void didUpdateWidget(SyncOffsetControl oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialOffset != oldWidget.initialOffset) {
setState(() {
_currentOffset = widget.initialOffset.toDouble();
});
}
}
Future<void> _applyOffset(double offsetMs) async {
// Convert milliseconds to seconds for media_kit
final offsetSeconds = offsetMs / 1000.0;
// Apply to player using setProperty
await (widget.player.platform as dynamic).setProperty(
widget.propertyName,
offsetSeconds.toString(),
);
// Notify parent and save to settings
await widget.onOffsetChanged(offsetMs.round());
}
void _resetOffset() {
setState(() {
_currentOffset = 0;
});
_applyOffset(0);
}
String _formatOffset(double offsetMs) {
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs.round()}ms';
}
String _getDescriptionText() {
if (_currentOffset > 0) {
return '${widget.labelText} plays later';
} else if (_currentOffset < 0) {
return '${widget.labelText} plays earlier';
} else {
return 'No offset';
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Current offset display
Text(
_formatOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_getDescriptionText(),
style: const TextStyle(color: Colors.white70, fontSize: 16),
),
const SizedBox(height: 48),
// Slider
Row(
children: [
const Text('-2s', style: TextStyle(color: Colors.white70)),
Expanded(
child: Slider(
value: _currentOffset,
min: -2000,
max: 2000,
divisions: 80, // 50ms steps
activeColor: Colors.blue,
inactiveColor: Colors.white24,
onChanged: (value) {
setState(() {
_currentOffset = value;
});
},
onChangeEnd: (value) {
_applyOffset(value);
},
),
),
const Text('+2s', style: TextStyle(color: Colors.white70)),
],
),
const SizedBox(height: 24),
// Reset button
ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset to 0ms'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[850],
disabledForegroundColor: Colors.white38,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
}