Files
plezy/lib/widgets/video_controls/sheets/video_settings_sheet.dart
T

433 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/platform_detector.dart';
import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'video_sheet_header.dart';
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
/// Reusable menu item widget for settings sheet
class _SettingsMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String valueText;
final VoidCallback onTap;
final bool isHighlighted;
final bool allowValueOverflow;
const _SettingsMenuItem({
required this.icon,
required this.title,
required this.valueText,
required this.onTap,
this.isHighlighted = false,
this.allowValueOverflow = false,
});
@override
Widget build(BuildContext context) {
final valueWidget = Text(
valueText,
style: TextStyle(
color: isHighlighted ? Colors.amber : Colors.white70,
fontSize: 14,
),
overflow: allowValueOverflow ? TextOverflow.ellipsis : null,
);
return ListTile(
leading: Icon(icon, color: isHighlighted ? Colors.amber : Colors.white70),
title: Text(title, style: const TextStyle(color: Colors.white)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget,
const SizedBox(width: 8),
const Icon(Icons.chevron_right, color: Colors.white70),
],
),
onTap: onTap,
);
}
}
/// 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 Future<void> show(
BuildContext context,
Player player,
int audioSyncOffset,
int subtitleSyncOffset,
) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: BaseVideoControlSheet.getBottomSheetConstraints(context),
builder: (context) => VideoSettingsSheet(
player: player,
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
),
);
}
@override
State<VideoSettingsSheet> createState() => _VideoSettingsSheetState();
}
class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsView _currentView = _SettingsView.menu;
late int _audioSyncOffset;
late int _subtitleSyncOffset;
@override
void initState() {
super.initState();
_audioSyncOffset = widget.audioSyncOffset;
_subtitleSyncOffset = widget.subtitleSyncOffset;
}
void _navigateTo(_SettingsView view) {
setState(() {
_currentView = view;
});
}
void _navigateBack() {
setState(() {
_currentView = _SettingsView.menu;
});
}
String _getTitle() {
switch (_currentView) {
case _SettingsView.menu:
return 'Playback Settings';
case _SettingsView.speed:
return 'Playback Speed';
case _SettingsView.sleep:
return 'Sleep Timer';
case _SettingsView.audioSync:
return 'Audio Sync';
case _SettingsView.subtitleSync:
return 'Subtitle Sync';
case _SettingsView.audioDevice:
return 'Audio Output';
}
}
IconData _getIcon() {
switch (_currentView) {
case _SettingsView.menu:
return Icons.tune;
case _SettingsView.speed:
return Icons.speed;
case _SettingsView.sleep:
return Icons.bedtime;
case _SettingsView.audioSync:
return Icons.sync;
case _SettingsView.subtitleSync:
return Icons.subtitles;
case _SettingsView.audioDevice:
return Icons.speaker;
}
}
String _formatSpeed(double speed) {
if (speed == 1.0) return 'Normal';
return '${speed.toStringAsFixed(2)}x';
}
String _formatAudioSync(int offsetMs) {
if (offsetMs == 0) return '0ms';
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs}ms';
}
String _formatSleepTimer(SleepTimerService sleepTimer) {
if (!sleepTimer.isActive) return 'Off';
final remaining = sleepTimer.remainingTime;
if (remaining == null) return 'Off';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds.remainder(60);
if (minutes > 0) {
return 'Active (${minutes}m ${seconds}s)';
} else {
return 'Active (${seconds}s)';
}
}
Widget _buildHeader() {
final sleepTimer = SleepTimerService();
final isIconActive =
_currentView == _SettingsView.menu &&
(sleepTimer.isActive ||
_audioSyncOffset != 0 ||
_subtitleSyncOffset != 0);
return VideoSheetHeader(
title: _getTitle(),
icon: _getIcon(),
iconColor: isIconActive ? Colors.amber : Colors.white,
onBack: _currentView != _SettingsView.menu ? _navigateBack : null,
);
}
Widget _buildMenuView() {
final sleepTimer = SleepTimerService();
final isDesktop = PlatformDetector.isDesktop(context);
return ListView(
children: [
// Playback Speed
StreamBuilder<double>(
stream: widget.player.stream.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
return _SettingsMenuItem(
icon: Icons.speed,
title: 'Playback Speed',
valueText: _formatSpeed(currentRate),
onTap: () => _navigateTo(_SettingsView.speed),
);
},
),
// Sleep Timer
ListenableBuilder(
listenable: sleepTimer,
builder: (context, _) {
final isActive = sleepTimer.isActive;
return _SettingsMenuItem(
icon: isActive ? Icons.bedtime : Icons.bedtime_outlined,
title: 'Sleep Timer',
valueText: _formatSleepTimer(sleepTimer),
isHighlighted: isActive,
onTap: () => _navigateTo(_SettingsView.sleep),
);
},
),
// Audio Sync
_SettingsMenuItem(
icon: Icons.sync,
title: 'Audio Sync',
valueText: _formatAudioSync(_audioSyncOffset),
isHighlighted: _audioSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.audioSync),
),
// Subtitle Sync
_SettingsMenuItem(
icon: Icons.subtitles,
title: 'Subtitle Sync',
valueText: _formatAudioSync(_subtitleSyncOffset),
isHighlighted: _subtitleSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.subtitleSync),
),
// Audio Output Device (Desktop only)
if (isDesktop)
StreamBuilder<AudioDevice>(
stream: widget.player.stream.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, snapshot) {
final currentDevice =
snapshot.data ?? widget.player.state.audioDevice;
final deviceLabel = currentDevice.description.isEmpty
? currentDevice.name
: '${currentDevice.name} · ${currentDevice.description}';
return _SettingsMenuItem(
icon: Icons.speaker,
title: 'Audio Output',
valueText: deviceLabel,
allowValueOverflow: true,
onTap: () => _navigateTo(_SettingsView.audioDevice),
);
},
),
],
);
}
Widget _buildSpeedView() {
return StreamBuilder<double>(
stream: widget.player.stream.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
return ListView.builder(
itemCount: speeds.length,
itemBuilder: (context, index) {
final speed = speeds[index];
final isSelected = (currentRate - speed).abs() < 0.01;
final label = speed == 1.0
? 'Normal'
: '${speed.toStringAsFixed(2)}x';
return ListTile(
title: Text(
label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
widget.player.setRate(speed);
Navigator.pop(context); // Close sheet after selection
},
);
},
);
},
);
}
Widget _buildSleepView() {
final sleepTimer = SleepTimerService();
return SleepTimerContent(
player: widget.player,
sleepTimer: sleepTimer,
onCancel: () => Navigator.pop(context),
);
}
Widget _buildAudioSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'audio-delay',
initialOffset: _audioSyncOffset,
labelText: t.videoControls.audioLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offset);
setState(() {
_audioSyncOffset = offset;
});
},
);
}
Widget _buildSubtitleSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'sub-delay',
initialOffset: _subtitleSyncOffset,
labelText: t.videoControls.subtitlesLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setSubtitleSyncOffset(offset);
setState(() {
_subtitleSyncOffset = offset;
});
},
);
}
Widget _buildAudioDeviceView() {
return StreamBuilder<List<AudioDevice>>(
stream: widget.player.stream.audioDevices,
initialData: widget.player.state.audioDevices,
builder: (context, snapshot) {
final devices = snapshot.data ?? [];
return StreamBuilder<AudioDevice>(
stream: widget.player.stream.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, selectedSnapshot) {
final currentDevice =
selectedSnapshot.data ?? widget.player.state.audioDevice;
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final device = devices[index];
final isSelected = device.name == currentDevice.name;
final label = device.description.isEmpty
? device.name
: device.description;
return ListTile(
title: Text(
label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
widget.player.setAudioDevice(device);
Navigator.pop(context); // Close sheet after selection
},
);
},
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
_buildHeader(),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: () {
switch (_currentView) {
case _SettingsView.menu:
return _buildMenuView();
case _SettingsView.speed:
return _buildSpeedView();
case _SettingsView.sleep:
return _buildSleepView();
case _SettingsView.audioSync:
return _buildAudioSyncView();
case _SettingsView.subtitleSync:
return _buildSubtitleSyncView();
case _SettingsView.audioDevice:
return _buildAudioDeviceView();
}
}(),
),
],
),
),
);
}
}