387 lines
13 KiB
Dart
387 lines
13 KiB
Dart
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<MpvConfigScreen> createState() => _MpvConfigScreenState();
|
|
}
|
|
|
|
class _MpvConfigScreenState extends State<MpvConfigScreen> 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<bool>? _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<String>(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<bool> _flushPending() {
|
|
_saveDebouncer.cancel();
|
|
final existing = _drainFuture;
|
|
if (existing != null) return existing;
|
|
|
|
late final Future<bool> drain;
|
|
drain = _drainPending().whenComplete(() {
|
|
if (identical(_drainFuture, drain)) {
|
|
_drainFuture = null;
|
|
_notifySaveStateChanged();
|
|
}
|
|
});
|
|
_drainFuture = drain;
|
|
_notifySaveStateChanged();
|
|
return drain;
|
|
}
|
|
|
|
Future<bool> _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<void> _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<void> _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<void> _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<void> _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<List<MpvPreset>>(
|
|
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<String>(
|
|
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;
|
|
}
|