import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../models/plex_metadata.dart'; import '../services/plex_client.dart'; import '../utils/dialogs.dart'; import '../utils/language_codes.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_icon.dart'; import '../widgets/artwork_picker_dialog.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/focused_scroll_scaffold.dart'; import '../widgets/plex_optimized_image.dart'; import '../widgets/tag_edit_dialog.dart'; class MetadataEditScreen extends StatefulWidget { final PlexMetadata metadata; const MetadataEditScreen({super.key, required this.metadata}); @override State createState() => _MetadataEditScreenState(); } class _MetadataEditScreenState extends State { late PlexClient _client; PlexMetadata? _fullMetadata; bool _isLoading = true; bool _isSaving = false; // Text field values String? _title; String? _titleSort; String? _originalTitle; String? _originallyAvailableAt; String? _contentRating; String? _studio; String? _tagline; String? _summary; // Original values for change detection String? _origTitle; String? _origTitleSort; String? _origOriginalTitle; String? _origOriginallyAvailableAt; String? _origContentRating; String? _origStudio; String? _origTagline; String? _origSummary; // Tag field values final Map> _tags = {}; final Map> _origTags = {}; // Advanced prefs (loaded from metadata JSON) final Map _currentPrefs = {}; static bool _tagsEqual(List a, List b) => a.length == b.length && a.every((e) => b.contains(e)); bool get _hasTagChanges => _tags.keys.any((k) => !_tagsEqual(_tags[k] ?? [], _origTags[k] ?? [])); bool get _hasChanges => _title != _origTitle || _titleSort != _origTitleSort || _originalTitle != _origOriginalTitle || _originallyAvailableAt != _origOriginallyAvailableAt || _contentRating != _origContentRating || _studio != _origStudio || _tagline != _origTagline || _summary != _origSummary || _hasTagChanges; PlexMediaType get _mediaType => widget.metadata.mediaType; @override void initState() { super.initState(); _client = context.getClientWithFallback(widget.metadata.serverId); _loadMetadata(); } Future _loadMetadata() async { try { // If the passed metadata already has full fields (e.g., from detail screen), // use it directly instead of re-fetching. We check both summary and // librarySectionID since the edit screen needs both for display and save. if (widget.metadata.summary != null && widget.metadata.librarySectionID != null) { _fullMetadata = widget.metadata; _initFieldsFromMetadata(widget.metadata); setState(() => _isLoading = false); return; } final meta = await _client.getMetadataWithImages(widget.metadata.ratingKey); if (!mounted) return; if (meta != null) { _fullMetadata = meta; _initFieldsFromMetadata(meta); } else { _initFieldsFromMetadata(widget.metadata); } setState(() => _isLoading = false); } catch (e) { if (!mounted) return; _initFieldsFromMetadata(widget.metadata); setState(() => _isLoading = false); } } void _initFieldsFromMetadata(PlexMetadata meta) { _title = meta.title; _titleSort = meta.titleSort ?? ''; _originalTitle = meta.originalTitle ?? ''; _originallyAvailableAt = meta.originallyAvailableAt ?? ''; _contentRating = meta.contentRating ?? ''; _studio = meta.studio ?? ''; _tagline = meta.tagline ?? ''; _summary = meta.summary ?? ''; _origTitle = _title; _origTitleSort = _titleSort; _origOriginalTitle = _originalTitle; _origOriginallyAvailableAt = _originallyAvailableAt; _origContentRating = _contentRating; _origStudio = _studio; _origTagline = _tagline; _origSummary = _summary; void initTag(String key, List? values) { _tags[key] = List.of(values ?? []); _origTags[key] = List.of(values ?? []); } initTag('genre', meta.genre); initTag('director', meta.director); initTag('writer', meta.writer); initTag('producer', meta.producer); initTag('country', meta.country); initTag('collection', meta.collection); initTag('label', meta.label); initTag('style', meta.style); initTag('mood', meta.mood); } Future _save() async { if (!_hasChanges || _isSaving) return; final sectionId = _fullMetadata?.librarySectionID ?? widget.metadata.librarySectionID; if (sectionId == null) { if (mounted) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); return; } setState(() => _isSaving = true); Map current, List original})>? tagChanges; for (final key in _tags.keys) { final current = _tags[key] ?? []; final original = _origTags[key] ?? []; if (!_tagsEqual(current, original)) { tagChanges ??= {}; tagChanges[key] = (current: current, original: original); } } final success = await _client.updateMetadata( sectionId: sectionId, ratingKey: widget.metadata.ratingKey, typeNumber: _mediaType.typeNumber, title: _title != _origTitle ? _title : null, titleSort: _titleSort != _origTitleSort ? _titleSort : null, originalTitle: _originalTitle != _origOriginalTitle ? _originalTitle : null, originallyAvailableAt: _originallyAvailableAt != _origOriginallyAvailableAt ? _originallyAvailableAt : null, contentRating: _contentRating != _origContentRating ? _contentRating : null, studio: _studio != _origStudio ? _studio : null, tagline: _tagline != _origTagline ? _tagline : null, summary: _summary != _origSummary ? _summary : null, tagChanges: tagChanges, ); if (!mounted) return; setState(() => _isSaving = false); if (success) { showSuccessSnackBar(context, t.metadataEdit.metadataUpdated); Navigator.pop(context, true); } else { showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } } Future _editTextField({ required String title, required String label, required String? currentValue, required ValueChanged onChanged, bool multiline = false, }) async { final String? result; if (multiline) { result = await showMultilineTextInputDialog(context, title: title, labelText: label, initialValue: currentValue); } else { result = await showTextInputDialog( context, title: title, labelText: label, hintText: '', initialValue: currentValue, ); } if (result != null && mounted) { final value = result; setState(() => onChanged(value)); } } Future _editDate() async { DateTime initial = DateTime.now(); if (_originallyAvailableAt != null && _originallyAvailableAt!.isNotEmpty) { final parsed = DateTime.tryParse(_originallyAvailableAt!); if (parsed != null) initial = parsed; } final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(1800), lastDate: DateTime(2200), ); if (picked != null && mounted) { setState(() { _originallyAvailableAt = '${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}'; }); } } Future _openArtworkPicker(String element) async { final result = await showDialog( context: context, builder: (context) => ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.ratingKey, element: element), ); if (result == true && mounted) { // Re-fetch metadata to get updated artwork paths without resetting // any text field edits the user may have made. await _reloadArtwork(); } } Future _reloadArtwork() async { try { final meta = await _client.getMetadataWithImages(widget.metadata.ratingKey); if (!mounted) return; if (meta != null) { setState(() => _fullMetadata = meta); } } catch (_) { // Artwork was already saved by the picker; display will refresh next // time the editor is opened. } } Future _showAdvancedSettingDialog({ required String title, required String prefKey, required List<({String value, String label})> options, }) async { // Determine current value from metadata or default final currentValue = _currentPrefs[prefKey] ?? _getMetadataPrefValue(prefKey); final result = await showDialog( context: context, builder: (dialogContext) { String? selected = currentValue; return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(title), content: SizedBox( width: double.maxFinite, child: RadioGroup( groupValue: selected, onChanged: (val) { setDialogState(() => selected = val); Navigator.pop(dialogContext, val); }, child: ListView( shrinkWrap: true, children: options.map((option) { return FocusableRadioListTile(title: Text(option.label), value: option.value); }).toList(), ), ), ), actions: [ FocusableButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext), child: TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), ), ], ); }, ); }, ); if (result != null && mounted) { setState(() => _currentPrefs[prefKey] = result); await _client.updateMetadataPrefs(widget.metadata.ratingKey, {prefKey: result}); } } String _getMetadataPrefValue(String key) { // These prefs appear as keys on the raw metadata JSON when non-default. // Since we use typed models, we check known fields. final meta = _fullMetadata ?? widget.metadata; switch (key) { case 'audioLanguage': return meta.audioLanguage ?? ''; case 'subtitleLanguage': return meta.subtitleLanguage ?? ''; case 'subtitleMode': return meta.subtitleMode?.toString() ?? '-1'; default: return ''; } } String _getDisplayValueForPref(String prefKey, List<({String value, String label})> options) { final val = _currentPrefs[prefKey] ?? _getMetadataPrefValue(prefKey); for (final option in options) { if (option.value == val) return option.label; } return options.first.label; } // ===== Field visibility ===== bool get _showSortTitle => _mediaType != PlexMediaType.season; bool get _showOriginalTitle => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; bool get _showReleaseDate => _mediaType != PlexMediaType.season; bool get _showContentRating => _mediaType != PlexMediaType.season; bool get _showStudio => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; bool get _showTagline => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; bool get _showBackground => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show || _mediaType == PlexMediaType.episode; bool get _showExtendedArtwork => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show || _mediaType == PlexMediaType.collection; bool get _showAdvanced => _mediaType != PlexMediaType.episode; List<({String key, String label})> get _tagFields { switch (_mediaType) { case PlexMediaType.movie: case PlexMediaType.show: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer), (key: 'producer', label: t.metadataEdit.producer), (key: 'country', label: t.metadataEdit.country), (key: 'collection', label: t.metadataEdit.collection), (key: 'label', label: t.metadataEdit.label), ]; case PlexMediaType.episode: return [(key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer)]; case PlexMediaType.artist: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'style', label: t.metadataEdit.style), (key: 'mood', label: t.metadataEdit.mood), (key: 'country', label: t.metadataEdit.country), (key: 'collection', label: t.metadataEdit.collection), ]; case PlexMediaType.album: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'style', label: t.metadataEdit.style), (key: 'mood', label: t.metadataEdit.mood), (key: 'collection', label: t.metadataEdit.collection), ]; default: return []; } } Future _editTag(String key, String label) async { final result = await showDialog>( context: context, builder: (context) => TagEditDialog(title: label, initialTags: _tags[key] ?? []), ); if (result != null && mounted) { setState(() => _tags[key] = result); } } @override Widget build(BuildContext context) { if (_isLoading) { return FocusedScrollScaffold( title: Text(t.metadataEdit.screenTitle), slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], ); } return FocusedScrollScaffold( title: Text(t.metadataEdit.screenTitle), actions: [ if (_isSaving) const Padding( padding: EdgeInsets.all(12), child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), ) else IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), ], slivers: [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ _buildBasicInfoCard(), if (_tagFields.isNotEmpty) ...[const SizedBox(height: 16), _buildTagsCard()], const SizedBox(height: 16), _buildArtworkCard(), if (_showAdvanced) ...[const SizedBox(height: 16), _buildAdvancedSettingsCard()], ]), ), ), ], ); } Widget _buildBasicInfoCard() { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.metadataEdit.basicInfo, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), _buildFieldTile( label: t.metadataEdit.title, value: _title, onTap: () => _editTextField( title: t.metadataEdit.title, label: t.metadataEdit.title, currentValue: _title, onChanged: (v) => _title = v, ), ), if (_showSortTitle) _buildFieldTile( label: t.metadataEdit.sortTitle, value: _titleSort, onTap: () => _editTextField( title: t.metadataEdit.sortTitle, label: t.metadataEdit.sortTitle, currentValue: _titleSort, onChanged: (v) => _titleSort = v, ), ), if (_showOriginalTitle) _buildFieldTile( label: t.metadataEdit.originalTitle, value: _originalTitle, onTap: () => _editTextField( title: t.metadataEdit.originalTitle, label: t.metadataEdit.originalTitle, currentValue: _originalTitle, onChanged: (v) => _originalTitle = v, ), ), if (_showReleaseDate) _buildFieldTile(label: t.metadataEdit.releaseDate, value: _originallyAvailableAt, onTap: _editDate), if (_showContentRating) _buildFieldTile( label: t.metadataEdit.contentRating, value: _contentRating, onTap: () => _editTextField( title: t.metadataEdit.contentRating, label: t.metadataEdit.contentRating, currentValue: _contentRating, onChanged: (v) => _contentRating = v, ), ), if (_showStudio) _buildFieldTile( label: t.metadataEdit.studio, value: _studio, onTap: () => _editTextField( title: t.metadataEdit.studio, label: t.metadataEdit.studio, currentValue: _studio, onChanged: (v) => _studio = v, ), ), if (_showTagline) _buildFieldTile( label: t.metadataEdit.tagline, value: _tagline, onTap: () => _editTextField( title: t.metadataEdit.tagline, label: t.metadataEdit.tagline, currentValue: _tagline, onChanged: (v) => _tagline = v, ), ), _buildFieldTile( label: t.metadataEdit.summary, value: _summary, onTap: () => _editTextField( title: t.metadataEdit.summary, label: t.metadataEdit.summary, currentValue: _summary, onChanged: (v) => _summary = v, multiline: true, ), ), ], ), ); } Widget _buildFieldTile({required String label, String? value, required VoidCallback onTap}) { final displayValue = (value == null || value.isEmpty) ? t.metadataEdit.notSet : value; final isNotSet = value == null || value.isEmpty; return ListTile( title: Text(label), subtitle: Text( displayValue, maxLines: 2, overflow: TextOverflow.ellipsis, style: isNotSet ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) : null, ), trailing: const AppIcon(Symbols.chevron_right_rounded), onTap: onTap, ); } Widget _buildTagsCard() { final fields = _tagFields; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.metadataEdit.tags, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), for (final field in fields) _buildFieldTile( label: field.label, value: (_tags[field.key] ?? []).isEmpty ? null : (_tags[field.key]!).join(', '), onTap: () => _editTag(field.key, field.label), ), ], ), ); } Widget _buildArtworkTile({ required double width, required double height, required String? imagePath, required String label, required String element, BoxFit fit = BoxFit.cover, }) { return ListTile( leading: SizedBox( width: width, height: height, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: PlexOptimizedImage(client: _client, imagePath: imagePath, width: width, height: height, fit: fit), ), ), title: Text(label), trailing: const AppIcon(Symbols.chevron_right_rounded), onTap: () => _openArtworkPicker(element), ); } Widget _buildArtworkCard() { final meta = _fullMetadata ?? widget.metadata; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.metadataEdit.artwork, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), _buildArtworkTile( width: 40, height: 60, imagePath: meta.thumb, label: t.metadataEdit.poster, element: 'posters', ), if (_showBackground) _buildArtworkTile( width: 80, height: 45, imagePath: meta.art, label: t.metadataEdit.background, element: 'arts', ), if (_showExtendedArtwork) _buildArtworkTile( width: 80, height: 32, imagePath: meta.clearLogo, label: t.metadataEdit.logo, element: 'clearLogos', fit: BoxFit.contain, ), if (_showExtendedArtwork) _buildArtworkTile( width: 50, height: 50, imagePath: meta.backgroundSquare, label: t.metadataEdit.squareArt, element: 'squareArts', ), ], ), ); } Widget _buildAdvancedSettingsCard() { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.metadataEdit.advancedSettings, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), if (_mediaType == PlexMediaType.show) ..._buildShowAdvancedSettings(), if (_mediaType == PlexMediaType.movie) ..._buildMovieAdvancedSettings(), if (_mediaType == PlexMediaType.season) ..._buildSeasonAdvancedSettings(), ], ), ); } List _buildShowAdvancedSettings() { return [ _buildAdvancedTile( title: t.metadataEdit.episodeSorting, prefKey: 'episodeSort', options: [ (value: '-1', label: t.metadataEdit.libraryDefault), (value: '0', label: t.metadataEdit.oldestFirst), (value: '1', label: t.metadataEdit.newestFirst), ], ), _buildAdvancedTile( title: t.metadataEdit.keep, prefKey: 'autoDeletionItemPolicyUnwatchedLibrary', options: [ (value: '0', label: t.metadataEdit.allEpisodes), (value: '5', label: t.metadataEdit.latestEpisodes(count: '5')), (value: '3', label: t.metadataEdit.latestEpisodes(count: '3')), (value: '1', label: t.metadataEdit.latestEpisode), (value: '-3', label: t.metadataEdit.episodesAddedPastDays(count: '3')), (value: '-7', label: t.metadataEdit.episodesAddedPastDays(count: '7')), (value: '-30', label: t.metadataEdit.episodesAddedPastDays(count: '30')), ], ), _buildAdvancedTile( title: t.metadataEdit.deleteAfterPlaying, prefKey: 'autoDeletionItemPolicyWatchedLibrary', options: [ (value: '0', label: t.metadataEdit.never), (value: '1', label: t.metadataEdit.afterADay), (value: '7', label: t.metadataEdit.afterAWeek), (value: '30', label: t.metadataEdit.afterAMonth), (value: '100', label: t.metadataEdit.onNextRefresh), ], ), _buildAdvancedTile( title: t.metadataEdit.seasons, prefKey: 'flattenSeasons', options: [ (value: '-1', label: t.metadataEdit.libraryDefault), (value: '0', label: t.metadataEdit.show), (value: '1', label: t.metadataEdit.hide), ], ), _buildAdvancedTile( title: t.metadataEdit.episodeOrdering, prefKey: 'showOrdering', options: [ (value: '', label: t.metadataEdit.libraryDefault), (value: 'tmdbAiring', label: t.metadataEdit.tmdbAiring), (value: 'tvdbAiring', label: t.metadataEdit.tvdbAiring), (value: 'tvdbAbsolute', label: t.metadataEdit.tvdbAbsolute), ], ), _buildAdvancedTile( title: t.metadataEdit.metadataLanguage, prefKey: 'languageOverride', options: _metadataLanguageOptions(t.metadataEdit.libraryDefault), ), _buildAdvancedTile( title: t.metadataEdit.useOriginalTitle, prefKey: 'useOriginalTitle', options: [ (value: '-1', label: t.metadataEdit.libraryDefault), (value: '0', label: t.common.no), (value: '1', label: t.common.yes), ], ), _buildAdvancedTile( title: t.metadataEdit.preferredAudioLanguage, prefKey: 'audioLanguage', options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault), ), _buildAdvancedTile( title: t.metadataEdit.preferredSubtitleLanguage, prefKey: 'subtitleLanguage', options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault), ), _buildAdvancedTile( title: t.metadataEdit.subtitleMode, prefKey: 'subtitleMode', options: [ (value: '-1', label: t.metadataEdit.accountDefault), (value: '0', label: t.metadataEdit.manuallySelected), (value: '1', label: t.metadataEdit.shownWithForeignAudio), (value: '2', label: t.metadataEdit.alwaysEnabled), ], ), ]; } List _buildMovieAdvancedSettings() { return [ _buildAdvancedTile( title: t.metadataEdit.metadataLanguage, prefKey: 'languageOverride', options: _metadataLanguageOptions(t.metadataEdit.libraryDefault), ), _buildAdvancedTile( title: t.metadataEdit.useOriginalTitle, prefKey: 'useOriginalTitle', options: [ (value: '-1', label: t.metadataEdit.libraryDefault), (value: '0', label: t.common.no), (value: '1', label: t.common.yes), ], ), ]; } List _buildSeasonAdvancedSettings() { return [ _buildAdvancedTile( title: t.metadataEdit.preferredAudioLanguage, prefKey: 'audioLanguage', options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault), ), _buildAdvancedTile( title: t.metadataEdit.preferredSubtitleLanguage, prefKey: 'subtitleLanguage', options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault), ), _buildAdvancedTile( title: t.metadataEdit.subtitleMode, prefKey: 'subtitleMode', options: [ (value: '-1', label: t.metadataEdit.seriesDefault), (value: '0', label: t.metadataEdit.manuallySelected), (value: '1', label: t.metadataEdit.shownWithForeignAudio), (value: '2', label: t.metadataEdit.alwaysEnabled), ], ), ]; } Widget _buildAdvancedTile({ required String title, required String prefKey, required List<({String value, String label})> options, }) { return ListTile( title: Text(title), subtitle: Text(_getDisplayValueForPref(prefKey, options)), trailing: const AppIcon(Symbols.chevron_right_rounded), onTap: () => _showAdvancedSettingDialog(title: title, prefKey: prefKey, options: options), ); } } // ===== Language option lists ===== // Plex locale codes for metadata agent language. const _plexLocaleCodes = [ 'ar-SA', 'bg-BG', 'ca-ES', 'zh-CN', 'zh-HK', 'zh-TW', 'hr-HR', 'cs-CZ', 'da-DK', 'nl-NL', 'en-US', 'en-AU', 'en-CA', 'en-GB', 'et-EE', 'fi-FI', 'fr-FR', 'fr-CA', 'de-DE', 'el-GR', 'he-IL', 'hi-IN', 'hu-HU', 'is-IS', 'id-ID', 'it-IT', 'ja-JP', 'ko-KR', 'lv-LV', 'lt-LT', 'nb-NO', 'fa-IR', 'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'ru-RU', 'sk-SK', 'es-ES', 'es-MX', 'sv-SE', 'th-TH', 'tr-TR', 'uk-UA', 'vi-VN', ]; // Common 2-letter codes shown at the top of audio/subtitle pickers. const _commonAudioSubtitleCodes = ['en', 'ja', 'fr', 'de', 'it', 'es', 'pt', 'ru', 'ar']; List<({String value, String label})> _buildLanguageOptions(String defaultLabel, List codes) { return [(value: '', label: defaultLabel), ...codes.map((c) => (value: c, label: LanguageCodes.getDisplayName(c)))]; } List<({String value, String label})> _metadataLanguageOptions(String defaultLabel) => _buildLanguageOptions(defaultLabel, _plexLocaleCodes); List<({String value, String label})> _audioSubtitleLanguageOptions(String defaultLabel) => _buildLanguageOptions(defaultLabel, [..._commonAudioSubtitleCodes, ..._plexLocaleCodes]);