feat: configurable audio delay

This commit is contained in:
edde746
2025-11-05 14:15:14 +01:00
parent 6e58f26ee4
commit 1c8d36f0e9
4 changed files with 335 additions and 98 deletions
+113 -98
View File
@@ -130,6 +130,16 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
),
);
// Apply audio sync offset
final audioSyncOffset = settingsService.getAudioSyncOffset();
if (audioSyncOffset != 0) {
final offsetSeconds = audioSyncOffset / 1000.0;
await (player!.platform as dynamic).setProperty(
'audio-delay',
offsetSeconds.toString(),
);
}
// Notify that player is ready
if (mounted) {
setState(() {
@@ -296,7 +306,11 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
setState(() {
_boxFitMode = (_boxFitMode + 1) % 3;
});
final modes = ['contain (letterbox)', 'cover (fill screen)', 'fill (stretch)'];
final modes = [
'contain (letterbox)',
'cover (fill screen)',
'fill (stretch)',
];
appLogger.d('BoxFit mode: ${modes[_boxFitMode]}');
}
@@ -1059,7 +1073,8 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
child: Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls
behavior: HitTestBehavior
.translucent, // Allow taps to pass through to controls
onScaleStart: (details) {
// Initialize pinch gesture tracking (mobile only)
if (!PlatformDetector.isMobile(context)) return;
@@ -1099,117 +1114,117 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
),
),
),
// Play Next Dialog
if (_showPlayNextDialog && _nextEpisode != null)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.8),
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_circle_outline,
size: 64,
color: Colors.white,
),
const SizedBox(height: 24),
Consumer<PlaybackStateProvider>(
builder: (context, playbackState, child) {
final isShuffleActive =
playbackState.isShuffleActive;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Up Next',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (isShuffleActive) ...[
const SizedBox(width: 8),
const Icon(
Icons.shuffle,
size: 20,
color: Colors.white70,
),
],
],
);
},
),
const SizedBox(height: 16),
Text(
_nextEpisode!.grandparentTitle ??
_nextEpisode!.title,
style: const TextStyle(
// Play Next Dialog
if (_showPlayNextDialog && _nextEpisode != null)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.8),
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_circle_outline,
size: 64,
color: Colors.white,
fontSize: 18,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (_nextEpisode!.parentIndex != null &&
_nextEpisode!.index != null)
const SizedBox(height: 24),
Consumer<PlaybackStateProvider>(
builder: (context, playbackState, child) {
final isShuffleActive =
playbackState.isShuffleActive;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Up Next',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (isShuffleActive) ...[
const SizedBox(width: 8),
const Icon(
Icons.shuffle,
size: 20,
color: Colors.white70,
),
],
],
);
},
),
const SizedBox(height: 16),
Text(
'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}',
_nextEpisode!.grandparentTitle ??
_nextEpisode!.title,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
color: Colors.white,
fontSize: 18,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () {
setState(() {
_showPlayNextDialog = false;
});
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
const SizedBox(height: 8),
if (_nextEpisode!.parentIndex != null &&
_nextEpisode!.index != null)
Text(
'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}',
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
),
child: const Text('Cancel'),
textAlign: TextAlign.center,
),
const SizedBox(width: 16),
FilledButton(
onPressed: _playNext,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () {
setState(() {
_showPlayNextDialog = false;
});
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
child: const Text('Cancel'),
),
child: const Text('Play Now'),
),
],
),
],
const SizedBox(width: 16),
FilledButton(
onPressed: _playNext,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
child: const Text('Play Now'),
),
],
),
],
),
),
),
),
),
),
],
),
),
+11
View File
@@ -27,6 +27,7 @@ class SettingsService {
static const String _keyMediaVersionPreferences = 'media_version_preferences';
static const String _keyShowHeroSection = 'show_hero_section';
static const String _keySleepTimerDuration = 'sleep_timer_duration';
static const String _keyAudioSyncOffset = 'audio_sync_offset';
static SettingsService? _instance;
late SharedPreferences _prefs;
@@ -179,6 +180,15 @@ class SettingsService {
return _prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes
}
// Audio Sync Offset (in milliseconds)
Future<void> setAudioSyncOffset(int milliseconds) async {
await _prefs.setInt(_keyAudioSyncOffset, milliseconds);
}
int getAudioSyncOffset() {
return _prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset)
}
// Keyboard Shortcuts (Legacy String-based)
Map<String, String> getDefaultKeyboardShortcuts() {
return {
@@ -693,6 +703,7 @@ class SettingsService {
_prefs.remove(_keySeekTimeLarge),
_prefs.remove(_keyMediaVersionPreferences),
_prefs.remove(_keySleepTimerDuration),
_prefs.remove(_keyAudioSyncOffset),
]);
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:plezy/services/settings_service.dart';
/// Bottom sheet for adjusting audio sync offset
class AudioSyncSheet extends StatefulWidget {
final Player player;
final int initialOffset;
const AudioSyncSheet({
super.key,
required this.player,
required this.initialOffset,
});
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 600;
return BoxConstraints(
maxWidth: isDesktop ? 700 : double.infinity,
maxHeight: isDesktop ? 400 : size.height * 0.75,
minHeight: isDesktop ? 300 : size.height * 0.5,
);
}
static void show(BuildContext context, Player player, int initialOffset) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: getBottomSheetConstraints(context),
builder: (context) =>
AudioSyncSheet(player: player, initialOffset: initialOffset),
);
}
@override
State<AudioSyncSheet> createState() => _AudioSyncSheetState();
}
class _AudioSyncSheetState extends State<AudioSyncSheet> {
late double _currentOffset;
@override
void initState() {
super.initState();
_currentOffset = widget.initialOffset.toDouble();
}
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(
'audio-delay',
offsetSeconds.toString(),
);
// Save to settings
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offsetMs.round());
}
void _resetOffset() {
setState(() {
_currentOffset = 0;
});
_applyOffset(0);
}
String _formatOffset(double offsetMs) {
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs.round()}ms';
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.sync, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Audio Sync',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: 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(
_currentOffset > 0
? 'Audio plays later'
: _currentOffset < 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: _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,
),
),
),
],
),
),
),
],
),
),
);
}
}
@@ -17,6 +17,7 @@ import '../../utils/provider_extensions.dart';
import '../../screens/video_player_screen.dart';
import '../app_bar_back_button.dart';
import 'painters/chapter_marker_painter.dart';
import 'sheets/audio_sync_sheet.dart';
import 'sheets/audio_track_sheet.dart';
import 'sheets/chapter_sheet.dart';
import 'sheets/playback_speed_sheet.dart';
@@ -83,6 +84,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
late final FocusNode _focusNode;
KeyboardShortcutsService? _keyboardService;
int _seekTimeSmall = 10; // Default, loaded from settings
int _audioSyncOffset = 0; // Default, loaded from settings
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;
@@ -114,6 +116,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (mounted) {
setState(() {
_seekTimeSmall = settingsService.getSeekTimeSmall();
_audioSyncOffset = settingsService.getAudioSyncOffset();
});
}
}
@@ -343,6 +346,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
icon: const Icon(Icons.audiotrack, color: Colors.white),
onPressed: () => AudioTrackSheet.show(context, widget.player),
),
// Audio sync offset button
IconButton(
icon: Icon(
Icons.sync,
color: _audioSyncOffset != 0 ? Colors.amber : Colors.white,
),
onPressed: () async {
AudioSyncSheet.show(context, widget.player, _audioSyncOffset);
// Reload offset after sheet closes (in case it changed)
await Future.delayed(const Duration(milliseconds: 300));
if (mounted) {
_loadSeekTimes();
}
},
),
if (_hasSubtitles(tracks))
IconButton(
icon: const Icon(Icons.subtitles, color: Colors.white),