#305 heic motion photo support

This commit is contained in:
Thibault Deckers 2022-08-23 11:49:22 +02:00
parent 9b252a9588
commit 0cce0c1e11
12 changed files with 179 additions and 98 deletions

View file

@ -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

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) {
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
result.error("getMultiPageInfo-args", "missing arguments", null)
return
}
val pages: ArrayList<FieldMap>? = 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<FieldMap>? = 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<String>("mimeType")
val uri = call.argument<String>("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

View file

@ -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<MultiPage>()
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<FieldMap> {
@ -218,4 +249,4 @@ object MultiPage {
}
return null
}
}
}

View file

@ -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 {

View file

@ -17,4 +17,27 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
}
return removed
}
}
}
// 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
}

View file

@ -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<ContextUtils>()
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
}
}

View file

@ -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) {

View file

@ -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}';
}

View file

@ -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<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) {

View file

@ -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

View file

@ -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}',

View file

@ -533,6 +533,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
}
void _jumpToHorizontalPageByDelta(int delta) {
if (_horizontalPager.positions.isEmpty) return;
final page = _horizontalPager.page?.round();
if (page != null) {
_jumpToHorizontalPageByIndex(page + delta);