From 0cce0c1e1152bd442a75bf086b8e2b94241386ca Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 23 Aug 2022 11:49:22 +0200 Subject: [PATCH] #305 heic motion photo support --- CHANGELOG.md | 1 + .../channel/calls/MetadataFetchHandler.kt | 87 ++++--------------- .../thibault/aves/metadata/MultiPage.kt | 77 +++++++++++----- .../deckers/thibault/aves/metadata/XMP.kt | 22 +++++ .../thibault/aves/utils/CollectionUtils.kt | 25 +++++- .../thibault/aves/utils/ContextUtils.kt | 43 +++++++++ lib/model/entry.dart | 5 +- lib/model/metadata/catalog.dart | 10 ++- .../metadata/metadata_fetch_service.dart | 1 + .../action/entry_info_action_delegate.dart | 2 +- lib/widgets/viewer/debug/debug_page.dart | 2 + lib/widgets/viewer/entry_viewer_stack.dart | 2 + 12 files changed, 179 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a099a1d40..1e2d737c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - option to hide confirmation message after moving items to the bin - Collection / Info: edit description via Exif / IPTC / XMP - Info: read XMP from HEIC on Android >=11 +- Collection: support HEIC motion photos on Android >=11 - Dutch translation (thanks Martijn Fabrie, Koen Koppens) ### Changed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 4beeb6c32..e6f373544 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -1,13 +1,10 @@ package deckers.thibault.aves.channel.calls import android.annotation.SuppressLint -import android.content.ContentUris import android.content.Context -import android.database.Cursor import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build -import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException @@ -69,16 +66,15 @@ import deckers.thibault.aves.metadata.XMP.getSafeString import deckers.thibault.aves.metadata.XMP.isMotionPhoto import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isHeic -import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils -import deckers.thibault.aves.utils.UriUtils.tryParseId import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -370,7 +366,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - checkHeicXmp(uri, mimeType, foundXmp) { xmpMeta -> + XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta -> val thisDirName = XmpDirectory().name val dirMap = metadataMap[thisDirName] ?: HashMap() metadataMap[thisDirName] = dirMap @@ -499,7 +495,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // identification of motion photo if (xmpMeta.isMotionPhoto()) { - flags = flags or MASK_IS_MULTIPAGE + flags = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) @@ -659,7 +655,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE @@ -828,16 +824,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() - if (mimeType == null || uri == null || sizeBytes == null) { + val isMotionPhoto = call.argument("isMotionPhoto") + if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) { result.error("getMultiPageInfo-args", "missing arguments", null) return } - val pages: ArrayList? = when (mimeType) { - MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) - MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes) - MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) - else -> null + val pages: ArrayList? = if (isMotionPhoto) { + MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes) + } else { + when (mimeType) { + MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) + MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) + else -> null + } } if (pages?.isEmpty() == true) { result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null) @@ -888,7 +888,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) if (fields.isEmpty()) { result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null) @@ -961,7 +961,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) if (xmpStrings.isEmpty()) { result.success(null) @@ -998,65 +998,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } try { - val value = queryContentResolverProp(uri, mimeType, prop) + val value = context.queryContentResolverProp(uri, mimeType, prop) result.success(value?.toString()) } catch (e: Exception) { result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message) } } - private fun queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? { - var contentUri: Uri = uri - if (StorageUtils.isMediaStoreContentUri(uri)) { - uri.tryParseId()?.let { id -> - contentUri = when { - isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) - isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) - else -> uri - } - contentUri = StorageUtils.getOriginalUri(context, contentUri) - } - } - - // throws SQLiteException when the requested prop is not a known column - val cursor = context.contentResolver.query(contentUri, arrayOf(prop), null, null, null) - if (cursor == null || !cursor.moveToFirst()) { - throw Exception("failed to get cursor for contentUri=$contentUri") - } - - var value: Any? = null - try { - value = when (cursor.getType(0)) { - Cursor.FIELD_TYPE_NULL -> null - Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) - Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0) - Cursor.FIELD_TYPE_STRING -> cursor.getString(0) - Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0) - else -> null - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e) - } - cursor.close() - return value - } - - // as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images, - // so we fall back to the native content resolver, if possible - private fun checkHeicXmp(uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) { - if (isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - val xmpBytes = queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP) - if (xmpBytes is ByteArray) { - val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, MetadataExtractorSafeXmpReader.PARSE_OPTIONS) - processXmp(xmpMeta) - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e) - } - } - } - private fun getDate(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -1231,6 +1179,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_GEOTIFF = 1 shl 2 private const val MASK_IS_360 = 1 shl 3 private const val MASK_IS_MULTIPAGE = 1 shl 4 + private const val MASK_IS_MOTION_PHOTO = 1 shl 5 private const val XMP_SUBJECTS_SEPARATOR = ";" // overlay metadata diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index f11011a43..86d08975b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log +import com.adobe.internal.xmp.XMPMeta import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.XMP.countPropArrayItems import deckers.thibault.aves.metadata.XMP.doesPropExist @@ -16,11 +17,15 @@ import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.indexOfBytes import org.beyka.tiffbitmapfactory.TiffBitmapFactory +import java.io.DataInputStream object MultiPage { private val LOG_TAG = LogUtils.createTag() + private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray() + // page info private const val KEY_MIME_TYPE = "mimeType" private const val KEY_HEIGHT = "height" @@ -142,30 +147,53 @@ object MultiPage { } fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { + if (MimeTypes.isHeic(mimeType)) { + // XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video. + // This item does not contain the video itself, but only some kind of metadata (no doc, no spec), + // so we ignore the `Item:Length` and look instead for the MP4 marker bytes indicating the start of the video. + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val bytes = ByteArray(sizeBytes.toInt()) + DataInputStream(input).use { + it.readFully(bytes) + } + val index = bytes.indexOfBytes(heicMotionPhotoVideoStartIndicator) + if (index != -1) { + return sizeBytes - index + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) + } + } + + var offsetFromEnd: Long? = null + var foundXmp = false + + fun processXmp(xmpMeta: XMPMeta) { + if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { + // `GCamera` motion photo + xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } + } else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) { + // `Container` motion photo + val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME) + if (count == 2) { + // expect the video to be the second item + val i = 2 + val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value + if (MimeTypes.isVideo(mime) && length != null) { + offsetFromEnd = length.toLong() + } + } + } + } + try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = MetadataExtractorHelper.safeRead(input) - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - var offsetFromEnd: Long? = null - val xmpMeta = dir.xmpMeta - if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { - // GCamera motion photo - xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } - } else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) { - // Container motion photo - val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME) - if (count == 2) { - // expect the video to be the second item - val i = 2 - val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value - val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value - if (MimeTypes.isVideo(mime) && length != null) { - offsetFromEnd = length.toLong() - } - } - } - return offsetFromEnd - } + foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } + metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp) } } catch (e: Exception) { Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) @@ -174,7 +202,10 @@ object MultiPage { } catch (e: AssertionError) { Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) } - return null + + XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) + + return offsetFromEnd } fun getTiffPages(context: Context, uri: Uri): ArrayList { @@ -218,4 +249,4 @@ object MultiPage { } return null } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 5dc46b657..5d94b9044 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -1,13 +1,19 @@ package deckers.thibault.aves.metadata +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore import android.util.Log import com.adobe.internal.xmp.XMPError import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMetaFactory import com.adobe.internal.xmp.properties.XMPProperty +import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.StorageUtils import java.util.* object XMP { @@ -85,6 +91,22 @@ object XMP { GPANO_FULL_PANO_WIDTH_PROP_NAME, ) + // as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images, + // so we fall back to the native content resolver, if possible + fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) { + if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP) + if (xmpBytes is ByteArray) { + val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, MetadataExtractorSafeXmpReader.PARSE_OPTIONS) + processXmp(xmpMeta) + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e) + } + } + } + // extensions fun XMPMeta.isMotionPhoto(): Boolean { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt index 3875d1f3a..9fc81e5c7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt @@ -17,4 +17,27 @@ fun MutableList.compatRemoveIf(filter: (t: E) -> Boolean): Boolean { } return removed } -} \ No newline at end of file +} + +// Boyer-Moore algorithm for pattern searching +fun ByteArray.indexOfBytes(pattern: ByteArray): Int { + val n: Int = this.size + val m: Int = pattern.size + val badChar = Array(256) { 0 } + var i = 0 + while (i < m) { + badChar[pattern[i].toUByte().toInt()] = i + i += 1 + } + var j: Int = m - 1 + var s = 0 + while (s <= (n - m)) { + while (j >= 0 && pattern[j] == this[s + j]) { + j -= 1 + } + if (j < 0) return s + s += Integer.max(1, j - badChar[this[s + j].toUByte().toInt()]) + j = m - 1 + } + return -1 +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt index 7b4d57941..0dd235137 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -3,10 +3,17 @@ package deckers.thibault.aves.utils import android.app.ActivityManager import android.app.Service import android.content.ContentResolver +import android.content.ContentUris import android.content.Context +import android.database.Cursor import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import deckers.thibault.aves.utils.UriUtils.tryParseId object ContextUtils { + private val LOG_TAG = LogUtils.createTag() + fun Context.resourceUri(resourceId: Int): Uri = with(resources) { Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) @@ -22,4 +29,40 @@ object ContextUtils { @Suppress("deprecation") return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } } + + fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? { + var contentUri: Uri = uri + if (StorageUtils.isMediaStoreContentUri(uri)) { + uri.tryParseId()?.let { id -> + contentUri = when { + MimeTypes.isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + MimeTypes.isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + contentUri = StorageUtils.getOriginalUri(this, contentUri) + } + } + + // throws SQLiteException when the requested prop is not a known column + val cursor = contentResolver.query(contentUri, arrayOf(prop), null, null, null) + if (cursor == null || !cursor.moveToFirst()) { + throw Exception("failed to get cursor for contentUri=$contentUri") + } + + var value: Any? = null + try { + value = when (cursor.getType(0)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) + Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0) + Cursor.FIELD_TYPE_STRING -> cursor.getString(0) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0) + else -> null + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e) + } + cursor.close() + return value + } } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1ece39906..30c928f39 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -754,7 +754,10 @@ class AvesEntry { bool get isBurst => burstEntries?.isNotEmpty == true; - bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg; + // for backwards compatibility + bool get _isMotionPhotoLegacy => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg; + + bool get isMotionPhoto => (_catalogMetadata?.isMotionPhoto ?? false) || _isMotionPhotoLegacy; String? get burstKey { if (filenameWithoutExtension != null) { diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 85ed4ef4e..0b75a69a0 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; class CatalogMetadata { final int id; final int? dateMillis; - final bool isAnimated, isGeotiff, is360, isMultiPage; + final bool isAnimated, isGeotiff, is360, isMultiPage, isMotionPhoto; bool isFlipped; int? rotationDegrees; final String? mimeType, xmpSubjects, xmpTitleDescription; @@ -18,6 +18,7 @@ class CatalogMetadata { static const _isGeotiffMask = 1 << 2; static const _is360Mask = 1 << 3; static const _isMultiPageMask = 1 << 4; + static const _isMotionPhotoMask = 1 << 5; CatalogMetadata({ required this.id, @@ -28,6 +29,7 @@ class CatalogMetadata { this.isGeotiff = false, this.is360 = false, this.isMultiPage = false, + this.isMotionPhoto = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -67,6 +69,7 @@ class CatalogMetadata { isGeotiff: isGeotiff, is360: is360, isMultiPage: isMultiPage ?? this.isMultiPage, + isMotionPhoto: isMotionPhoto, rotationDegrees: rotationDegrees ?? this.rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -87,6 +90,7 @@ class CatalogMetadata { isGeotiff: flags & _isGeotiffMask != 0, is360: flags & _is360Mask != 0, isMultiPage: flags & _isMultiPageMask != 0, + isMotionPhoto: flags & _isMotionPhotoMask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -101,7 +105,7 @@ class CatalogMetadata { 'id': id, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0), + 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0) | (isMotionPhoto ? _isMotionPhotoMask : 0), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -111,5 +115,5 @@ class CatalogMetadata { }; @override - String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; + String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, isMotionPhoto=$isMotionPhoto, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index b4f741675..527cabc97 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -146,6 +146,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, + 'isMotionPhoto': entry.isMotionPhoto, }); final pageMaps = ((result as List?) ?? []).cast(); if (entry.isMotionPhoto && pageMaps.isNotEmpty) { diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index c273cd5c1..a73bc78e6 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -73,7 +73,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi return true; // motion photo case EntryInfoAction.convertMotionPhotoToStillImage: - return entry.canEdit; + return entry.canEditXmp; case EntryInfoAction.viewMotionPhotoVideo: return true; // debug diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index d4bb1e904..7a00847ee 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -135,6 +135,8 @@ class ViewerDebugPage extends StatelessWidget { 'isAnimated': '${entry.isAnimated}', 'isGeotiff': '${entry.isGeotiff}', 'is360': '${entry.is360}', + 'isMultiPage': '${entry.isMultiPage}', + 'isMotionPhoto': '${entry.isMotionPhoto}', 'canEdit': '${entry.canEdit}', 'canEditDate': '${entry.canEditDate}', 'canEditTags': '${entry.canEditTags}', diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 91741f943..85920157d 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -533,6 +533,8 @@ class _EntryViewerStackState extends State with EntryViewContr } void _jumpToHorizontalPageByDelta(int delta) { + if (_horizontalPager.positions.isEmpty) return; + final page = _horizontalPager.page?.round(); if (page != null) { _jumpToHorizontalPageByIndex(page + delta);