Files
plezy/lib/screens/plex_metadata_edit_screen.dart
T

902 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../widgets/dialog_action_button.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.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/optimized_media_image.dart';
import '../widgets/tag_edit_dialog.dart';
import '../widgets/loading_indicator_box.dart';
/// Plex `type` number used by `/library/sections/{id}/all` PUT — required by
/// [PlexClient.updateMetadata]. Mirrors the legacy `PlexMediaType.typeNumber`
/// helper so the migrated [PlexMetadataEditScreen] (which now operates on
/// [MediaItem]) can still talk to the Plex update endpoint.
int _plexTypeNumberForKind(MediaKind kind) => switch (kind) {
MediaKind.movie => 1,
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
MediaKind.artist => 8,
MediaKind.album => 9,
MediaKind.track => 10,
_ => 0,
};
/// Plex-only metadata editor. Calls Plex-specific PUT endpoints; the Jellyfin
/// backend has no analogous surface yet.
class PlexMetadataEditScreen extends StatefulWidget {
final MediaItem metadata;
const PlexMetadataEditScreen({super.key, required this.metadata});
@override
State<PlexMetadataEditScreen> createState() => _PlexMetadataEditScreenState();
}
class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
late PlexClient _client;
/// Full neutral metadata reloaded after save / artwork picker. Metadata
/// editing uses Plex-only update endpoints (Jellyfin has no equivalent in
/// the current scope), so the in-memory model is [MediaItem] but the
/// boundary call to [PlexClient.updateMetadata] is Plex-only.
MediaItem? _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<String, List<String>> _tags = {};
final Map<String, List<String>> _origTags = {};
// Advanced prefs (loaded from metadata JSON)
final Map<String, String> _currentPrefs = {};
static bool _tagsEqual(List<String> a, List<String> 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;
MediaKind get _mediaType => widget.metadata.kind;
/// Library section id required by the Plex update endpoint. Plex stores it
/// as an int; [MediaItem.libraryId] preserves it as a string.
int? get _librarySectionId => int.tryParse(_fullMetadata?.libraryId ?? widget.metadata.libraryId ?? '');
@override
void initState() {
super.initState();
_client = context.getPlexClientWithFallback(widget.metadata.serverId);
_loadMetadata();
}
Future<void> _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
// libraryId since the edit screen needs both for display and save.
if (widget.metadata.summary != null && widget.metadata.libraryId != null) {
_fullMetadata = widget.metadata;
_initFieldsFromMetadata(widget.metadata);
setState(() => _isLoading = false);
return;
}
final meta = await _client.fetchItem(widget.metadata.id);
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(MediaItem 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<String>? values) {
_tags[key] = List.of(values ?? []);
_origTags[key] = List.of(values ?? []);
}
initTag('genre', meta.genres);
initTag('director', meta.directors);
initTag('writer', meta.writers);
initTag('producer', meta.producers);
initTag('country', meta.countries);
initTag('collection', meta.collections);
initTag('label', meta.labels);
initTag('style', meta.styles);
initTag('mood', meta.moods);
}
Future<void> _save() async {
if (!_hasChanges || _isSaving) return;
final sectionId = _librarySectionId;
if (sectionId == null) {
if (mounted) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
return;
}
setState(() => _isSaving = true);
Map<String, ({List<String> current, List<String> 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);
}
}
bool success = false;
try {
success = await _client.updateMetadata(
sectionId: sectionId,
ratingKey: widget.metadata.id,
typeNumber: _plexTypeNumberForKind(_mediaType),
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,
);
} catch (e, st) {
// [PlexClient._wrapBoolApiCall] rethrows on HTTP/network errors —
// catch here so `_isSaving` doesn't get stuck `true`.
appLogger.e('Failed to update metadata', error: e, stackTrace: st);
}
if (!mounted) return;
setState(() => _isSaving = false);
if (success) {
showSuccessSnackBar(context, t.metadataEdit.metadataUpdated);
Navigator.pop(context, true);
} else {
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
}
}
Future<void> _editTextField({
required String title,
required String label,
required String? currentValue,
required ValueChanged<String> 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<void> _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<void> _openArtworkPicker(String element) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.id, 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<void> _reloadArtwork() async {
try {
final meta = await _client.fetchItem(widget.metadata.id);
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<void> _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<String>(
context: context,
builder: (dialogContext) {
String? selected = currentValue;
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(title),
content: SizedBox(
width: double.maxFinite,
child: RadioGroup<String>(
groupValue: selected,
onChanged: (val) {
setDialogState(() => selected = val);
Navigator.pop(dialogContext, val);
},
child: ListView(
shrinkWrap: true,
children: options.map((option) {
return FocusableRadioListTile<String>(title: Text(option.label), value: option.value);
}).toList(),
),
),
),
actions: [DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel)],
);
},
);
},
);
if (result != null && mounted) {
final previous = _currentPrefs[prefKey];
setState(() => _currentPrefs[prefKey] = result);
try {
await _client.updateMetadataPrefs(widget.metadata.id, {prefKey: result});
} catch (e, st) {
// [PlexClient._wrapBoolApiCall] rethrows — revert the optimistic
// UI change and surface a snackbar so the radio doesn't lie.
appLogger.e('Failed to update metadata prefs', error: e, stackTrace: st);
if (!mounted) return;
setState(() {
if (previous == null) {
_currentPrefs.remove(prefKey);
} else {
_currentPrefs[prefKey] = previous;
}
});
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
}
}
}
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. Falls back to the
// public [MediaItem] when the Plex-typed cache hasn't loaded yet.
//
// [subtitleLanguage] / [subtitleMode] live on [PlexMediaItem] (Plex-only
// — Jellyfin has no per-item subtitle preference). This screen is
// documented as Plex-only at the class level, so the cast is safe; on
// the off chance a Jellyfin item slips through, fall back to the
// unset/default string.
final fullMeta = _fullMetadata;
final fullPlex = fullMeta is PlexMediaItem ? fullMeta : null;
final widgetMeta = widget.metadata;
final widgetPlex = widgetMeta is PlexMediaItem ? widgetMeta : null;
switch (key) {
case 'audioLanguage':
return fullMeta?.audioLanguage ?? widgetMeta.audioLanguage ?? '';
case 'subtitleLanguage':
return fullPlex?.subtitleLanguage ?? widgetPlex?.subtitleLanguage ?? '';
case 'subtitleMode':
return (fullPlex?.subtitleMode ?? widgetPlex?.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;
}
bool get _showSortTitle => _mediaType != MediaKind.season;
bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
bool get _showReleaseDate => _mediaType != MediaKind.season;
bool get _showContentRating => _mediaType != MediaKind.season;
bool get _showStudio => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
bool get _showTagline => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
bool get _showBackground =>
_mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.episode;
bool get _showExtendedArtwork =>
_mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.collection;
bool get _showAdvanced => _mediaType != MediaKind.episode;
List<({String key, String label})> get _tagFields {
switch (_mediaType) {
case MediaKind.movie:
case MediaKind.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 MediaKind.episode:
return [(key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer)];
case MediaKind.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 MediaKind.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<void> _editTag(String key, String label) async {
final result = await showDialog<List<String>>(
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: LoadingIndicatorBox(size: 24))
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: OptimizedMediaImage(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() {
// Prefer the freshly fetched metadata for image paths, falling back to
// the public [MediaItem] before the fetch resolves.
final fullMeta = _fullMetadata;
final thumb = fullMeta?.thumbPath ?? widget.metadata.thumbPath;
final art = fullMeta?.artPath ?? widget.metadata.artPath;
final clearLogo = fullMeta?.clearLogoPath ?? widget.metadata.clearLogoPath;
final backgroundSquare = fullMeta?.backgroundSquarePath ?? widget.metadata.backgroundSquarePath;
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: thumb, label: t.metadataEdit.poster, element: 'posters'),
if (_showBackground)
_buildArtworkTile(width: 80, height: 45, imagePath: art, label: t.metadataEdit.background, element: 'arts'),
if (_showExtendedArtwork)
_buildArtworkTile(
width: 80,
height: 32,
imagePath: clearLogo,
label: t.metadataEdit.logo,
element: 'clearLogos',
fit: BoxFit.contain,
),
if (_showExtendedArtwork)
_buildArtworkTile(
width: 50,
height: 50,
imagePath: 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 == MediaKind.show) ..._buildShowAdvancedSettings(),
if (_mediaType == MediaKind.movie) ..._buildMovieAdvancedSettings(),
if (_mediaType == MediaKind.season) ..._buildSeasonAdvancedSettings(),
],
),
);
}
List<Widget> _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),
],
),
..._buildMetadataLanguageTiles(),
..._buildAudioSubtitleTiles(t.metadataEdit.accountDefault),
];
}
List<Widget> _buildMovieAdvancedSettings() {
return _buildMetadataLanguageTiles();
}
List<Widget> _buildSeasonAdvancedSettings() {
return _buildAudioSubtitleTiles(t.metadataEdit.seriesDefault);
}
List<Widget> _buildMetadataLanguageTiles() {
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<Widget> _buildAudioSubtitleTiles(String defaultLabel) {
return [
_buildAdvancedTile(
title: t.metadataEdit.preferredAudioLanguage,
prefKey: 'audioLanguage',
options: _audioSubtitleLanguageOptions(defaultLabel),
),
_buildAdvancedTile(
title: t.metadataEdit.preferredSubtitleLanguage,
prefKey: 'subtitleLanguage',
options: _audioSubtitleLanguageOptions(defaultLabel),
),
_buildAdvancedTile(
title: t.metadataEdit.subtitleMode,
prefKey: 'subtitleMode',
options: [
(value: '-1', label: defaultLabel),
(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),
);
}
}
// 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<String> 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]);