motion photo support

This commit is contained in:
Thibault Deckers 2021-04-26 17:15:32 +09:00
parent 95b34b753b
commit 768a077857
38 changed files with 785 additions and 484 deletions

View file

@ -35,6 +35,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))

View file

@ -255,8 +255,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return when (uri.scheme?.toLowerCase(Locale.ROOT)) { return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> { ContentResolver.SCHEME_FILE -> {
uri.path?.let { path -> uri.path?.let { path ->
val applicationId = context.applicationContext.packageName val authority = "${context.applicationContext.packageName}.fileprovider"
FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) FileProvider.getUriForFile(context, authority, File(path))
} }
} }
else -> uri else -> uri

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("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<String>("uri")?.let { Uri.parse(it) }
val displayName = call.argument<String>("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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("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<EmbeddedDataHandler>()
const val CHANNEL = "deckers.thibault/aves/embedded"
}
}

View file

@ -4,17 +4,13 @@ import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag 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.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe 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.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis 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.getSafeString
import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap 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.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic 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.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File
import java.io.InputStream
import java.text.ParseException import java.text.ParseException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -90,10 +78,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } "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() else -> result.notImplemented()
} }
} }
@ -318,10 +302,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// File type // File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// * `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) // * `metadata-extractor` sometimes detects 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`) // * the content resolver / media store sometimes reports the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes return incorrect value // * `context.getContentResolver().getType()` sometimes returns an incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` // * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000`
// * file extension is unreliable // * file extension is unreliable
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives), // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// in which case we trust the file extension // in which case we trust the file extension
@ -385,6 +369,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.isPanorama()) { if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360 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) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) 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 metadataMap[KEY_FLAGS] = flags
} }
@ -594,68 +583,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) { private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("getMultiPageInfo-args", "failed because of missing arguments", null) result.error("getMultiPageInfo-args", "failed because of missing arguments", null)
return return
} }
val pages = ArrayList<Map<String, Any>>() val pages: ArrayList<FieldMap>? = when (mimeType) {
if (mimeType == MimeTypes.TIFF) { MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> { MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
return hashMapOf( MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
KEY_PAGE to page, else -> null
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<String, Any>(
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()
} }
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) { private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
@ -748,211 +693,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString()) result.success(value?.toString())
} }
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
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) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
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<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) {
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<String>("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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("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 { companion object {
private val LOG_TAG = LogUtils.createTag<MetadataHandler>() private val LOG_TAG = LogUtils.createTag<MetadataHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata"
private val allMetadataRedundantDirNames = setOf( private val allMetadataRedundantDirNames = setOf(
"MP4", "MP4",
"MP4 Metadata",
"MP4 Sound", "MP4 Sound",
"MP4 Video", "MP4 Video",
"QuickTime", "QuickTime",
@ -960,7 +707,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"QuickTime Video", "QuickTime Video",
) )
// catalog metadata & page info // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_FLAGS = "flags" 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_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" 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_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -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<MultiPage>()
// 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<FieldMap> {
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<FieldMap>()
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<String, Any?>(
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<FieldMap> {
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<FieldMap>()
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<String, Any?>(
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<FieldMap> {
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<FieldMap>()
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
}
}

View file

@ -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) { fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) {
try { try {
if (doesPropertyExist(schema, propName)) { if (doesPropertyExist(schema, propName)) {

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.OpenableColumns
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() { internal class ContentImageProvider : ImageProvider() {
@ -19,9 +20,9 @@ internal class ContentImageProvider : ImageProvider() {
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) { 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(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() cursor.close()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -42,9 +43,11 @@ internal class ContentImageProvider : ImageProvider() {
const val PATH = MediaStore.MediaColumns.DATA const val PATH = MediaStore.MediaColumns.DATA
private val projection = arrayOf( private val projection = arrayOf(
// standard columns for openable URI
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
// optional path underlying media content
PATH, PATH,
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DISPLAY_NAME
) )
} }
} }

View file

@ -10,7 +10,7 @@ object MimeTypes {
private const val DJVU = "image/vnd.djvu" private const val DJVU = "image/vnd.djvu"
const val GIF = "image/gif" const val GIF = "image/gif"
const val HEIC = "image/heic" const val HEIC = "image/heic"
private const val HEIF = "image/heif" const val HEIF = "image/heif"
private const val ICO = "image/x-icon" private const val ICO = "image/x-icon"
const val JPEG = "image/jpeg" const val JPEG = "image/jpeg"
const val PNG = "image/png" const val PNG = "image/png"
@ -98,5 +98,17 @@ object MimeTypes {
// extensions // 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) val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
} }

View file

@ -4,9 +4,8 @@
name="external_files" name="external_files"
path="." /> path="." />
<!-- for images & other media embedded in XMP <!-- embedded images & other media that are exported for viewing and sharing -->
and exported for viewing and sharing -->
<cache-path <cache-path
name="xmp_props" name="embedded"
path="." /> path="." />
</paths> </paths>

View file

@ -99,6 +99,8 @@
"@filterTagEmptyLabel": {}, "@filterTagEmptyLabel": {},
"filterTypeAnimatedLabel": "Animated", "filterTypeAnimatedLabel": "Animated",
"@filterTypeAnimatedLabel": {}, "@filterTypeAnimatedLabel": {},
"filterTypeMotionPhotoLabel": "Motion Photo",
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panorama", "filterTypePanoramaLabel": "Panorama",
"@filterTypePanoramaLabel": {}, "@filterTypePanoramaLabel": {},
"filterTypeSphericalVideoLabel": "360° Video", "filterTypeSphericalVideoLabel": "360° Video",

View file

@ -50,6 +50,7 @@
"filterLocationEmptyLabel": "장소 없음", "filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음", "filterTagEmptyLabel": "태그 없음",
"filterTypeAnimatedLabel": "애니메이션", "filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토",
"filterTypePanoramaLabel": "파노라마", "filterTypePanoramaLabel": "파노라마",
"filterTypeSphericalVideoLabel": "360° 동영상", "filterTypeSphericalVideoLabel": "360° 동영상",
"filterTypeGeotiffLabel": "GeoTIFF", "filterTypeGeotiffLabel": "GeoTIFF",

View file

@ -106,14 +106,14 @@ class AvesEntry {
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId; final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry( return AvesEntry(
uri: uri, uri: pageInfo.uri ?? uri,
path: path, path: path,
contentId: contentId, contentId: contentId,
pageId: pageId, pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? sourceMimeType, sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
width: pageInfo.width ?? width, width: pageInfo.width ?? width,
height: pageInfo.height ?? height, height: pageInfo.height ?? height,
sourceRotationDegrees: sourceRotationDegrees, sourceRotationDegrees: pageInfo.rotationDegrees ?? sourceRotationDegrees,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
sourceTitle: sourceTitle, sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
@ -122,7 +122,8 @@ class AvesEntry {
) )
..catalogMetadata = _catalogMetadata?.copyWith( ..catalogMetadata = _catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType, mimeType: pageInfo.mimeType,
isMultipage: false, isMultiPage: false,
rotationDegrees: pageInfo.rotationDegrees,
) )
..addressDetails = _addressDetails?.copyWith(); ..addressDetails = _addressDetails?.copyWith();
} }
@ -251,7 +252,9 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false; 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; bool get canEdit => path != null;

View file

@ -12,7 +12,7 @@ class EntryCache {
int oldRotationDegrees, int oldRotationDegrees,
bool oldIsFlipped, bool oldIsFlipped,
) async { ) 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; int pageId;
// evict fullscreen image // evict fullscreen image

View file

@ -9,6 +9,7 @@ class TypeFilter extends CollectionFilter {
static const _animated = 'animated'; // subset of `image/gif` and `image/webp` static const _animated = 'animated'; // subset of `image/gif` and `image/webp`
static const _geotiff = 'geotiff'; // subset of `image/tiff` 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 _panorama = 'panorama'; // subset of images
static const _sphericalVideo = 'spherical_video'; // subset of videos static const _sphericalVideo = 'spherical_video'; // subset of videos
@ -18,6 +19,7 @@ class TypeFilter extends CollectionFilter {
static final animated = TypeFilter._private(_animated); static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);
static final motionPhoto = TypeFilter._private(_motionPhoto);
static final panorama = TypeFilter._private(_panorama); static final panorama = TypeFilter._private(_panorama);
static final sphericalVideo = TypeFilter._private(_sphericalVideo); static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@ -27,13 +29,17 @@ class TypeFilter extends CollectionFilter {
_test = (entry) => entry.isAnimated; _test = (entry) => entry.isAnimated;
_icon = AIcons.animated; _icon = AIcons.animated;
break; break;
case _motionPhoto:
_test = (entry) => entry.isMotionPhoto;
_icon = AIcons.motionPhoto;
break;
case _panorama: case _panorama:
_test = (entry) => entry.isImage && entry.is360; _test = (entry) => entry.isImage && entry.is360;
_icon = AIcons.threesixty; _icon = AIcons.threeSixty;
break; break;
case _sphericalVideo: case _sphericalVideo:
_test = (entry) => entry.isVideo && entry.is360; _test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threesixty; _icon = AIcons.threeSixty;
break; break;
case _geotiff: case _geotiff:
_test = (entry) => entry.isGeotiff; _test = (entry) => entry.isGeotiff;
@ -64,6 +70,8 @@ class TypeFilter extends CollectionFilter {
switch (itemType) { switch (itemType) {
case _animated: case _animated:
return context.l10n.filterTypeAnimatedLabel; return context.l10n.filterTypeAnimatedLabel;
case _motionPhoto:
return context.l10n.filterTypeMotionPhotoLabel;
case _panorama: case _panorama:
return context.l10n.filterTypePanoramaLabel; return context.l10n.filterTypePanoramaLabel;
case _sphericalVideo: case _sphericalVideo:

View file

@ -29,7 +29,7 @@ class DateMetadata {
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis; final int contentId, dateMillis;
final bool isAnimated, isGeotiff, is360, isMultipage; final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped; bool isFlipped;
int rotationDegrees; int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
@ -41,7 +41,7 @@ class CatalogMetadata {
static const _isFlippedMask = 1 << 1; static const _isFlippedMask = 1 << 1;
static const _isGeotiffMask = 1 << 2; static const _isGeotiffMask = 1 << 2;
static const _is360Mask = 1 << 3; static const _is360Mask = 1 << 3;
static const _isMultipageMask = 1 << 4; static const _isMultiPageMask = 1 << 4;
CatalogMetadata({ CatalogMetadata({
this.contentId, this.contentId,
@ -51,7 +51,7 @@ class CatalogMetadata {
this.isFlipped = false, this.isFlipped = false,
this.isGeotiff = false, this.isGeotiff = false,
this.is360 = false, this.is360 = false,
this.isMultipage = false, this.isMultiPage = false,
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
@ -70,7 +70,8 @@ class CatalogMetadata {
CatalogMetadata copyWith({ CatalogMetadata copyWith({
int contentId, int contentId,
String mimeType, String mimeType,
bool isMultipage, bool isMultiPage,
int rotationDegrees,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
@ -80,8 +81,8 @@ class CatalogMetadata {
isFlipped: isFlipped, isFlipped: isFlipped,
isGeotiff: isGeotiff, isGeotiff: isGeotiff,
is360: is360, is360: is360,
isMultipage: isMultipage ?? this.isMultipage, isMultiPage: isMultiPage ?? this.isMultiPage,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees ?? this.rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
latitude: latitude, latitude: latitude,
@ -99,7 +100,7 @@ class CatalogMetadata {
isFlipped: flags & _isFlippedMask != 0, isFlipped: flags & _isFlippedMask != 0,
isGeotiff: flags & _isGeotiffMask != 0, isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0, is360: flags & _is360Mask != 0,
isMultipage: flags & _isMultipageMask != 0, isMultiPage: flags & _isMultiPageMask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0 // `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'], rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
@ -113,7 +114,7 @@ class CatalogMetadata {
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, '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, 'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
@ -122,7 +123,7 @@ class CatalogMetadata {
}; };
@override @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 { class OverlayMetadata {

View file

@ -1,14 +1,16 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class MultiPageInfo { class MultiPageInfo {
final String uri; final AvesEntry mainEntry;
final List<SinglePageInfo> pages; final List<SinglePageInfo> pages;
int get pageCount => pages.length; int get pageCount => pages.length;
MultiPageInfo({ MultiPageInfo({
@required this.uri, @required this.mainEntry,
this.pages, this.pages,
}) { }) {
if (pages.isNotEmpty) { if (pages.isNotEmpty) {
@ -21,9 +23,9 @@ class MultiPageInfo {
} }
} }
factory MultiPageInfo.fromPageMaps(String uri, List<Map> pageMaps) { factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List<Map> pageMaps) {
return MultiPageInfo( return MultiPageInfo(
uri: uri, mainEntry: mainEntry,
pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(), 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); SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
Future<void> 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 @override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, pages=$pages}'; String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}';
} }
class SinglePageInfo implements Comparable<SinglePageInfo> { class SinglePageInfo implements Comparable<SinglePageInfo> {
final int index, pageId; final int index, pageId;
final String mimeType;
final bool isDefault; final bool isDefault;
final int width, height, durationMillis; final String uri, mimeType;
final int width, height, rotationDegrees, durationMillis;
const SinglePageInfo({ const SinglePageInfo({
this.index, this.index,
this.pageId, this.pageId,
this.mimeType,
this.isDefault, this.isDefault,
this.uri,
this.mimeType,
this.width, this.width,
this.height, this.height,
this.rotationDegrees,
this.durationMillis, this.durationMillis,
}); });
SinglePageInfo copyWith({ SinglePageInfo copyWith({
bool isDefault, bool isDefault,
String uri,
}) { }) {
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
pageId: pageId, pageId: pageId,
mimeType: mimeType,
isDefault: isDefault ?? this.isDefault, isDefault: isDefault ?? this.isDefault,
uri: uri ?? this.uri,
mimeType: mimeType,
width: width, width: width,
height: height, height: height,
rotationDegrees: rotationDegrees,
durationMillis: durationMillis, durationMillis: durationMillis,
); );
} }
@ -73,10 +97,11 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
pageId: index, pageId: index,
mimeType: map['mimeType'] as String,
isDefault: map['isDefault'] as bool ?? false, isDefault: map['isDefault'] as bool ?? false,
mimeType: map['mimeType'] as String,
width: map['width'] as int ?? 0, width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0, height: map['height'] as int ?? 0,
rotationDegrees: map['rotationDegrees'] as int,
durationMillis: map['durationMillis'] as int, durationMillis: map['durationMillis'] as int,
); );
} }
@ -84,7 +109,7 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
bool get isVideo => MimeTypes.isVideo(mimeType); bool get isVideo => MimeTypes.isVideo(mimeType);
@override @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 @override
int compareTo(SinglePageInfo other) => index.compareTo(other.index); int compareTo(SinglePageInfo other) => index.compareTo(other.index);

View file

@ -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<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformEmbeddedDataService implements EmbeddedDataService {
static const platform = MethodChannel('deckers.thibault/aves/embedded');
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
'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<Map> extractVideoEmbeddedPicture(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'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<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'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;
}
}

View file

@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
@ -21,14 +19,6 @@ abstract class MetadataService {
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry); Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
Future<String> getContentResolverProp(AvesEntry entry, String prop); Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(String uri);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
} }
class PlatformMetadataService implements MetadataService { class PlatformMetadataService implements MetadataService {
@ -113,9 +103,16 @@ class PlatformMetadataService implements MetadataService {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{ final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}); });
final pageMaps = (result as List).cast<Map>(); final pageMaps = (result as List).cast<Map>();
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) { } on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -153,64 +150,4 @@ class PlatformMetadataService implements MetadataService {
} }
return null; return null;
} }
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
'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<Map> extractVideoEmbeddedPicture(String uri) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'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<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'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;
}
} }

View file

@ -1,5 +1,6 @@
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.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/image_file_service.dart';
import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
@ -14,6 +15,7 @@ final pContext = getIt<p.Context>();
final availability = getIt<AvesAvailability>(); final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>(); final metadataDb = getIt<MetadataDb>();
final embeddedDataService = getIt<EmbeddedDataService>();
final imageFileService = getIt<ImageFileService>(); final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>(); final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>(); final metadataService = getIt<MetadataService>();
@ -25,6 +27,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability()); getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb()); getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService()); getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService()); getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService()); getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());

View file

@ -72,9 +72,10 @@ class AIcons {
// thumbnail overlay // thumbnail overlay
static const IconData animated = Icons.slideshow; static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined; 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 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 selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;
} }

View file

@ -34,7 +34,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
AnimatedImageIcon() AnimatedImageIcon()
else ...[ else ...[
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) RawIcon(), if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) RawIcon(),
if (entry.isMultipage) MultipageIcon(), if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) GeotiffIcon(), if (entry.isGeotiff) GeotiffIcon(),
if (entry.is360) SphericalImageIcon(), if (entry.is360) SphericalImageIcon(),
] ]

View file

@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget { class ThumbnailTheme extends StatelessWidget {
final double extent; final double extent;
final bool showLocation;
final Widget child; final Widget child;
const ThumbnailTheme({ const ThumbnailTheme({
@required this.extent, @required this.extent,
this.showLocation,
@required this.child, @required this.child,
}); });
@ -22,7 +24,7 @@ class ThumbnailTheme extends StatelessWidget {
return ThumbnailThemeData( return ThumbnailThemeData(
iconSize: iconSize, iconSize: iconSize,
fontSize: fontSize, fontSize: fontSize,
showLocation: settings.showThumbnailLocation, showLocation: showLocation ?? settings.showThumbnailLocation,
showRaw: settings.showThumbnailRaw, showRaw: settings.showThumbnailRaw,
showVideoDuration: settings.showThumbnailVideoDuration, showVideoDuration: settings.showThumbnailVideoDuration,
); );

View file

@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
final thumbnailTheme = context.watch<ThumbnailThemeData>(); final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration; final showDuration = thumbnailTheme.showVideoDuration;
Widget child = OverlayIcon( Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threesixty : AIcons.play, icon: entry.is360 ? AIcons.threeSixty : AIcons.play,
size: thumbnailTheme.iconSize, size: thumbnailTheme.iconSize,
text: showDuration ? entry.durationText : null, text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1, iconScale: entry.is360 && showDuration ? .9 : 1,
@ -72,7 +72,7 @@ class SphericalImageIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.threesixty, icon: AIcons.threeSixty,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
); );
} }
@ -102,13 +102,18 @@ class RawIcon extends StatelessWidget {
} }
} }
class MultipageIcon extends StatelessWidget { class MultiPageIcon extends StatelessWidget {
const MultipageIcon({Key key}) : super(key: key); final AvesEntry entry;
const MultiPageIcon({
Key key,
this.entry,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.multipage, icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
iconScale: .8, iconScale: .8,
); );

View file

@ -34,6 +34,7 @@ class CollectionSearchDelegate {
MimeFilter.image, MimeFilter.image,
MimeFilter.video, MimeFilter.video,
TypeFilter.animated, TypeFilter.animated,
TypeFilter.motionPhoto,
TypeFilter.panorama, TypeFilter.panorama,
TypeFilter.sphericalVideo, TypeFilter.sphericalVideo,
TypeFilter.geotiff, TypeFilter.geotiff,

View file

@ -169,8 +169,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultipage) { if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) { for (final page in multiPageInfo.pages) {
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false); final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);

View file

@ -44,7 +44,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
final entry = entries[index]; final entry = entries[index];
Widget child; Widget child;
if (entry.isMultipage) { if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry); final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = FutureBuilder<MultiPageInfo>(
@ -110,7 +110,7 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
super.build(context); super.build(context);
Widget child; Widget child;
if (entry.isMultipage) { if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry); final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = FutureBuilder<MultiPageInfo>(

View file

@ -141,6 +141,9 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
} else { } else {
Navigator.pop(context); 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) // when the entry image itself changed (e.g. after rotation)

View file

@ -107,7 +107,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
collection: collection, collection: collection,
showInfo: () => _goToVerticalPage(infoPage), showInfo: () => _goToVerticalPage(infoPage),
); );
_initViewStateControllers(); _initEntryControllers();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
@ -255,14 +255,14 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Widget _buildExtraBottomOverlay(AvesEntry pageEntry) { Widget _buildExtraBottomOverlay(AvesEntry pageEntry) {
// a 360 video is both a video and a panorama but only the video controls are displayed // a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) { if (pageEntry.isVideo) {
final videoController = context.read<VideoConductor>().getController(pageEntry); return Selector<VideoConductor, AvesVideoController>(
if (videoController != null) { selector: (context, vc) => vc.getController(pageEntry),
return VideoControlOverlay( builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry, entry: pageEntry,
controller: videoController, controller: videoController,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
),
); );
}
} else if (pageEntry.is360) { } else if (pageEntry.is360) {
return PanoramaOverlay( return PanoramaOverlay(
entry: pageEntry, entry: pageEntry,
@ -272,7 +272,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
return null; return null;
} }
final multiPageController = entry.isMultipage ? context.read<MultiPageConductor>().getController(entry) : null; final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
final extraBottomOverlay = multiPageController != null final extraBottomOverlay = multiPageController != null
? FutureBuilder<MultiPageInfo>( ? FutureBuilder<MultiPageInfo>(
future: multiPageController.info, future: multiPageController.info,
@ -409,7 +409,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
} }
} }
void _updateEntry() { Future<void> _updateEntry() async {
if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) { 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 // 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 // so we manually track the page change, and let the entry update follow
@ -420,8 +420,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return; if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry; _entryNotifier.value = newEntry;
_pauseVideoControllers(); await _pauseVideoControllers();
_initViewStateControllers(); await _initEntryControllers();
} }
void _popVisual() { void _popVisual() {
@ -498,52 +498,75 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
// state controllers/monitors // state controllers/monitors
void _initViewStateControllers() { Future<void> _initEntryControllers() async {
final entry = _entryNotifier.value; final entry = _entryNotifier.value;
if (entry == null) return; if (entry == null) return;
final uri = entry.uri; _initViewStateController(entry);
_initViewSpecificController<ValueNotifier<ViewState>>(
uri,
_viewStateNotifiers,
() => ValueNotifier<ViewState>(ViewState.zero),
(_) => _.dispose(),
);
if (entry.isVideo) { if (entry.isVideo) {
await _initVideoController(entry);
}
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>(ViewState.zero));
}
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
}
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry); final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (settings.enableVideoAutoPlay) { if (settings.enableVideoAutoPlay) {
_playVideo(controller, () => entry == _entryNotifier.value); await _playVideo(controller, () => entry == _entryNotifier.value);
} }
} }
if (entry.isMultipage) {
Future<void> _initMultiPageController(AvesEntry entry) async {
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry); final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
multiPageController.info.then((info) { setState(() {});
final videoPageEntries = info.pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet();
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) { if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it // init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>(); final videoConductor = context.read<VideoConductor>();
videoPageEntries.forEach(videoConductor.getOrCreateController); videoPageEntries.forEach(videoConductor.getOrCreateController);
// auto play/pause when changing page // auto play/pause when changing page
void _onPageChange() { Future<void> _onPageChange() async {
_pauseVideoControllers(); await _pauseVideoControllers();
if (settings.enableVideoAutoPlay) { if (settings.enableVideoAutoPlay) {
final page = multiPageController.page; final page = multiPageController.page;
final pageInfo = info.getByIndex(page); final pageInfo = multiPageInfo.getByIndex(page);
if (pageInfo.isVideo) { if (pageInfo.isVideo) {
final pageVideoController = videoConductor.getController(entry.getPageEntry(pageInfo)); final pageEntry = entry.getPageEntry(pageInfo);
_playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page); final pageVideoController = videoConductor.getController(pageEntry);
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
} }
} }
} }
multiPageController.pageNotifier.addListener(_onPageChange); multiPageController.pageNotifier.addListener(_onPageChange);
_onPageChange(); await _onPageChange();
} }
});
}
setState(() {});
} }
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async { Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
@ -562,18 +585,5 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
} }
} }
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) { Future<void> _pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
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<VideoConductor>().pauseAll();
} }

View file

@ -79,6 +79,7 @@ class BasicSection extends StatelessWidget {
MimeFilter(entry.mimeType), MimeFilter(entry.mimeType),
if (entry.isAnimated) TypeFilter.animated, if (entry.isAnimated) TypeFilter.animated,
if (entry.isGeotiff) TypeFilter.geotiff, if (entry.isGeotiff) TypeFilter.geotiff,
if (entry.isMotionPhoto) TypeFilter.motionPhoto,
if (entry.isImage && entry.is360) TypeFilter.panorama, if (entry.isImage && entry.is360) TypeFilter.panorama,
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
if (entry.isVideo && !entry.is360) MimeFilter.video, if (entry.isVideo && !entry.is360) MimeFilter.video,

View file

@ -122,13 +122,13 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
Map fields; Map fields;
switch (notification.source) { switch (notification.source) {
case EmbeddedDataSource.motionPhotoVideo: case EmbeddedDataSource.motionPhotoVideo:
fields = await metadataService.extractMotionPhotoVideo(entry); fields = await embeddedDataService.extractMotionPhotoVideo(entry);
break; break;
case EmbeddedDataSource.videoCover: case EmbeddedDataSource.videoCover:
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri); fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
break; break;
case EmbeddedDataSource.xmp: case EmbeddedDataSource.xmp:
fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
break; break;
} }
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {

View file

@ -158,7 +158,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
}).toList(); }).toList();
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) { if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
directories.addAll(await _getStreamDirectories()); directories.addAll(await _getStreamDirectories());
} }

View file

@ -28,7 +28,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loader = metadataService.getExifThumbnails(entry); _loader = embeddedDataService.getExifThumbnails(entry);
} }
@override @override

View file

@ -178,7 +178,7 @@ class _BottomOverlayContent extends AnimatedWidget {
infoColumn = _buildInfoColumn(orientation); infoColumn = _buildInfoColumn(orientation);
} }
if (mainEntry.isMultipage && multiPageController != null) { if (mainEntry.isMultiPage && multiPageController != null) {
infoColumn = Column( infoColumn = Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -19,7 +19,7 @@ class MultiPageOverlay extends StatefulWidget {
@required this.mainEntry, @required this.mainEntry,
@required this.controller, @required this.controller,
@required this.availableWidth, @required this.availableWidth,
}) : assert(mainEntry.isMultipage), }) : assert(mainEntry.isMultiPage),
assert(controller != null), assert(controller != null),
super(key: key); super(key: key);
@ -83,12 +83,13 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
return ThumbnailTheme( return ThumbnailTheme(
extent: extent, extent: extent,
showLocation: false,
child: FutureBuilder<MultiPageInfo>( child: FutureBuilder<MultiPageInfo>(
future: controller.info, future: controller.info,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = snapshot.data;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox();
if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); if (multiPageInfo.mainEntry != mainEntry) return SizedBox();
return SizedBox( return SizedBox(
height: extent, height: extent,
child: ListView.separated( child: ListView.separated(

View file

@ -41,7 +41,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
AvesVideoController get controller => widget.controller; AvesVideoController get controller => widget.controller;
bool get isPlaying => controller.isPlaying; Stream<VideoStatus> get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle);
Stream<int> get positionStream => controller?.positionStream ?? Stream.value(0);
bool get isPlaying => controller?.isPlaying ?? false;
@override @override
void initState() { void initState() {
@ -68,9 +72,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
} }
void _registerWidget(VideoControlOverlay widget) { void _registerWidget(VideoControlOverlay widget) {
if (widget.controller != null) {
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange)); _subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
_onStatusChange(widget.controller.status); _onStatusChange(widget.controller.status);
} }
}
void _unregisterWidget(VideoControlOverlay widget) { void _unregisterWidget(VideoControlOverlay widget) {
_subscriptions _subscriptions
@ -81,10 +87,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder<VideoStatus>( return StreamBuilder<VideoStatus>(
stream: controller.statusStream, stream: statusStream,
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // 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( return TooltipTheme(
data: TooltipTheme.of(context).copyWith( data: TooltipTheme.of(context).copyWith(
preferBelow: false, preferBelow: false,
@ -157,10 +163,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
Row( Row(
children: [ children: [
StreamBuilder<int>( StreamBuilder<int>(
stream: controller.positionStream, stream: positionStream,
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // 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))); return Text(formatFriendlyDuration(Duration(milliseconds: position)));
}), }),
Spacer(), Spacer(),
@ -170,10 +176,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: StreamBuilder<int>( child: StreamBuilder<int>(
stream: controller.positionStream, stream: positionStream,
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // 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; if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator( return LinearProgressIndicator(
value: progress, value: progress,
@ -199,6 +205,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
} }
Future<void> _togglePlayPause() async { Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) { if (isPlaying) {
await controller.pause(); await controller.pause();
} else { } else {
@ -210,6 +217,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
} }
void _seekFromTap(Offset globalPosition) async { void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext; final keyContext = _progressBarKey.currentContext;
final RenderBox box = keyContext.findRenderObject(); final RenderBox box = keyContext.findRenderObject();
final localPosition = box.globalToLocal(globalPosition); final localPosition = box.globalToLocal(globalPosition);

View file

@ -70,7 +70,7 @@ class ViewerTopOverlay extends StatelessWidget {
child: Minimap( child: Minimap(
mainEntry: entry, mainEntry: entry,
viewStateNotifier: viewStateNotifier, viewStateNotifier: viewStateNotifier,
multiPageController: entry.isMultipage ? context.read<MultiPageConductor>().getController(entry) : null, multiPageController: entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null,
), ),
) )
], ],

View file

@ -47,7 +47,7 @@ class EntryPrinter with FeedbackMixin {
)); ));
} }
if (entry.isMultipage) { if (entry.isMultiPage && !entry.isMotionPhoto) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
final streamController = StreamController<AvesEntry>.broadcast(); final streamController = StreamController<AvesEntry>.broadcast();

View file

@ -35,5 +35,5 @@ class VideoConductor {
return _controllers.firstWhere((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId, orElse: () => null); return _controllers.firstWhere((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId, orElse: () => null);
} }
void pauseAll() => _controllers.forEach((controller) => controller.pause()); Future<void> pauseAll() => Future.forEach(_controllers, (controller) => controller.pause());
} }