diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index b3d63d1bc..e41bb1d0c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -35,6 +35,7 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) + MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 6c1956ef4..f3c8e551c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -255,8 +255,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return when (uri.scheme?.toLowerCase(Locale.ROOT)) { ContentResolver.SCHEME_FILE -> { uri.path?.let { path -> - val applicationId = context.applicationContext.packageName - FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) + val authority = "${context.applicationContext.packageName}.fileprovider" + FileProvider.getUriForFile(context, authority, File(path)) } } else -> uri diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt new file mode 100644 index 000000000..261e9dcb3 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -0,0 +1,243 @@ +package deckers.thibault.aves.channel.calls + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import androidx.exifinterface.media.ExifInterface +import com.adobe.internal.xmp.XMPException +import com.adobe.internal.xmp.XMPUtils +import com.bumptech.glide.load.resource.bitmap.TransformationUtils +import com.drew.imaging.ImageMetadataReader +import com.drew.metadata.file.FileTypeDirectory +import com.drew.metadata.xmp.XmpDirectory +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus +import deckers.thibault.aves.metadata.Metadata +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.metadata.MultiPage +import deckers.thibault.aves.metadata.XMP +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.provider.ContentImageProvider +import deckers.thibault.aves.model.provider.ImageProvider +import deckers.thibault.aves.utils.BitmapUtils +import deckers.thibault.aves.utils.BitmapUtils.getBytes +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.extensionFor +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface +import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.File +import java.io.InputStream +import java.util.* + +class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) } + "extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) } + "extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) } + "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } + else -> result.notImplemented() + } + } + + private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { + 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) { + result.error("getExifThumbnails-args", "failed because of missing arguments", null) + return + } + + val thumbnails = ArrayList() + if (isSupportedByExifInterface(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val exif = ExifInterface(input) + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + exif.thumbnailBitmap?.let { bitmap -> + TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { + it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) } + } + } + } + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure + } + } + result.success(thumbnails) + } + + private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val displayName = call.argument("displayName") + if (mimeType == null || uri == null || sizeBytes == null) { + result.error("extractMotionPhotoVideo-args", "failed because of missing arguments", null) + return + } + + try { + MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> + val videoStartOffset = sizeBytes - videoSizeBytes + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(videoStartOffset) + copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input) + } + return + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to extract video from motion photo", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to extract video from motion photo", e) + } + + result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null) + } + + private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + val displayName = call.argument("displayName") + if (uri == null) { + result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null) + return + } + + val retriever = StorageUtils.openMetadataRetriever(context, uri) + if (retriever != null) { + try { + retriever.embeddedPicture?.let { bytes -> + var embedMimeType: String? = null + bytes.inputStream().use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir -> + dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it } + } + } + embedMimeType?.let { mime -> + copyEmbeddedBytes(result, mime, displayName, bytes.inputStream()) + return + } + } + } catch (e: Exception) { + result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message) + } finally { + // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs + retriever.release() + } + } + result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null) + } + + private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val displayName = call.argument("displayName") + val dataPropPath = call.argument("propPath") + val embedMimeType = call.argument("propMimeType") + if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) { + result.error("extractXmpDataProp-args", "failed because of missing arguments", null) + return + } + + if (isSupportedByMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + // data can be large and stored in "Extended XMP", + // which is returned as a second XMP directory + val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) + try { + val pathParts = dataPropPath.split('/') + + val embedBytes: ByteArray = if (pathParts.size == 1) { + val propName = pathParts[0] + val propNs = XMP.namespaceForPropPath(propName) + xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null } + } else { + val structName = pathParts[0] + val structNs = XMP.namespaceForPropPath(structName) + val fieldName = pathParts[1] + val fieldNs = XMP.namespaceForPropPath(fieldName) + xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let { + XMPUtils.decodeBase64(it.value) + } + } + + copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream()) + return + } catch (e: XMPException) { + result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message) + return + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } + } + result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) + } + + private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) { + val extension = extensionFor(mimeType) + val file = File.createTempFile("aves", extension, context.cacheDir).apply { + deleteOnExit() + outputStream().use { outputStream -> + embeddedByteStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + val authority = "${context.applicationContext.packageName}.fileprovider" + val uri = if (displayName != null) { + // add extension to ease type identification when sharing this content + val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { + displayName + } else { + "$displayName$extension" + } + FileProvider.getUriForFile(context, authority, file, displayNameWithExtension) + } else { + FileProvider.getUriForFile(context, authority, file) + } + val resultFields: FieldMap = hashMapOf( + "uri" to uri.toString(), + "mimeType" to mimeType, + ) + if (isImage(mimeType) || isVideo(mimeType)) { + GlobalScope.launch(Dispatchers.IO) { + ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback { + override fun onSuccess(fields: FieldMap) { + resultFields.putAll(fields) + result.success(resultFields) + } + + override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message) + }) + } + } else { + result.success(resultFields) + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/embedded" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 0d02540ad..13b425bf4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -4,17 +4,13 @@ import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor -import android.media.MediaExtractor -import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException -import com.adobe.internal.xmp.XMPUtils import com.adobe.internal.xmp.properties.XMPPropertyInfo -import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader import com.drew.lang.Rational import com.drew.metadata.Tag @@ -28,7 +24,6 @@ import com.drew.metadata.png.PngDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis @@ -54,10 +49,6 @@ import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeString import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.model.FieldMap -import deckers.thibault.aves.model.provider.FileImageProvider -import deckers.thibault.aves.model.provider.ImageProvider -import deckers.thibault.aves.utils.BitmapUtils -import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isHeic @@ -74,9 +65,6 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import org.beyka.tiffbitmapfactory.TiffBitmapFactory -import java.io.File -import java.io.InputStream import java.text.ParseException import java.util.* import kotlin.math.roundToLong @@ -90,10 +78,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } - "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) } - "extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) } - "extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) } - "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } else -> result.notImplemented() } } @@ -318,10 +302,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // File type for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { - // * `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) - // * the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`) - // * `context.getContentResolver().getType()` sometimes return incorrect value - // * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` + // * `metadata-extractor` sometimes detects the wrong mime type (e.g. `pef` file as `tiff`) + // * the content resolver / media store sometimes reports the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`) + // * `context.getContentResolver().getType()` sometimes returns an incorrect value + // * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000` // * file extension is unreliable // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives), // in which case we trust the file extension @@ -385,6 +369,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (xmpMeta.isPanorama()) { flags = flags or MASK_IS_360 } + + // identification of motion photo + if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { + flags = flags or MASK_IS_MULTIPAGE + } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } @@ -474,7 +463,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (mimeType == MimeTypes.TIFF && isMultiPageTiff(uri)) flags = flags or MASK_IS_MULTIPAGE + if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE metadataMap[KEY_FLAGS] = flags } @@ -594,67 +583,23 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } - if (mimeType == null || uri == null) { + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null || sizeBytes == null) { result.error("getMultiPageInfo-args", "failed because of missing arguments", null) return } - val pages = ArrayList>() - if (mimeType == MimeTypes.TIFF) { - fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap { - return hashMapOf( - KEY_PAGE to page, - KEY_MIME_TYPE to mimeType, - KEY_WIDTH to options.outWidth, - KEY_HEIGHT to options.outHeight, - ) - } - getTiffPageInfo(uri, 0)?.let { first -> - pages.add(toMap(0, first)) - val pageCount = first.outDirectoryCount - for (i in 1 until pageCount) { - getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) } - } - } - } else if (isHeic(mimeType)) { - fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { - if (this.containsKey(key)) save(this.getInteger(key)) - } - - fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) { - if (this.containsKey(key)) save(this.getLong(key)) - } - - val extractor = MediaExtractor() - extractor.setDataSource(context, uri, null) - for (i in 0 until extractor.trackCount) { - try { - val format = extractor.getTrackFormat(i) - format.getString(MediaFormat.KEY_MIME)?.let { mime -> - val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime - val page = hashMapOf( - KEY_PAGE to i, - KEY_MIME_TYPE to trackMime, - ) - - // do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks - // e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1 - - format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 } - format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it } - format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } - if (isVideo(trackMime)) { - format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 } - } - pages.add(page) - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e) - } - } - extractor.release() + 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 + } + if (pages?.isEmpty() == true) { + result.error("getMultiPageInfo-empty", "failed to get pages for uri=$uri", null) + } else { + result.success(pages) } - result.success(pages) } private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) { @@ -748,211 +693,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(value?.toString()) } - private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { - 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) { - result.error("getExifThumbnails-args", "failed because of missing arguments", null) - return - } - - val thumbnails = ArrayList() - if (isSupportedByExifInterface(mimeType)) { - try { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val exif = ExifInterface(input) - val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - exif.thumbnailBitmap?.let { bitmap -> - TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { - it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) } - } - } - } - } catch (e: Exception) { - // ExifInterface initialization can fail with a RuntimeException - // caused by an internal MediaMetadataRetriever failure - } - } - result.success(thumbnails) - } - - private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) { - 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) { - result.error("extractMotionPhotoVideo-args", "failed because of missing arguments", null) - return - } - - try { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - val xmpMeta = dir.xmpMeta - // offset from end - var offsetFromEnd: Int? = null - xmpMeta.getSafeInt(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } - if (offsetFromEnd != null) { - StorageUtils.openInputStream(context, uri)?.let { original -> - original.skip(sizeBytes - offsetFromEnd!!) - copyEmbeddedBytes(result, MimeTypes.MP4, original) - } - return - } - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract video from motion photo", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract video from motion photo", e) - } - - result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null) - } - - private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (uri == null) { - result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null) - return - } - - val retriever = StorageUtils.openMetadataRetriever(context, uri) - if (retriever != null) { - try { - retriever.embeddedPicture?.let { bytes -> - var embedMimeType: String? = null - bytes.inputStream().use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir -> - dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it } - } - } - embedMimeType?.let { mime -> - copyEmbeddedBytes(result, mime, bytes.inputStream()) - return - } - } - } catch (e: Exception) { - result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message) - } finally { - // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs - retriever.release() - } - } - result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null) - } - - private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val sizeBytes = call.argument("sizeBytes")?.toLong() - val dataPropPath = call.argument("propPath") - val embedMimeType = call.argument("propMimeType") - if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) { - result.error("extractXmpDataProp-args", "failed because of missing arguments", null) - return - } - - if (isSupportedByMetadataExtractor(mimeType)) { - try { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - // data can be large and stored in "Extended XMP", - // which is returned as a second XMP directory - val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) - try { - val pathParts = dataPropPath.split('/') - - val embedBytes: ByteArray = if (pathParts.size == 1) { - val propName = pathParts[0] - val propNs = XMP.namespaceForPropPath(propName) - xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null } - } else { - val structName = pathParts[0] - val structNs = XMP.namespaceForPropPath(structName) - val fieldName = pathParts[1] - val fieldNs = XMP.namespaceForPropPath(fieldName) - xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let { - XMPUtils.decodeBase64(it.value) - } - } - - copyEmbeddedBytes(result, embedMimeType, embedBytes.inputStream()) - return - } catch (e: XMPException) { - result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message) - return - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract file from XMP", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract file from XMP", e) - } - } - result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) - } - - private fun copyEmbeddedBytes(result: MethodChannel.Result, embedMimeType: String, embedByteStream: InputStream) { - val embedFile = File.createTempFile("aves", null, context.cacheDir).apply { - deleteOnExit() - outputStream().use { outputStream -> - embedByteStream.use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - val embedUri = Uri.fromFile(embedFile) - val embedFields: FieldMap = hashMapOf( - "uri" to embedUri.toString(), - "mimeType" to embedMimeType, - ) - if (isImage(embedMimeType) || isVideo(embedMimeType)) { - GlobalScope.launch(Dispatchers.IO) { - FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { - override fun onSuccess(fields: FieldMap) { - embedFields.putAll(fields) - result.success(embedFields) - } - - override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message) - }) - } - } else { - result.success(embedFields) - } - } - - private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1 - - private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? { - try { - val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() - if (fd == null) { - Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri") - return null - } - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - inDirectoryNumber = page - } - TiffBitmapFactory.decodeFileDescriptor(fd, options) - return options - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e) - } - return null - } - companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/metadata" private val allMetadataRedundantDirNames = setOf( "MP4", + "MP4 Metadata", "MP4 Sound", "MP4 Video", "QuickTime", @@ -960,7 +707,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "QuickTime Video", ) - // catalog metadata & page info + // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_FLAGS = "flags" @@ -969,11 +716,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val KEY_LONGITUDE = "longitude" private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" - private const val KEY_HEIGHT = "height" - private const val KEY_WIDTH = "width" - private const val KEY_PAGE = "page" - private const val KEY_IS_DEFAULT = "isDefault" - private const val KEY_DURATION = "durationMillis" private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 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 new file mode 100644 index 000000000..63ce5f0e4 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -0,0 +1,190 @@ +package deckers.thibault.aves.metadata + +import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import com.drew.imaging.ImageMetadataReader +import com.drew.metadata.xmp.XmpDirectory +import deckers.thibault.aves.metadata.XMP.getSafeLong +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes +import org.beyka.tiffbitmapfactory.TiffBitmapFactory +import java.util.* + +object MultiPage { + private val LOG_TAG = LogUtils.createTag() + + // page info + private const val KEY_MIME_TYPE = "mimeType" + private const val KEY_HEIGHT = "height" + private const val KEY_WIDTH = "width" + private const val KEY_PAGE = "page" + private const val KEY_IS_DEFAULT = "isDefault" + private const val KEY_DURATION = "durationMillis" + private const val KEY_ROTATION_DEGREES = "rotationDegrees" + + fun getHeicTracks(context: Context, uri: Uri): ArrayList { + fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { + if (this.containsKey(key)) save(this.getInteger(key)) + } + + fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) { + if (this.containsKey(key)) save(this.getLong(key)) + } + + val tracks = ArrayList() + val extractor = MediaExtractor() + extractor.setDataSource(context, uri, null) + for (i in 0 until extractor.trackCount) { + try { + val format = extractor.getTrackFormat(i) + format.getString(MediaFormat.KEY_MIME)?.let { mime -> + val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime + val track = hashMapOf( + KEY_PAGE to i, + KEY_MIME_TYPE to trackMime, + ) + + // do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks + // e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1 + + format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 } + format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } + format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } + } + if (MimeTypes.isVideo(trackMime)) { + format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 } + } + tracks.add(track) + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, track num=$i", e) + } + } + extractor.release() + return tracks + } + + fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList { + fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { + if (this.containsKey(key)) save(this.getInteger(key)) + } + + fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) { + if (this.containsKey(key)) save(this.getLong(key)) + } + + val tracks = ArrayList() + val extractor = MediaExtractor() + var pfd: ParcelFileDescriptor? = null + try { + getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> + val videoStartOffset = sizeBytes - videoSizeBytes + pfd = context.contentResolver.openFileDescriptor(uri, "r") + pfd?.fileDescriptor?.let { fd -> + extractor.setDataSource(fd, videoStartOffset, videoSizeBytes) + // set the original image as the first and default track + var trackCount = 0 + tracks.add( + hashMapOf( + KEY_PAGE to trackCount++, + KEY_MIME_TYPE to mimeType, + KEY_IS_DEFAULT to true, + ) + ) + // add video tracks from the appended video + for (i in 0 until extractor.trackCount) { + try { + val format = extractor.getTrackFormat(i) + format.getString(MediaFormat.KEY_MIME)?.let { mime -> + if (MimeTypes.isVideo(mime)) { + val track = hashMapOf( + KEY_PAGE to trackCount++, + KEY_MIME_TYPE to MimeTypes.MP4, + KEY_IS_DEFAULT to false, + ) + format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } + format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } + } + format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 } + tracks.add(track) + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$i", e) + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e) + } finally { + extractor.release() + pfd?.close() + } + return tracks + } + + fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { + var offsetFromEnd: Long? = null + dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } + return offsetFromEnd + } + } + return null + } + + fun getTiffPages(context: Context, uri: Uri): ArrayList { + fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap { + return hashMapOf( + KEY_PAGE to page, + KEY_MIME_TYPE to MimeTypes.TIFF, + KEY_WIDTH to options.outWidth, + KEY_HEIGHT to options.outHeight, + ) + } + + val pages = ArrayList() + getTiffPageInfo(context, uri, 0)?.let { first -> + pages.add(toMap(0, first)) + val pageCount = first.outDirectoryCount + for (i in 1 until pageCount) { + getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) } + } + } + return pages + } + + fun isMultiPageTiff(context: Context, uri: Uri) = getTiffPageInfo(context, uri, 0)?.outDirectoryCount ?: 1 > 1 + + private fun getTiffPageInfo(context: Context, uri: Uri, page: Int): TiffBitmapFactory.Options? { + try { + val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + Log.w(LOG_TAG, "failed to get TIFF file descriptor for uri=$uri") + return null + } + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + inDirectoryNumber = page + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + return options + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e) + } + 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 177d97119..278e18b34 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 @@ -117,6 +117,20 @@ object XMP { } } + fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) { + try { + if (doesPropertyExist(schema, propName)) { + val item = getPropertyLong(schema, propName) + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + save(item) + } + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get long for XMP schema=$schema, propName=$propName", e) + } + } + fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) { try { if (doesPropertyExist(schema, propName)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 3aef05e2a..0f85cc768 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider import android.content.Context import android.net.Uri import android.provider.MediaStore +import android.provider.OpenableColumns import deckers.thibault.aves.model.SourceEntry internal class ContentImageProvider : ImageProvider() { @@ -19,9 +20,9 @@ internal class ContentImageProvider : ImageProvider() { try { val cursor = context.contentResolver.query(uri, projection, null, null, null) if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } + cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } cursor.close() } } catch (e: Exception) { @@ -42,9 +43,11 @@ internal class ContentImageProvider : ImageProvider() { const val PATH = MediaStore.MediaColumns.DATA private val projection = arrayOf( + // standard columns for openable URI + OpenableColumns.DISPLAY_NAME, + OpenableColumns.SIZE, + // optional path underlying media content PATH, - MediaStore.MediaColumns.SIZE, - MediaStore.MediaColumns.DISPLAY_NAME ) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 328777783..eb8a24780 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -10,7 +10,7 @@ object MimeTypes { private const val DJVU = "image/vnd.djvu" const val GIF = "image/gif" const val HEIC = "image/heic" - private const val HEIF = "image/heif" + const val HEIF = "image/heif" private const val ICO = "image/x-icon" const val JPEG = "image/jpeg" const val PNG = "image/png" @@ -98,5 +98,17 @@ object MimeTypes { // extensions + fun extensionFor(mimeType: String): String? = when (mimeType) { + BMP -> ".bmp" + GIF -> ".gif" + HEIC, HEIF -> ".heif" + JPEG -> ".jpg" + MP4 -> ".mp4" + PNG -> ".png" + TIFF -> ".tiff" + WEBP -> ".webp" + else -> null + } + val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) } diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml index 2a7a21e70..bdf6728a3 100644 --- a/android/app/src/main/res/xml/provider_paths.xml +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -4,9 +4,8 @@ name="external_files" path="." /> - + \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2b2e4ded7..5cbeedb7d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -99,6 +99,8 @@ "@filterTagEmptyLabel": {}, "filterTypeAnimatedLabel": "Animated", "@filterTypeAnimatedLabel": {}, + "filterTypeMotionPhotoLabel": "Motion Photo", + "@filterTypeMotionPhotoLabel": {}, "filterTypePanoramaLabel": "Panorama", "@filterTypePanoramaLabel": {}, "filterTypeSphericalVideoLabel": "360° Video", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cc48063c5..391f5e0bd 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -50,6 +50,7 @@ "filterLocationEmptyLabel": "장소 없음", "filterTagEmptyLabel": "태그 없음", "filterTypeAnimatedLabel": "애니메이션", + "filterTypeMotionPhotoLabel": "모션 포토", "filterTypePanoramaLabel": "파노라마", "filterTypeSphericalVideoLabel": "360° 동영상", "filterTypeGeotiffLabel": "GeoTIFF", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 66e732851..a43ac9247 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -106,14 +106,14 @@ class AvesEntry { final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId; return AvesEntry( - uri: uri, + uri: pageInfo.uri ?? uri, path: path, contentId: contentId, pageId: pageId, sourceMimeType: pageInfo.mimeType ?? sourceMimeType, width: pageInfo.width ?? width, height: pageInfo.height ?? height, - sourceRotationDegrees: sourceRotationDegrees, + sourceRotationDegrees: pageInfo.rotationDegrees ?? sourceRotationDegrees, sizeBytes: sizeBytes, sourceTitle: sourceTitle, dateModifiedSecs: dateModifiedSecs, @@ -122,7 +122,8 @@ class AvesEntry { ) ..catalogMetadata = _catalogMetadata?.copyWith( mimeType: pageInfo.mimeType, - isMultipage: false, + isMultiPage: false, + rotationDegrees: pageInfo.rotationDegrees, ) ..addressDetails = _addressDetails?.copyWith(); } @@ -251,7 +252,9 @@ class AvesEntry { bool get is360 => _catalogMetadata?.is360 ?? false; - bool get isMultipage => _catalogMetadata?.isMultipage ?? false; + bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false; + + bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg; bool get canEdit => path != null; diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index 1e794f202..b535c0973 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -12,7 +12,7 @@ class EntryCache { int oldRotationDegrees, bool oldIsFlipped, ) async { - // TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them + // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them int pageId; // evict fullscreen image diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index 361ac27c4..9087f1879 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -9,6 +9,7 @@ class TypeFilter extends CollectionFilter { static const _animated = 'animated'; // subset of `image/gif` and `image/webp` static const _geotiff = 'geotiff'; // subset of `image/tiff` + static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg` static const _panorama = 'panorama'; // subset of images static const _sphericalVideo = 'spherical_video'; // subset of videos @@ -18,6 +19,7 @@ class TypeFilter extends CollectionFilter { static final animated = TypeFilter._private(_animated); static final geotiff = TypeFilter._private(_geotiff); + static final motionPhoto = TypeFilter._private(_motionPhoto); static final panorama = TypeFilter._private(_panorama); static final sphericalVideo = TypeFilter._private(_sphericalVideo); @@ -27,13 +29,17 @@ class TypeFilter extends CollectionFilter { _test = (entry) => entry.isAnimated; _icon = AIcons.animated; break; + case _motionPhoto: + _test = (entry) => entry.isMotionPhoto; + _icon = AIcons.motionPhoto; + break; case _panorama: _test = (entry) => entry.isImage && entry.is360; - _icon = AIcons.threesixty; + _icon = AIcons.threeSixty; break; case _sphericalVideo: _test = (entry) => entry.isVideo && entry.is360; - _icon = AIcons.threesixty; + _icon = AIcons.threeSixty; break; case _geotiff: _test = (entry) => entry.isGeotiff; @@ -64,6 +70,8 @@ class TypeFilter extends CollectionFilter { switch (itemType) { case _animated: return context.l10n.filterTypeAnimatedLabel; + case _motionPhoto: + return context.l10n.filterTypeMotionPhotoLabel; case _panorama: return context.l10n.filterTypePanoramaLabel; case _sphericalVideo: diff --git a/lib/model/metadata.dart b/lib/model/metadata.dart index 9a045d36d..2d8157faf 100644 --- a/lib/model/metadata.dart +++ b/lib/model/metadata.dart @@ -29,7 +29,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis; - final bool isAnimated, isGeotiff, is360, isMultipage; + final bool isAnimated, isGeotiff, is360, isMultiPage; bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; @@ -41,7 +41,7 @@ class CatalogMetadata { static const _isFlippedMask = 1 << 1; static const _isGeotiffMask = 1 << 2; static const _is360Mask = 1 << 3; - static const _isMultipageMask = 1 << 4; + static const _isMultiPageMask = 1 << 4; CatalogMetadata({ this.contentId, @@ -51,7 +51,7 @@ class CatalogMetadata { this.isFlipped = false, this.isGeotiff = false, this.is360 = false, - this.isMultipage = false, + this.isMultiPage = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -70,7 +70,8 @@ class CatalogMetadata { CatalogMetadata copyWith({ int contentId, String mimeType, - bool isMultipage, + bool isMultiPage, + int rotationDegrees, }) { return CatalogMetadata( contentId: contentId ?? this.contentId, @@ -80,8 +81,8 @@ class CatalogMetadata { isFlipped: isFlipped, isGeotiff: isGeotiff, is360: is360, - isMultipage: isMultipage ?? this.isMultipage, - rotationDegrees: rotationDegrees, + isMultiPage: isMultiPage ?? this.isMultiPage, + rotationDegrees: rotationDegrees ?? this.rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, latitude: latitude, @@ -99,7 +100,7 @@ class CatalogMetadata { isFlipped: flags & _isFlippedMask != 0, isGeotiff: flags & _isGeotiffMask != 0, is360: flags & _is360Mask != 0, - isMultipage: flags & _isMultipageMask != 0, + isMultiPage: flags & _isMultiPageMask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -113,7 +114,7 @@ class CatalogMetadata { 'contentId': contentId, '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), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -122,7 +123,7 @@ class CatalogMetadata { }; @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultipage=$isMultipage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } class OverlayMetadata { diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index b19d2a100..73c9837c9 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -1,14 +1,16 @@ +import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; class MultiPageInfo { - final String uri; + final AvesEntry mainEntry; final List pages; int get pageCount => pages.length; MultiPageInfo({ - @required this.uri, + @required this.mainEntry, this.pages, }) { if (pages.isNotEmpty) { @@ -21,9 +23,9 @@ class MultiPageInfo { } } - factory MultiPageInfo.fromPageMaps(String uri, List pageMaps) { + factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List pageMaps) { return MultiPageInfo( - uri: uri, + mainEntry: mainEntry, pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(), ); } @@ -34,36 +36,58 @@ class MultiPageInfo { SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null); + Future extractMotionPhotoVideo() async { + final videoPage = pages.firstWhere((page) => page.isVideo, orElse: () => null); + if (videoPage != null && videoPage.uri == null) { + final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry); + final extractedUri = fields != null ? fields['uri'] as String : null; + if (extractedUri != null) { + final pageIndex = pages.indexOf(videoPage); + pages.removeAt(pageIndex); + pages.insert( + pageIndex, + videoPage.copyWith( + uri: extractedUri, + )); + } + } + } + @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, pages=$pages}'; + String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}'; } class SinglePageInfo implements Comparable { final int index, pageId; - final String mimeType; final bool isDefault; - final int width, height, durationMillis; + final String uri, mimeType; + final int width, height, rotationDegrees, durationMillis; const SinglePageInfo({ this.index, this.pageId, - this.mimeType, this.isDefault, + this.uri, + this.mimeType, this.width, this.height, + this.rotationDegrees, this.durationMillis, }); SinglePageInfo copyWith({ bool isDefault, + String uri, }) { return SinglePageInfo( index: index, pageId: pageId, - mimeType: mimeType, isDefault: isDefault ?? this.isDefault, + uri: uri ?? this.uri, + mimeType: mimeType, width: width, height: height, + rotationDegrees: rotationDegrees, durationMillis: durationMillis, ); } @@ -73,10 +97,11 @@ class SinglePageInfo implements Comparable { return SinglePageInfo( index: index, pageId: index, - mimeType: map['mimeType'] as String, isDefault: map['isDefault'] as bool ?? false, + mimeType: map['mimeType'] as String, width: map['width'] as int ?? 0, height: map['height'] as int ?? 0, + rotationDegrees: map['rotationDegrees'] as int, durationMillis: map['durationMillis'] as int, ); } @@ -84,7 +109,7 @@ class SinglePageInfo implements Comparable { bool get isVideo => MimeTypes.isVideo(mimeType); @override - String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}'; + String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, isDefault=$isDefault, uri=$uri, mimeType=$mimeType, width=$width, height=$height, rotationDegrees=$rotationDegrees, durationMillis=$durationMillis}'; @override int compareTo(SinglePageInfo other) => index.compareTo(other.index); diff --git a/lib/services/embedded_data_service.dart b/lib/services/embedded_data_service.dart new file mode 100644 index 000000000..b92aa2410 --- /dev/null +++ b/lib/services/embedded_data_service.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:aves/model/entry.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +abstract class EmbeddedDataService { + Future> getExifThumbnails(AvesEntry entry); + + Future extractMotionPhotoVideo(AvesEntry entry); + + Future extractVideoEmbeddedPicture(AvesEntry entry); + + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType); +} + +class PlatformEmbeddedDataService implements EmbeddedDataService { + static const platform = MethodChannel('deckers.thibault/aves/embedded'); + + @override + Future> getExifThumbnails(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getExifThumbnails', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } + + @override + Future extractMotionPhotoVideo(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('extractMotionPhotoVideo', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'displayName': '${entry.bestTitle} • Video', + }); + return result; + } on PlatformException catch (e) { + debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } + + @override + Future extractVideoEmbeddedPicture(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('extractVideoEmbeddedPicture', { + 'uri': entry.uri, + 'displayName': '${entry.bestTitle} • Cover', + }); + return result; + } on PlatformException catch (e) { + debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } + + @override + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { + try { + final result = await platform.invokeMethod('extractXmpDataProp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'displayName': '${entry.bestTitle} • $propPath', + 'propPath': propPath, + 'propMimeType': propMimeType, + }); + return result; + } on PlatformException catch (e) { + debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } +} diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 8ed734735..e5f2eabe6 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata.dart'; import 'package:aves/model/multipage.dart'; @@ -21,14 +19,6 @@ abstract class MetadataService { Future getPanoramaInfo(AvesEntry entry); Future getContentResolverProp(AvesEntry entry, String prop); - - Future> getExifThumbnails(AvesEntry entry); - - Future extractMotionPhotoVideo(AvesEntry entry); - - Future extractVideoEmbeddedPicture(String uri); - - Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType); } class PlatformMetadataService implements MetadataService { @@ -113,9 +103,16 @@ class PlatformMetadataService implements MetadataService { final result = await platform.invokeMethod('getMultiPageInfo', { 'mimeType': entry.mimeType, 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, }); final pageMaps = (result as List).cast(); - return MultiPageInfo.fromPageMaps(entry.uri, pageMaps); + if (entry.isMotionPhoto && pageMaps.isNotEmpty) { + final imagePage = pageMaps[0]; + imagePage['width'] = entry.width; + imagePage['height'] = entry.height; + imagePage['rotationDegrees'] = entry.rotationDegrees; + } + return MultiPageInfo.fromPageMaps(entry, pageMaps); } on PlatformException catch (e) { debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } @@ -153,64 +150,4 @@ class PlatformMetadataService implements MetadataService { } return null; } - - @override - Future> getExifThumbnails(AvesEntry entry) async { - try { - final result = await platform.invokeMethod('getExifThumbnails', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }); - return (result as List).cast(); - } on PlatformException catch (e) { - debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return []; - } - - @override - Future extractMotionPhotoVideo(AvesEntry entry) async { - try { - final result = await platform.invokeMethod('extractMotionPhotoVideo', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }); - return result; - } on PlatformException catch (e) { - debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return null; - } - - @override - Future extractVideoEmbeddedPicture(String uri) async { - try { - final result = await platform.invokeMethod('extractVideoEmbeddedPicture', { - 'uri': uri, - }); - return result; - } on PlatformException catch (e) { - debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return null; - } - - @override - Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { - try { - final result = await platform.invokeMethod('extractXmpDataProp', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - 'propPath': propPath, - 'propMimeType': propMimeType, - }); - return result; - } on PlatformException catch (e) { - debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return null; - } } diff --git a/lib/services/services.dart b/lib/services/services.dart index bf1ebc7ee..c863b0a36 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -1,5 +1,6 @@ import 'package:aves/model/availability.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/embedded_data_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/metadata_service.dart'; @@ -14,6 +15,7 @@ final pContext = getIt(); final availability = getIt(); final metadataDb = getIt(); +final embeddedDataService = getIt(); final imageFileService = getIt(); final mediaStoreService = getIt(); final metadataService = getIt(); @@ -25,6 +27,7 @@ void initPlatformServices() { getIt.registerLazySingleton(() => LiveAvesAvailability()); getIt.registerLazySingleton(() => SqfliteMetadataDb()); + getIt.registerLazySingleton(() => PlatformEmbeddedDataService()); getIt.registerLazySingleton(() => PlatformImageFileService()); getIt.registerLazySingleton(() => PlatformMediaStoreService()); getIt.registerLazySingleton(() => PlatformMetadataService()); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index d939e66a6..61315ca85 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -72,9 +72,10 @@ class AIcons { // thumbnail overlay static const IconData animated = Icons.slideshow; static const IconData geo = Icons.language_outlined; - static const IconData multipage = Icons.burst_mode_outlined; + static const IconData motionPhoto = Icons.motion_photos_on_outlined; + static const IconData multiPage = Icons.burst_mode_outlined; static const IconData play = Icons.play_circle_outline; - static const IconData threesixty = Icons.threesixty_outlined; + static const IconData threeSixty = Icons.threesixty_outlined; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; } diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 26ee3c736..133088e95 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -34,7 +34,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { AnimatedImageIcon() else ...[ if (entry.isRaw && context.select((t) => t.showRaw)) RawIcon(), - if (entry.isMultipage) MultipageIcon(), + if (entry.isMultiPage) MultiPageIcon(entry: entry), if (entry.isGeotiff) GeotiffIcon(), if (entry.is360) SphericalImageIcon(), ] diff --git a/lib/widgets/collection/thumbnail/theme.dart b/lib/widgets/collection/thumbnail/theme.dart index 83bd64c28..fa85901f4 100644 --- a/lib/widgets/collection/thumbnail/theme.dart +++ b/lib/widgets/collection/thumbnail/theme.dart @@ -6,10 +6,12 @@ import 'package:provider/provider.dart'; class ThumbnailTheme extends StatelessWidget { final double extent; + final bool showLocation; final Widget child; const ThumbnailTheme({ @required this.extent, + this.showLocation, @required this.child, }); @@ -22,7 +24,7 @@ class ThumbnailTheme extends StatelessWidget { return ThumbnailThemeData( iconSize: iconSize, fontSize: fontSize, - showLocation: settings.showThumbnailLocation, + showLocation: showLocation ?? settings.showThumbnailLocation, showRaw: settings.showThumbnailRaw, showVideoDuration: settings.showThumbnailVideoDuration, ); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index efba5b527..bec2db615 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget { final thumbnailTheme = context.watch(); final showDuration = thumbnailTheme.showVideoDuration; Widget child = OverlayIcon( - icon: entry.is360 ? AIcons.threesixty : AIcons.play, + icon: entry.is360 ? AIcons.threeSixty : AIcons.play, size: thumbnailTheme.iconSize, text: showDuration ? entry.durationText : null, iconScale: entry.is360 && showDuration ? .9 : 1, @@ -72,7 +72,7 @@ class SphericalImageIcon extends StatelessWidget { @override Widget build(BuildContext context) { return OverlayIcon( - icon: AIcons.threesixty, + icon: AIcons.threeSixty, size: context.select((t) => t.iconSize), ); } @@ -102,13 +102,18 @@ class RawIcon extends StatelessWidget { } } -class MultipageIcon extends StatelessWidget { - const MultipageIcon({Key key}) : super(key: key); +class MultiPageIcon extends StatelessWidget { + final AvesEntry entry; + + const MultiPageIcon({ + Key key, + this.entry, + }) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( - icon: AIcons.multipage, + icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, size: context.select((t) => t.iconSize), iconScale: .8, ); diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 1708746c6..f0f9adfc1 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -34,6 +34,7 @@ class CollectionSearchDelegate { MimeFilter.image, MimeFilter.video, TypeFilter.animated, + TypeFilter.motionPhoto, TypeFilter.panorama, TypeFilter.sphericalVideo, TypeFilter.geotiff, diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index b445dc0bc..d3d2add74 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -169,8 +169,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; final selection = {}; - if (entry.isMultipage) { + if (entry.isMultiPage) { final multiPageInfo = await metadataService.getMultiPageInfo(entry); + if (entry.isMotionPhoto) { + await multiPageInfo.extractMotionPhotoVideo(); + } if (multiPageInfo.pageCount > 1) { for (final page in multiPageInfo.pages) { final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false); diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 1dfe4bc35..80d16a974 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -44,7 +44,7 @@ class _MultiEntryScrollerState extends State with AutomaticK final entry = entries[index]; Widget child; - if (entry.isMultipage) { + if (entry.isMultiPage) { final multiPageController = context.read().getController(entry); if (multiPageController != null) { child = FutureBuilder( @@ -110,7 +110,7 @@ class _SingleEntryScrollerState extends State with Automati super.build(context); Widget child; - if (entry.isMultipage) { + if (entry.isMultiPage) { final multiPageController = context.read().getController(entry); if (multiPageController != null) { child = FutureBuilder( diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index a054b7008..1a4b00540 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -141,6 +141,9 @@ class _ViewerVerticalPageViewState extends State { } else { Navigator.pop(context); } + + // needed to refresh when entry changes but the page does not (e.g. on page deletion) + setState(() {}); } // when the entry image itself changed (e.g. after rotation) diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 0b873ded9..bb3ccf7c3 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -107,7 +107,7 @@ class _EntryViewerStackState extends State with SingleTickerPr collection: collection, showInfo: () => _goToVerticalPage(infoPage), ); - _initViewStateControllers(); + _initEntryControllers(); _registerWidget(widget); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); @@ -255,14 +255,14 @@ class _EntryViewerStackState extends State with SingleTickerPr Widget _buildExtraBottomOverlay(AvesEntry pageEntry) { // a 360 video is both a video and a panorama but only the video controls are displayed if (pageEntry.isVideo) { - final videoController = context.read().getController(pageEntry); - if (videoController != null) { - return VideoControlOverlay( + return Selector( + selector: (context, vc) => vc.getController(pageEntry), + builder: (context, videoController, child) => VideoControlOverlay( entry: pageEntry, controller: videoController, scale: _bottomOverlayScale, - ); - } + ), + ); } else if (pageEntry.is360) { return PanoramaOverlay( entry: pageEntry, @@ -272,7 +272,7 @@ class _EntryViewerStackState extends State with SingleTickerPr return null; } - final multiPageController = entry.isMultipage ? context.read().getController(entry) : null; + final multiPageController = entry.isMultiPage ? context.read().getController(entry) : null; final extraBottomOverlay = multiPageController != null ? FutureBuilder( future: multiPageController.info, @@ -409,7 +409,7 @@ class _EntryViewerStackState extends State with SingleTickerPr } } - void _updateEntry() { + Future _updateEntry() async { if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) { // as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted // so we manually track the page change, and let the entry update follow @@ -420,8 +420,8 @@ class _EntryViewerStackState extends State with SingleTickerPr final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; if (_entryNotifier.value == newEntry) return; _entryNotifier.value = newEntry; - _pauseVideoControllers(); - _initViewStateControllers(); + await _pauseVideoControllers(); + await _initEntryControllers(); } void _popVisual() { @@ -498,52 +498,75 @@ class _EntryViewerStackState extends State with SingleTickerPr // state controllers/monitors - void _initViewStateControllers() { + Future _initEntryControllers() async { final entry = _entryNotifier.value; if (entry == null) return; - final uri = entry.uri; - _initViewSpecificController>( - uri, - _viewStateNotifiers, - () => ValueNotifier(ViewState.zero), - (_) => _.dispose(), - ); + _initViewStateController(entry); if (entry.isVideo) { - final controller = context.read().getOrCreateController(entry); - if (settings.enableVideoAutoPlay) { - _playVideo(controller, () => entry == _entryNotifier.value); - } + await _initVideoController(entry); } - if (entry.isMultipage) { - final multiPageController = context.read().getOrCreateController(entry); - multiPageController.info.then((info) { - final videoPageEntries = info.pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet(); - if (videoPageEntries.isNotEmpty) { - // init video controllers for all pages that could need it - final videoConductor = context.read(); - videoPageEntries.forEach(videoConductor.getOrCreateController); - - // auto play/pause when changing page - void _onPageChange() { - _pauseVideoControllers(); - if (settings.enableVideoAutoPlay) { - final page = multiPageController.page; - final pageInfo = info.getByIndex(page); - if (pageInfo.isVideo) { - final pageVideoController = videoConductor.getController(entry.getPageEntry(pageInfo)); - _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page); - } - } - } - - multiPageController.pageNotifier.addListener(_onPageChange); - _onPageChange(); - } - }); + if (entry.isMultiPage) { + await _initMultiPageController(entry); } + } + void _initViewStateController(AvesEntry entry) { + final uri = entry.uri; + var controller = _viewStateNotifiers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); + if (controller != null) { + _viewStateNotifiers.remove(controller); + } else { + controller = Tuple2(uri, ValueNotifier(ViewState.zero)); + } + _viewStateNotifiers.insert(0, controller); + while (_viewStateNotifiers.length > 3) { + _viewStateNotifiers.removeLast().item2.dispose(); + } + } + + Future _initVideoController(AvesEntry entry) async { + final controller = context.read().getOrCreateController(entry); setState(() {}); + + if (settings.enableVideoAutoPlay) { + await _playVideo(controller, () => entry == _entryNotifier.value); + } + } + + Future _initMultiPageController(AvesEntry entry) async { + final multiPageController = context.read().getOrCreateController(entry); + setState(() {}); + + final multiPageInfo = await multiPageController.info; + if (entry.isMotionPhoto) { + await multiPageInfo.extractMotionPhotoVideo(); + } + + final pages = multiPageInfo.pages; + final videoPageEntries = pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet(); + if (videoPageEntries.isNotEmpty) { + // init video controllers for all pages that could need it + final videoConductor = context.read(); + videoPageEntries.forEach(videoConductor.getOrCreateController); + + // auto play/pause when changing page + Future _onPageChange() async { + await _pauseVideoControllers(); + if (settings.enableVideoAutoPlay) { + final page = multiPageController.page; + final pageInfo = multiPageInfo.getByIndex(page); + if (pageInfo.isVideo) { + final pageEntry = entry.getPageEntry(pageInfo); + final pageVideoController = videoConductor.getController(pageEntry); + await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page); + } + } + } + + multiPageController.pageNotifier.addListener(_onPageChange); + await _onPageChange(); + } } Future _playVideo(AvesVideoController videoController, bool Function() isCurrent) async { @@ -562,18 +585,5 @@ class _EntryViewerStackState extends State with SingleTickerPr } } - void _initViewSpecificController(String uri, List> controllers, T Function() builder, void Function(T controller) disposer) { - var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); - if (controller != null) { - controllers.remove(controller); - } else { - controller = Tuple2(uri, builder()); - } - controllers.insert(0, controller); - while (controllers.length > 3) { - disposer?.call(controllers.removeLast().item2); - } - } - - void _pauseVideoControllers() => context.read().pauseAll(); + Future _pauseVideoControllers() => context.read().pauseAll(); } diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 56dc53e97..cae16dfa1 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -79,6 +79,7 @@ class BasicSection extends StatelessWidget { MimeFilter(entry.mimeType), if (entry.isAnimated) TypeFilter.animated, if (entry.isGeotiff) TypeFilter.geotiff, + if (entry.isMotionPhoto) TypeFilter.motionPhoto, if (entry.isImage && entry.is360) TypeFilter.panorama, if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && !entry.is360) MimeFilter.video, diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 022da31d8..078a39143 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -122,13 +122,13 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin { Map fields; switch (notification.source) { case EmbeddedDataSource.motionPhotoVideo: - fields = await metadataService.extractMotionPhotoVideo(entry); + fields = await embeddedDataService.extractMotionPhotoVideo(entry); break; case EmbeddedDataSource.videoCover: - fields = await metadataService.extractVideoEmbeddedPicture(entry.uri); + fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); break; case EmbeddedDataSource.xmp: - fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); + fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); break; } if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 0efc27272..307a05343 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -158,7 +158,7 @@ class _MetadataSectionSliverState extends State with Auto return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); }).toList(); - if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) { + if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) { directories.addAll(await _getStreamDirectories()); } diff --git a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart index 170601146..3c2c55005 100644 --- a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart @@ -28,7 +28,7 @@ class _MetadataThumbnailsState extends State { @override void initState() { super.initState(); - _loader = metadataService.getExifThumbnails(entry); + _loader = embeddedDataService.getExifThumbnails(entry); } @override diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 034b0631f..14e102118 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -178,7 +178,7 @@ class _BottomOverlayContent extends AnimatedWidget { infoColumn = _buildInfoColumn(orientation); } - if (mainEntry.isMultipage && multiPageController != null) { + if (mainEntry.isMultiPage && multiPageController != null) { infoColumn = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index 97ac36d5f..85608adc1 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -19,7 +19,7 @@ class MultiPageOverlay extends StatefulWidget { @required this.mainEntry, @required this.controller, @required this.availableWidth, - }) : assert(mainEntry.isMultipage), + }) : assert(mainEntry.isMultiPage), assert(controller != null), super(key: key); @@ -83,12 +83,13 @@ class _MultiPageOverlayState extends State { return ThumbnailTheme( extent: extent, + showLocation: false, child: FutureBuilder( future: controller.info, builder: (context, snapshot) { final multiPageInfo = snapshot.data; if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); - if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); + if (multiPageInfo.mainEntry != mainEntry) return SizedBox(); return SizedBox( height: extent, child: ListView.separated( diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 9dff72c5a..8327a1c74 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -41,7 +41,11 @@ class _VideoControlOverlayState extends State with SingleTi AvesVideoController get controller => widget.controller; - bool get isPlaying => controller.isPlaying; + Stream get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle); + + Stream get positionStream => controller?.positionStream ?? Stream.value(0); + + bool get isPlaying => controller?.isPlaying ?? false; @override void initState() { @@ -68,8 +72,10 @@ class _VideoControlOverlayState extends State with SingleTi } void _registerWidget(VideoControlOverlay widget) { - _subscriptions.add(widget.controller.statusStream.listen(_onStatusChange)); - _onStatusChange(widget.controller.status); + if (widget.controller != null) { + _subscriptions.add(widget.controller.statusStream.listen(_onStatusChange)); + _onStatusChange(widget.controller.status); + } } void _unregisterWidget(VideoControlOverlay widget) { @@ -81,10 +87,10 @@ class _VideoControlOverlayState extends State with SingleTi @override Widget build(BuildContext context) { return StreamBuilder( - stream: controller.statusStream, + stream: statusStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos - final status = controller.status; + final status = controller?.status ?? VideoStatus.idle; return TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, @@ -157,10 +163,10 @@ class _VideoControlOverlayState extends State with SingleTi Row( children: [ StreamBuilder( - stream: controller.positionStream, + stream: positionStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos - final position = controller.currentPosition?.floor() ?? 0; + final position = controller?.currentPosition?.floor() ?? 0; return Text(formatFriendlyDuration(Duration(milliseconds: position))); }), Spacer(), @@ -170,10 +176,10 @@ class _VideoControlOverlayState extends State with SingleTi ClipRRect( borderRadius: BorderRadius.circular(4), child: StreamBuilder( - stream: controller.positionStream, + stream: positionStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos - var progress = controller.progress; + var progress = controller?.progress ?? 0.0; if (!progress.isFinite) progress = 0.0; return LinearProgressIndicator( value: progress, @@ -199,6 +205,7 @@ class _VideoControlOverlayState extends State with SingleTi } Future _togglePlayPause() async { + if (controller == null) return; if (isPlaying) { await controller.pause(); } else { @@ -210,6 +217,7 @@ class _VideoControlOverlayState extends State with SingleTi } void _seekFromTap(Offset globalPosition) async { + if (controller == null) return; final keyContext = _progressBarKey.currentContext; final RenderBox box = keyContext.findRenderObject(); final localPosition = box.globalToLocal(globalPosition); diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 8473cca6a..e70aa5f67 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -70,7 +70,7 @@ class ViewerTopOverlay extends StatelessWidget { child: Minimap( mainEntry: entry, viewStateNotifier: viewStateNotifier, - multiPageController: entry.isMultipage ? context.read().getController(entry) : null, + multiPageController: entry.isMultiPage ? context.read().getController(entry) : null, ), ) ], diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index f8e35323c..4ee21bfda 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -47,7 +47,7 @@ class EntryPrinter with FeedbackMixin { )); } - if (entry.isMultipage) { + if (entry.isMultiPage && !entry.isMotionPhoto) { final multiPageInfo = await metadataService.getMultiPageInfo(entry); if (multiPageInfo.pageCount > 1) { final streamController = StreamController.broadcast(); diff --git a/lib/widgets/viewer/video/conductor.dart b/lib/widgets/viewer/video/conductor.dart index ed1e0e718..7a026aac5 100644 --- a/lib/widgets/viewer/video/conductor.dart +++ b/lib/widgets/viewer/video/conductor.dart @@ -35,5 +35,5 @@ class VideoConductor { return _controllers.firstWhere((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId, orElse: () => null); } - void pauseAll() => _controllers.forEach((controller) => controller.pause()); + Future pauseAll() => Future.forEach(_controllers, (controller) => controller.pause()); }