import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../../../models/shader_preset.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../providers/shader_provider.dart'; import '../../../services/file_picker_service.dart'; import '../../../services/settings_service.dart'; import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../services/video_filter_manager.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../utils/dialogs.dart'; import '../../../utils/formatters.dart'; import '../../../utils/platform_detector.dart'; import '../../../utils/quality_preset_labels.dart'; import '../../../utils/snackbar_helper.dart'; import '../../../theme/mono_tokens.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.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 'version_quality_sheet.dart'; enum _SettingsView { menu, speed, zoom, versionQuality, sleep, audioSync, subtitleSync, audioDevice, shader, dvConversion, } 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 t = tokens(context); final valueWidget = Text( valueText, style: TextStyle(color: isHighlighted ? Colors.amber : t.textMuted, fontSize: 14), overflow: allowValueOverflow ? TextOverflow.ellipsis : null, ); return FocusableListTile( leading: AppIcon(icon, fill: 1, color: isHighlighted ? Colors.amber : t.textMuted), title: Text(title), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget, const SizedBox(width: 8), AppIcon(Symbols.chevron_right_rounded, fill: 1, color: t.textMuted), ], ), onTap: onTap, ); } } class _SettingsToggleItem extends StatelessWidget { final Pref pref; final IconData icon; final String title; final FutureOr Function(bool value)? onAfterWrite; const _SettingsToggleItem({required this.pref, required this.icon, required this.title, this.onAfterWrite}); @override Widget build(BuildContext context) { final settings = SettingsService.instanceOrNull!; return ValueListenableBuilder( valueListenable: settings.listenable(pref), builder: (context, value, _) { Future write(bool next) async { await settings.write(pref, next); final callback = onAfterWrite; if (callback != null) await callback(next); } return FocusableListTile( leading: AppIcon(icon, fill: 1, color: value ? Colors.amber : tokens(context).textMuted), title: Text(title), trailing: Switch(value: value, onChanged: write, activeThumbColor: Colors.amber), onTap: () => write(!value), ); }, ); } } /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; final int audioSyncOffset; final int subtitleSyncOffset; final double videoZoomScale; final ValueChanged? onVideoZoomChanged; final VoidCallback? onResetVideoZoom; /// Whether the user can control playback (false hides speed option in host-only mode). final bool canControl; /// Whether this is a live TV stream (hides speed settings). final bool isLive; /// Available media versions and quality controls shown inside playback settings. final List availableVersions; final int selectedMediaIndex; final TranscodeQualityPreset selectedQualityPreset; final bool serverSupportsTranscoding; final int? sourceDurationMs; final ValueChanged? onVersionSelected; final ValueChanged? onQualitySelected; /// Optional shader service for MPV shader control final ShaderService? shaderService; /// Called when shader preset changes final VoidCallback? onShaderChanged; /// Whether ambient lighting is currently enabled final bool isAmbientLightingEnabled; /// Called to toggle ambient lighting on/off (null if unsupported) final VoidCallback? onToggleAmbientLighting; /// Called to cancel the video controls auto-hide timer. final VoidCallback? onCancelAutoHide; /// Called to restart the video controls auto-hide timer. final VoidCallback? onStartAutoHide; /// Called when a sync offset changes (so the parent can update its state). final void Function(String propertyName, int offset)? onSyncOffsetChanged; const VideoSettingsSheet({ super.key, required this.player, required this.audioSyncOffset, required this.subtitleSyncOffset, this.videoZoomScale = 1.0, this.onVideoZoomChanged, this.onResetVideoZoom, this.canControl = true, this.isLive = false, this.availableVersions = const [], this.selectedMediaIndex = 0, this.selectedQualityPreset = TranscodeQualityPreset.original, this.serverSupportsTranscoding = false, this.sourceDurationMs, this.onVersionSelected, this.onQualitySelected, this.shaderService, this.onShaderChanged, this.isAmbientLightingEnabled = false, this.onToggleAmbientLighting, this.onCancelAutoHide, this.onStartAutoHide, this.onSyncOffsetChanged, }); @override State createState() => _VideoSettingsSheetState(); } class _VideoSettingsSheetState extends State { _SettingsView _currentView = _SettingsView.menu; late int _audioSyncOffset; late int _subtitleSyncOffset; late double _zoomScale; String _dvConversionMode = 'auto'; bool get _showDebugDvConversionMode { if (!kDebugMode) return false; if (Platform.isAndroid) return widget.player.playerType == 'exoplayer'; return (Platform.isIOS || Platform.isMacOS) && widget.player.playerType == 'mpv'; } @override void initState() { super.initState(); _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; _zoomScale = widget.videoZoomScale; _loadDebugDvConversionMode(); } @override void didUpdateWidget(covariant VideoSettingsSheet oldWidget) { super.didUpdateWidget(oldWidget); if ((widget.videoZoomScale - oldWidget.videoZoomScale).abs() > 0.0001) { _zoomScale = widget.videoZoomScale; } } Future _loadDebugDvConversionMode() async { if (!_showDebugDvConversionMode) return; final dvConversionMode = await widget.player.getProperty('dv-conversion-mode'); if (!mounted) return; setState(() { _dvConversionMode = _normalizeDvConversionMode(dvConversionMode); }); } Future _setDebugDvConversionMode(String mode) async { await widget.player.setProperty('dv-conversion-mode', mode); if (!mounted) return; setState(() { _dvConversionMode = mode; }); OverlaySheetController.of(context).close(); } void _navigateTo(_SettingsView view) { // Sync views open as a compact top bar instead of a sub-view if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) { _openSyncBar(view); return; } setState(() { _currentView = view; }); OverlaySheetController.maybeOf(context)?.refocus(); } void _openSyncBar(_SettingsView view) { final controller = OverlaySheetController.maybeOf(context); if (controller == null) return; final isSubtitle = view == _SettingsView.subtitleSync; final title = isSubtitle ? t.videoSettings.subtitleSync : t.videoSettings.audioSync; final icon = isSubtitle ? Symbols.subtitles_rounded : Symbols.sync_rounded; final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay'; final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset; // Created here so we can pass it as initialFocusNode to the overlay sheet, // ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar. final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider'); // show() with new alignment replaces the current sheet (completing the // settings sheet future, which restarts the auto-hide timer via // whenComplete in track_chapter_controls). Cancel it again here. controller .show( alignment: Alignment.topCenter, constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900), initialFocusNode: sliderFocusNode, builder: (_) => _CompactSyncBar( title: title, icon: icon, player: widget.player, propertyName: propertyName, initialOffset: initialOffset, sliderFocusNode: sliderFocusNode, onOffsetChanged: (offset) async { final settings = SettingsService.instanceOrNull!; if (isSubtitle) { await settings.write(SettingsService.subtitleSyncOffset, offset); } else { await settings.write(SettingsService.audioSyncOffset, offset); } widget.onSyncOffsetChanged?.call(propertyName, offset); }, ), ) .whenComplete(() { widget.onStartAutoHide?.call(); }); // Cancel auto-hide after show() — the previous sheet's whenComplete // fires as a microtask and restarts the timer, so schedule our cancel // to run after that microtask. Future.microtask(() => widget.onCancelAutoHide?.call()); } void _navigateBack() { setState(() { _currentView = _SettingsView.menu; }); OverlaySheetController.maybeOf(context)?.refocus(); } String _getTitle() { switch (_currentView) { case _SettingsView.menu: return t.videoControls.settingsButton; case _SettingsView.speed: return t.videoSettings.playbackSpeed; case _SettingsView.zoom: return t.videoSettings.zoom; case _SettingsView.versionQuality: return _versionQualityTitle(); case _SettingsView.sleep: return t.videoSettings.sleepTimer; case _SettingsView.audioSync: return t.videoSettings.audioSync; case _SettingsView.subtitleSync: return t.videoSettings.subtitleSync; case _SettingsView.audioDevice: return t.videoSettings.audioOutput; case _SettingsView.shader: return t.shaders.title; case _SettingsView.dvConversion: return 'DV Conversion Mode'; } } IconData _getIcon() { switch (_currentView) { case _SettingsView.menu: return Symbols.tune_rounded; case _SettingsView.speed: return Symbols.speed_rounded; case _SettingsView.zoom: return Symbols.zoom_in_rounded; case _SettingsView.versionQuality: return Symbols.art_track; case _SettingsView.sleep: return Symbols.bedtime_rounded; case _SettingsView.audioSync: return Symbols.sync_rounded; case _SettingsView.subtitleSync: return Symbols.subtitles_rounded; case _SettingsView.audioDevice: return Symbols.speaker_rounded; case _SettingsView.shader: return Symbols.auto_fix_high_rounded; case _SettingsView.dvConversion: return Symbols.hdr_strong_rounded; } } String _normalizeDvConversionMode(String? mode) { return switch (mode?.trim().toLowerCase()) { 'disabled' || 'native' => 'disabled', 'dv81' || 'p8' || 'p7_to_p8' || 'p7-to-p8' => 'dv81', 'hevc' || 'hevc_strip' || 'p7_to_hevc' || 'p7-to-hevc' => 'hevc_strip', _ => 'auto', }; } String _formatDvConversionMode(String mode) { return switch (_normalizeDvConversionMode(mode)) { 'disabled' => 'Native / Disabled', 'dv81' => 'P7 → P8.1', 'hevc_strip' => 'P7 → HEVC', _ => 'Auto', }; } String _formatSleepTimer(SleepTimerService sleepTimer) { if (!sleepTimer.isActive) return 'Off'; final remaining = sleepTimer.remainingTime; if (remaining == null) return 'Off'; return 'Active (${formatDurationWithSeconds(remaining)})'; } String _formatZoomScale(double scale) => '${(scale * 100).round()}%'; void _setZoomScale(double scale) { final next = scale.clamp(VideoFilterManager.minZoomScale, VideoFilterManager.maxZoomScale).toDouble(); setState(() { _zoomScale = next; }); widget.onVideoZoomChanged?.call(next); } void _resetZoomScale() { setState(() { _zoomScale = 1.0; }); final reset = widget.onResetVideoZoom; if (reset != null) { reset(); } else { widget.onVideoZoomChanged?.call(1.0); } } bool get _hasVersionQuality { return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) && (widget.onVersionSelected != null || widget.onQualitySelected != null); } String _versionQualityTitle() { return versionQualityPickerTitle( showVersions: widget.availableVersions.length > 1, showQuality: widget.serverSupportsTranscoding, ); } String _versionQualityValueText() { final values = []; if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel()); if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset)); return values.join(' / '); } String _selectedVersionLabel() { final index = widget.selectedMediaIndex; if (index >= 0 && index < widget.availableVersions.length) { return widget.availableVersions[index].displayLabel; } return t.videoControls.versionColumnHeader; } Widget _buildMenuView() { final sleepTimer = SleepTimerService(); final isDesktop = PlatformDetector.isDesktop(context); return ListView( children: [ // Playback Speed - hidden for live TV and when user cannot control playback if (widget.canControl && !widget.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; return _SettingsMenuItem( icon: Symbols.speed_rounded, title: t.videoSettings.playbackSpeed, valueText: formatPlaybackRate(currentRate, normalAtOne: true), onTap: () => _navigateTo(_SettingsView.speed), ); }, ), if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null) _SettingsMenuItem( icon: Symbols.zoom_in_rounded, title: t.videoSettings.zoom, valueText: _formatZoomScale(_zoomScale), isHighlighted: (_zoomScale - 1.0).abs() > 0.0001, onTap: () => _navigateTo(_SettingsView.zoom), ), if (_hasVersionQuality) _SettingsMenuItem( icon: Symbols.art_track, title: _versionQualityTitle(), valueText: _versionQualityValueText(), allowValueOverflow: true, onTap: () => _navigateTo(_SettingsView.versionQuality), ), // Sleep Timer ListenableBuilder( listenable: sleepTimer, builder: (context, _) { final isActive = sleepTimer.isActive; return _SettingsMenuItem( icon: Symbols.bedtime_rounded, title: t.videoSettings.sleepTimer, valueText: _formatSleepTimer(sleepTimer), isHighlighted: isActive, onTap: () => _navigateTo(_SettingsView.sleep), ); }, ), // Audio Sync _SettingsMenuItem( icon: Symbols.sync_rounded, title: t.videoSettings.audioSync, valueText: formatSyncOffset(_audioSyncOffset.toDouble()), isHighlighted: _audioSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.audioSync), ), // Subtitle Sync _SettingsMenuItem( icon: Symbols.subtitles_rounded, title: t.videoSettings.subtitleSync, valueText: formatSyncOffset(_subtitleSyncOffset.toDouble()), isHighlighted: _subtitleSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.subtitleSync), ), // HDR Toggle (iOS, macOS, and Windows) if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) _SettingsToggleItem( pref: SettingsService.enableHDR, icon: Symbols.hdr_strong_rounded, title: t.videoSettings.hdr, onAfterWrite: (value) => widget.player.setProperty('hdr-enabled', value ? 'yes' : 'no'), ), // Auto-Play Next Episode Toggle _SettingsToggleItem( pref: SettingsService.autoPlayNextEpisode, icon: Symbols.skip_next_rounded, title: t.videoControls.autoPlayNext, ), // Audio Output Device (Desktop only) if (isDesktop) StreamBuilder( stream: widget.player.streams.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.description; return _SettingsMenuItem( icon: Symbols.speaker_rounded, title: t.videoSettings.audioOutput, valueText: deviceLabel, allowValueOverflow: true, onTap: () => _navigateTo(_SettingsView.audioDevice), ); }, ), // Audio Passthrough (Desktop only) if (isDesktop) _SettingsToggleItem( pref: SettingsService.audioPassthrough, icon: Symbols.surround_sound_rounded, title: t.videoSettings.audioPassthrough, onAfterWrite: widget.player.setAudioPassthrough, ), // Audio Normalization (MPV only) if (widget.player.playerType == 'mpv') _SettingsToggleItem( pref: SettingsService.audioNormalization, icon: Symbols.graphic_eq_rounded, title: t.videoSettings.audioNormalization, onAfterWrite: (value) => widget.player.setProperty('af', value ? 'loudnorm=I=-14:TP=-3:LRA=4' : ''), ), // Shader Preset (MPV only) if (widget.shaderService != null && widget.shaderService!.isSupported) _SettingsMenuItem( icon: Symbols.auto_fix_high_rounded, title: t.shaders.title, valueText: widget.shaderService!.currentPreset.name, isHighlighted: widget.shaderService!.currentPreset.isEnabled, onTap: () => _navigateTo(_SettingsView.shader), ), // Ambient Lighting (MPV only) if (widget.onToggleAmbientLighting != null) FocusableListTile( leading: AppIcon( Symbols.blur_on, fill: 1, color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, ), title: Text(t.videoControls.ambientLighting), trailing: Switch( value: widget.isAmbientLightingEnabled, onChanged: (_) { widget.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, activeThumbColor: Colors.amber, ), onTap: () { widget.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, ), // Performance Overlay Toggle _SettingsToggleItem( pref: SettingsService.showPerformanceOverlay, icon: Symbols.analytics_rounded, title: t.videoSettings.performanceOverlay, ), if (_showDebugDvConversionMode) _SettingsMenuItem( icon: Symbols.hdr_strong_rounded, title: 'DV Conversion Mode', valueText: _formatDvConversionMode(_dvConversionMode), isHighlighted: _dvConversionMode != 'auto', onTap: () => _navigateTo(_SettingsView.dvConversion), ), // Debug: Trigger MPV Fallback (Android ExoPlayer only) if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer') FocusableListTile( leading: AppIcon(Symbols.swap_horiz_rounded, fill: 1, color: tokens(context).textMuted), title: const Text('Trigger MPV Fallback'), onTap: () { const MethodChannel('com.plezy/exo_player').invokeMethod('triggerFallback'); OverlaySheetController.of(context).close(); }, ), if (kDebugMode) FocusableListTile( leading: AppIcon(Symbols.bug_report_rounded, fill: 1, color: tokens(context).textMuted), title: const Text('Simulate HTTP 500 from server'), onTap: () { final player = widget.player; OverlaySheetController.of(context).close(); if (player is PlayerBase) { player.debugSimulateServer500(); } }, ), ], ); } Widget _buildDvConversionView() { const modes = [ (value: 'auto', title: 'Auto', subtitle: 'Use device capability detection and normal fallback behavior'), (value: 'disabled', title: 'Native / Disabled', subtitle: 'Force native DV7 and suppress DV conversion retry'), (value: 'dv81', title: 'P7 → P8.1', subtitle: 'Force inline RPU conversion to Dolby Vision profile 8.1'), (value: 'hevc_strip', title: 'P7 → HEVC', subtitle: 'Strip Dolby Vision RPU/EL layers and present plain HEVC'), ]; final primary = Theme.of(context).colorScheme.primary; return ListView( children: [ for (final mode in modes) FocusableListTile( title: Text(mode.title, style: TextStyle(color: _dvConversionMode == mode.value ? primary : null)), subtitle: Text(mode.subtitle, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)), trailing: _dvConversionMode == mode.value ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () => _setDebugDvConversionMode(mode.value), ), ], ); } Widget _buildSpeedView() { return StreamBuilder( stream: widget.player.streams.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, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0]; return ListView.builder( itemCount: speeds.length, itemBuilder: (context, index) { final speed = speeds[index]; final isSelected = (currentRate - speed).abs() < 0.01; final label = formatPlaybackRate(speed, normalAtOne: true); final primary = Theme.of(context).colorScheme.primary; return FocusableListTile( title: Text(label, style: TextStyle(color: isSelected ? primary : null)), trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () async { await widget.player.setRate(speed); // Save as default playback speed await SettingsService.instanceOrNull!.write(SettingsService.defaultPlaybackSpeed, speed); if (context.mounted) { OverlaySheetController.of(context).close(); // Close sheet after selection } }, ); }, ); }, ); } Widget _buildZoomView() { const zoomPresets = [0.5, 0.75, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.75, 2.0]; final primary = Theme.of(context).colorScheme.primary; return ListView( children: [ FocusableListTile( leading: AppIcon(Symbols.restart_alt_rounded, fill: 1, color: tokens(context).textMuted), title: Text(t.common.reset), onTap: _resetZoomScale, ), for (final scale in zoomPresets) FocusableListTile( title: Text( _formatZoomScale(scale), style: TextStyle(color: (_zoomScale - scale).abs() < 0.005 ? primary : null), ), trailing: (_zoomScale - scale).abs() < 0.005 ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () => _setZoomScale(scale), ), ], ); } Widget _buildSleepView() { final sleepTimer = SleepTimerService(); return SleepTimerContent( player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close(), ); } Widget _buildVersionQualityView() { return VersionQualityPicker( availableVersions: widget.availableVersions, selectedMediaIndex: widget.selectedMediaIndex, selectedQualityPreset: widget.selectedQualityPreset, serverSupportsTranscoding: widget.serverSupportsTranscoding, sourceDurationMs: widget.sourceDurationMs, onVersionSelected: (index) => widget.onVersionSelected?.call(index), onQualitySelected: (preset) => widget.onQualitySelected?.call(preset), ); } /// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn"). static String _audioBackend(String name) { final slash = name.indexOf('/'); return slash > 0 ? name.substring(0, slash) : name; } /// Pretty-print a backend identifier. static String _formatBackend(String backend) { const labels = { 'coreaudio': 'CoreAudio', 'avfoundation': 'AVFoundation', 'wasapi': 'WASAPI', 'pulse': 'PulseAudio', 'pipewire': 'PipeWire', 'alsa': 'ALSA', 'jack': 'JACK', 'oss': 'OSS', }; return labels[backend] ?? backend; } Widget _buildAudioDeviceView() { return StreamBuilder>( stream: widget.player.streams.audioDevices, initialData: widget.player.state.audioDevices, builder: (context, snapshot) { final devices = snapshot.data ?? []; return StreamBuilder( stream: widget.player.streams.audioDevice, initialData: widget.player.state.audioDevice, builder: (context, selectedSnapshot) { final currentDevice = selectedSnapshot.data ?? widget.player.state.audioDevice; // Check for duplicate descriptions (same physical device across multiple backends). final descCounts = {}; for (final d in devices) { final desc = d.description.isEmpty ? d.name : d.description; descCounts[desc] = (descCounts[desc] ?? 0) + 1; } final hasDuplicates = descCounts.values.any((c) => c > 1); if (!hasDuplicates) { return _buildFlatDeviceList(devices, currentDevice); } final ungrouped = []; final groups = >{}; for (final d in devices) { final backend = _audioBackend(d.name); if (!d.name.contains('/')) { ungrouped.add(d); } else { (groups[backend] ??= []).add(d); } } return ListView( children: [ for (final d in ungrouped) _buildDeviceTile(d, currentDevice), for (final entry in groups.entries) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( _formatBackend(entry.key), style: TextStyle(color: tokens(context).textMuted, fontSize: 12, fontWeight: FontWeight.w600), ), ), for (final d in entry.value) _buildDeviceTile(d, currentDevice), ], ], ); }, ); }, ); } Widget _buildFlatDeviceList(List devices, AudioDevice currentDevice) { return ListView.builder( itemCount: devices.length, itemBuilder: (context, index) => _buildDeviceTile(devices[index], currentDevice), ); } Widget _buildDeviceTile(AudioDevice device, AudioDevice currentDevice) { final isSelected = device.name == currentDevice.name; final label = device.description.isEmpty ? device.name : device.description; final primary = Theme.of(context).colorScheme.primary; return FocusableListTile( title: Text(label, style: TextStyle(color: isSelected ? primary : null)), trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () { widget.player.setAudioDevice(device); OverlaySheetController.of(context).close(); }, ); } Widget _buildShaderView() { if (widget.shaderService == null) return const SizedBox.shrink(); return Consumer( builder: (context, shaderProvider, _) { final currentPreset = widget.shaderService!.currentPreset; final presets = shaderProvider.allPresets; // +1 for the import button at the end return ListView.builder( itemCount: presets.length + 1, itemBuilder: (context, index) { if (index == presets.length) { return FocusableListTile( leading: AppIcon(Symbols.add_rounded, fill: 1, color: tokens(context).textMuted), title: Text(t.shaders.importShader), onTap: () => _importCustomShader(shaderProvider), ); } final preset = presets[index]; final isSelected = preset.id == currentPreset.id; final isCustom = preset.type == ShaderPresetType.custom; return FocusableListTile( title: Text(preset.name, style: TextStyle(color: isSelected ? Colors.amber : null)), subtitle: _getShaderSubtitle(preset) != null ? Text(_getShaderSubtitle(preset)!, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)) : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (isSelected) const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.amber), if (isCustom) ...[ if (isSelected) const SizedBox(width: 8), GestureDetector( onTap: () => _deleteCustomShader(shaderProvider, preset), child: AppIcon(Symbols.delete_rounded, fill: 1, color: tokens(context).textMuted, size: 20), ), ], ], ), onTap: () async { // Disable ambient lighting when selecting a shader if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { widget.onToggleAmbientLighting?.call(); } await widget.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!context.mounted) return; widget.onShaderChanged?.call(); OverlaySheetController.of(context).close(); }, ); }, ); }, ); } Future _importCustomShader(ShaderProvider shaderProvider) async { final result = await FilePickerService.instance.pickFiles(type: FileType.custom, allowedExtensions: ['glsl']); if (result == null || result.files.isEmpty || !mounted) return; final filePath = result.files.first.path; if (filePath == null) return; try { final displayName = path.basenameWithoutExtension(filePath); final preset = await shaderProvider.importCustomShader(filePath, displayName); if (widget.shaderService != null && mounted) { if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { widget.onToggleAmbientLighting?.call(); } await widget.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!mounted) return; widget.onShaderChanged?.call(); } if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported); } catch (_) { if (mounted) showErrorSnackBar(context, t.shaders.shaderImportFailed); } } Future _deleteCustomShader(ShaderProvider shaderProvider, ShaderPreset preset) async { final confirmed = await showDeleteConfirmation( context, title: t.shaders.deleteShader, message: t.shaders.deleteShaderConfirm(name: preset.name), ); if (!confirmed || !mounted) return; // If the deleted shader is active, clear it from the player first if (widget.shaderService!.currentPreset.id == preset.id) { await widget.shaderService!.applyPreset(ShaderPreset.none); if (mounted) widget.onShaderChanged?.call(); } await shaderProvider.deleteCustomShader(preset); } String? _getShaderSubtitle(ShaderPreset preset) { switch (preset.type) { case ShaderPresetType.none: return t.shaders.noShaderDescription; case ShaderPresetType.nvscaler: return t.shaders.nvscalerDescription; case ShaderPresetType.artcnn: if (preset.artcnnConfig != null) { final variant = switch (preset.artcnnConfig!.variant) { ArtCNNVariant.neutral => t.shaders.artcnnVariantNeutral, ArtCNNVariant.denoise => t.shaders.artcnnVariantDenoise, ArtCNNVariant.denoiseSharpen => t.shaders.artcnnVariantDenoiseSharpen, }; return '${preset.artcnnModelDisplayName} - $variant'; } return null; case ShaderPresetType.anime4k: if (preset.anime4kConfig != null) { final quality = preset.anime4kConfig!.quality == Anime4KQuality.fast ? t.shaders.qualityFast : t.shaders.qualityHQ; final mode = preset.modeDisplayName; return '$quality - ${t.shaders.mode} $mode'; } return null; case ShaderPresetType.custom: return t.shaders.customShaderDescription; } } @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled; final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001; final isIconActive = _currentView == _SettingsView.menu && (sleepTimer.isActive || _audioSyncOffset != 0 || _subtitleSyncOffset != 0 || isShaderActive || isZoomActive); return BaseVideoControlSheet( title: _getTitle(), icon: _getIcon(), iconColor: () { if (isIconActive) return Colors.amber; if (_currentView == _SettingsView.shader && isShaderActive) return Colors.amber; return null; }(), onBack: _currentView != _SettingsView.menu ? _navigateBack : null, child: () { switch (_currentView) { case _SettingsView.menu: return _buildMenuView(); case _SettingsView.speed: return _buildSpeedView(); case _SettingsView.zoom: return _buildZoomView(); case _SettingsView.versionQuality: return _buildVersionQualityView(); case _SettingsView.sleep: return _buildSleepView(); case _SettingsView.audioSync: case _SettingsView.subtitleSync: return _buildMenuView(); // Sync views open as top bars, fallback to menu case _SettingsView.audioDevice: return _buildAudioDeviceView(); case _SettingsView.shader: return _buildShaderView(); case _SettingsView.dvConversion: return _buildDvConversionView(); } }(), ); } } /// Compact sync bar shown at the top of the screen so subtitles remain visible. class _CompactSyncBar extends StatefulWidget { final String title; final IconData icon; final Player player; final String propertyName; final int initialOffset; final Future Function(int offset) onOffsetChanged; final FocusNode sliderFocusNode; const _CompactSyncBar({ required this.title, required this.icon, required this.player, required this.propertyName, required this.initialOffset, required this.onOffsetChanged, required this.sliderFocusNode, }); @override State<_CompactSyncBar> createState() => _CompactSyncBarState(); } class _CompactSyncBarState extends State<_CompactSyncBar> { final _resetFocusNode = FocusNode(debugLabel: 'SyncResetButton'); final _closeFocusNode = FocusNode(debugLabel: 'SyncCloseButton'); @override void dispose() { widget.sliderFocusNode.dispose(); _resetFocusNode.dispose(); _closeFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( children: [ const SizedBox(width: 16), AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20), const SizedBox(width: 8), Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), Expanded( child: SyncOffsetControl( player: widget.player, propertyName: widget.propertyName, initialOffset: widget.initialOffset, labelText: widget.title, onOffsetChanged: widget.onOffsetChanged, compact: true, sliderFocusNode: widget.sliderFocusNode, resetFocusNode: _resetFocusNode, closeFocusNode: _closeFocusNode, ), ), const SizedBox(width: 8), FocusableWrapper( focusNode: _closeFocusNode, onSelect: () => OverlaySheetController.of(context).close(), onNavigateLeft: () => _resetFocusNode.requestFocus(), borderRadius: 18, autoScroll: false, useBackgroundFocus: true, child: GestureDetector( onTap: () => OverlaySheetController.of(context).close(), child: Container( width: 36, height: 36, alignment: Alignment.center, child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22), ), ), ), const SizedBox(width: 12), ], ); } }