import 'dart:async'; 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 '../../focus/dpad_navigator.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/mpv_config_models.dart'; import '../../utils/dialogs.dart'; import '../../utils/app_logger.dart'; import '../../utils/debouncer.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../mixins/settings_effect_mixin.dart'; import '../../services/settings_service.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; class MpvConfigScreen extends StatefulWidget { const MpvConfigScreen({super.key}); @override State createState() => _MpvConfigScreenState(); } class _MpvConfigScreenState extends State with SettingsEffectMixin, ControllerDisposerMixin { SettingsService get _settingsService => SettingsService.instance; late final TextEditingController _textController = createTextEditingController( text: _settingsService.read(SettingsService.mpvConfigText), ); final _savePresetFocusNode = FocusNode(); final _textFieldFocusNode = FocusNode(); final _saveDebouncer = Debouncer(const Duration(milliseconds: 400)); String _persistedText = ''; int _revision = 0; int _persistedRevision = 0; _QueuedMpvConfig? _pendingSave; _QueuedMpvConfig? _activeSave; Future? _drainFuture; bool _isLeaving = false; bool _allowPop = false; bool _disposing = false; @override void initState() { super.initState(); _persistedText = _textController.text; _textFieldFocusNode.addListener(_handleTextFieldFocusChanged); // Keep a clean editor synchronized with imports, reset, and other // settings producers without allowing a completed local write to replace // a newer queued edit. bindEffect(SettingsService.mpvConfigText, _handlePersistedText, fireImmediately: false); } @override void dispose() { _disposing = true; _saveDebouncer.dispose(); _textFieldFocusNode.removeListener(_handleTextFieldFocusChanged); if (_pendingSave != null || _drainFuture != null) { unawaited(_flushPending()); } _savePresetFocusNode.dispose(); _textFieldFocusNode.dispose(); super.dispose(); } bool get _hasUnsavedWork => _pendingSave != null || _activeSave != null || _drainFuture != null; void _handleTextFieldFocusChanged() { if (!_textFieldFocusNode.hasFocus) { unawaited(_flushPending()); } } void _handlePersistedText(String value) { final active = _activeSave; if (active != null && active.text == value) return; if (active == null && _pendingSave == null && _textController.text == value) { _persistedText = value; return; } // An import/reset/other producer wins when observed. An in-flight local // write cannot be cancelled, so queue the external value behind it to // ensure that obsolete write cannot become the final persisted value. _saveDebouncer.cancel(); final revision = ++_revision; _persistedText = value; _persistedRevision = revision; _pendingSave = active == null ? null : _QueuedMpvConfig(text: value, revision: revision); _textController.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); _notifySaveStateChanged(); } void _queueTextSave(String text) { if (_disposing) return; if (_activeSave == null && _pendingSave == null && text == _persistedText) { _notifySaveStateChanged(); return; } _pendingSave = _QueuedMpvConfig(text: text, revision: ++_revision); _saveDebouncer.run(() => unawaited(_flushPending())); _notifySaveStateChanged(); } Future _flushPending() { _saveDebouncer.cancel(); final existing = _drainFuture; if (existing != null) return existing; late final Future drain; drain = _drainPending().whenComplete(() { if (identical(_drainFuture, drain)) { _drainFuture = null; _notifySaveStateChanged(); } }); _drainFuture = drain; _notifySaveStateChanged(); return drain; } Future _drainPending() async { while (true) { final next = _pendingSave; if (next == null) return true; _pendingSave = null; _activeSave = next; try { await _settingsService.write(SettingsService.mpvConfigText, next.text); } catch (error, stackTrace) { _pendingSave ??= next; _activeSave = null; appLogger.e('MPV configuration save failed', error: error, stackTrace: stackTrace); if (mounted && !_disposing) showErrorSnackBar(context, t.settings.saveFailed); return false; } _activeSave = null; if (next.revision >= _persistedRevision) { _persistedRevision = next.revision; _persistedText = next.text; } } } void _notifySaveStateChanged() { if (mounted && !_disposing) setState(() {}); } Future _flushAndPop() async { if (_isLeaving) return; _isLeaving = true; _notifySaveStateChanged(); final saved = await _flushPending(); if (!mounted || _disposing) return; if (!saved || _hasUnsavedWork) { _isLeaving = false; _notifySaveStateChanged(); return; } setState(() => _allowPop = true); Navigator.pop(context); } Future _showSavePresetDialog() async { if (_textController.text.trim().isEmpty) return; final name = await showTextInputDialog( context, title: t.mpvConfig.saveAsPreset, labelText: t.mpvConfig.presetName, hintText: t.mpvConfig.presetNameHint, ); if (name == null || name.trim().isEmpty) return; if (!await _flushPending() || !mounted) return; await _settingsService.saveMpvPreset(name.trim(), _textController.text); if (mounted) showSuccessSnackBar(context, t.mpvConfig.presetSaved); } Future _loadPreset(MpvPreset preset) async { _textController.value = TextEditingValue( text: preset.text, selection: TextSelection.collapsed(offset: preset.text.length), ); _queueTextSave(preset.text); final saved = await _flushPending(); if (mounted && saved) showAppSnackBar(context, t.mpvConfig.presetLoaded); } Future _deletePreset(MpvPreset preset) async { final confirmed = await showDeleteConfirmation( context, title: t.mpvConfig.deletePreset, message: t.mpvConfig.confirmDeletePreset, ); if (!confirmed) return; await _settingsService.deleteMpvPreset(preset.name); if (mounted) showSuccessSnackBar(context, t.mpvConfig.presetDeleted); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _textFieldFocusNode, builder: (context, _) { return PopScope( canPop: _allowPop || (PlatformDetector.isHandheldIOS(context) && !_textFieldFocusNode.hasFocus && !_hasUnsavedWork && !_isLeaving), onPopInvokedWithResult: (didPop, _) { if (didPop || _isLeaving) return; if (BackKeyCoordinator.consumeIfHandled()) return; BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (_textFieldFocusNode.hasFocus && _savePresetFocusNode.canRequestFocus) { _savePresetFocusNode.requestFocus(); } else { unawaited(_flushAndPop()); } }, child: FocusedScrollScaffold( title: Text(t.screens.mpvConfig), slivers: [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ _buildConfigEditor(), const SizedBox(height: 16), _buildPresetsCard(), const SizedBox(height: 24), ]), ), ), ], ), ); }, ); } Widget _buildConfigEditor() { return Focus( canRequestFocus: false, onKeyEvent: (_, event) { // Back/Escape: move focus to the save preset button instead of exiting. // Suppress the KeyUp so it doesn't reach handleBackKeyNavigation // on the new focus chain after focus moves away from the text field. if (event.logicalKey.isBackKey) { if (!_savePresetFocusNode.canRequestFocus) { return KeyEventResult.ignored; } if (event is KeyDownEvent) { BackKeyUpSuppressor.suppressBackUntilKeyUp(); _savePresetFocusNode.requestFocus(); } return KeyEventResult.handled; } // We must consume Enter to prevent parent handlers from unfocusing, // but that also blocks Flutter's text editing shortcuts (which are // higher in the focus tree). So we manually insert newlines here. if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { if (event is KeyDownEvent || event is KeyRepeatEvent) { final sel = _textController.selection; if (sel.isValid) { final text = _textController.text; final value = TextEditingValue( text: text.replaceRange(sel.start, sel.end, '\n'), selection: TextSelection.collapsed(offset: sel.start + 1), ); _textController.value = value; _queueTextSave(value.text); } } return KeyEventResult.handled; } if (event.logicalKey.isSelectKey) { return KeyEventResult.handled; } if (event.logicalKey.isDownKey && event.isActionable) { final sel = _textController.selection; if (sel.isValid && _textController.text.indexOf('\n', sel.extentOffset) == -1) { _savePresetFocusNode.requestFocus(); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: FocusableTextField( controller: _textController, focusNode: _textFieldFocusNode, keyboardType: TextInputType.multiline, maxLines: null, minLines: 12, decoration: InputDecoration( hintText: t.mpvConfig.configPlaceholder, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.all(12), ), style: const TextStyle(fontFamily: 'monospace', fontSize: 13), onChanged: _queueTextSave, ), ); } Widget _buildPresetsCard() { return SettingValueBuilder>( pref: SettingsService.mpvPresets, builder: (context, presets, _) => SettingsGroup( title: t.mpvConfig.presets, // The page already pads its slivers by 16. margin: EdgeInsets.zero, children: [ FocusableListTile( focusNode: _savePresetFocusNode, leading: const AppIcon(Symbols.save_rounded, fill: 1), title: Text(t.mpvConfig.saveAsPreset), enabled: _textController.text.trim().isNotEmpty, onTap: _textController.text.trim().isNotEmpty ? _showSavePresetDialog : null, ), if (presets.isNotEmpty) ...presets.map( (preset) => FocusableListTile( leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(preset.name), trailing: FocusablePopupMenuButton( icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), onSelected: (value) { if (value == 'load') { _loadPreset(preset); } else if (value == 'delete') { _deletePreset(preset); } }, itemBuilder: (context) => [ AppMenuItem(value: 'load', label: t.mpvConfig.loadPreset), AppMenuItem(value: 'delete', label: t.mpvConfig.deletePreset), ], ), onTap: () => _loadPreset(preset), ), ) else Padding( padding: const EdgeInsets.all(16), child: Text( t.mpvConfig.noPresets, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ], ), ); } } class _QueuedMpvConfig { const _QueuedMpvConfig({required this.text, required this.revision}); final String text; final int revision; }