motion photo support
This commit is contained in:
parent
95b34b753b
commit
768a077857
38 changed files with 785 additions and 484 deletions
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)) {
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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",
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"filterLocationEmptyLabel": "장소 없음",
|
"filterLocationEmptyLabel": "장소 없음",
|
||||||
"filterTagEmptyLabel": "태그 없음",
|
"filterTagEmptyLabel": "태그 없음",
|
||||||
"filterTypeAnimatedLabel": "애니메이션",
|
"filterTypeAnimatedLabel": "애니메이션",
|
||||||
|
"filterTypeMotionPhotoLabel": "모션 포토",
|
||||||
"filterTypePanoramaLabel": "파노라마",
|
"filterTypePanoramaLabel": "파노라마",
|
||||||
"filterTypeSphericalVideoLabel": "360° 동영상",
|
"filterTypeSphericalVideoLabel": "360° 동영상",
|
||||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
82
lib/services/embedded_data_service.dart
Normal file
82
lib/services/embedded_data_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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')) {
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue