refactor: share controller disposal and Plex DTO parsing
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Owns [TextEditingController] instances created by a [State] and disposes
|
||||
/// them automatically from the state's `dispose` chain.
|
||||
mixin ControllerDisposerMixin<T extends StatefulWidget> on State<T> {
|
||||
final List<TextEditingController> _textEditingControllers = [];
|
||||
|
||||
TextEditingController createTextEditingController({String? text, TextEditingValue? value}) {
|
||||
assert(text == null || value == null, 'Provide either text or value, not both.');
|
||||
final controller = value == null ? TextEditingController(text: text) : TextEditingController.fromValue(value);
|
||||
_textEditingControllers.add(controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
TextEditingController registerTextEditingController(TextEditingController controller) {
|
||||
_textEditingControllers.add(controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _textEditingControllers.reversed) {
|
||||
controller.dispose();
|
||||
}
|
||||
_textEditingControllers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../connection/connection.dart';
|
||||
import '../connection/connection_registry.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile.dart';
|
||||
@@ -409,17 +410,11 @@ class _DebugTokenDialog extends StatefulWidget {
|
||||
State<_DebugTokenDialog> createState() => _DebugTokenDialogState();
|
||||
}
|
||||
|
||||
class _DebugTokenDialogState extends State<_DebugTokenDialog> {
|
||||
final TextEditingController _tokenController = TextEditingController();
|
||||
class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDisposerMixin {
|
||||
late final TextEditingController _tokenController = createTextEditingController();
|
||||
String? _errorMessage;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final token = _tokenController.text.trim();
|
||||
if (token.isEmpty) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../theme/mono_tokens.dart';
|
||||
@@ -659,14 +660,8 @@ class _SearchBottomSheet extends StatefulWidget {
|
||||
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
|
||||
}
|
||||
|
||||
class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerDisposerMixin {
|
||||
late final _controller = createTextEditingController();
|
||||
|
||||
void _submit(String text) {
|
||||
final trimmed = text.trim();
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../focus/focusable_text_field.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../models/plex/plex_match_result.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -33,10 +34,12 @@ class PlexMatchScreen extends StatefulWidget {
|
||||
State<PlexMatchScreen> createState() => _PlexMatchScreenState();
|
||||
}
|
||||
|
||||
class _PlexMatchScreenState extends State<PlexMatchScreen> {
|
||||
class _PlexMatchScreenState extends State<PlexMatchScreen> with ControllerDisposerMixin {
|
||||
late final PlexClient _client;
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _yearController;
|
||||
late final TextEditingController _nameController = createTextEditingController(text: widget.metadata.title);
|
||||
late final TextEditingController _yearController = createTextEditingController(
|
||||
text: widget.metadata.year?.toString() ?? '',
|
||||
);
|
||||
final _nameFocus = FocusNode();
|
||||
final _yearFocus = FocusNode();
|
||||
final _searchFocus = FocusNode();
|
||||
@@ -58,8 +61,6 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_client = context.getPlexClientWithFallback(widget.metadata.serverId);
|
||||
_nameController = TextEditingController(text: widget.metadata.title);
|
||||
_yearController = TextEditingController(text: widget.metadata.year?.toString() ?? '');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
@@ -71,8 +72,6 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_yearController.dispose();
|
||||
_nameFocus.dispose();
|
||||
_yearFocus.dispose();
|
||||
_searchFocus.dispose();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../profiles/profile_registry.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
@@ -27,23 +28,11 @@ class AddLocalProfileScreen extends StatefulWidget {
|
||||
State<AddLocalProfileScreen> createState() => _AddLocalProfileScreenState();
|
||||
}
|
||||
|
||||
class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> {
|
||||
late final TextEditingController _nameController;
|
||||
class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with ControllerDisposerMixin {
|
||||
late final TextEditingController _nameController = createTextEditingController();
|
||||
String? _pinHash;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _setPin() async {
|
||||
final pin = await captureAndConfirmPin(
|
||||
context,
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../focus/key_event_utils.dart';
|
||||
import '../../focus/key_repeat_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
|
||||
@@ -138,14 +139,14 @@ class _TvPinInput extends StatefulWidget {
|
||||
State<_TvPinInput> createState() => _TvPinInputState();
|
||||
}
|
||||
|
||||
class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput> {
|
||||
class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput>, ControllerDisposerMixin {
|
||||
final List<int?> _digits = [null, null, null, null];
|
||||
int _activeIndex = 0;
|
||||
bool _isFocused = false;
|
||||
|
||||
// Hidden text fields for mobile keyboard input
|
||||
final List<FocusNode> _mobileFocusNodes = List.generate(4, (_) => FocusNode());
|
||||
final List<TextEditingController> _mobileControllers = List.generate(4, (_) => TextEditingController());
|
||||
late final List<TextEditingController> _mobileControllers = List.generate(4, (_) => createTextEditingController());
|
||||
|
||||
// Main focus node for TV/desktop keyboard handling
|
||||
late final FocusNode _focusNode;
|
||||
@@ -172,9 +173,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
for (final node in _mobileFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
for (final controller in _mobileControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../connection/connection.dart';
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../models/plex/plex_home_user.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
import '../../profiles/plex_home_service.dart';
|
||||
@@ -43,21 +44,14 @@ class ProfileDetailScreen extends StatefulWidget {
|
||||
State<ProfileDetailScreen> createState() => _ProfileDetailScreenState();
|
||||
}
|
||||
|
||||
class _ProfileDetailScreenState extends State<ProfileDetailScreen> {
|
||||
late final TextEditingController _nameController;
|
||||
class _ProfileDetailScreenState extends State<ProfileDetailScreen> with ControllerDisposerMixin {
|
||||
late final TextEditingController _nameController = createTextEditingController(text: widget.profile.displayName);
|
||||
late Profile _profile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_profile = widget.profile;
|
||||
_nameController = TextEditingController(text: widget.profile.displayName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveName() async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:rate_limiter/rate_limiter.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -26,8 +27,8 @@ class SearchScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen>
|
||||
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab {
|
||||
final _searchController = TextEditingController();
|
||||
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, ControllerDisposerMixin {
|
||||
late final _searchController = createTextEditingController();
|
||||
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
|
||||
final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult');
|
||||
List<MediaItem> _searchResults = [];
|
||||
@@ -49,7 +50,6 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
void dispose() {
|
||||
_searchDebounce.cancel();
|
||||
_searchController.removeListener(_onSearchChanged);
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_firstResultFocusNode.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../../connection/connection.dart';
|
||||
import '../../exceptions/media_server_exceptions.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
@@ -64,10 +65,10 @@ class AddJellyfinScreen extends StatefulWidget {
|
||||
State<AddJellyfinScreen> createState() => _AddJellyfinScreenState();
|
||||
}
|
||||
|
||||
class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormStateMixin, ControllerDisposerMixin {
|
||||
late final _urlController = createTextEditingController();
|
||||
late final _usernameController = createTextEditingController();
|
||||
late final _passwordController = createTextEditingController();
|
||||
// Owned so the username field can advance focus on Enter; mobile keyboards
|
||||
// act on `textInputAction: next` automatically but TV remotes / hardware
|
||||
// keyboards need the explicit `onFieldSubmitted` handler below.
|
||||
@@ -86,9 +87,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
// setState after the widget is gone.
|
||||
_qcCancelled = true;
|
||||
_qcAttemptId++;
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -125,106 +125,109 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
||||
final valueFocusNode = FocusNode();
|
||||
final saveFocusNode = FocusNode();
|
||||
var selectedType = CustomPlayerType.command;
|
||||
String? playerName;
|
||||
String? playerValue;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final isUrlScheme = selectedType == CustomPlayerType.urlScheme;
|
||||
final String fieldLabel;
|
||||
final String fieldHint;
|
||||
if (isUrlScheme) {
|
||||
fieldLabel = t.externalPlayer.playerUrlScheme;
|
||||
fieldHint = 'myplayer://play?url=';
|
||||
} else if (Platform.isAndroid) {
|
||||
fieldLabel = t.externalPlayer.playerPackage;
|
||||
fieldHint = 'com.example.player';
|
||||
} else {
|
||||
fieldLabel = t.externalPlayer.playerCommand;
|
||||
fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player';
|
||||
}
|
||||
try {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final isUrlScheme = selectedType == CustomPlayerType.urlScheme;
|
||||
final String fieldLabel;
|
||||
final String fieldHint;
|
||||
if (isUrlScheme) {
|
||||
fieldLabel = t.externalPlayer.playerUrlScheme;
|
||||
fieldHint = 'myplayer://play?url=';
|
||||
} else if (Platform.isAndroid) {
|
||||
fieldLabel = t.externalPlayer.playerPackage;
|
||||
fieldHint = 'com.example.player';
|
||||
} else {
|
||||
fieldLabel = t.externalPlayer.playerCommand;
|
||||
fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player';
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(t.externalPlayer.addCustomPlayer),
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => primaryFocus?.nextFocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<CustomPlayerType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: CustomPlayerType.command,
|
||||
label: Text(
|
||||
Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand,
|
||||
),
|
||||
),
|
||||
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
||||
],
|
||||
selected: {selectedType},
|
||||
onSelectionChanged: (value) => setDialogState(() => selectedType = value.first),
|
||||
return AlertDialog(
|
||||
title: Text(t.externalPlayer.addCustomPlayer),
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => primaryFocus?.nextFocus(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: valueController,
|
||||
focusNode: valueFocusNode,
|
||||
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => saveFocusNode.requestFocus(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<CustomPlayerType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: CustomPlayerType.command,
|
||||
label: Text(
|
||||
Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand,
|
||||
),
|
||||
),
|
||||
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
||||
],
|
||||
selected: {selectedType},
|
||||
onSelectionChanged: (value) => setDialogState(() => selectedType = value.first),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: valueController,
|
||||
focusNode: valueFocusNode,
|
||||
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => saveFocusNode.requestFocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
FocusableButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () {
|
||||
if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
},
|
||||
child: FilledButton(
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
FocusableButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () {
|
||||
if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
},
|
||||
child: Text(t.common.save),
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
},
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
valueFocusNode.dispose();
|
||||
saveFocusNode.dispose();
|
||||
|
||||
if (result != true) return;
|
||||
if (result != true) return;
|
||||
playerName = nameController.text;
|
||||
playerValue = valueController.text;
|
||||
} finally {
|
||||
nameController.dispose();
|
||||
valueController.dispose();
|
||||
valueFocusNode.dispose();
|
||||
saveFocusNode.dispose();
|
||||
}
|
||||
|
||||
final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final newPlayer = ExternalPlayer.custom(
|
||||
id: id,
|
||||
name: nameController.text,
|
||||
value: valueController.text,
|
||||
type: selectedType,
|
||||
);
|
||||
final newPlayer = ExternalPlayer.custom(id: id, name: playerName, value: playerValue, type: selectedType);
|
||||
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
await svc.write(SettingsService.customExternalPlayers, [
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/dpad_navigator.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/snackbar_helper.dart';
|
||||
@@ -20,17 +21,18 @@ class MpvConfigScreen extends StatefulWidget {
|
||||
State<MpvConfigScreen> createState() => _MpvConfigScreenState();
|
||||
}
|
||||
|
||||
class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMixin {
|
||||
class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMixin, ControllerDisposerMixin {
|
||||
SettingsService get _settingsService => SettingsService.instanceOrNull!;
|
||||
|
||||
late final TextEditingController _textController;
|
||||
late final TextEditingController _textController = createTextEditingController(
|
||||
text: _settingsService.read(SettingsService.mpvConfigText),
|
||||
);
|
||||
final _savePresetFocusNode = FocusNode();
|
||||
final _textFieldFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = TextEditingController(text: _settingsService.read(SettingsService.mpvConfigText));
|
||||
// Sync the editor when the pref is mutated externally (e.g. loadMpvPreset).
|
||||
// Skip when the listener fires for the same value the controller already
|
||||
// holds — avoids fighting user-typed text mid-edit.
|
||||
@@ -41,7 +43,6 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_savePresetFocusNode.dispose();
|
||||
_textFieldFocusNode.dispose();
|
||||
super.dispose();
|
||||
|
||||
+129
-172
@@ -44,6 +44,69 @@ Map<String, dynamic> _obfuscatePlaylistJson(Map<String, dynamic> json) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0;
|
||||
|
||||
Map? _firstPartMap(Object? raw) {
|
||||
final parts = flexibleList(raw);
|
||||
if (parts == null || parts.isEmpty) return null;
|
||||
final part = parts.first;
|
||||
return part is Map ? part : null;
|
||||
}
|
||||
|
||||
String _partKeyFromJson(Object? raw) => _firstPartMap(raw)?['key']?.toString() ?? '';
|
||||
|
||||
bool? _partAccessibleFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['accessible']);
|
||||
|
||||
bool? _partExistsFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['exists']);
|
||||
|
||||
Object? _readPartKey(Map json, String _) => _partKeyFromJson(json['Part']);
|
||||
|
||||
Object? _readPartAccessible(Map json, String _) => _partAccessibleFromJson(json['Part']);
|
||||
|
||||
Object? _readPartExists(Map json, String _) => _partExistsFromJson(json['Part']);
|
||||
|
||||
String _hubTitleFromJson(Object? raw) {
|
||||
final title = raw as String? ?? 'Unknown';
|
||||
return kBlurArtwork ? obfuscateText(title) : title;
|
||||
}
|
||||
|
||||
Object? _readHubItems(Map json, String _) {
|
||||
final entries = <Map<String, dynamic>>[];
|
||||
|
||||
void append(Object? raw, {required bool isDirectory}) {
|
||||
if (raw is! List) return;
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final entry = Map<String, dynamic>.from(item);
|
||||
if (isDirectory && !entry.containsKey('type')) {
|
||||
entry['type'] = (entry.containsKey('leafCount') || entry.containsKey('childCount')) ? 'show' : 'folder';
|
||||
}
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
append(json['Metadata'], isDirectory: false);
|
||||
append(json['Directory'], isDirectory: true);
|
||||
return entries;
|
||||
}
|
||||
|
||||
List<PlexMetadataDto> _hubItemsFromJson(Object? raw) {
|
||||
final items = <PlexMetadataDto>[];
|
||||
if (raw is! List) return items;
|
||||
for (final item in raw) {
|
||||
try {
|
||||
items.add(PlexMetadataDto.fromJsonWithImages(item as Map<String, dynamic>));
|
||||
} catch (_) {
|
||||
// Skip hub entries that fail to parse; Plex hubs can mix item shapes.
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Object? _readMetadataRatingKey(Map json, String _) => (json['ratingKey'] ?? json['key'])?.toString() ?? '';
|
||||
|
||||
List<String>? _tagListFromJson(Object? raw) => stringListFromRaw(raw, mapKey: 'tag');
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexRoleDto {
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
@@ -61,16 +124,27 @@ class PlexRoleDto {
|
||||
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => _$PlexRoleDtoFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexMediaVersionDto {
|
||||
@JsonKey(fromJson: _flexibleIntOrZero)
|
||||
final int id;
|
||||
@JsonKey(readValue: readStringField)
|
||||
final String? videoResolution;
|
||||
@JsonKey(readValue: readStringField)
|
||||
final String? videoCodec;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? bitrate;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? width;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? height;
|
||||
@JsonKey(readValue: readStringField)
|
||||
final String? container;
|
||||
@JsonKey(readValue: _readPartKey)
|
||||
final String partKey;
|
||||
@JsonKey(readValue: _readPartAccessible)
|
||||
final bool? accessible;
|
||||
@JsonKey(readValue: _readPartExists)
|
||||
final bool? exists;
|
||||
|
||||
const PlexMediaVersionDto({
|
||||
@@ -86,23 +160,7 @@ class PlexMediaVersionDto {
|
||||
this.exists,
|
||||
});
|
||||
|
||||
factory PlexMediaVersionDto.fromJson(Map<String, dynamic> json) {
|
||||
final parts = flexibleList(json['Part']);
|
||||
final part = parts != null && parts.isNotEmpty && parts.first is Map ? parts.first as Map : null;
|
||||
final partKey = part?['key']?.toString() ?? '';
|
||||
return PlexMediaVersionDto(
|
||||
id: flexibleInt(json['id']) ?? 0,
|
||||
videoResolution: json['videoResolution']?.toString(),
|
||||
videoCodec: json['videoCodec']?.toString(),
|
||||
bitrate: flexibleInt(json['bitrate']),
|
||||
width: flexibleInt(json['width']),
|
||||
height: flexibleInt(json['height']),
|
||||
container: json['container']?.toString(),
|
||||
partKey: partKey,
|
||||
accessible: flexibleBoolNullable(part?['accessible']),
|
||||
exists: flexibleBoolNullable(part?['exists']),
|
||||
);
|
||||
}
|
||||
factory PlexMediaVersionDto.fromJson(Map<String, dynamic> json) => _$PlexMediaVersionDtoFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
@@ -255,15 +313,24 @@ class PlexPlaylistDto {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexHubDto {
|
||||
@JsonKey(name: 'key', readValue: readStringField, defaultValue: '')
|
||||
final String hubKey;
|
||||
@JsonKey(fromJson: _hubTitleFromJson)
|
||||
final String title;
|
||||
@JsonKey(defaultValue: 'hub')
|
||||
final String type;
|
||||
final String? hubIdentifier;
|
||||
@JsonKey(fromJson: _flexibleIntOrZero)
|
||||
final int size;
|
||||
@JsonKey(fromJson: flexibleBool)
|
||||
final bool more;
|
||||
@JsonKey(readValue: _readHubItems, fromJson: _hubItemsFromJson)
|
||||
final List<PlexMetadataDto> items;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverId;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverName;
|
||||
|
||||
const PlexHubDto({
|
||||
@@ -279,39 +346,18 @@ class PlexHubDto {
|
||||
});
|
||||
|
||||
factory PlexHubDto.fromJson(Map<String, dynamic> json, {String? serverId, String? serverName}) {
|
||||
final items = <PlexMetadataDto>[];
|
||||
void parseEntries(List? entries, {bool isDirectory = false}) {
|
||||
if (entries == null) return;
|
||||
for (final item in entries) {
|
||||
try {
|
||||
Map<String, dynamic> entry = item as Map<String, dynamic>;
|
||||
if (isDirectory && !entry.containsKey('type')) {
|
||||
entry = Map<String, dynamic>.from(entry);
|
||||
entry['type'] = (entry.containsKey('leafCount') || entry.containsKey('childCount')) ? 'show' : 'folder';
|
||||
}
|
||||
var parsed = PlexMetadataDto.fromJsonWithImages(entry);
|
||||
if (serverId != null || serverName != null) {
|
||||
parsed = parsed.copyWith(serverId: serverId, serverName: serverName);
|
||||
}
|
||||
items.add(parsed);
|
||||
} catch (_) {
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseEntries(json['Metadata'] as List?);
|
||||
parseEntries(json['Directory'] as List?, isDirectory: true);
|
||||
final parsed = _$PlexHubDtoFromJson(json);
|
||||
final items = serverId == null && serverName == null
|
||||
? parsed.items
|
||||
: parsed.items.map((item) => item.copyWith(serverId: serverId, serverName: serverName)).toList();
|
||||
|
||||
return PlexHubDto(
|
||||
hubKey: json['key'] as String? ?? '',
|
||||
title: kBlurArtwork
|
||||
? obfuscateText(json['title'] as String? ?? 'Unknown')
|
||||
: json['title'] as String? ?? 'Unknown',
|
||||
type: json['type'] as String? ?? 'hub',
|
||||
hubIdentifier: json['hubIdentifier'] as String?,
|
||||
hubKey: parsed.hubKey,
|
||||
title: parsed.title,
|
||||
type: parsed.type,
|
||||
hubIdentifier: parsed.hubIdentifier,
|
||||
size: flexibleInt(json['size']) ?? items.length,
|
||||
more: flexibleBool(json['more']),
|
||||
more: parsed.more,
|
||||
items: items,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
@@ -319,7 +365,9 @@ class PlexHubDto {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class PlexMetadataDto {
|
||||
@JsonKey(readValue: _readMetadataRatingKey, defaultValue: '')
|
||||
final String ratingKey;
|
||||
final String? key;
|
||||
final String? guid;
|
||||
@@ -332,45 +380,74 @@ class PlexMetadataDto {
|
||||
final double? rating;
|
||||
final double? audienceRating;
|
||||
final double? userRating;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? year;
|
||||
final String? originallyAvailableAt;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? duration;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? addedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? updatedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? lastViewedAt;
|
||||
final String? grandparentTitle;
|
||||
final String? grandparentThumb;
|
||||
final String? grandparentArt;
|
||||
@JsonKey(readValue: readStringField)
|
||||
final String? grandparentRatingKey;
|
||||
final String? parentTitle;
|
||||
final String? parentThumb;
|
||||
@JsonKey(readValue: readStringField)
|
||||
final String? parentRatingKey;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? parentIndex;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? index;
|
||||
final String? grandparentTheme;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? viewOffset;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? viewCount;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? leafCount;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? viewedLeafCount;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? childCount;
|
||||
@JsonKey(name: 'Role', includeToJson: false)
|
||||
final List<PlexRoleDto>? role;
|
||||
@JsonKey(name: 'Media', includeToJson: false)
|
||||
final List<PlexMediaVersionDto>? mediaVersions;
|
||||
@JsonKey(name: 'Genre', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? genre;
|
||||
@JsonKey(name: 'Director', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? director;
|
||||
@JsonKey(name: 'Writer', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? writer;
|
||||
@JsonKey(name: 'Producer', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? producer;
|
||||
@JsonKey(name: 'Country', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? country;
|
||||
@JsonKey(name: 'Collection', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? collection;
|
||||
@JsonKey(name: 'Label', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? label;
|
||||
@JsonKey(name: 'Style', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? style;
|
||||
@JsonKey(name: 'Mood', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? mood;
|
||||
final String? audioLanguage;
|
||||
final String? subtitleLanguage;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? subtitleMode;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? playlistItemID;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? playQueueItemID;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? librarySectionID;
|
||||
final String? librarySectionTitle;
|
||||
final String? ratingImage;
|
||||
@@ -379,9 +456,12 @@ class PlexMetadataDto {
|
||||
final String? originalTitle;
|
||||
final String? editionTitle;
|
||||
final String? subtype;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? extraType;
|
||||
final String? primaryExtraKey;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
final String? clearLogo;
|
||||
final String? backgroundSquare;
|
||||
@@ -457,75 +537,7 @@ class PlexMetadataDto {
|
||||
factory PlexMetadataDto.fromJson(Map<String, dynamic> rawJson) {
|
||||
final json = kBlurArtwork ? _obfuscateJson(rawJson) : rawJson;
|
||||
try {
|
||||
final roleList = (json['Role'] as List?)?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>)).toList();
|
||||
final mediaList = (json['Media'] as List?)
|
||||
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return PlexMetadataDto(
|
||||
ratingKey: (json['ratingKey'] ?? json['key'] ?? '').toString(),
|
||||
key: json['key'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
studio: json['studio'] as String?,
|
||||
type: json['type'] as String?,
|
||||
title: json['title'] as String?,
|
||||
titleSort: json['titleSort'] as String?,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
rating: (json['rating'] as num?)?.toDouble(),
|
||||
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
|
||||
userRating: (json['userRating'] as num?)?.toDouble(),
|
||||
year: flexibleInt(json['year']),
|
||||
originallyAvailableAt: json['originallyAvailableAt'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
duration: flexibleInt(json['duration']),
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
grandparentThumb: json['grandparentThumb'] as String?,
|
||||
grandparentArt: json['grandparentArt'] as String?,
|
||||
grandparentRatingKey: json['grandparentRatingKey']?.toString(),
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
parentThumb: json['parentThumb'] as String?,
|
||||
parentRatingKey: json['parentRatingKey']?.toString(),
|
||||
parentIndex: flexibleInt(json['parentIndex']),
|
||||
index: flexibleInt(json['index']),
|
||||
grandparentTheme: json['grandparentTheme'] as String?,
|
||||
viewOffset: flexibleInt(json['viewOffset']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
|
||||
childCount: flexibleInt(json['childCount']),
|
||||
role: roleList,
|
||||
mediaVersions: mediaList,
|
||||
genre: stringListFromRaw(json['Genre'], mapKey: 'tag'),
|
||||
director: stringListFromRaw(json['Director'], mapKey: 'tag'),
|
||||
writer: stringListFromRaw(json['Writer'], mapKey: 'tag'),
|
||||
producer: stringListFromRaw(json['Producer'], mapKey: 'tag'),
|
||||
country: stringListFromRaw(json['Country'], mapKey: 'tag'),
|
||||
collection: stringListFromRaw(json['Collection'], mapKey: 'tag'),
|
||||
label: stringListFromRaw(json['Label'], mapKey: 'tag'),
|
||||
style: stringListFromRaw(json['Style'], mapKey: 'tag'),
|
||||
mood: stringListFromRaw(json['Mood'], mapKey: 'tag'),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
subtitleMode: flexibleInt(json['subtitleMode']),
|
||||
playlistItemID: flexibleInt(json['playlistItemID']),
|
||||
playQueueItemID: flexibleInt(json['playQueueItemID']),
|
||||
librarySectionID: flexibleInt(json['librarySectionID']),
|
||||
librarySectionTitle: json['librarySectionTitle'] as String?,
|
||||
ratingImage: json['ratingImage'] as String?,
|
||||
audienceRatingImage: json['audienceRatingImage'] as String?,
|
||||
tagline: json['tagline'] as String?,
|
||||
originalTitle: json['originalTitle'] as String?,
|
||||
editionTitle: json['editionTitle'] as String?,
|
||||
subtype: json['subtype'] as String?,
|
||||
extraType: flexibleInt(json['extraType']),
|
||||
primaryExtraKey: json['primaryExtraKey'] as String?,
|
||||
clearLogo: json['clearLogo'] as String?,
|
||||
backgroundSquare: json['backgroundSquare'] as String?,
|
||||
);
|
||||
return _$PlexMetadataDtoFromJson(json);
|
||||
} on TypeError catch (e, st) {
|
||||
Sentry.captureException(
|
||||
e,
|
||||
@@ -579,62 +591,7 @@ class PlexMetadataDto {
|
||||
/// Top-level scalar fields surface as a plain Plex JSON map. Used by the
|
||||
/// download-manager cache layer to overlay scalar updates on top of an
|
||||
/// existing Plex response without losing Chapter/Marker/Media arrays.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'ratingKey': ratingKey,
|
||||
if (key != null) 'key': key,
|
||||
if (guid != null) 'guid': guid,
|
||||
if (studio != null) 'studio': studio,
|
||||
if (type != null) 'type': type,
|
||||
if (title != null) 'title': title,
|
||||
if (titleSort != null) 'titleSort': titleSort,
|
||||
if (contentRating != null) 'contentRating': contentRating,
|
||||
if (summary != null) 'summary': summary,
|
||||
if (rating != null) 'rating': rating,
|
||||
if (audienceRating != null) 'audienceRating': audienceRating,
|
||||
if (userRating != null) 'userRating': userRating,
|
||||
if (year != null) 'year': year,
|
||||
if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt,
|
||||
if (thumb != null) 'thumb': thumb,
|
||||
if (art != null) 'art': art,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (addedAt != null) 'addedAt': addedAt,
|
||||
if (updatedAt != null) 'updatedAt': updatedAt,
|
||||
if (lastViewedAt != null) 'lastViewedAt': lastViewedAt,
|
||||
if (grandparentTitle != null) 'grandparentTitle': grandparentTitle,
|
||||
if (grandparentThumb != null) 'grandparentThumb': grandparentThumb,
|
||||
if (grandparentArt != null) 'grandparentArt': grandparentArt,
|
||||
if (grandparentRatingKey != null) 'grandparentRatingKey': grandparentRatingKey,
|
||||
if (parentTitle != null) 'parentTitle': parentTitle,
|
||||
if (parentThumb != null) 'parentThumb': parentThumb,
|
||||
if (parentRatingKey != null) 'parentRatingKey': parentRatingKey,
|
||||
if (parentIndex != null) 'parentIndex': parentIndex,
|
||||
if (index != null) 'index': index,
|
||||
if (grandparentTheme != null) 'grandparentTheme': grandparentTheme,
|
||||
if (viewOffset != null) 'viewOffset': viewOffset,
|
||||
if (viewCount != null) 'viewCount': viewCount,
|
||||
if (leafCount != null) 'leafCount': leafCount,
|
||||
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
|
||||
if (childCount != null) 'childCount': childCount,
|
||||
if (audioLanguage != null) 'audioLanguage': audioLanguage,
|
||||
if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage,
|
||||
if (subtitleMode != null) 'subtitleMode': subtitleMode,
|
||||
if (playlistItemID != null) 'playlistItemID': playlistItemID,
|
||||
if (playQueueItemID != null) 'playQueueItemID': playQueueItemID,
|
||||
if (librarySectionID != null) 'librarySectionID': librarySectionID,
|
||||
if (librarySectionTitle != null) 'librarySectionTitle': librarySectionTitle,
|
||||
if (ratingImage != null) 'ratingImage': ratingImage,
|
||||
if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage,
|
||||
if (tagline != null) 'tagline': tagline,
|
||||
if (originalTitle != null) 'originalTitle': originalTitle,
|
||||
if (editionTitle != null) 'editionTitle': editionTitle,
|
||||
if (subtype != null) 'subtype': subtype,
|
||||
if (extraType != null) 'extraType': extraType,
|
||||
if (primaryExtraKey != null) 'primaryExtraKey': primaryExtraKey,
|
||||
if (clearLogo != null) 'clearLogo': clearLogo,
|
||||
if (backgroundSquare != null) 'backgroundSquare': backgroundSquare,
|
||||
};
|
||||
}
|
||||
Map<String, dynamic> toJson() => _$PlexMetadataDtoToJson(this);
|
||||
|
||||
PlexMetadataDto copyWith({
|
||||
String? ratingKey,
|
||||
|
||||
@@ -16,35 +16,189 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
|
||||
count: flexibleInt(json['count']),
|
||||
);
|
||||
|
||||
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) => PlexLibraryDto(
|
||||
key: readStringField(json, 'key') as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
agent: json['agent'] as String?,
|
||||
scanner: json['scanner'] as String?,
|
||||
language: json['language'] as String?,
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
createdAt: flexibleInt(json['createdAt']),
|
||||
hidden: flexibleInt(json['hidden']),
|
||||
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexMediaVersionDto(
|
||||
id: _flexibleIntOrZero(json['id']),
|
||||
videoResolution: readStringField(json, 'videoResolution') as String?,
|
||||
videoCodec: readStringField(json, 'videoCodec') as String?,
|
||||
bitrate: flexibleInt(json['bitrate']),
|
||||
width: flexibleInt(json['width']),
|
||||
height: flexibleInt(json['height']),
|
||||
container: readStringField(json, 'container') as String?,
|
||||
partKey: _readPartKey(json, 'partKey') as String,
|
||||
accessible: _readPartAccessible(json, 'accessible') as bool?,
|
||||
exists: _readPartExists(json, 'exists') as bool?,
|
||||
);
|
||||
|
||||
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexLibraryDto(
|
||||
key: readStringField(json, 'key') as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
agent: json['agent'] as String?,
|
||||
scanner: json['scanner'] as String?,
|
||||
language: json['language'] as String?,
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
createdAt: flexibleInt(json['createdAt']),
|
||||
hidden: flexibleInt(json['hidden']),
|
||||
);
|
||||
|
||||
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexPlaylistDto(
|
||||
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
|
||||
key: json['key'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool? ?? false,
|
||||
playlistType: json['playlistType'] as String? ?? '',
|
||||
duration: flexibleInt(json['duration']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
PlexHubDto _$PlexHubDtoFromJson(Map<String, dynamic> json) => PlexHubDto(
|
||||
hubKey: readStringField(json, 'key') as String? ?? '',
|
||||
title: _hubTitleFromJson(json['title']),
|
||||
type: json['type'] as String? ?? 'hub',
|
||||
hubIdentifier: json['hubIdentifier'] as String?,
|
||||
size: _flexibleIntOrZero(json['size']),
|
||||
more: flexibleBool(json['more']),
|
||||
items: _hubItemsFromJson(_readHubItems(json, 'items')),
|
||||
);
|
||||
|
||||
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) => PlexPlaylistDto(
|
||||
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
|
||||
key: json['key'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool? ?? false,
|
||||
playlistType: json['playlistType'] as String? ?? '',
|
||||
duration: flexibleInt(json['duration']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexMetadataDto(
|
||||
ratingKey: _readMetadataRatingKey(json, 'ratingKey') as String? ?? '',
|
||||
key: json['key'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
studio: json['studio'] as String?,
|
||||
type: json['type'] as String?,
|
||||
title: json['title'] as String?,
|
||||
titleSort: json['titleSort'] as String?,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
rating: (json['rating'] as num?)?.toDouble(),
|
||||
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
|
||||
userRating: (json['userRating'] as num?)?.toDouble(),
|
||||
year: flexibleInt(json['year']),
|
||||
originallyAvailableAt: json['originallyAvailableAt'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
duration: flexibleInt(json['duration']),
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
grandparentThumb: json['grandparentThumb'] as String?,
|
||||
grandparentArt: json['grandparentArt'] as String?,
|
||||
grandparentRatingKey:
|
||||
readStringField(json, 'grandparentRatingKey') as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
parentThumb: json['parentThumb'] as String?,
|
||||
parentRatingKey: readStringField(json, 'parentRatingKey') as String?,
|
||||
parentIndex: flexibleInt(json['parentIndex']),
|
||||
index: flexibleInt(json['index']),
|
||||
grandparentTheme: json['grandparentTheme'] as String?,
|
||||
viewOffset: flexibleInt(json['viewOffset']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
|
||||
childCount: flexibleInt(json['childCount']),
|
||||
role: (json['Role'] as List<dynamic>?)
|
||||
?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
mediaVersions: (json['Media'] as List<dynamic>?)
|
||||
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
genre: _tagListFromJson(json['Genre']),
|
||||
director: _tagListFromJson(json['Director']),
|
||||
writer: _tagListFromJson(json['Writer']),
|
||||
producer: _tagListFromJson(json['Producer']),
|
||||
country: _tagListFromJson(json['Country']),
|
||||
collection: _tagListFromJson(json['Collection']),
|
||||
label: _tagListFromJson(json['Label']),
|
||||
style: _tagListFromJson(json['Style']),
|
||||
mood: _tagListFromJson(json['Mood']),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
subtitleMode: flexibleInt(json['subtitleMode']),
|
||||
playlistItemID: flexibleInt(json['playlistItemID']),
|
||||
playQueueItemID: flexibleInt(json['playQueueItemID']),
|
||||
librarySectionID: flexibleInt(json['librarySectionID']),
|
||||
librarySectionTitle: json['librarySectionTitle'] as String?,
|
||||
ratingImage: json['ratingImage'] as String?,
|
||||
audienceRatingImage: json['audienceRatingImage'] as String?,
|
||||
tagline: json['tagline'] as String?,
|
||||
originalTitle: json['originalTitle'] as String?,
|
||||
editionTitle: json['editionTitle'] as String?,
|
||||
subtype: json['subtype'] as String?,
|
||||
extraType: flexibleInt(json['extraType']),
|
||||
primaryExtraKey: json['primaryExtraKey'] as String?,
|
||||
clearLogo: json['clearLogo'] as String?,
|
||||
backgroundSquare: json['backgroundSquare'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': ?instance.key,
|
||||
'guid': ?instance.guid,
|
||||
'studio': ?instance.studio,
|
||||
'type': ?instance.type,
|
||||
'title': ?instance.title,
|
||||
'titleSort': ?instance.titleSort,
|
||||
'contentRating': ?instance.contentRating,
|
||||
'summary': ?instance.summary,
|
||||
'rating': ?instance.rating,
|
||||
'audienceRating': ?instance.audienceRating,
|
||||
'userRating': ?instance.userRating,
|
||||
'year': ?instance.year,
|
||||
'originallyAvailableAt': ?instance.originallyAvailableAt,
|
||||
'thumb': ?instance.thumb,
|
||||
'art': ?instance.art,
|
||||
'duration': ?instance.duration,
|
||||
'addedAt': ?instance.addedAt,
|
||||
'updatedAt': ?instance.updatedAt,
|
||||
'lastViewedAt': ?instance.lastViewedAt,
|
||||
'grandparentTitle': ?instance.grandparentTitle,
|
||||
'grandparentThumb': ?instance.grandparentThumb,
|
||||
'grandparentArt': ?instance.grandparentArt,
|
||||
'grandparentRatingKey': ?instance.grandparentRatingKey,
|
||||
'parentTitle': ?instance.parentTitle,
|
||||
'parentThumb': ?instance.parentThumb,
|
||||
'parentRatingKey': ?instance.parentRatingKey,
|
||||
'parentIndex': ?instance.parentIndex,
|
||||
'index': ?instance.index,
|
||||
'grandparentTheme': ?instance.grandparentTheme,
|
||||
'viewOffset': ?instance.viewOffset,
|
||||
'viewCount': ?instance.viewCount,
|
||||
'leafCount': ?instance.leafCount,
|
||||
'viewedLeafCount': ?instance.viewedLeafCount,
|
||||
'childCount': ?instance.childCount,
|
||||
'audioLanguage': ?instance.audioLanguage,
|
||||
'subtitleLanguage': ?instance.subtitleLanguage,
|
||||
'subtitleMode': ?instance.subtitleMode,
|
||||
'playlistItemID': ?instance.playlistItemID,
|
||||
'playQueueItemID': ?instance.playQueueItemID,
|
||||
'librarySectionID': ?instance.librarySectionID,
|
||||
'librarySectionTitle': ?instance.librarySectionTitle,
|
||||
'ratingImage': ?instance.ratingImage,
|
||||
'audienceRatingImage': ?instance.audienceRatingImage,
|
||||
'tagline': ?instance.tagline,
|
||||
'originalTitle': ?instance.originalTitle,
|
||||
'editionTitle': ?instance.editionTitle,
|
||||
'subtype': ?instance.subtype,
|
||||
'extraType': ?instance.extraType,
|
||||
'primaryExtraKey': ?instance.primaryExtraKey,
|
||||
'clearLogo': ?instance.clearLogo,
|
||||
'backgroundSquare': ?instance.backgroundSquare,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
@@ -212,7 +213,7 @@ Future<String?> showMultilineTextInputDialog(
|
||||
/// Shared lifecycle for the two private text-input dialogs below: a single
|
||||
/// [TextEditingController] seeded from [initialValue], plus a focus node for
|
||||
/// the save button.
|
||||
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T> {
|
||||
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, ControllerDisposerMixin<T> {
|
||||
late final TextEditingController _controller;
|
||||
final _saveFocusNode = FocusNode();
|
||||
|
||||
@@ -221,12 +222,11 @@ mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: initialValue);
|
||||
_controller = createTextEditingController(text: initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -244,7 +244,7 @@ class _MultilineTextInputDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
|
||||
with _TextInputDialogStateMixin<_MultilineTextInputDialog> {
|
||||
with ControllerDisposerMixin, _TextInputDialogStateMixin<_MultilineTextInputDialog> {
|
||||
@override
|
||||
String? get initialValue => widget.initialValue;
|
||||
|
||||
@@ -299,7 +299,8 @@ class _TextInputDialog extends StatefulWidget {
|
||||
State<_TextInputDialog> createState() => _TextInputDialogState();
|
||||
}
|
||||
|
||||
class _TextInputDialogState extends State<_TextInputDialog> with _TextInputDialogStateMixin<_TextInputDialog> {
|
||||
class _TextInputDialogState extends State<_TextInputDialog>
|
||||
with ControllerDisposerMixin, _TextInputDialogStateMixin<_TextInputDialog> {
|
||||
@override
|
||||
String? get initialValue => widget.initialValue;
|
||||
|
||||
|
||||
@@ -307,23 +307,27 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
|
||||
Future<void> _renameRoom(RecentRoom room) async {
|
||||
final controller = TextEditingController(text: room.name ?? '');
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.watchTogether.renameRoom),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: room.code),
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
String? name;
|
||||
try {
|
||||
name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.watchTogether.renameRoom),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: room.code),
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(t.common.save)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(t.common.save)),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
);
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
if (name == null || !mounted) return;
|
||||
|
||||
await RecentRoomsService.renameRoom(room.code, name.isEmpty ? null : name);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
|
||||
/// Dialog for joining a watch together session
|
||||
class JoinSessionDialog extends StatefulWidget {
|
||||
@@ -14,15 +15,9 @@ class JoinSessionDialog extends StatefulWidget {
|
||||
State<JoinSessionDialog> createState() => _JoinSessionDialogState();
|
||||
}
|
||||
|
||||
class _JoinSessionDialogState extends State<JoinSessionDialog> {
|
||||
class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDisposerMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _sessionIdController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sessionIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
late final _sessionIdController = createTextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../models/plex/plex_home.dart';
|
||||
import '../../profiles/active_plex_identity.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
@@ -22,8 +23,8 @@ class DiscoveryView extends StatefulWidget {
|
||||
State<DiscoveryView> createState() => _DiscoveryViewState();
|
||||
}
|
||||
|
||||
class _DiscoveryViewState extends State<DiscoveryView> {
|
||||
final _hostAddressController = TextEditingController();
|
||||
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin {
|
||||
late final _hostAddressController = createTextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isConnecting = false;
|
||||
String? _errorMessage;
|
||||
@@ -105,7 +106,6 @@ class _DiscoveryViewState extends State<DiscoveryView> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostAddressController.dispose();
|
||||
_discoverySubscription?.cancel();
|
||||
_searchTimeout?.cancel();
|
||||
_provider.stopDiscovery();
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
@@ -1586,16 +1587,10 @@ class _CollectionSelectionDialog extends StatefulWidget {
|
||||
State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState();
|
||||
}
|
||||
|
||||
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> {
|
||||
final _filterController = TextEditingController();
|
||||
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
||||
late final _filterController = createTextEditingController();
|
||||
late List<MediaItem> _filteredCollections = widget.collections;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_filterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFilterChanged(String query) {
|
||||
final lower = query.toLowerCase();
|
||||
setState(() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
@@ -17,8 +18,8 @@ class TagEditDialog extends StatefulWidget {
|
||||
State<TagEditDialog> createState() => _TagEditDialogState();
|
||||
}
|
||||
|
||||
class _TagEditDialogState extends State<TagEditDialog> {
|
||||
late final TextEditingController _controller;
|
||||
class _TagEditDialogState extends State<TagEditDialog> with ControllerDisposerMixin {
|
||||
late final TextEditingController _controller = createTextEditingController();
|
||||
late final FocusNode _textFieldFocusNode;
|
||||
late final List<String> _tags;
|
||||
final _saveFocusNode = FocusNode();
|
||||
@@ -26,7 +27,6 @@ class _TagEditDialogState extends State<TagEditDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
_textFieldFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
@@ -42,7 +42,6 @@ class _TagEditDialogState extends State<TagEditDialog> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_textFieldFocusNode.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_repeat_helper.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'app_icon.dart';
|
||||
@@ -27,7 +28,7 @@ class TvColorPicker extends StatefulWidget {
|
||||
State<TvColorPicker> createState() => _TvColorPickerState();
|
||||
}
|
||||
|
||||
class _TvColorPickerState extends State<TvColorPicker> {
|
||||
class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMixin {
|
||||
late int _hue;
|
||||
late int _saturation;
|
||||
late int _value;
|
||||
@@ -41,13 +42,12 @@ class _TvColorPickerState extends State<TvColorPicker> {
|
||||
_hue = hsv.hue.round();
|
||||
_saturation = (hsv.saturation * 100).round();
|
||||
_value = (hsv.value * 100).round();
|
||||
_hexController = TextEditingController(text: _currentHex());
|
||||
_hexController = createTextEditingController(text: _currentHex());
|
||||
_hexFocusNode = FocusNode(debugLabel: 'TvColorPicker_hex', onKeyEvent: _handleHexKeyEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hexController.dispose();
|
||||
_hexFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../focus/focusable_button.dart';
|
||||
import '../../../focus/focusable_text_field.dart';
|
||||
import '../../../focus/input_mode_tracker.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../../models/plex/plex_subtitle_search_result.dart';
|
||||
import '../../../services/plex_client.dart';
|
||||
import '../../../utils/language_codes.dart';
|
||||
@@ -37,10 +38,10 @@ class SubtitleSearchSheet extends StatefulWidget {
|
||||
State<SubtitleSearchSheet> createState() => _SubtitleSearchSheetState();
|
||||
}
|
||||
|
||||
class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> {
|
||||
class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with ControllerDisposerMixin {
|
||||
String _languageCode = 'en';
|
||||
String _languageName = 'English';
|
||||
final _titleController = TextEditingController();
|
||||
late final _titleController = createTextEditingController();
|
||||
final _languageFocusNode = FocusNode(debugLabel: 'SubtitleSearch_language');
|
||||
final _titleFocusNode = FocusNode(debugLabel: 'SubtitleSearch_title');
|
||||
final _firstResultFocusNode = FocusNode(debugLabel: 'SubtitleSearch_firstResult');
|
||||
@@ -76,7 +77,6 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> {
|
||||
_languageFocusNode.dispose();
|
||||
_titleFocusNode.dispose();
|
||||
_firstResultFocusNode.dispose();
|
||||
_titleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -361,8 +361,8 @@ class _LanguagePickerView extends StatefulWidget {
|
||||
State<_LanguagePickerView> createState() => _LanguagePickerViewState();
|
||||
}
|
||||
|
||||
class _LanguagePickerViewState extends State<_LanguagePickerView> {
|
||||
final _filterController = TextEditingController();
|
||||
class _LanguagePickerViewState extends State<_LanguagePickerView> with ControllerDisposerMixin {
|
||||
late final _filterController = createTextEditingController();
|
||||
final _filterFocusNode = FocusNode(debugLabel: 'SubtitleLanguage_filter');
|
||||
final _firstLanguageFocusNode = FocusNode(debugLabel: 'SubtitleLanguage_firstResult');
|
||||
late List<({String code, String name})> _allLanguages;
|
||||
@@ -379,7 +379,6 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> {
|
||||
void dispose() {
|
||||
_filterFocusNode.dispose();
|
||||
_firstLanguageFocusNode.dispose();
|
||||
_filterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user