feat: configurable audio delay
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user