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