#305 heic motion photo support
This commit is contained in:
parent
9b252a9588
commit
0cce0c1e11
12 changed files with 179 additions and 98 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}',
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue