feat: subtitle delay config
This commit is contained in:
@@ -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(() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}(),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user