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, 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))

View file

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

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.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,68 +583,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
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)
return
}
val pages = ArrayList<Map<String, Any>>()
if (mimeType == MimeTypes.TIFF) {
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
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<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()
val pages: ArrayList<FieldMap>? = when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null
}
if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for uri=$uri", null)
} else {
result.success(pages)
}
}
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
@ -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<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 {
private val LOG_TAG = LogUtils.createTag<MetadataHandler>()
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

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) {
try {
if (doesPropertyExist(schema, propName)) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SinglePageInfo> 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<Map> pageMaps) {
factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List<Map> 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<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
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, pages=$pages}';
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}';
}
class SinglePageInfo implements Comparable<SinglePageInfo> {
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<SinglePageInfo> {
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<SinglePageInfo> {
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);

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/metadata.dart';
import 'package:aves/model/multipage.dart';
@ -21,14 +19,6 @@ abstract class MetadataService {
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
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 {
@ -113,9 +103,16 @@ class PlatformMetadataService implements MetadataService {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
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) {
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<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/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<p.Context>();
final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final embeddedDataService = getIt<EmbeddedDataService>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
@ -25,6 +27,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());

View file

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

View file

@ -34,7 +34,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
AnimatedImageIcon()
else ...[
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.is360) SphericalImageIcon(),
]

View file

@ -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,
);

View file

@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
final thumbnailTheme = context.watch<ThumbnailThemeData>();
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<ThumbnailThemeData, double>((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<ThumbnailThemeData, double>((t) => t.iconSize),
iconScale: .8,
);

View file

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

View file

@ -169,8 +169,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
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);

View file

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

View file

@ -141,6 +141,9 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
} 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)

View file

@ -107,7 +107,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<EntryViewerStack> 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<VideoConductor>().getController(pageEntry);
if (videoController != null) {
return VideoControlOverlay(
return Selector<VideoConductor, AvesVideoController>(
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<EntryViewerStack> with SingleTickerPr
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
? FutureBuilder<MultiPageInfo>(
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) {
// 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<EntryViewerStack> 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<EntryViewerStack> with SingleTickerPr
// state controllers/monitors
void _initViewStateControllers() {
Future<void> _initEntryControllers() async {
final entry = _entryNotifier.value;
if (entry == null) return;
final uri = entry.uri;
_initViewSpecificController<ValueNotifier<ViewState>>(
uri,
_viewStateNotifiers,
() => ValueNotifier<ViewState>(ViewState.zero),
(_) => _.dispose(),
);
_initViewStateController(entry);
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);
setState(() {});
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);
multiPageController.info.then((info) {
final videoPageEntries = info.pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet();
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<VideoConductor>();
videoPageEntries.forEach(videoConductor.getOrCreateController);
// auto play/pause when changing page
void _onPageChange() {
_pauseVideoControllers();
Future<void> _onPageChange() async {
await _pauseVideoControllers();
if (settings.enableVideoAutoPlay) {
final page = multiPageController.page;
final pageInfo = info.getByIndex(page);
final pageInfo = multiPageInfo.getByIndex(page);
if (pageInfo.isVideo) {
final pageVideoController = videoConductor.getController(entry.getPageEntry(pageInfo));
_playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
final pageEntry = entry.getPageEntry(pageInfo);
final pageVideoController = videoConductor.getController(pageEntry);
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
}
}
}
multiPageController.pageNotifier.addListener(_onPageChange);
_onPageChange();
await _onPageChange();
}
});
}
setState(() {});
}
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) {
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();
Future<void> _pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
}

View file

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

View file

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

View file

@ -158,7 +158,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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());
}

View file

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

View file

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

View file

@ -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<MultiPageOverlay> {
return ThumbnailTheme(
extent: extent,
showLocation: false,
child: FutureBuilder<MultiPageInfo>(
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(

View file

@ -41,7 +41,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
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
void initState() {
@ -68,9 +72,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
}
void _registerWidget(VideoControlOverlay widget) {
if (widget.controller != null) {
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
_onStatusChange(widget.controller.status);
}
}
void _unregisterWidget(VideoControlOverlay widget) {
_subscriptions
@ -81,10 +87,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
@override
Widget build(BuildContext context) {
return StreamBuilder<VideoStatus>(
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<VideoControlOverlay> with SingleTi
Row(
children: [
StreamBuilder<int>(
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<VideoControlOverlay> with SingleTi
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: StreamBuilder<int>(
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<VideoControlOverlay> with SingleTi
}
Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) {
await controller.pause();
} else {
@ -210,6 +217,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> 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);

View file

@ -70,7 +70,7 @@ class ViewerTopOverlay extends StatelessWidget {
child: Minimap(
mainEntry: entry,
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);
if (multiPageInfo.pageCount > 1) {
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);
}
void pauseAll() => _controllers.forEach((controller) => controller.pause());
Future<void> pauseAll() => Future.forEach(_controllers, (controller) => controller.pause());
}