refactor: share controller disposal and Plex DTO parsing

This commit is contained in:
edde746
2026-05-02 09:43:08 +02:00
parent 7fc136f592
commit 244b291255
21 changed files with 511 additions and 407 deletions
+28
View File
@@ -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();
}
}
+3 -8
View File
@@ -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();
+6 -7
View File
@@ -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,
+3 -5
View File
@@ -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 {
+3 -3
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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,
+184 -30
View File
@@ -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,
};
+6 -5
View File
@@ -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();
+3 -8
View File
@@ -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 -4
View File
@@ -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();
+3 -3
View File
@@ -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();
}