This commit is contained in:
Thibault Deckers 2025-02-04 20:35:46 +01:00
parent 34e22cd486
commit bbd819df19
10 changed files with 147 additions and 136 deletions

View file

@ -1,25 +1,29 @@
package deckers.thibault.aves.model
// entry fields exported and imported from/to the platform side
// should match `EntryFields` on Dart side
object EntryFields {
const val ORIGIN = "origin" // int
const val URI = "uri" // string
const val CONTENT_ID = "contentId" // long
const val PATH = "path" // string
const val PAGE_ID = "pageId" // int
const val SOURCE_MIME_TYPE = "sourceMimeType" // string
const val MIME_TYPE = "mimeType" // string
const val WIDTH = "width" // int
const val HEIGHT = "height" // int
const val SOURCE_ROTATION_DEGREES = "sourceRotationDegrees" // int
const val ROTATION_DEGREES = "rotationDegrees" // int
const val IS_FLIPPED = "isFlipped" // boolean
const val SIZE_BYTES = "sizeBytes" // long
const val TRASHED = "trashed" // boolean
const val TRASH_PATH = "trashPath" // string
const val TITLE = "title" // string
const val DATE_ADDED_SECS = "dateAddedSecs" // long
const val DATE_MODIFIED_SECS = "dateModifiedSecs" // long
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
const val DURATION_MILLIS = "durationMillis" // long
const val CONTENT_ID = "contentId" // long
const val SIZE_BYTES = "sizeBytes" // long
const val TRASHED = "trashed" // boolean
const val TRASH_PATH = "trashPath" // string
const val TITLE = "title" // string
}

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:aves/model/entry/cache.dart';
import 'package:aves/model/entry/dirs.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
@ -127,63 +128,63 @@ class AvesEntry with AvesEntryBase {
// from DB or platform source entry
factory AvesEntry.fromMap(Map map) {
return AvesEntry(
id: map['id'] as int?,
uri: map['uri'] as String,
path: map['path'] as String?,
id: map[EntryFields.id] as int?,
uri: map[EntryFields.uri] as String,
path: map[EntryFields.path] as String?,
pageId: null,
contentId: map['contentId'] as int?,
sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int? ?? 0,
height: map['height'] as int? ?? 0,
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
sizeBytes: map['sizeBytes'] as int?,
sourceTitle: map['title'] as String?,
dateAddedSecs: map['dateAddedSecs'] as int?,
dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int?,
trashed: (map['trashed'] as int? ?? 0) != 0,
origin: map['origin'] as int,
contentId: map[EntryFields.contentId] as int?,
sourceMimeType: map[EntryFields.sourceMimeType] as String,
width: map[EntryFields.width] as int? ?? 0,
height: map[EntryFields.height] as int? ?? 0,
sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
sizeBytes: map[EntryFields.sizeBytes] as int?,
sourceTitle: map[EntryFields.title] as String?,
dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: map[EntryFields.dateModifiedSecs] as int?,
sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
durationMillis: map[EntryFields.durationMillis] as int?,
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
origin: map[EntryFields.origin] as int,
);
}
// for DB only
Map<String, dynamic> toMap() {
return {
'id': id,
'uri': uri,
'path': path,
'contentId': contentId,
'sourceMimeType': sourceMimeType,
'width': width,
'height': height,
'sourceRotationDegrees': sourceRotationDegrees,
'sizeBytes': sizeBytes,
'title': sourceTitle,
'dateAddedSecs': dateAddedSecs,
'dateModifiedSecs': dateModifiedSecs,
'sourceDateTakenMillis': sourceDateTakenMillis,
'durationMillis': durationMillis,
'trashed': trashed ? 1 : 0,
'origin': origin,
EntryFields.id: id,
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.contentId: contentId,
EntryFields.sourceMimeType: sourceMimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.sourceRotationDegrees: sourceRotationDegrees,
EntryFields.sizeBytes: sizeBytes,
EntryFields.title: sourceTitle,
EntryFields.dateAddedSecs: dateAddedSecs,
EntryFields.dateModifiedSecs: dateModifiedSecs,
EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
EntryFields.durationMillis: durationMillis,
EntryFields.trashed: trashed ? 1 : 0,
EntryFields.origin: origin,
};
}
Map<String, dynamic> toPlatformEntryMap() {
return {
'uri': uri,
'path': path,
'pageId': pageId,
'mimeType': mimeType,
'width': width,
'height': height,
'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'dateModifiedSecs': dateModifiedSecs,
'sizeBytes': sizeBytes,
'trashed': trashed,
'trashPath': trashDetails?.path,
'origin': origin,
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.pageId: pageId,
EntryFields.mimeType: mimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.rotationDegrees: rotationDegrees,
EntryFields.isFlipped: isFlipped,
EntryFields.dateModifiedSecs: dateModifiedSecs,
EntryFields.sizeBytes: sizeBytes,
EntryFields.trashed: trashed,
EntryFields.trashPath: trashDetails?.path,
EntryFields.origin: origin,
};
}
@ -402,34 +403,34 @@ class AvesEntry with AvesEntryBase {
final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped;
final uri = newFields['uri'];
final uri = newFields[EntryFields.uri];
if (uri is String) this.uri = uri;
final path = newFields['path'];
final path = newFields[EntryFields.path];
if (path is String) this.path = path;
final contentId = newFields['contentId'];
final contentId = newFields[EntryFields.contentId];
if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title'];
final sourceTitle = newFields[EntryFields.title];
if (sourceTitle is String) this.sourceTitle = sourceTitle;
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
final width = newFields['width'];
final width = newFields[EntryFields.width];
if (width is int) this.width = width;
final height = newFields['height'];
final height = newFields[EntryFields.height];
if (height is int) this.height = height;
final durationMillis = newFields['durationMillis'];
final durationMillis = newFields[EntryFields.durationMillis];
if (durationMillis is int) this.durationMillis = durationMillis;
final sizeBytes = newFields['sizeBytes'];
final sizeBytes = newFields[EntryFields.sizeBytes];
if (sizeBytes is int) this.sizeBytes = sizeBytes;
final dateModifiedSecs = newFields['dateModifiedSecs'];
final dateModifiedSecs = newFields[EntryFields.dateModifiedSecs];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees'];
final rotationDegrees = newFields[EntryFields.rotationDegrees];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final isFlipped = newFields['isFlipped'];
final isFlipped = newFields[EntryFields.isFlipped];
if (isFlipped is bool) this.isFlipped = isFlipped;
if (persist) {

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/media/geotiff.dart';
import 'package:aves/model/metadata/catalog.dart';
@ -22,8 +23,8 @@ extension ExtraAvesEntryCatalog on AvesEntry {
final size = await SvgMetadataService.getSize(this);
if (size != null) {
final fields = {
'width': size.width.ceil(),
'height': size.height.ceil(),
EntryFields.width: size.width.ceil(),
EntryFields.height: size.height.ceil(),
};
await applyNewFields(fields, persist: persist);
}
@ -34,8 +35,8 @@ extension ExtraAvesEntryCatalog on AvesEntry {
// exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
// check size as the video interpreter may fail on some AVIF stills
final width = fields['width'];
final height = fields['height'];
final width = fields[EntryFields.width];
final height = fields[EntryFields.height];
final isValid = (width == null || width > 0) && (height == null || height > 0);
if (isValid) {
await applyNewFields(fields, persist: persist);
@ -47,7 +48,7 @@ extension ExtraAvesEntryCatalog on AvesEntry {
// post-processing
if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
catalogMetadata = await VideoMetadataFormatter.completeCatalogMetadata(this);
}
if (isGeotiff && !hasGps) {
final info = await metadataFetchService.getGeoTiffInfo(this);

View file

@ -0,0 +1,28 @@
// entry fields exported and imported from/to the platform side
// should match `EntryFields` on platform side
class EntryFields {
static const id = 'id'; // int
static const origin = 'origin'; // int
static const uri = 'uri'; // string
static const contentId = 'contentId'; // long
static const path = 'path'; // string
static const pageId = 'pageId'; // int
static const sourceMimeType = 'sourceMimeType'; // string
static const mimeType = 'mimeType'; // string
static const width = 'width'; // int
static const height = 'height'; // int
static const sourceRotationDegrees = 'sourceRotationDegrees'; // int
static const rotationDegrees = 'rotationDegrees'; // int
static const isFlipped = 'isFlipped'; // boolean
static const dateAddedSecs = 'dateAddedSecs'; // long
static const dateModifiedSecs = 'dateModifiedSecs'; // long
static const sourceDateTakenMillis = 'sourceDateTakenMillis'; // long
static const durationMillis = 'durationMillis'; // long
static const sizeBytes = 'sizeBytes'; // long
static const trashed = 'trashed'; // boolean
static const trashPath = 'trashPath'; // string
static const title = 'title'; // string
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/media/video/channel_layouts.dart';
import 'package:aves/model/media/video/codecs.dart';
import 'package:aves/model/media/video/profiles/aac.dart';
@ -46,6 +47,7 @@ class VideoMetadataFormatter {
Codecs.webm: 'WebM',
};
// fetch size and duration
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
final fields = <String, int>{};
@ -58,25 +60,26 @@ class VideoMetadataFormatter {
final width = sizedStream[Keys.videoWidth];
final height = sizedStream[Keys.videoHeight];
if (width is int && height is int) {
fields['width'] = width;
fields['height'] = height;
fields[EntryFields.width] = width;
fields[EntryFields.height] = height;
}
}
}
final durationMicros = mediaInfo[Keys.durationMicros];
if (durationMicros is num) {
fields['durationMillis'] = (durationMicros / 1000).round();
fields[EntryFields.durationMillis] = (durationMicros / 1000).round();
} else {
final duration = _parseDuration(mediaInfo[Keys.duration]);
if (duration != null && duration > Duration.zero) {
fields['durationMillis'] = duration.inMilliseconds;
fields[EntryFields.durationMillis] = duration.inMilliseconds;
}
}
return fields;
}
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
// fetch date and animated status
static Future<CatalogMetadata?> completeCatalogMetadata(AvesEntry entry) async {
var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id);
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);

View file

@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart';
@ -242,29 +243,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
newFields.keys.forEach((key) {
final newValue = newFields[key];
switch (key) {
case 'contentId':
case EntryFields.contentId:
entry.contentId = newValue as int?;
case 'dateModifiedSecs':
case EntryFields.dateModifiedSecs:
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
entry.dateModifiedSecs = newValue as int?;
case 'path':
case EntryFields.path:
entry.path = newValue as String?;
case 'title':
case EntryFields.title:
entry.sourceTitle = newValue as String?;
case 'trashed':
case EntryFields.trashed:
final trashed = newValue as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields['trashPath'] as String,
path: newFields[EntryFields.trashPath] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
case 'uri':
case EntryFields.uri:
entry.uri = newValue as String;
case 'origin':
case EntryFields.origin:
entry.origin = newValue as int;
}
});
@ -341,7 +342,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
if (movedOps.isEmpty) return;
final replacedUris = movedOps
.map((movedOp) => movedOp.newFields['path'] as String?)
.map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
.map((targetPath) {
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
return existingEntry?.uri;
@ -362,14 +363,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry.copyWith(
id: localMediaDb.nextId,
uri: newFields['uri'] as String?,
path: newFields['path'] as String?,
contentId: newFields['contentId'] as int?,
uri: newFields[EntryFields.uri] as String?,
path: newFields[EntryFields.path] as String?,
contentId: newFields[EntryFields.contentId] as int?,
// title can change when moved files are automatically renamed to avoid conflict
title: newFields['title'] as String?,
dateAddedSecs: newFields['dateAddedSecs'] as int?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
origin: newFields['origin'] as int?,
title: newFields[EntryFields.title] as String?,
dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: newFields[EntryFields.dateModifiedSecs] as int?,
origin: newFields[EntryFields.origin] as int?,
));
} else {
debugPrint('failed to find source entry with uri=$sourceUri');
@ -386,7 +387,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) {
if (moveType == MoveType.fromBin) {
newFields['trashed'] = false;
newFields[EntryFields.trashed] = false;
} else {
fromAlbums.add(entry.directory);
}

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/favourites.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/favourites.dart';
@ -110,8 +111,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((op) => op.success).toSet();
final exportedOps = successOps.where((op) => !op.skipped && op.newFields['uri'] != null).toSet();
final newUris = exportedOps.map((op) => op.newFields['uri'] as String).toSet();
final exportedOps = successOps.where((op) => !op.skipped && op.newFields[EntryFields.uri] != null).toSet();
final newUris = exportedOps.map((op) => op.newFields[EntryFields.uri] as String).toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
// check source favourite status
@ -120,7 +121,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
exportedOps.forEach((op) {
final sourceUri = op.uri;
if (favouriteSourceUris.contains(sourceUri)) {
final newUri = op.newFields['uri'] as String;
final newUri = op.newFields[EntryFields.uri] as String;
favouriteNewUris.add(newUri);
}
});
@ -483,7 +484,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Set<String> destinationAlbums,
Set<MoveOpEvent> movedOps,
) async {
final newUris = movedOps.map((op) => op.newFields['uri'] as String?).toSet();
final newUris = movedOps.map((op) => op.newFields[EntryFields.uri] as String?).toSet();
bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri);
final collection = context.read<CollectionLens?>();

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/filters/covered/stored_album.dart';
@ -134,7 +135,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
onPressed: () {
if (navigator != null) {
final source = _collection.source;
final newUri = newFields['uri'] as String?;
final newUri = newFields[EntryFields.uri] as String?;
navigator.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),

View file

@ -103,21 +103,7 @@ class _DbTabState extends State<DbTab> {
child: const Text('Duplicate entry'),
),
InfoRowGroup(
info: {
'uri': data.uri,
'path': data.path ?? '',
'sourceMimeType': data.sourceMimeType,
'width': '${data.width}',
'height': '${data.height}',
'sourceRotationDegrees': '${data.sourceRotationDegrees}',
'sizeBytes': '${data.sizeBytes}',
'sourceTitle': data.sourceTitle ?? '',
'dateAddedSecs': '${data.dateAddedSecs}',
'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}',
'trashed': '${data.trashed}',
},
info: Map.fromEntries(data.toMap().entries.map((kv) => MapEntry(kv.key, kv.value?.toString() ?? ''))),
),
],
],
@ -137,18 +123,7 @@ class _DbTabState extends State<DbTab> {
Text('DB metadata:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup(
info: {
'mimeType': data.mimeType ?? '',
'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}',
'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
'xmpSubjects': data.xmpSubjects ?? '',
'xmpTitle': data.xmpTitle ?? '',
'rating': '${data.rating}',
},
info: Map.fromEntries(data.toMap().entries.map((kv) => MapEntry(kv.key, kv.value?.toString() ?? ''))),
),
],
);
@ -167,12 +142,7 @@ class _DbTabState extends State<DbTab> {
Text('DB address:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup(
info: {
'countryCode': data.countryCode ?? '',
'countryName': data.countryName ?? '',
'adminArea': data.adminArea ?? '',
'locality': data.locality ?? '',
},
info: Map.fromEntries(data.toMap().entries.map((kv) => MapEntry(kv.key, kv.value?.toString() ?? ''))),
),
],
);

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/origins.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/image_op_events.dart';
@ -73,10 +74,10 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
skipped: false,
uri: entry.uri,
newFields: {
'uri': 'content://media/external/images/media/$newContentId',
'contentId': newContentId,
'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum),
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
EntryFields.uri: 'content://media/external/images/media/$newContentId',
EntryFields.contentId: newContentId,
EntryFields.path: entry.path!.replaceFirst(sourceAlbum, destinationAlbum),
EntryFields.dateModifiedSecs: FakeMediaStoreService.dateSecs,
},
deleted: false,
);
@ -90,10 +91,10 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
skipped: false,
uri: entry.uri,
newFields: {
'uri': 'content://media/external/images/media/$newContentId',
'contentId': newContentId,
'path': entry.path!.replaceFirst(oldName, newName),
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
EntryFields.uri: 'content://media/external/images/media/$newContentId',
EntryFields.contentId: newContentId,
EntryFields.path: entry.path!.replaceFirst(oldName, newName),
EntryFields.dateModifiedSecs: FakeMediaStoreService.dateSecs,
},
deleted: false,
);