903 lines
30 KiB
Dart
903 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/formatters.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}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}';
|
|
});
|
|
}
|
|
}
|
|
|
|
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>(
|
|
key: ValueKey(option.value),
|
|
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: [LoadingIndicatorBox.sliver]);
|
|
}
|
|
|
|
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]);
|