import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../utils/media_image_helper.dart'; enum MetadataEditFieldType { text, multilineText, date, stringList, choice, artwork } enum MetadataEditSaveMode { draft, immediate } enum MetadataArtworkFit { cover, contain } class MetadataEditOption { final String value; final String label; const MetadataEditOption({required this.value, required this.label}); } class MetadataArtworkConfig { final String key; final String selectTitle; final double previewWidth; final double previewHeight; final int gridColumns; final double gridAspectRatio; final MetadataArtworkFit fit; final ImageType imageType; const MetadataArtworkConfig({ required this.key, required this.selectTitle, required this.previewWidth, required this.previewHeight, required this.gridColumns, required this.gridAspectRatio, this.fit = MetadataArtworkFit.cover, this.imageType = ImageType.poster, }); } class MetadataEditField { final String id; final String label; final MetadataEditFieldType type; final MetadataEditSaveMode saveMode; final List options; final MetadataArtworkConfig? artwork; const MetadataEditField({ required this.id, required this.label, required this.type, this.saveMode = MetadataEditSaveMode.draft, this.options = const [], this.artwork, }); } class MetadataEditSection { final String id; final String title; final List fields; const MetadataEditSection({required this.id, required this.title, required this.fields}); } class MetadataArtworkOption { final String id; final String thumbnailPath; final String sourceUrl; final bool selected; final String? provider; final int? width; final int? height; const MetadataArtworkOption({ required this.id, required this.thumbnailPath, required this.sourceUrl, this.selected = false, this.provider, this.width, this.height, }); } class MetadataEditDraft { final MediaItem sourceItem; MediaItem currentItem; final Map values; final Map originalValues; final Map extras; List? cachedSchema; MetadataEditDraft({ required this.sourceItem, required this.currentItem, required this.values, Map? originalValues, Map? extras, }) : originalValues = originalValues ?? Map.from(values), extras = extras ?? {}; T? value(String id) => values[id] as T?; void setValue(String id, Object? value) { values[id] = value; } bool fieldChanged(String id) => !metadataEditValueEquals(values[id], originalValues[id]); void acceptChanges() { originalValues ..clear() ..addAll(Map.from(values)); } } abstract class MetadataEditAdapter { MediaBackend get backend; MediaServerClient get mediaClient; bool supportsKind(MediaKind kind); Future load(MediaItem item); List buildSchema(MetadataEditDraft draft); List schemaFor(MetadataEditDraft draft) => draft.cachedSchema ??= buildSchema(draft); bool hasChanges(MetadataEditDraft draft) { final draftFields = schemaFor( draft, ).expand((section) => section.fields).where((field) => field.saveMode == MetadataEditSaveMode.draft); return draftFields.any((field) => metadataEditFieldChanged(draft, field)); } Future save(MetadataEditDraft draft); Future saveImmediateField(MetadataEditDraft draft, MetadataEditField field, Object? value) async { draft.setValue(field.id, value); final success = await save(draft); if (success) draft.acceptChanges(); return success; } Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { return applyArtworkFromUrl(draft, field, option.sourceUrl); } Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); Future uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List bytes, {String? fileName}); Future reloadItem(MetadataEditDraft draft) => mediaClient.fetchItem(draft.sourceItem.id); void syncReloadedItem(MetadataEditDraft draft, MediaItem item) { draft.currentItem = item; } } /// Basic-info fields shared by every backend; only the studio field type differs. List metadataBasicFields( MediaKind kind, { MetadataEditFieldType studioType = MetadataEditFieldType.text, }) { return [ MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), if (kind != MediaKind.season) MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), if (kind == MediaKind.movie || kind == MediaKind.show) MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), if (kind != MediaKind.season) MetadataEditField( id: 'originallyAvailableAt', label: t.metadataEdit.releaseDate, type: MetadataEditFieldType.date, ), if (kind != MediaKind.season) MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), if (kind == MediaKind.movie || kind == MediaKind.show) MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType), if (kind == MediaKind.movie || kind == MediaKind.show) MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), ]; } /// Artwork fields shared by every backend; each backend supplies its own artwork /// key names, which kinds carry a logo, and whether square art exists. List metadataArtworkFields( MediaKind kind, { required String posterKey, required String backdropKey, required String logoKey, String? squareKey, Set logoKinds = const {MediaKind.movie, MediaKind.show}, }) { final fields = [ // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. kind == MediaKind.episode ? metadataArtworkField( posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 80, 45, 2, 16 / 9, imageType: ImageType.thumb, ) : metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), ]; if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { fields.add( metadataArtworkField( backdropKey, t.metadataEdit.background, t.metadataEdit.selectBackground, 80, 45, 2, 16 / 9, imageType: ImageType.art, ), ); } if (logoKinds.contains(kind)) { fields.add( metadataArtworkField( logoKey, t.metadataEdit.logo, t.metadataEdit.selectLogo, 80, 32, 2, 2.5, fit: MetadataArtworkFit.contain, imageType: ImageType.logo, ), ); if (squareKey != null) { fields.add( metadataArtworkField( squareKey, t.metadataEdit.squareArt, t.metadataEdit.selectSquareArt, 50, 50, 3, 1, imageType: ImageType.avatar, ), ); } } return fields; } MetadataEditField metadataArtworkField( String key, String label, String title, double width, double height, int columns, double aspectRatio, { MetadataArtworkFit fit = MetadataArtworkFit.cover, ImageType imageType = ImageType.poster, }) { return MetadataEditField( id: 'artwork:$key', label: label, type: MetadataEditFieldType.artwork, saveMode: MetadataEditSaveMode.immediate, artwork: MetadataArtworkConfig( key: key, selectTitle: title, previewWidth: width, previewHeight: height, gridColumns: columns, gridAspectRatio: aspectRatio, fit: fit, imageType: imageType, ), ); } bool metadataEditValueEquals(Object? a, Object? b) { if (identical(a, b)) return true; if (a is List && b is List) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (!metadataEditValueEquals(a[i], b[i])) return false; } return true; } if (a is Map && b is Map) { if (a.length != b.length) return false; for (final key in a.keys) { if (!b.containsKey(key) || !metadataEditValueEquals(a[key], b[key])) return false; } return true; } return a == b; } bool metadataEditFieldChanged(MetadataEditDraft draft, MetadataEditField field) { final current = draft.values[field.id]; final original = draft.originalValues[field.id]; return field.type == MetadataEditFieldType.stringList ? !metadataEditStringListEquals(current, original) : !metadataEditValueEquals(current, original); } bool metadataEditStringListEquals(Object? a, Object? b) { final left = metadataStringList(a).toSet(); final right = metadataStringList(b).toSet(); if (left.length != right.length) return false; return left.every(right.contains); } List metadataStringList(Object? value) { if (value is List) { return value.whereType().where((v) => v.trim().isNotEmpty).map((v) => v.trim()).toList(); } if (value is String && value.trim().isNotEmpty) return [value.trim()]; return []; } String metadataFirstString(Object? value) { final list = metadataStringList(value); return list.isEmpty ? '' : list.first; } String? metadataEmptyToNull(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; }