diff --git a/lib/mixins/controller_disposer_mixin.dart b/lib/mixins/controller_disposer_mixin.dart new file mode 100644 index 00000000..a0784c68 --- /dev/null +++ b/lib/mixins/controller_disposer_mixin.dart @@ -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 on State { + final List _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(); + } +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 006bc3a5..caec22d4 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -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 _submit() async { final token = _tokenController.text.trim(); if (token.isEmpty) { diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index b86312cd..1ce40e11 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -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(); diff --git a/lib/screens/plex_match_screen.dart b/lib/screens/plex_match_screen.dart index 9095ddf4..e7f18aed 100644 --- a/lib/screens/plex_match_screen.dart +++ b/lib/screens/plex_match_screen.dart @@ -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 createState() => _PlexMatchScreenState(); } -class _PlexMatchScreenState extends State { +class _PlexMatchScreenState extends State 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 { 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 { @override void dispose() { - _nameController.dispose(); - _yearController.dispose(); _nameFocus.dispose(); _yearFocus.dispose(); _searchFocus.dispose(); diff --git a/lib/screens/profile/add_local_profile_screen.dart b/lib/screens/profile/add_local_profile_screen.dart index 4795b3a3..c613e2ea 100644 --- a/lib/screens/profile/add_local_profile_screen.dart +++ b/lib/screens/profile/add_local_profile_screen.dart @@ -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 createState() => _AddLocalProfileScreenState(); } -class _AddLocalProfileScreenState extends State { - late final TextEditingController _nameController; +class _AddLocalProfileScreenState extends State 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 _setPin() async { final pin = await captureAndConfirmPin( context, diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index b646344f..d8ea7d09 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -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 _digits = [null, null, null, null]; int _activeIndex = 0; bool _isFocused = false; // Hidden text fields for mobile keyboard input final List _mobileFocusNodes = List.generate(4, (_) => FocusNode()); - final List _mobileControllers = List.generate(4, (_) => TextEditingController()); + late final List _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(); } diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index d5daf224..9a350706 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -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 createState() => _ProfileDetailScreenState(); } -class _ProfileDetailScreenState extends State { - late final TextEditingController _nameController; +class _ProfileDetailScreenState extends State 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 _saveName() async { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d443dab3..8479f6fd 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 - 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 _searchResults = []; @@ -49,7 +50,6 @@ class _SearchScreenState extends State void dispose() { _searchDebounce.cancel(); _searchController.removeListener(_onSearchChanged); - _searchController.dispose(); _searchFocusNode.dispose(); _firstResultFocusNode.dispose(); super.dispose(); diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index f9b8e25e..98598ba3 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -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 createState() => _AddJellyfinScreenState(); } -class _AddJellyfinScreenState extends State with AsyncFormStateMixin { - final _urlController = TextEditingController(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); +class _AddJellyfinScreenState extends State 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 with AsyncFormSta // setState after the widget is gone. _qcCancelled = true; _qcAttemptId++; - _urlController.dispose(); - _usernameController.dispose(); - _passwordController.dispose(); _passwordFocus.dispose(); super.dispose(); } diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 3917c6e5..2a7fa073 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -125,106 +125,109 @@ Future _showAddCustomPlayerDialog(BuildContext context) async { final valueFocusNode = FocusNode(); final saveFocusNode = FocusNode(); var selectedType = CustomPlayerType.command; + String? playerName; + String? playerValue; - final result = await showDialog( - 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( + 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( - 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( + 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, [ diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index f3c82f67..01fb2e92 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -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 createState() => _MpvConfigScreenState(); } -class _MpvConfigScreenState extends State with SettingsEffectMixin { +class _MpvConfigScreenState extends State 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 with SettingsEffectMi @override void dispose() { - _textController.dispose(); _savePresetFocusNode.dispose(); _textFieldFocusNode.dispose(); super.dispose(); diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 0a609752..dd51c3c9 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -44,6 +44,69 @@ Map _obfuscatePlaylistJson(Map 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 = >[]; + + 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.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 _hubItemsFromJson(Object? raw) { + final items = []; + if (raw is! List) return items; + for (final item in raw) { + try { + items.add(PlexMetadataDto.fromJsonWithImages(item as Map)); + } 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? _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 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 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 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 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 json, {String? serverId, String? serverName}) { - final items = []; - void parseEntries(List? entries, {bool isDirectory = false}) { - if (entries == null) return; - for (final item in entries) { - try { - Map entry = item as Map; - if (isDirectory && !entry.containsKey('type')) { - entry = Map.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? role; + @JsonKey(name: 'Media', includeToJson: false) final List? mediaVersions; + @JsonKey(name: 'Genre', fromJson: _tagListFromJson, includeToJson: false) final List? genre; + @JsonKey(name: 'Director', fromJson: _tagListFromJson, includeToJson: false) final List? director; + @JsonKey(name: 'Writer', fromJson: _tagListFromJson, includeToJson: false) final List? writer; + @JsonKey(name: 'Producer', fromJson: _tagListFromJson, includeToJson: false) final List? producer; + @JsonKey(name: 'Country', fromJson: _tagListFromJson, includeToJson: false) final List? country; + @JsonKey(name: 'Collection', fromJson: _tagListFromJson, includeToJson: false) final List? collection; + @JsonKey(name: 'Label', fromJson: _tagListFromJson, includeToJson: false) final List? label; + @JsonKey(name: 'Style', fromJson: _tagListFromJson, includeToJson: false) final List? style; + @JsonKey(name: 'Mood', fromJson: _tagListFromJson, includeToJson: false) final List? 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 rawJson) { final json = kBlurArtwork ? _obfuscateJson(rawJson) : rawJson; try { - final roleList = (json['Role'] as List?)?.map((e) => PlexRoleDto.fromJson(e as Map)).toList(); - final mediaList = (json['Media'] as List?) - ?.map((e) => PlexMediaVersionDto.fromJson(e as Map)) - .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 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 toJson() => _$PlexMetadataDtoToJson(this); PlexMetadataDto copyWith({ String? ratingKey, diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart index a0e79d19..21be3f4b 100644 --- a/lib/services/plex_mappers.g.dart +++ b/lib/services/plex_mappers.g.dart @@ -16,35 +16,189 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map json) => PlexRoleDto( count: flexibleInt(json['count']), ); -PlexLibraryDto _$PlexLibraryDtoFromJson(Map 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 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 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 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 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 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 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?) + ?.map((e) => PlexRoleDto.fromJson(e as Map)) + .toList(), + mediaVersions: (json['Media'] as List?) + ?.map((e) => PlexMediaVersionDto.fromJson(e as Map)) + .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 _$PlexMetadataDtoToJson(PlexMetadataDto instance) => + { + '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, + }; diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 2dae9e9f..f06b67d9 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -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 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 on State { +mixin _TextInputDialogStateMixin on State, ControllerDisposerMixin { late final TextEditingController _controller; final _saveFocusNode = FocusNode(); @@ -221,12 +222,11 @@ mixin _TextInputDialogStateMixin on State { @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; diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 33a2119f..a3481291 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -307,23 +307,27 @@ class _NotInSessionViewState extends State<_NotInSessionView> { Future _renameRoom(RecentRoom room) async { final controller = TextEditingController(text: room.name ?? ''); - final name = await showDialog( - 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( + 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); diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index fd81cfad..9a4cdf04 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -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 createState() => _JoinSessionDialogState(); } -class _JoinSessionDialogState extends State { +class _JoinSessionDialogState extends State with ControllerDisposerMixin { final _formKey = GlobalKey(); - final _sessionIdController = TextEditingController(); - - @override - void dispose() { - _sessionIdController.dispose(); - super.dispose(); - } + late final _sessionIdController = createTextEditingController(); @override Widget build(BuildContext context) { diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 0ce98e3a..6352dc93 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -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 createState() => _DiscoveryViewState(); } -class _DiscoveryViewState extends State { - final _hostAddressController = TextEditingController(); +class _DiscoveryViewState extends State with ControllerDisposerMixin { + late final _hostAddressController = createTextEditingController(); final _formKey = GlobalKey(); bool _isConnecting = false; String? _errorMessage; @@ -105,7 +106,6 @@ class _DiscoveryViewState extends State { @override void dispose() { - _hostAddressController.dispose(); _discoverySubscription?.cancel(); _searchTimeout?.cancel(); _provider.stopDiscovery(); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 91ddf6b9..c1e7f45f 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 _filteredCollections = widget.collections; - @override - void dispose() { - _filterController.dispose(); - super.dispose(); - } - void _onFilterChanged(String query) { final lower = query.toLowerCase(); setState(() { diff --git a/lib/widgets/tag_edit_dialog.dart b/lib/widgets/tag_edit_dialog.dart index 30157427..ea1bc52f 100644 --- a/lib/widgets/tag_edit_dialog.dart +++ b/lib/widgets/tag_edit_dialog.dart @@ -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 createState() => _TagEditDialogState(); } -class _TagEditDialogState extends State { - late final TextEditingController _controller; +class _TagEditDialogState extends State with ControllerDisposerMixin { + late final TextEditingController _controller = createTextEditingController(); late final FocusNode _textFieldFocusNode; late final List _tags; final _saveFocusNode = FocusNode(); @@ -26,7 +27,6 @@ class _TagEditDialogState extends State { @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 { @override void dispose() { - _controller.dispose(); _textFieldFocusNode.dispose(); _saveFocusNode.dispose(); super.dispose(); diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 1da5cca1..4d42e9d2 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -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 createState() => _TvColorPickerState(); } -class _TvColorPickerState extends State { +class _TvColorPickerState extends State with ControllerDisposerMixin { late int _hue; late int _saturation; late int _value; @@ -41,13 +42,12 @@ class _TvColorPickerState extends State { _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(); } diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index e5a2937b..0cbadfd5 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -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 createState() => _SubtitleSearchSheetState(); } -class _SubtitleSearchSheetState extends State { +class _SubtitleSearchSheetState extends State 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 { _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(); }