Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
344 lines
10 KiB
Dart
344 lines
10 KiB
Dart
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<MetadataEditOption> 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<MetadataEditField> 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<String, Object?> values;
|
|
final Map<String, Object?> originalValues;
|
|
final Map<String, Object?> extras;
|
|
List<MetadataEditSection>? cachedSchema;
|
|
|
|
MetadataEditDraft({
|
|
required this.sourceItem,
|
|
required this.currentItem,
|
|
required this.values,
|
|
Map<String, Object?>? originalValues,
|
|
Map<String, Object?>? extras,
|
|
}) : originalValues = originalValues ?? Map<String, Object?>.from(values),
|
|
extras = extras ?? <String, Object?>{};
|
|
|
|
T? value<T>(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<String, Object?>.from(values));
|
|
}
|
|
}
|
|
|
|
abstract class MetadataEditAdapter {
|
|
MediaBackend get backend;
|
|
MediaServerClient get mediaClient;
|
|
|
|
bool supportsKind(MediaKind kind);
|
|
|
|
Future<MetadataEditDraft> load(MediaItem item);
|
|
|
|
List<MetadataEditSection> buildSchema(MetadataEditDraft draft);
|
|
|
|
List<MetadataEditSection> 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<bool> save(MetadataEditDraft draft);
|
|
|
|
Future<bool> 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<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
|
|
|
|
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
|
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
|
}
|
|
|
|
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
|
|
|
|
Future<bool> uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List<int> bytes, {String? fileName});
|
|
|
|
Future<MediaItem?> 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<MetadataEditField> 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<MetadataEditField> metadataArtworkFields(
|
|
MediaKind kind, {
|
|
required String posterKey,
|
|
required String backdropKey,
|
|
required String logoKey,
|
|
String? squareKey,
|
|
Set<MediaKind> logoKinds = const {MediaKind.movie, MediaKind.show},
|
|
}) {
|
|
final fields = <MetadataEditField>[
|
|
// 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<String> metadataStringList(Object? value) {
|
|
if (value is List) {
|
|
return value.whereType<String>().where((v) => v.trim().isNotEmpty).map((v) => v.trim()).toList();
|
|
}
|
|
if (value is String && value.trim().isNotEmpty) return [value.trim()];
|
|
return <String>[];
|
|
}
|
|
|
|
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;
|
|
}
|