From fd5bb222d7240babd2b0c3d01f0ae8f1a1452250 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 13 Apr 2020 11:20:37 +0900 Subject: [PATCH] filters: replaced GIF mime filter by animated webp or gif --- .../aves/channelhandlers/MetadataHandler.java | 132 ++++++++++++------ .../thibault/aves/utils/MimeTypes.java | 1 + lib/model/filters/favourite.dart | 4 +- lib/model/filters/location.dart | 4 +- lib/model/filters/mime.dart | 16 ++- lib/model/filters/tag.dart | 4 +- lib/model/image_entry.dart | 7 +- lib/model/image_metadata.dart | 11 +- lib/model/metadata_db.dart | 34 ++++- lib/model/metadata_service.dart | 2 + lib/model/mime_types.dart | 4 + lib/widgets/album/collection_drawer.dart | 24 ++-- lib/widgets/album/search/search_delegate.dart | 2 +- lib/widgets/album/thumbnail.dart | 4 +- lib/widgets/album/thumbnail_collection.dart | 7 +- lib/widgets/common/icons.dart | 74 ++++++---- lib/widgets/fullscreen/debug.dart | 5 +- .../fullscreen/info/basic_section.dart | 4 +- .../fullscreen/info/location_section.dart | 3 +- lib/widgets/fullscreen/overlay/bottom.dart | 5 +- lib/widgets/fullscreen/overlay/top.dart | 10 +- lib/widgets/stats.dart | 4 +- 22 files changed, 240 insertions(+), 121 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index a0103767c..0b338f642 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -19,8 +19,11 @@ import com.drew.lang.GeoLocation; import com.drew.metadata.Directory; import com.drew.metadata.Metadata; import com.drew.metadata.Tag; +import com.drew.metadata.exif.ExifIFD0Directory; import com.drew.metadata.exif.ExifSubIFDDirectory; import com.drew.metadata.exif.GpsDirectory; +import com.drew.metadata.gif.GifAnimationDirectory; +import com.drew.metadata.webp.WebpDirectory; import com.drew.metadata.xmp.XmpDirectory; import java.io.FileInputStream; @@ -41,13 +44,30 @@ import io.flutter.plugin.common.MethodChannel; public class MetadataHandler implements MethodChannel.MethodCallHandler { public static final String CHANNEL = "deckers.thibault/aves/metadata"; + // catalog metadata + private static final String KEY_DATE_MILLIS = "dateMillis"; + private static final String KEY_IS_ANIMATED = "isAnimated"; + private static final String KEY_LATITUDE = "latitude"; + private static final String KEY_LONGITUDE = "longitude"; + private static final String KEY_VIDEO_ROTATION = "videoRotation"; + private static final String KEY_XMP_SUBJECTS = "xmpSubjects"; + private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"; + + // overlay metadata + private static final String KEY_APERTURE = "aperture"; + private static final String KEY_EXPOSURE_TIME = "exposureTime"; + private static final String KEY_FOCAL_LENGTH = "focalLength"; + private static final String KEY_ISO = "iso"; + + // XMP private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"; private static final String XMP_SUBJECT_PROP_NAME = "dc:subject"; private static final String XMP_TITLE_PROP_NAME = "dc:title"; private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description"; private static final String XMP_GENERIC_LANG = ""; private static final String XMP_SPECIFIC_LANG = "en-US"; - private static final Pattern videoLocationPattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?"); + + private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?"); private Context context; @@ -179,12 +199,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { if (!MimeTypes.MP2T.equals(mimeType)) { Metadata metadata = ImageMetadataReader.readMetadata(is); - // EXIF Sub-IFD - ExifSubIFDDirectory exifSubDir = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); - if (exifSubDir != null) { - if (exifSubDir.containsTag(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)) { - metadataMap.put("dateMillis", exifSubDir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, null, TimeZone.getDefault()).getTime()); - } + // EXIF + putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); } // GPS @@ -192,8 +210,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { if (gpsDir != null) { GeoLocation geoLocation = gpsDir.getGeoLocation(); if (geoLocation != null) { - metadataMap.put("latitude", geoLocation.getLatitude()); - metadataMap.put("longitude", geoLocation.getLongitude()); + metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude()); + metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude()); } } @@ -209,30 +227,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); sb.append(";").append(item.getValue()); } - metadataMap.put("xmpSubjects", sb.toString()); + metadataMap.put(KEY_XMP_SUBJECTS, sb.toString()); } - // double check retrieved items as the property sometimes is reported to exist but it is actually null - String titleDescription = null; - if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME)) { - XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); - if (item != null) { - titleDescription = item.getValue(); - } - } - if (titleDescription == null && xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME)) { - XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); - if (item != null) { - titleDescription = item.getValue(); - } - } - if (titleDescription != null) { - metadataMap.put("xmpTitleDescription", titleDescription); + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME); + if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME); } } catch (XMPException e) { e.printStackTrace(); } } + + // Animated GIF & WEBP + if (MimeTypes.GIF.equals(mimeType)) { + metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class)); + } else if (MimeTypes.WEBP.equals(mimeType)) { + WebpDirectory webpDir = metadata.getFirstDirectoryOfType(WebpDirectory.class); + if (webpDir != null) { + if (webpDir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) { + metadataMap.put(KEY_IS_ANIMATED, webpDir.getBoolean(WebpDirectory.TAG_IS_ANIMATION)); + } + } + } } if (isVideo(mimeType)) { @@ -251,14 +268,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString); // some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time if (dateMillis > 0) { - metadataMap.put("dateMillis", dateMillis); + metadataMap.put(KEY_DATE_MILLIS, dateMillis); } } if (rotationString != null) { - metadataMap.put("videoRotation", Integer.parseInt(rotationString)); + metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString)); } if (locationString != null) { - Matcher locationMatcher = videoLocationPattern.matcher(locationString); + Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString); if (locationMatcher.find() && locationMatcher.groupCount() == 2) { String latitudeString = locationMatcher.group(1); String longitudeString = locationMatcher.group(2); @@ -267,8 +284,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { double latitude = Double.parseDouble(latitudeString); double longitude = Double.parseDouble(longitudeString); if (latitude != 0 && longitude != 0) { - metadataMap.put("latitude", latitude); - metadataMap.put("longitude", longitude); + metadataMap.put(KEY_LATITUDE, latitude); + metadataMap.put(KEY_LONGITUDE, longitude); } } catch (NumberFormatException ex) { // ignore @@ -297,7 +314,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { String path = call.argument("path"); String uri = call.argument("uri"); - Map metadataMap = new HashMap<>(); + Map metadataMap = new HashMap<>(); if (isVideo(mimeType)) { result.success(metadataMap); @@ -308,17 +325,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { Metadata metadata = ImageMetadataReader.readMetadata(is); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); if (directory != null) { - if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { - metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { - metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { - metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); - } + putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER); + putStringFromTag(metadataMap, KEY_EXPOSURE_TIME, directory, ExifSubIFDDirectory.TAG_EXPOSURE_TIME); + putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH); if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { - metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); + metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); } } result.success(metadataMap); @@ -330,4 +341,41 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { result.error("getOverlayMetadata-exception", "failed to get metadata for uri=" + uri + ", path=" + path, e.getMessage()); } } + + // convenience methods + + private static void putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { + Directory dir = metadata.getFirstDirectoryOfType(dirClass); + if (dir != null) { + putDateFromTag(metadataMap, key, dir, tag); + } + } + + private static void putDateFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime()); + } + } + + private static void putDescriptionFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getDescription(tag)); + } + } + + private static void putStringFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getString(tag)); + } + } + + private static void putLocalizedTextFromXmp(Map metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException { + if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) { + XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + metadataMap.put(key, item.getValue()); + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java index 5285c967f..b2d4c1a46 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java @@ -8,6 +8,7 @@ public class MimeTypes { public static final String JPEG = "image/jpeg"; public static final String PNG = "image/png"; public static final String SVG = "image/svg+xml"; + public static final String WEBP = "image/webp"; public static final String VIDEO = "video"; public static final String AVI = "video/avi"; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 1b4b17c62..454f11e64 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -1,8 +1,8 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; @@ -14,7 +14,7 @@ class FavouriteFilter extends CollectionFilter { String get label => 'Favourite'; @override - Widget iconBuilder(context, size) => Icon(OMIcons.favoriteBorder, size: size); + Widget iconBuilder(context, size) => Icon(AIcons.favourite, size: size); @override String get typeKey => type; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index b22a016e8..b6ce96e67 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class LocationFilter extends CollectionFilter { static const type = 'country'; @@ -26,7 +26,7 @@ class LocationFilter extends CollectionFilter { Widget iconBuilder(context, size) { final flag = countryCodeToFlag(_countryCode); if (flag != null) return Text(flag, style: TextStyle(fontSize: size)); - return Icon(OMIcons.place, size: size); + return Icon(AIcons.location, size: size); } @override diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index a94e0a797..8e8825f9a 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -1,12 +1,16 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/mime_types.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; class MimeFilter extends CollectionFilter { static const type = 'mime'; + // fake mime type + static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` + final String mime; bool Function(ImageEntry) _filter; String _label; @@ -14,19 +18,21 @@ class MimeFilter extends CollectionFilter { MimeFilter(this.mime) { var lowMime = mime.toLowerCase(); - if (lowMime.endsWith('/*')) { + if (mime == animated) { + _filter = (entry) => entry.isAnimated; + _label = 'Animated'; + _icon = AIcons.animated; + } else if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _filter = (entry) => entry.mimeType.startsWith(lowMime); if (lowMime == 'video') { _label = 'Video'; - _icon = OMIcons.movie; + _icon = AIcons.video; } _label ??= lowMime.split('/')[0].toUpperCase(); } else { _filter = (entry) => entry.mimeType == lowMime; - if (lowMime == MimeTypes.GIF) { - _icon = OMIcons.gif; - } else if (lowMime == MimeTypes.SVG) { + if (lowMime == MimeTypes.SVG) { _label = 'SVG'; } _label ??= lowMime.split('/')[1].toUpperCase(); diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index d3d473dcf..8e2bf02bf 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class TagFilter extends CollectionFilter { static const type = 'tag'; @@ -20,7 +20,7 @@ class TagFilter extends CollectionFilter { String get label => tag; @override - Widget iconBuilder(context, size) => Icon(OMIcons.localOffer, size: size); + Widget iconBuilder(context, size) => Icon(AIcons.tag, size: size); @override String get typeKey => type; diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 868351d93..bb8e17991 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -106,14 +106,17 @@ class ImageEntry { bool get isFavourite => favourites.isFavourite(this); - bool get isGif => mimeType == MimeTypes.GIF; - bool get isSvg => mimeType == MimeTypes.SVG; + // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) + bool get isPhoto => [MimeTypes.HEIC, MimeTypes.HEIF, MimeTypes.JPEG].contains(mimeType); + bool get isVideo => mimeType.startsWith('video'); bool get isCatalogued => _catalogMetadata != null; + bool get isAnimated => _catalogMetadata?.isAnimated ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 6d8f00b69..c314123a7 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -29,6 +29,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis, videoRotation; + final bool isAnimated; final String xmpSubjects, xmpTitleDescription; final double latitude, longitude; Address address; @@ -36,6 +37,7 @@ class CatalogMetadata { CatalogMetadata({ this.contentId, this.dateMillis, + this.isAnimated, this.videoRotation, this.xmpSubjects, this.xmpTitleDescription, @@ -46,10 +48,12 @@ class CatalogMetadata { : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; - factory CatalogMetadata.fromMap(Map map) { + factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { + final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( contentId: map['contentId'], dateMillis: map['dateMillis'] ?? 0, + isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, videoRotation: map['videoRotation'] ?? 0, xmpSubjects: map['xmpSubjects'] ?? '', xmpTitleDescription: map['xmpTitleDescription'] ?? '', @@ -58,9 +62,10 @@ class CatalogMetadata { ); } - Map toMap() => { + Map toMap({bool boolAsInteger = false}) => { 'contentId': contentId, 'dateMillis': dateMillis, + 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'videoRotation': videoRotation, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -70,7 +75,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 060bbed8f..92994fe76 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -24,10 +24,32 @@ class MetadataDb { _database = openDatabase( await path, onCreate: (db, version) async { - await db.execute('CREATE TABLE $dateTakenTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER)'); - await db.execute('CREATE TABLE $metadataTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, xmpTitleDescription TEXT, latitude REAL, longitude REAL)'); - await db.execute('CREATE TABLE $addressTable(contentId INTEGER PRIMARY KEY, addressLine TEXT, countryCode TEXT, countryName TEXT, adminArea TEXT, locality TEXT)'); - await db.execute('CREATE TABLE $favouriteTable(contentId INTEGER PRIMARY KEY, path TEXT)'); + await db.execute('CREATE TABLE $dateTakenTable(' + 'contentId INTEGER PRIMARY KEY' + ', dateMillis INTEGER' + ')'); + await db.execute('CREATE TABLE $metadataTable(' + 'contentId INTEGER PRIMARY KEY' + ', dateMillis INTEGER' + ', isAnimated INTEGER' + ', videoRotation INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ')'); + await db.execute('CREATE TABLE $addressTable(' + 'contentId INTEGER PRIMARY KEY' + ', addressLine TEXT' + ', countryCode TEXT' + ', countryName TEXT' + ', adminArea TEXT' + ', locality TEXT' + ')'); + await db.execute('CREATE TABLE $favouriteTable(' + 'contentId INTEGER PRIMARY KEY' + ', path TEXT' + ')'); }, version: 1, ); @@ -74,7 +96,7 @@ class MetadataDb { // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(metadataTable); - final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); + final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList(); // debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); return metadataEntries; } @@ -94,7 +116,7 @@ class MetadataDb { } batch.insert( metadataTable, - metadata.toMap(), + metadata.toMap(boolAsInteger: true), conflictAlgorithm: ConflictAlgorithm.replace, ); }); diff --git a/lib/model/metadata_service.dart b/lib/model/metadata_service.dart index c37c03447..bc6923353 100644 --- a/lib/model/metadata_service.dart +++ b/lib/model/metadata_service.dart @@ -29,8 +29,10 @@ class MetadataService { try { // return map with: // 'dateMillis': date taken in milliseconds since Epoch (long) + // 'isAnimated': animated gif/webp (bool) // 'latitude': latitude (double) // 'longitude': longitude (double) + // 'videoRotation': video rotation degrees (int) // 'xmpSubjects': ';' separated XMP subjects (string) // 'xmpTitleDescription': XMP title or XMP description (string) final result = await platform.invokeMethod('getCatalogMetadata', { diff --git a/lib/model/mime_types.dart b/lib/model/mime_types.dart index eb2d2ed7b..f84af8658 100644 --- a/lib/model/mime_types.dart +++ b/lib/model/mime_types.dart @@ -1,8 +1,12 @@ class MimeTypes { + static const String ANY_IMAGE = 'image/*'; static const String GIF = 'image/gif'; + static const String HEIC = 'image/heic'; + static const String HEIF = 'image/heif'; static const String JPEG = 'image/jpeg'; static const String PNG = 'image/png'; static const String SVG = 'image/svg+xml'; + static const String WEBP = 'image/webp'; static const String ANY_VIDEO = 'video/*'; static const String AVI = 'video/avi'; diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index 5b5748874..f9e7ea1e8 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -77,19 +77,19 @@ class _CollectionDrawerState extends State { ); final videoEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.movie), + leading: const Icon(AIcons.video), title: 'Videos', filter: MimeFilter(MimeTypes.ANY_VIDEO), ); - final gifEntry = _FilteredCollectionNavTile( + final animatedEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.gif), - title: 'GIFs', - filter: MimeFilter(MimeTypes.GIF), + leading: const Icon(AIcons.animated), + title: 'Animated', + filter: MimeFilter(MimeFilter.animated), ); final favouriteEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.favoriteBorder), + leading: const Icon(AIcons.favourite), title: 'Favourites', filter: FavouriteFilter(), ); @@ -103,7 +103,7 @@ class _CollectionDrawerState extends State { final buildTagEntry = (String tag) => _FilteredCollectionNavTile( source: source, leading: Icon( - OMIcons.localOffer, + AIcons.tag, color: stringToColor(tag), ), title: tag, @@ -130,7 +130,7 @@ class _CollectionDrawerState extends State { style: TextStyle(fontSize: IconTheme.of(context).size), ) : Icon( - OMIcons.place, + AIcons.location, color: stringToColor(title), ), title: title, @@ -161,7 +161,7 @@ class _CollectionDrawerState extends State { header, allMediaEntry, videoEntry, - gifEntry, + animatedEntry, favouriteEntry, if (specialAlbums.isNotEmpty) ...[ const Divider(), @@ -198,7 +198,7 @@ class _CollectionDrawerState extends State { top: false, bottom: false, child: ExpansionTile( - leading: const Icon(OMIcons.place), + leading: const Icon(AIcons.location), title: Row( children: [ const Text('Cities'), @@ -220,7 +220,7 @@ class _CollectionDrawerState extends State { top: false, bottom: false, child: ExpansionTile( - leading: const Icon(OMIcons.place), + leading: const Icon(AIcons.location), title: Row( children: [ const Text('Countries'), @@ -242,7 +242,7 @@ class _CollectionDrawerState extends State { top: false, bottom: false, child: ExpansionTile( - leading: const Icon(OMIcons.localOffer), + leading: const Icon(AIcons.tag), title: Row( children: [ const Text('Tags'), diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index fd942aaac..4aa37980b 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -62,7 +62,7 @@ class ImageSearchDelegate extends SearchDelegate { children: [ _buildFilterRow( context: context, - filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeTypes.GIF), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)), + filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeFilter.animated), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)), ), _buildFilterRow( context: context, diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index de30930ba..e33acdc60 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -125,8 +125,8 @@ class _ThumbnailOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (entry.hasGps) GpsIcon(iconSize: iconSize), - if (entry.isGif) - GifIcon(iconSize: iconSize) + if (entry.isAnimated) + AnimatedImageIcon(iconSize: iconSize) else if (entry.isVideo) DefaultTextStyle( style: TextStyle( diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index dd61e1481..a252f854b 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -8,10 +8,10 @@ import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/grid/list_sliver.dart'; import 'package:aves/widgets/album/grid/scaling.dart'; import 'package:aves/widgets/album/tile_extent_manager.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/scroll_thumb.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -40,7 +40,6 @@ class ThumbnailCollection extends StatelessWidget { return Consumer( builder: (context, collection, child) { // debugPrint('$runtimeType collection builder entries=${collection.entryCount}'); - final sectionKeys = collection.sections.keys.toList(); final showHeaders = collection.showHeaders; return GridScaleGestureDetector( scrollableKey: _scrollableKey, @@ -119,12 +118,12 @@ class ThumbnailCollection extends StatelessWidget { Widget _buildEmptyCollectionPlaceholder(CollectionLens collection) { return collection.filters.any((filter) => filter is FavouriteFilter) ? const EmptyContent( - icon: OMIcons.favoriteBorder, + icon: AIcons.favourite, text: 'No favourites!', ) : collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO) ? const EmptyContent( - icon: OMIcons.movie, + icon: AIcons.video, ) : const EmptyContent(); } diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 9ed6faf0c..e55548c92 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -6,6 +6,16 @@ import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart import 'package:flutter/material.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; +class AIcons { + static const IconData animated = Icons.slideshow; + static const IconData video = OMIcons.movie; + static const IconData favourite = OMIcons.favoriteBorder; + static const IconData favouriteActive = OMIcons.favorite; + static const IconData date = OMIcons.calendarToday; + static const IconData location = OMIcons.place; + static const IconData tag = OMIcons.localOffer; +} + class VideoIcon extends StatelessWidget { final ImageEntry entry; final double iconSize; @@ -16,22 +26,23 @@ class VideoIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: OMIcons.playCircleOutline, - iconSize: iconSize, + size: iconSize, text: entry.durationText, ); } } -class GifIcon extends StatelessWidget { +class AnimatedImageIcon extends StatelessWidget { final double iconSize; - const GifIcon({Key key, this.iconSize}) : super(key: key); + const AnimatedImageIcon({Key key, this.iconSize}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( - icon: OMIcons.gif, - iconSize: iconSize, + icon: AIcons.animated, + size: iconSize, + iconSize: iconSize * .8, ); } } @@ -44,44 +55,57 @@ class GpsIcon extends StatelessWidget { @override Widget build(BuildContext context) { return OverlayIcon( - icon: OMIcons.place, - iconSize: iconSize, + icon: AIcons.location, + size: iconSize, ); } } class OverlayIcon extends StatelessWidget { final IconData icon; - final double iconSize; + final double size, iconSize; final String text; - const OverlayIcon({Key key, this.icon, this.iconSize, this.text}) : super(key: key); + const OverlayIcon({ + Key key, + @required this.icon, + @required this.size, + double iconSize, + this.text, + }) : iconSize = iconSize ?? size, + super(key: key); @override Widget build(BuildContext context) { + final iconChild = SizedBox( + width: size, + height: size, + child: Icon( + icon, + size: iconSize, + ), + ); + return Container( margin: const EdgeInsets.all(1), - padding: text != null ? EdgeInsets.only(right: iconSize / 4) : null, + padding: text != null ? EdgeInsets.only(right: size / 4) : null, decoration: BoxDecoration( color: const Color(0xBB000000), borderRadius: BorderRadius.all( - Radius.circular(iconSize), + Radius.circular(size), ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - icon, - size: iconSize, - ), - if (text != null) ...[ - const SizedBox(width: 2), - Text(text), - ] - ], - ), + child: text == null + ? iconChild + : Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + iconChild, + const SizedBox(width: 2), + Text(text), + ], + ), ); } } diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 526c7b4be..21ffad671 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -68,6 +68,7 @@ class _FullscreenDebugPageState extends State { if (data != null) InfoRowGroup({ 'dateMillis': '${data.dateMillis}', + 'isAnimated': '${data.isAnimated}', 'videoRotation': '${data.videoRotation}', 'latitude': '${data.latitude}', 'longitude': '${data.longitude}', @@ -91,7 +92,7 @@ class _FullscreenDebugPageState extends State { Text('DB address:${data == null ? ' no row' : ''}'), if (data != null) InfoRowGroup({ - 'dateMillis': '${data.addressLine}', + 'addressLine': '${data.addressLine}', 'countryCode': '${data.countryCode}', 'countryName': '${data.countryName}', 'adminArea': '${data.adminArea}', @@ -101,6 +102,8 @@ class _FullscreenDebugPageState extends State { ); }, ), + const Divider(), + Text('Catalog metadata: ${widget.entry.catalogMetadata}'), ], ), ), diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index b4aaa2d1f..4b259d509 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -28,7 +28,7 @@ class BasicSection extends StatelessWidget { Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : '?'; - final showMegaPixels = !entry.isVideo && !entry.isGif && entry.megaPixels != null && entry.megaPixels > 0; + final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); @@ -50,7 +50,7 @@ class BasicSection extends StatelessWidget { final album = entry.directory; final filters = [ if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO), - if (entry.isGif) MimeFilter(MimeTypes.GIF), + if (entry.isAnimated) MimeFilter(MimeFilter.animated), if (isFavourite) FavouriteFilter(), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), ...tags.map((tag) => TagFilter(tag)), diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 15b4d8e8f..3c750e2c0 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -5,6 +5,7 @@ import 'package:aves/model/settings.dart'; import 'package:aves/utils/android_app_service.dart'; import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/map_initializer.dart'; import 'package:flutter/material.dart'; @@ -93,7 +94,7 @@ class _LocationSectionState extends State { if (widget.showTitle) const Padding( padding: EdgeInsets.only(bottom: 8), - child: SectionRow(OMIcons.place), + child: SectionRow(AIcons.location), ), ImageMap( markerId: entry.uri ?? entry.path, diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 89de81bc5..f0c5beaa2 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_service.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; @@ -216,7 +217,7 @@ class _LocationRow extends AnimatedWidget { } return Row( children: [ - const Icon(OMIcons.place, size: _iconSize), + const Icon(AIcons.location, size: _iconSize), const SizedBox(width: _iconPadding), Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), ], @@ -236,7 +237,7 @@ class _DateRow extends StatelessWidget { final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; return Row( children: [ - const Icon(OMIcons.calendarToday, size: _iconSize), + const Icon(AIcons.date, size: _iconSize), const SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 940c27d17..2059b3e8c 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -2,12 +2,12 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/fullscreen/fullscreen_actions.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; class FullscreenTopOverlay extends StatelessWidget { @@ -130,12 +130,12 @@ class FullscreenTopOverlay extends StatelessWidget { alignment: Alignment.center, children: [ IconButton( - icon: Icon(isFavourite ? OMIcons.favorite : OMIcons.favoriteBorder), + icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), onPressed: onPressed, tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites', ), Sweeper( - builder: (context) => Icon(OMIcons.favoriteBorder, color: Colors.redAccent), + builder: (context) => Icon(AIcons.favourite, color: Colors.redAccent), toggledNotifier: entry.isFavouriteNotifier, ), ], @@ -181,11 +181,11 @@ class FullscreenTopOverlay extends StatelessWidget { child = entry.isFavouriteNotifier.value ? const MenuRow( text: 'Remove from favourites', - icon: OMIcons.favorite, + icon: AIcons.favouriteActive, ) : const MenuRow( text: 'Add to favourites', - icon: OMIcons.favoriteBorder, + icon: AIcons.favourite, ); break; case FullscreenAction.info: diff --git a/lib/widgets/stats.dart b/lib/widgets/stats.dart index 6dee9f129..5cef20aa4 100644 --- a/lib/widgets/stats.dart +++ b/lib/widgets/stats.dart @@ -11,11 +11,11 @@ import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; class StatsPage extends StatelessWidget { @@ -77,7 +77,7 @@ class StatsPage extends StatelessWidget { backgroundColor: Colors.white24, progressColor: Theme.of(context).accentColor, animation: true, - leading: const Icon(OMIcons.place), + leading: const Icon(AIcons.location), // right padding to match leading, so that inside label is aligned with outside label below padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(right: 24), center: Text(NumberFormat.percentPattern().format(withGpsPercent)),