Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-04-29 12:28:00 +09:00
commit f0b87f39c3
117 changed files with 2251 additions and 1363 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: beta channel: beta
flutter-version: '2.1.0-12.2.pre' flutter-version: '2.2.0-10.1.pre'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: beta channel: beta
flutter-version: '2.1.0-12.2.pre' flutter-version: '2.2.0-10.1.pre'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441 # https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build apk --bundle-sksl-path shaders_2.1.0-12.2.pre.sksl.json flutter build apk --bundle-sksl-path shaders_2.2.0-10.1.pre.sksl.json
flutter build appbundle --bundle-sksl-path shaders_2.1.0-12.2.pre.sksl.json flutter build appbundle --bundle-sksl-path shaders_2.2.0-10.1.pre.sksl.json
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks

View file

@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v1.4.1] - 2021-04-29
### Added
- Motion photo support
- Viewer: play videos in multi-track HEIC
- Handle share intent
### Changed
- Upgraded Flutter to beta v2.2.0-10.1.pre
### Fixed
- fixed crash when cataloguing large MP4/PSD
- prevent videos playing in the background when quickly switching entries
## [v1.4.0] - 2021-04-16 ## [v1.4.0] - 2021-04-16
### Added ### Added
- Viewer: support for videos with EAC3/FLAC/OPUS audio - Viewer: support for videos with EAC3/FLAC/OPUS audio

View file

@ -62,6 +62,14 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError"> <intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -69,6 +77,7 @@
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="com.android.camera.action.REVIEW" /> <action android:name="com.android.camera.action.REVIEW" />
@ -88,6 +97,7 @@
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.PICK" /> <action android:name="android.intent.action.PICK" />
@ -96,6 +106,7 @@
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- file provider to share files having a file:// URI --> <!-- file provider to share files having a file:// URI -->

View file

@ -4,6 +4,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
@ -12,6 +13,7 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@ -33,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))
@ -83,7 +86,8 @@ class MainActivity : FlutterActivity() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { when (requestCode) {
VOLUME_ACCESS_REQUEST -> {
val treeUri = data?.data val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null) PermissionManager.onPermissionResult(requestCode, null)
@ -99,6 +103,13 @@ class MainActivity : FlutterActivity() {
// resume pending action // resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri) PermissionManager.onPermissionResult(requestCode, treeUri)
} }
DELETE_PERMISSION_REQUEST -> {
// delete permission may be requested on Android 10+ only
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
}
}
}
} }
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
@ -111,8 +122,8 @@ class MainActivity : FlutterActivity() {
) )
} }
} }
Intent.ACTION_VIEW, "com.android.camera.action.REVIEW" -> { Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
intent.data?.let { uri -> (intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
return hashMapOf( return hashMapOf(
"action" to "view", "action" to "view",
"uri" to uri.toString(), "uri" to uri.toString(),
@ -171,7 +182,9 @@ class MainActivity : FlutterActivity() {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java) private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val VOLUME_ACCESS_REQUEST = 1
const val DELETE_PERMISSION_REQUEST = 2
} }
} }

View file

@ -12,6 +12,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
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.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -30,7 +31,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) } "getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) } "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) }
"edit" -> { "edit" -> {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -109,7 +110,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
result.success(ArrayList(packages.values)) result.success(ArrayList(packages.values))
} }
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) { private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
val packageName = call.argument<String>("packageName") val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Double>("sizeDip") val sizeDip = call.argument<Double>("sizeDip")
if (packageName == null || sizeDip == null) { if (packageName == null || sizeDip == null) {
@ -254,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
@ -263,7 +264,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(AppAdapterHandler::class.java) private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
const val CHANNEL = "deckers.thibault/aves/app" const val CHANNEL = "deckers.thibault/aves/app"
} }
} }

View file

@ -305,7 +305,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
) )
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java) private val LOG_TAG = LogUtils.createTag<DebugHandler>()
const val CHANNEL = "deckers.thibault/aves/debug" const val CHANNEL = "deckers.thibault/aves/debug"
} }
} }

View file

@ -0,0 +1,237 @@
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
}
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
}
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 { output ->
embeddedByteStream.use { input ->
input.copyTo(output)
}
}
}
val authority = "${context.applicationContext.packageName}.fileprovider"
val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
displayName
} else {
"$displayName$extension"
}
FileProvider.getUriForFile(context, authority, file, displayNameWithExtension)
} else {
FileProvider.getUriForFile(context, authority, file)
}
val resultFields: FieldMap = hashMapOf(
"uri" to uri.toString(),
"mimeType" to mimeType,
)
if (isImage(mimeType) || isVideo(mimeType)) {
GlobalScope.launch(Dispatchers.IO) {
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
resultFields.putAll(fields)
result.success(resultFields)
}
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
})
}
} else {
result.success(resultFields)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<EmbeddedDataHandler>()
const val CHANNEL = "deckers.thibault/aves/embedded"
}
}

View file

@ -3,7 +3,6 @@ package deckers.thibault.aves.channel.calls
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
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.channel.calls.Coresult.Companion.safesus
@ -31,8 +30,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) } "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) } "rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
@ -61,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
}) })
} }
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) { private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri") val uri = call.argument<String>("uri")
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong() val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
@ -93,7 +92,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
).fetch() ).fetch()
} }
private fun getRegion(call: MethodCall, result: MethodChannel.Result) { private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId") val pageId = call.argument<Int>("pageId")
@ -185,7 +184,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
result.error("changeOrientation-args", "failed because entry fields are missing", null) result.error("changeOrientation-args", "failed because entry fields are missing", null)
return return
} }
@ -196,7 +196,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return return
} }
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback { provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
}) })

View file

@ -4,17 +4,13 @@ import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag import com.drew.metadata.Tag
@ -51,12 +47,9 @@ import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText 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.isMotionPhoto
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
@ -73,8 +66,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.text.ParseException import java.text.ParseException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -88,9 +79,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) { safe(call, result, ::getExifThumbnails) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -315,10 +303,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
@ -382,6 +370,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.isMotionPhoto()) {
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)
} }
@ -471,7 +464,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
} }
@ -591,68 +584,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")
@ -745,176 +694,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString()) result.success(value?.toString())
} }
private 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 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(bytes, mime, result)
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(embedBytes, embedMimeType, result)
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(embedBytes: ByteArray, embedMimeType: String, result: MethodChannel.Result) {
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
embedBytes.inputStream().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::class.java) 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",
@ -922,7 +708,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"
@ -931,11 +717,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LONGITUDE = "longitude" private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_HEIGHT = "height"
private const val KEY_WIDTH = "width"
private const val KEY_PAGE = "page"
private const val KEY_IS_DEFAULT = "isDefault"
private const val KEY_DURATION = "durationMillis"
private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -30,7 +30,7 @@ class RegionFetcher internal constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .skipMemoryCache(true)
fun fetch( suspend fun fetch(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
pageId: Int?, pageId: Int?,
@ -114,8 +114,8 @@ class RegionFetcher internal constructor(
val bitmap = target.get() val bitmap = target.get()
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply { val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit() deleteOnExit()
outputStream().use { outputStream -> outputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
} }
} }
return Uri.fromFile(tempFile) return Uri.fromFile(tempFile)

View file

@ -45,7 +45,7 @@ class ThumbnailFetcher internal constructor(
private val multiTrackFetch = isHeic(mimeType) && pageId != null private val multiTrackFetch = isHeic(mimeType) && pageId != null
private val customFetch = tiffFetch || multiTrackFetch private val customFetch = tiffFetch || multiTrackFetch
fun fetch() { suspend fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var exception: Exception? = null var exception: Exception? = null

View file

@ -11,7 +11,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class TiffRegionFetcher internal constructor( class TiffRegionFetcher internal constructor(
private val context: Context, private val context: Context,
) { ) {
fun fetch( suspend fun fetch(
uri: Uri, uri: Uri,
page: Int, page: Int,
sampleSize: Int, sampleSize: Int,

View file

@ -58,7 +58,7 @@ class ContentChangeStreamHandler(private val context: Context) : EventChannel.St
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag<ContentChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/contentchange" const val CHANNEL = "deckers.thibault/aves/contentchange"
} }
} }

View file

@ -76,7 +76,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP // - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats // - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java // - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private fun streamImage() { private suspend fun streamImage() {
if (arguments !is Map<*, *>) { if (arguments !is Map<*, *>) {
endOfStream() endOfStream()
return return
@ -114,7 +114,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
} }
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeic(mimeType) && pageId != null) { val model: Any = if (isHeic(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId) MultiTrackImage(activity, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
@ -145,7 +145,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
} }
private fun streamVideoByGlide(uri: Uri) { private suspend fun streamVideoByGlide(uri: Uri) {
val target = Glide.with(activity) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
@ -175,7 +175,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
private fun streamBytes(inputStream: InputStream) { private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize) val buffer = ByteArray(BUFFER_SIZE)
var len: Int var len: Int
while (inputStream.read(buffer).also { len = it } != -1) { while (inputStream.read(buffer).also { len = it } != -1) {
// cannot decode image on Flutter side when using `buffer` directly // cannot decode image on Flutter side when using `buffer` directly
@ -184,10 +184,10 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(ImageByteStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/imagebytestream" const val CHANNEL = "deckers.thibault/aves/imagebytestream"
const val bufferSize = 2 shl 17 // 256kB const val BUFFER_SIZE = 2 shl 17 // 256kB
// request a fresh image with the highest quality format // request a fresh image with the highest quality format
val glideOptions = RequestOptions() val glideOptions = RequestOptions()

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.content.Context import android.app.Activity
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -18,7 +18,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
class ImageOpStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -103,7 +103,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
"uri" to uri.toString(), "uri" to uri.toString(),
) )
try { try {
provider.delete(context, uri, path) provider.delete(activity, uri, path)
result["success"] = true result["success"] = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e) Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
@ -138,7 +138,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback { provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
}) })
@ -168,7 +168,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback { provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
}) })
@ -176,7 +176,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/imageopstream" const val CHANNEL = "deckers.thibault/aves/imageopstream"
} }
} }

View file

@ -61,7 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/mediastorestream" const val CHANNEL = "deckers.thibault/aves/mediastorestream"
} }
} }

View file

@ -75,7 +75,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(StorageAccessStreamHandler::class.java) private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/storageaccessstream" const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
} }
} }

View file

@ -19,6 +19,9 @@ import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
@ -47,6 +50,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> { internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) { override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
GlobalScope.launch(Dispatchers.IO) {
val retriever = openMetadataRetriever(model.context, model.uri) val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) { if (retriever != null) {
try { try {
@ -84,6 +88,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
} }
} }
} }
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd // already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {} override fun cleanup() {}

View file

@ -17,7 +17,7 @@ import kotlin.math.floor
import kotlin.math.roundToLong import kotlin.math.roundToLong
object ExifInterfaceHelper { object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java) private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT) private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
private const val precisionErrorTolerance = 1e-10 private const val precisionErrorTolerance = 1e-10

View file

@ -2,7 +2,9 @@ package deckers.thibault.aves.metadata
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.File import java.io.File
@ -13,6 +15,8 @@ import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object Metadata { object Metadata {
private val LOG_TAG = LogUtils.createTag<Metadata>()
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples: // Examples:
// "+37.5090+127.0243/" (Samsung) // "+37.5090+127.0243/" (Samsung)
@ -96,10 +100,10 @@ object Metadata {
return dateMillis return dateMillis
} }
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), // opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// so we define an arbitrary threshold to avoid a crash on launch. // so we define an arbitrary threshold to avoid a crash on launch.
// It is not clear whether it is because of the file itself or its metadata. // It is not clear whether it is because of the file itself or its metadata.
private const val tiffSizeBytesMax = 100 * (1 shl 20) // MB private const val fileSizeBytesMax = 100 * (1 shl 20) // MB
// we try and read metadata from large files by copying an arbitrary amount from its beginning // we try and read metadata from large files by copying an arbitrary amount from its beginning
// to a temporary file, and reusing that preview file for all metadata reading purposes // to a temporary file, and reusing that preview file for all metadata reading purposes
@ -108,25 +112,39 @@ object Metadata {
private val previewFiles = HashMap<Uri, File>() private val previewFiles = HashMap<Uri, File>()
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri { private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
if (mimeType != MimeTypes.TIFF) return uri return when (mimeType) {
// formats known to yield OOM for large files
if (sizeBytes != null && sizeBytes < tiffSizeBytesMax) return uri MimeTypes.MP4,
MimeTypes.PSD_VND,
MimeTypes.PSD_X,
MimeTypes.TIFF -> {
if (sizeBytes != null && sizeBytes < fileSizeBytesMax) {
// small enough to be safe as it is
uri
} else {
// make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk
Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes")
var previewFile = previewFiles[uri] var previewFile = previewFiles[uri]
if (previewFile == null) { if (previewFile == null) {
previewFile = File.createTempFile("aves", null, context.cacheDir).apply { previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit() deleteOnExit()
outputStream().use { outputStream -> outputStream().use { output ->
StorageUtils.openInputStream(context, uri)?.use { inputStream -> StorageUtils.openInputStream(context, uri)?.use { input ->
val b = ByteArray(previewSize) val b = ByteArray(previewSize)
inputStream.read(b, 0, previewSize) input.read(b, 0, previewSize)
outputStream.write(b) output.write(b)
} }
} }
} }
previewFiles[uri] = previewFile previewFiles[uri] = previewFile
} }
return Uri.fromFile(previewFile) Uri.fromFile(previewFile)
}
}
// *probably* safe
else -> uri
}
} }
fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? { fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? {

View file

@ -0,0 +1,196 @@
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? {
try {
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
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
return null
}
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf(
KEY_PAGE to page,
KEY_MIME_TYPE to MimeTypes.TIFF,
KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
)
}
val pages = ArrayList<FieldMap>()
getTiffPageInfo(context, uri, 0)?.let { first ->
pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) {
getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) }
}
}
return pages
}
fun isMultiPageTiff(context: Context, uri: Uri) = getTiffPageInfo(context, uri, 0)?.outDirectoryCount ?: 1 > 1
private fun getTiffPageInfo(context: Context, uri: Uri, page: Int): TiffBitmapFactory.Options? {
try {
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
Log.w(LOG_TAG, "failed to get TIFF file descriptor for uri=$uri")
return null
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
return options
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e)
}
return null
}
}

View file

@ -13,7 +13,7 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
object MultiTrackMedia { object MultiTrackMedia {
private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java) private val LOG_TAG = LogUtils.createTag<MultiTrackMedia>()
@RequiresApi(Build.VERSION_CODES.P) @RequiresApi(Build.VERSION_CODES.P)
fun getImage(context: Context, uri: Uri, trackIndex: Int?): Bitmap? { fun getImage(context: Context, uri: Uri, trackIndex: Int?): Bitmap? {

View file

@ -82,7 +82,7 @@ class GSpherical(xmlBytes: ByteArray) {
).filterValues { it != null } ).filterValues { it != null }
companion object SphericalVideo { companion object SphericalVideo {
private val LOG_TAG = LogUtils.createTag(SphericalVideo::class.java) private val LOG_TAG = LogUtils.createTag<SphericalVideo>()
// cf https://github.com/google/spatial-media // cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd" const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"

View file

@ -8,7 +8,7 @@ import deckers.thibault.aves.utils.LogUtils
import java.util.* import java.util.*
object XMP { object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java) private val LOG_TAG = LogUtils.createTag<XMP>()
// standard namespaces // standard namespaces
// cf com.adobe.internal.xmp.XMPConst // cf com.adobe.internal.xmp.XMPConst
@ -42,6 +42,12 @@ object XMP {
fun isDataPath(path: String) = knownDataPaths.contains(path) fun isDataPath(path: String) = knownDataPaths.contains(path)
// motion photo
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
// panorama // panorama
// cf https://developers.google.com/streetview/spherical-metadata // cf https://developers.google.com/streetview/spherical-metadata
@ -71,6 +77,19 @@ object XMP {
// extensions // extensions
fun XMPMeta.isMotionPhoto(): Boolean {
try {
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
// from a non standard namespace, and that namespace is not declared in the XMP
Log.w(LOG_TAG, "failed to check Google motion photo props from XMP", e)
}
}
return false
}
fun XMPMeta.isPanorama(): Boolean { fun XMPMeta.isPanorama(): Boolean {
// Google // Google
try { try {
@ -111,6 +130,20 @@ object XMP {
} }
} }
fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) {
try {
if (doesPropertyExist(schema, propName)) {
val item = getPropertyLong(schema, propName)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
save(item)
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get long for XMP schema=$schema, propName=$propName", e)
}
}
fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) { fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) {
try { try {
if (doesPropertyExist(schema, propName)) { if (doesPropertyExist(schema, propName)) {

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.OpenableColumns
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() { internal class ContentImageProvider : ImageProvider() {
@ -19,8 +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(MediaStore.MediaColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.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.close() cursor.close()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -37,9 +39,15 @@ internal class ContentImageProvider : ImageProvider() {
} }
companion object { companion object {
@Suppress("DEPRECATION")
const val PATH = MediaStore.MediaColumns.DATA
private val projection = arrayOf( private val projection = arrayOf(
MediaStore.MediaColumns.SIZE, // standard columns for openable URI
MediaStore.MediaColumns.DISPLAY_NAME OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
// optional path underlying media content
PATH,
) )
} }
} }

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@ -16,16 +17,18 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
@ -39,21 +42,25 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException()) callback.onFailure(UnsupportedOperationException())
} }
open suspend fun delete(context: Context, uri: Uri, path: String?) { open suspend fun delete(activity: Activity, uri: Uri, path: String?) {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException()) callback.onFailure(UnsupportedOperationException())
} }
suspend fun exportMultiple( suspend fun exportMultiple(
context: Context, context: Context,
mimeType: String, imageExportMimeType: String,
destinationDir: String, destinationDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
throw Exception("unsupported export MIME type=$imageExportMimeType")
}
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) { if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
@ -71,13 +78,15 @@ abstract class ImageProvider {
"success" to false, "success" to false,
) )
val sourceMimeType = entry.mimeType
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try { try {
val newFields = exportSingleByTreeDocAndScan( val newFields = exportSingleByTreeDocAndScan(
context = context, context = context,
sourceEntry = entry, sourceEntry = entry,
destinationDir = destinationDir, destinationDir = destinationDir,
destinationDirDocFile = destinationDirDocFile, destinationDirDocFile = destinationDirDocFile,
exportMimeType = mimeType, exportMimeType = exportMimeType,
) )
result["newFields"] = newFields result["newFields"] = newFields
result["success"] = true result["success"] = true
@ -111,12 +120,7 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
} }
val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) { val desiredFileName = desiredNameWithoutExtension + extensionFor(exportMimeType)
MimeTypes.JPEG -> ".jpg"
MimeTypes.PNG -> ".png"
MimeTypes.WEBP -> ".webp"
else -> throw Exception("unsupported export MIME type=$exportMimeType")
}
if (File(destinationDir, desiredFileName).exists()) { if (File(destinationDir, desiredFileName).exists()) {
throw Exception("file with name=$desiredFileName already exists in destination directory") throw Exception("file with name=$desiredFileName already exists in destination directory")
@ -130,6 +134,11 @@ abstract class ImageProvider {
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
if (isVideo(sourceMimeType)) {
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
sourceDocFile.copyTo(destinationDocFile)
} else {
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId) MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
@ -157,6 +166,11 @@ abstract class ImageProvider {
} }
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId") bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use { output ->
if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output)
} else {
val quality = 100 val quality = 100
val format = when (exportMimeType) { val format = when (exportMimeType) {
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
@ -173,14 +187,13 @@ abstract class ImageProvider {
} }
else -> throw Exception("unsupported export MIME type=$exportMimeType") else -> throw Exception("unsupported export MIME type=$exportMimeType")
} }
bitmap.compress(format, quality, output)
@Suppress("BlockingMethodInNonBlockingContext") }
destinationDocFile.openOutputStream().use {
bitmap.compress(format, quality, it)
} }
} finally { } finally {
Glide.with(context).clear(target) Glide.with(context).clear(target)
} }
}
val fileName = destinationDocFile.name val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName val destinationFullPath = destinationDir + fileName
@ -218,7 +231,7 @@ abstract class ImageProvider {
} }
} }
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) {
if (!canEditExif(mimeType)) { if (!canEditExif(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return return
@ -230,16 +243,44 @@ abstract class ImageProvider {
return return
} }
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
var videoBytes: ByteArray? = null
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
if (videoSizeBytes != null) {
// handle motion photo and embedded video separately
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
videoBytes = ByteArray(videoSizeBytes)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSizeBytes)
input.read(imageBytes, 0, imageSizeBytes)
input.read(videoBytes, 0, videoSizeBytes)
// copy only the image to a temporary file for editing
// video will be appended after EXIF modification
ByteArrayInputStream(imageBytes).use { imageInput ->
imageInput.copyTo(output)
}
}
} else {
// copy original file to a temporary file for editing // copy original file to a temporary file for editing
val editablePath = copyFileToTemp(originalDocumentFile, path) originalDocumentFile.openInputStream().use { imageInput ->
if (editablePath == null) { imageInput.copyTo(output)
callback.onFailure(Exception("failed to create a temporary file for path=$path")) }
}
}
} catch (e: Exception) {
callback.onFailure(e)
return return
} }
}
val newFields = HashMap<String, Any?>() val newFields = HashMap<String, Any?>()
try { try {
val exif = ExifInterface(editablePath) val exif = ExifInterface(editableFile)
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
// in that case we explicitly set it to `normal` first // in that case we explicitly set it to `normal` first
// because ExifInterface fails to rotate an image with undefined orientation // because ExifInterface fails to rotate an image with undefined orientation
@ -255,8 +296,12 @@ abstract class ImageProvider {
} }
exif.saveAttributes() exif.saveAttributes()
if (videoBytes != null) {
// append motion photo video, if any
editableFile.appendBytes(videoBytes!!)
}
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(File(editablePath)).copyTo(originalDocumentFile) DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
newFields["rotationDegrees"] = exif.rotationDegrees newFields["rotationDegrees"] = exif.rotationDegrees
newFields["isFlipped"] = exif.isFlipped newFields["isFlipped"] = exif.isFlipped
@ -285,7 +330,7 @@ abstract class ImageProvider {
// as of androidx.exifinterface:exifinterface:1.3.0 // as of androidx.exifinterface:exifinterface:1.3.0
private fun canEditExif(mimeType: String): Boolean { private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) { return when (mimeType) {
"image/jpeg", "image/png", "image/webp" -> true MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
else -> false else -> false
} }
} }
@ -300,9 +345,9 @@ abstract class ImageProvider {
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = newUri.tryParseId() contentId = newUri.tryParseId()
if (contentId != null) { if (contentId != null) {
if (MimeTypes.isImage(mimeType)) { if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (MimeTypes.isVideo(mimeType)) { } else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
} }
} }
@ -349,6 +394,8 @@ abstract class ImageProvider {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java) private val LOG_TAG = LogUtils.createTag<ImageProvider>()
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
} }
} }

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -8,6 +10,7 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
@ -22,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() { class MediaStoreImageProvider : ImageProvider() {
@ -205,31 +209,55 @@ class MediaStoreImageProvider : ImageProvider() {
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI // `uri` is a media URI, not a document URI
override suspend fun delete(context: Context, uri: Uri, path: String?) { override suspend fun delete(activity: Activity, uri: Uri, path: String?) {
path ?: throw Exception("failed to delete file because path is null") path ?: throw Exception("failed to delete file because path is null")
if (File(path).exists() && requireAccessPermission(context, path)) { if (File(path).exists() && requireAccessPermission(activity, path)) {
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store // if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission // but it doesn't delete the file, even if the app has the permission
val df = getDocumentFile(context, path, uri) val df = getDocumentFile(activity, path, uri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return if (df != null && df.delete()) return
throw Exception("failed to delete file with df=$df") throw Exception("failed to delete file with df=$df")
} }
if (context.contentResolver.delete(uri, null, null) > 0) return try {
if (activity.contentResolver.delete(uri, null, null) > 0) return
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rse = securityException as? RecoverableSecurityException ?: throw securityException
val intentSender = rse.userAction.actionIntent.intentSender
// request user permission for this item
pendingDeleteCompleter = CompletableFuture<Boolean>()
activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null)
val granted = pendingDeleteCompleter!!.join()
pendingDeleteCompleter = null
if (granted) {
delete(activity, uri, path)
} else {
throw Exception("failed to get delete permission")
}
} else {
throw securityException
}
}
throw Exception("failed to delete row from content provider") throw Exception("failed to delete row from content provider")
} }
override suspend fun moveMultiple( override suspend fun moveMultiple(
context: Context, activity: Activity,
copy: Boolean, copy: Boolean,
destinationDir: String, destinationDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
if (destinationDirDocFile == null) { if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return return
@ -262,7 +290,7 @@ class MediaStoreImageProvider : ImageProvider() {
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try { try {
val newFields = moveSingleByTreeDocAndScan( val newFields = moveSingleByTreeDocAndScan(
context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
) )
result["newFields"] = newFields result["newFields"] = newFields
result["success"] = true result["success"] = true
@ -275,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
private suspend fun moveSingleByTreeDocAndScan( private suspend fun moveSingleByTreeDocAndScan(
context: Context, activity: Activity,
sourcePath: String, sourcePath: String,
sourceUri: Uri, sourceUri: Uri,
destinationDir: String, destinationDir: String,
@ -303,12 +331,12 @@ class MediaStoreImageProvider : ImageProvider() {
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(context, sourceUri) val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(destinationDocFile) source.copyTo(destinationDocFile)
@ -322,20 +350,20 @@ class MediaStoreImageProvider : ImageProvider() {
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(context, sourceUri, sourcePath) delete(activity, sourceUri, sourcePath)
deletedSource = true deletedSource = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
} }
} }
return scanNewPath(context, destinationFullPath, mimeType).apply { return scanNewPath(activity, destinationFullPath, mimeType).apply {
put("deletedSource", deletedSource) put("deletedSource", deletedSource)
} }
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java) private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
@ -366,6 +394,8 @@ class MediaStoreImageProvider : ImageProvider() {
MediaStore.MediaColumns.ORIENTATION, MediaStore.MediaColumns.ORIENTATION,
) else emptyArray() ) else emptyArray()
) )
var pendingDeleteCompleter: CompletableFuture<Boolean>? = null
} }
} }

View file

@ -6,24 +6,46 @@ import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode import deckers.thibault.aves.metadata.Metadata.getExifCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
object BitmapUtils { object BitmapUtils {
private val LOG_TAG = LogUtils.createTag(BitmapUtils::class.java) private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray? { private val freeBaos = ArrayList<ByteArrayOutputStream>()
private val mutex = Mutex()
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream
mutex.withLock {
// this method is called a lot, so we try and reuse output streams
// to reduce inner array allocations, and make the GC run less frequently
stream = if (freeBaos.isNotEmpty()) {
freeBaos.removeAt(0)
} else {
ByteArrayOutputStream(INITIAL_BUFFER_SIZE)
}
}
try { try {
val stream = ByteArrayOutputStream() // the Bitmap raw bytes are not decodable by Flutter
// we compress the bitmap because Flutter cannot decode the raw bytes // we need to format them (compress, or add a BMP header) before sending them
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency // `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
if (canHaveAlpha) { // the BMP format allows an alpha channel, but Android decoding seems to ignore it
if (canHaveAlpha && hasAlpha()) {
this.compress(Bitmap.CompressFormat.PNG, quality, stream) this.compress(Bitmap.CompressFormat.PNG, quality, stream)
} else { } else {
this.compress(Bitmap.CompressFormat.JPEG, quality, stream) this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
} }
if (recycle) this.recycle() if (recycle) this.recycle()
return stream.toByteArray() val byteArray = stream.toByteArray()
} catch (e: IllegalStateException) { stream.reset()
mutex.withLock {
freeBaos.add(stream)
}
return byteArray
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get bytes from bitmap", e) Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
} }
return null return null

View file

@ -0,0 +1,111 @@
package deckers.thibault.aves.utils
import android.graphics.Bitmap
import java.io.OutputStream
import java.nio.ByteBuffer
object BmpWriter {
private const val FILE_HEADER_SIZE = 14
private const val INFO_HEADER_SIZE = 40
private const val BYTE_PER_PIXEL = 3
private val pad = ByteArray(3)
// file header
private val bfType = byteArrayOf('B'.toByte(), 'M'.toByte())
private val bfReserved1 = intToWord(0)
private val bfReserved2 = intToWord(0)
private val bfOffBits = intToDWord(FILE_HEADER_SIZE + INFO_HEADER_SIZE)
// info header
private val biSize = intToDWord(INFO_HEADER_SIZE)
private val biPlanes = intToWord(1)
private val biBitCount = intToWord(BYTE_PER_PIXEL * 8)
private val biCompression = intToDWord(0)
private val biXPelsPerMeter = intToDWord(0)
private val biYPelsPerMeter = intToDWord(0)
private val biClrUsed = intToDWord(0)
private val biClrImportant = intToDWord(0)
// converts an int to a word (2-byte array)
private fun intToWord(v: Int): ByteArray {
val retValue = ByteArray(2)
retValue[0] = (v and 0xFF).toByte()
retValue[1] = (v shr 8 and 0xFF).toByte()
return retValue
}
// converts an int to a double word (4-byte array)
private fun intToDWord(v: Int): ByteArray {
val retValue = ByteArray(4)
retValue[0] = (v and 0xFF).toByte()
retValue[1] = (v shr 8 and 0xFF).toByte()
retValue[2] = (v shr 16 and 0xFF).toByte()
retValue[3] = (v shr 24 and 0xFF).toByte()
return retValue
}
fun writeRGB24(
bitmap: Bitmap,
outputStream: OutputStream
) {
// init
val biWidth = bitmap.width
val biHeight = bitmap.height
val padPerRow = (4 - (biWidth * BYTE_PER_PIXEL) % 4) % 4
val biSizeImage = (biWidth * BYTE_PER_PIXEL + padPerRow) * biHeight
val bfSize = FILE_HEADER_SIZE + INFO_HEADER_SIZE + biSizeImage
val buffer = ByteBuffer.allocate(bfSize)
val pixels = IntArray(biWidth * biHeight)
bitmap.getPixels(pixels, 0, biWidth, 0, 0, biWidth, biHeight)
// file header
buffer.put(bfType)
buffer.put(intToDWord(bfSize))
buffer.put(bfReserved1)
buffer.put(bfReserved2)
buffer.put(bfOffBits)
// info header
buffer.put(biSize)
buffer.put(intToDWord(biWidth))
buffer.put(intToDWord(biHeight))
buffer.put(biPlanes)
buffer.put(biBitCount)
buffer.put(biCompression)
buffer.put(intToDWord(biSizeImage))
buffer.put(biXPelsPerMeter)
buffer.put(biYPelsPerMeter)
buffer.put(biClrUsed)
buffer.put(biClrImportant)
// pixels
val rgb = ByteArray(BYTE_PER_PIXEL)
var value: Int
var row = biHeight - 1
while (row >= 0) {
var column = 0
while (column < biWidth) {
/*
alpha: (value shr 24 and 0xFF).toByte()
red: (value shr 16 and 0xFF).toByte()
green: (value shr 8 and 0xFF).toByte()
blue: (value and 0xFF).toByte()
*/
value = pixels[row * biWidth + column]
// blue: [0], green: [1], red: [2]
rgb[0] = (value and 0xFF).toByte()
rgb[1] = (value shr 8 and 0xFF).toByte()
rgb[2] = (value shr 16 and 0xFF).toByte()
buffer.put(rgb)
column++
}
if (padPerRow > 0) {
buffer.put(pad, 0, padPerRow)
}
row--
}
// write to output stream
outputStream.write(buffer.array())
}
}

View file

@ -3,16 +3,17 @@ package deckers.thibault.aves.utils
import java.util.regex.Pattern import java.util.regex.Pattern
object LogUtils { object LogUtils {
private const val LOG_TAG_MAX_LENGTH = 23 const val LOG_TAG_MAX_LENGTH = 23
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.") val LOG_TAG_PACKAGE_PATTERN: Pattern = Pattern.compile("(\\w)(\\w*)\\.")
// create an Android logger friendly log tag for the specified class // create an Android logger friendly log tag for the specified class
fun createTag(clazz: Class<*>): String { inline fun <reified T> createTag(): String {
val kClass = T::class
// shorten class name to "a.b.CccDdd" // shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.") var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(kClass.qualifiedName!!).replaceAll("$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "a.b.CD" // shorten class name to "a.b.CD"
val simpleName = clazz.simpleName val simpleName = kClass.simpleName!!
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "") val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "")
logTag = logTag.replace(simpleName, shortSimpleName) logTag = logTag.replace(simpleName, shortSimpleName)
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {

View file

@ -6,14 +6,16 @@ object MimeTypes {
private const val IMAGE = "image" private const val IMAGE = "image"
// generic raster // generic raster
private const val BMP = "image/bmp" const val BMP = "image/bmp"
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"
const val PSD_VND = "image/vnd.adobe.photoshop"
const val PSD_X = "image/x-photoshop"
const val TIFF = "image/tiff" const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp" private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp" const val WEBP = "image/webp"
@ -37,6 +39,7 @@ object MimeTypes {
private const val MP2T = "video/mp2t" private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts" private const val MP2TS = "video/mp2ts"
const val MP4 = "video/mp4"
private const val WEBM = "video/webm" private const val WEBM = "video/webm"
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE) fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@ -95,5 +98,17 @@ object MimeTypes {
// extensions // extensions
fun extensionFor(mimeType: String): String? = when (mimeType) {
BMP -> ".bmp"
GIF -> ".gif"
HEIC, HEIF -> ".heif"
JPEG -> ".jpg"
MP4 -> ".mp4"
PNG -> ".png"
TIFF -> ".tiff"
WEBP -> ".webp"
else -> null
}
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
} }

View file

@ -9,6 +9,7 @@ import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
@ -16,9 +17,7 @@ import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java) private val LOG_TAG = LogUtils.createTag<PermissionManager>()
const val VOLUME_ACCESS_REQUEST_CODE = 1
// permission request code to pending runnable // permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>() private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@ -39,8 +38,8 @@ object PermissionManager {
} }
if (intent.resolveActivity(activity.packageManager) != null) { if (intent.resolveActivity(activity.packageManager) != null) {
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied) pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST_CODE, null) activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null)
} else { } else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
onDenied() onDenied()

View file

@ -11,19 +11,17 @@ import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object StorageUtils { object StorageUtils {
private val LOG_TAG = LogUtils.createTag(StorageUtils::class.java) private val LOG_TAG = LogUtils.createTag<StorageUtils>()
/** /**
* Volume paths * Volume paths
@ -350,19 +348,6 @@ object StorageUtils {
} }
} }
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
try {
val temp = File.createTempFile("aves", ".$extension")
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
temp.deleteOnExit()
return temp.path
} catch (e: IOException) {
Log.e(LOG_TAG, "failed to copy file from path=$path")
}
return null
}
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? { private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null

View file

@ -5,7 +5,7 @@ import android.net.Uri
import android.util.Log import android.util.Log
object UriUtils { object UriUtils {
private val LOG_TAG = LogUtils.createTag(UriUtils::class.java) private val LOG_TAG = LogUtils.createTag<UriUtils>()
fun Uri.tryParseId(): Long? { fun Uri.tryParseId(): Long? {
try { try {

View file

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

View file

@ -4,7 +4,7 @@ import 'package:aves/geo/topojson.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
final CountryTopology countryTopology = CountryTopology._private(); final CountryTopology countryTopology = CountryTopology._private();

View file

@ -1,6 +1,6 @@
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
String _decimal2sexagesimal(final double degDecimal) { String _decimal2sexagesimal(final double degDecimal) {
List<int> _split(final double value) { List<int> _split(final double value) {

View file

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

View file

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

View file

@ -44,6 +44,12 @@ class EntryActions {
EntryAction.setAs, EntryAction.setAs,
EntryAction.openMap, EntryAction.openMap,
]; ];
static const pageActions = [
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.flip,
];
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {

View file

@ -4,7 +4,6 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -17,7 +16,7 @@ import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../ref/mime_types.dart'; import '../ref/mime_types.dart';
@ -43,7 +42,13 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions // TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd]; static const List<String> undecodable = [
MimeTypes.art,
MimeTypes.crw,
MimeTypes.djvu,
MimeTypes.psdVnd,
MimeTypes.psdX,
];
AvesEntry({ AvesEntry({
this.uri, this.uri,
@ -97,36 +102,6 @@ class AvesEntry {
return copied; return copied;
} }
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
if (pageInfo == null) return this;
// do not provide the page ID for the default page,
// so that we can treat this page like the main entry
// and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry(
uri: uri,
path: path,
contentId: contentId,
pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
width: pageInfo.width ?? width,
height: pageInfo.height ?? height,
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? durationMillis,
)
..catalogMetadata = _catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType,
isMultipage: false,
)
..addressDetails = _addressDetails?.copyWith();
}
// from DB or platform source entry // from DB or platform source entry
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(
@ -251,7 +226,9 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false; bool get is360 => _catalogMetadata?.is360 ?? false;
bool get isMultipage => _catalogMetadata?.isMultipage ?? false; bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;
bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;
bool get canEdit => path != null; bool get canEdit => path != null;

View file

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

View file

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

View file

@ -1,69 +1,144 @@
import 'package:aves/model/entry.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;
final Map<SinglePageInfo, AvesEntry> _pageEntries = {};
int get pageCount => pages.length; int get pageCount => _pages.length;
MultiPageInfo({ MultiPageInfo({
@required this.uri, @required this.mainEntry,
this.pages, List<SinglePageInfo> pages,
}) { }) : _pages = pages {
if (pages.isNotEmpty) { if (_pages.isNotEmpty) {
pages.sort(); _pages.sort();
// make sure there is a page marked as default // make sure there is a page marked as default
if (defaultPage == null) { if (defaultPage == null) {
final firstPage = pages.removeAt(0); final firstPage = _pages.removeAt(0);
pages.insert(0, firstPage.copyWith(isDefault: true)); _pages.insert(0, firstPage.copyWith(isDefault: true));
} }
} }
} }
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(),
); );
} }
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null); SinglePageInfo get defaultPage => _pages.firstWhere((page) => page.isDefault, orElse: () => null);
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null); SinglePageInfo getById(int pageId) => _pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null); SinglePageInfo getByIndex(int pageIndex) => _pages.firstWhere((page) => page.index == pageIndex, orElse: () => null);
AvesEntry getPageEntryByIndex(int pageIndex) => _getPageEntry(getByIndex(pageIndex));
AvesEntry _getPageEntry(SinglePageInfo pageInfo) {
if (pageInfo != null) {
return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo));
} else {
return mainEntry;
}
}
Set<AvesEntry> get videoPageEntries => _pages.where((page) => page.isVideo).map(_getPageEntry).toSet();
List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList();
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);
if (fields != null) {
final pageIndex = _pages.indexOf(videoPage);
_pages.removeAt(pageIndex);
_pages.insert(
pageIndex,
videoPage.copyWith(
uri: fields['uri'] as String,
// the initial fake page may contain inaccurate values for the following fields
// so we override them with values from the extracted standalone video
rotationDegrees: fields['sourceRotationDegrees'] as int,
durationMillis: fields['durationMillis'] as int,
));
_pageEntries.remove(videoPage);
}
}
}
AvesEntry _createPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
// do not provide the page ID for the default page,
// so that we can treat this page like the main entry
// and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
return AvesEntry(
uri: pageInfo.uri ?? mainEntry.uri,
path: mainEntry.path,
contentId: mainEntry.contentId,
pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? mainEntry.sourceMimeType,
width: pageInfo.width ?? mainEntry.width,
height: pageInfo.height ?? mainEntry.height,
sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees,
sizeBytes: mainEntry.sizeBytes,
sourceTitle: mainEntry.sourceTitle,
dateModifiedSecs: mainEntry.dateModifiedSecs,
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
)
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType,
isMultiPage: false,
rotationDegrees: pageInfo.rotationDegrees,
)
..addressDetails = mainEntry.addressDetails?.copyWith();
}
@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,
int rotationDegrees,
int durationMillis,
}) { }) {
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,
durationMillis: durationMillis, rotationDegrees: rotationDegrees ?? this.rotationDegrees,
durationMillis: durationMillis ?? this.durationMillis,
); );
} }
@ -72,16 +147,19 @@ 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,
); );
} }
bool get isVideo => MimeTypes.isVideo(mimeType);
@override @override
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}'; String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, isDefault=$isDefault, uri=$uri, mimeType=$mimeType, width=$width, height=$height, rotationDegrees=$rotationDegrees, durationMillis=$durationMillis}';
@override @override
int compareTo(SinglePageInfo other) => index.compareTo(other.index); int compareTo(SinglePageInfo other) => index.compareTo(other.index);

View file

@ -1,7 +1,7 @@
import 'package:aves/geo/format.dart'; import 'package:aves/geo/format.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'enums.dart'; import 'enums.dart';

View file

@ -10,7 +10,7 @@ import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/video/fijkplayer.dart'; import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:fijkplayer/fijkplayer.dart'; import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -33,8 +33,12 @@ class VideoMetadataFormatter {
static Future<Map> getVideoMetadata(AvesEntry entry) async { static Future<Map> getVideoMetadata(AvesEntry entry) async {
final player = FijkPlayer(); final player = FijkPlayer();
await player.setDataSourceUntilPrepared(entry.uri); final info = await player.setDataSourceUntilPrepared(entry.uri).then((v) {
final info = await player.getInfo(); return player.getInfo();
}).catchError((error) {
debugPrint('failed to get video metadata for entry=$entry, error=$error');
return {};
});
await player.release(); await player.release();
return info; return info;
} }
@ -111,7 +115,9 @@ class VideoMetadataFormatter {
save('Channel Layout', _formatChannelLayout(value)); save('Channel Layout', _formatChannelLayout(value));
break; break;
case Keys.codecName: case Keys.codecName:
if (value != 'none') {
save('Format', _formatCodecName(value)); save('Format', _formatCodecName(value));
}
break; break;
case Keys.codecPixelFormat: case Keys.codecPixelFormat:
if (streamType == StreamTypes.video) { if (streamType == StreamTypes.video) {
@ -292,6 +298,7 @@ class VideoMetadataFormatter {
} }
class StreamTypes { class StreamTypes {
static const attachment = 'attachment';
static const audio = 'audio'; static const audio = 'audio';
static const metadata = 'metadata'; static const metadata = 'metadata';
static const subtitle = 'subtitle'; static const subtitle = 'subtitle';

View file

@ -12,13 +12,15 @@ class MimeTypes {
static const tiff = 'image/tiff'; static const tiff = 'image/tiff';
static const webp = 'image/webp'; static const webp = 'image/webp';
static const psd = 'image/vnd.adobe.photoshop'; static const art = 'image/x-jg';
static const djvu = 'image/vnd.djvu';
static const psdVnd = 'image/vnd.adobe.photoshop';
static const psdX = 'image/x-photoshop';
static const arw = 'image/x-sony-arw'; static const arw = 'image/x-sony-arw';
static const cr2 = 'image/x-canon-cr2'; static const cr2 = 'image/x-canon-cr2';
static const crw = 'image/x-canon-crw'; static const crw = 'image/x-canon-crw';
static const dcr = 'image/x-kodak-dcr'; static const dcr = 'image/x-kodak-dcr';
static const djvu = 'image/vnd.djvu';
static const dng = 'image/x-adobe-dng'; static const dng = 'image/x-adobe-dng';
static const erf = 'image/x-epson-erf'; static const erf = 'image/x-epson-erf';
static const k25 = 'image/x-kodak-k25'; static const k25 = 'image/x-kodak-k25';

View file

@ -16,6 +16,7 @@ class XMP {
'GettyImagesGIFT': 'Getty Images', 'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP', 'GIMP': 'GIMP',
'GCamera': 'Google Camera', 'GCamera': 'Google Camera',
'GCreations': 'Google Creations',
'GFocus': 'Google Focus', 'GFocus': 'Google Focus',
'GPano': 'Google Panorama', 'GPano': 'Google Panorama',
'illustrator': 'Illustrator', 'illustrator': 'Illustrator',

View file

@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract class EmbeddedDataService {
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformEmbeddedDataService implements EmbeddedDataService {
static const platform = MethodChannel('deckers.thibault/aves/embedded');
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'displayName': '${entry.bestTitle} • Video',
});
return result;
} on PlatformException catch (e) {
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'uri': entry.uri,
'displayName': '${entry.bestTitle} • Cover',
});
return result;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'displayName': '${entry.bestTitle}$propPath',
'propPath': propPath,
'propMimeType': propMimeType,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
}

View file

@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
class GeocodingService { class GeocodingService {
static const platform = MethodChannel('deckers.thibault/aves/geocoding'); static const platform = MethodChannel('deckers.thibault/aves/geocoding');

View file

@ -75,7 +75,7 @@ abstract class ImageFileService {
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg, @required String mimeType,
@required String destinationAlbum, @required String destinationAlbum,
}); });
@ -103,6 +103,7 @@ class PlatformImageFileService implements ImageFileService {
'rotationDegrees': entry.rotationDegrees, 'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped, 'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs, 'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
}; };
} }
@ -316,7 +317,7 @@ class PlatformImageFileService implements ImageFileService {
@override @override
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg, @required String mimeType,
@required String destinationAlbum, @required String destinationAlbum,
}) { }) {
try { try {

View file

@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
@ -21,12 +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> extractVideoEmbeddedPicture(String uri);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
} }
class PlatformMetadataService implements MetadataService { class PlatformMetadataService implements MetadataService {
@ -111,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}');
} }
@ -151,49 +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> extractVideoEmbeddedPicture(String uri) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'uri': uri,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'propPath': propPath,
'propMimeType': propMimeType,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
} }

View file

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

View file

@ -4,6 +4,7 @@ class Durations {
// Flutter animations (with margin) // Flutter animations (with margin)
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
// common animations // common animations
@ -12,12 +13,15 @@ class Durations {
static const sweepingAnimation = Duration(milliseconds: 650); static const sweepingAnimation = Duration(milliseconds: 650);
static const staggeredAnimation = Duration(milliseconds: 375); static const staggeredAnimation = Duration(milliseconds: 375);
static const staggeredAnimationPageTarget = Duration(milliseconds: 900); static const staggeredAnimationPageTarget = Duration(milliseconds: 800);
static const dialogFieldReachAnimation = Duration(milliseconds: 300); static const dialogFieldReachAnimation = Duration(milliseconds: 300);
static const appBarTitleAnimation = Duration(milliseconds: 300); static const appBarTitleAnimation = Duration(milliseconds: 300);
static const appBarActionChangeAnimation = Duration(milliseconds: 200); static const appBarActionChangeAnimation = Duration(milliseconds: 200);
// drawer
static const newsBadgeAnimation = Duration(milliseconds: 200);
// filter grids animations // filter grids animations
static const chipDecorationAnimation = Duration(milliseconds: 200); static const chipDecorationAnimation = Duration(milliseconds: 200);
static const highlightScrollAnimationMinMillis = 400; static const highlightScrollAnimationMinMillis = 400;
@ -60,7 +64,7 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250); static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 500); static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
// app life // app life
static const lastVersionCheckInterval = Duration(days: 7); static const lastVersionCheckInterval = Duration(days: 7);

View file

@ -8,6 +8,7 @@ class AIcons {
static const IconData vector = Icons.code_outlined; static const IconData vector = Icons.code_outlined;
static const IconData android = Icons.android; static const IconData android = Icons.android;
static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined; static const IconData checked = Icons.done_outlined;
static const IconData date = Icons.calendar_today_outlined; static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record; static const IconData disc = Icons.fiber_manual_record;
@ -71,9 +72,10 @@ class AIcons {
// thumbnail overlay // thumbnail overlay
static const IconData animated = Icons.slideshow; static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined; static const IconData geo = Icons.language_outlined;
static const IconData multipage = Icons.burst_mode_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_outlined;
static const IconData play = Icons.play_circle_outline; static const IconData play = Icons.play_circle_outline;
static const IconData threesixty = Icons.threesixty_outlined; static const IconData threeSixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline; static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;
} }

View file

@ -10,7 +10,6 @@ class Themes {
brightness: Brightness.dark, brightness: Brightness.dark,
accentColor: _accentColor, accentColor: _accentColor,
scaffoldBackgroundColor: Colors.grey[900], scaffoldBackgroundColor: Colors.grey[900],
buttonColor: _accentColor,
dialogBackgroundColor: Colors.grey[850], dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: _accentColor, toggleableActiveColor: _accentColor,
tooltipTheme: TooltipThemeData( tooltipTheme: TooltipThemeData(
@ -25,6 +24,12 @@ class Themes {
), ),
), ),
), ),
colorScheme: ColorScheme.dark(
primary: _accentColor,
secondary: _accentColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800], backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle( contentTextStyle: TextStyle(
@ -32,16 +37,6 @@ class Themes {
), ),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: _accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: _accentColor,
),
),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
primary: Colors.white, primary: Colors.white,

View file

@ -2,7 +2,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
class Constants { class Constants {
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
@ -42,8 +42,8 @@ class Constants {
Dependency( Dependency(
name: 'Android-TiffBitmapFactory', name: 'Android-TiffBitmapFactory',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory/blob/master/license.txt', licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory', sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory',
), ),
Dependency( Dependency(
name: 'CWAC-Document', name: 'CWAC-Document',

View file

@ -1,8 +1,13 @@
import 'dart:io';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart'; import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget { class ErrorThumbnail extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final String tooltip; final String tooltip;
@ -13,23 +18,53 @@ class ErrorThumbnail extends StatelessWidget {
@required this.tooltip, @required this.tooltip,
}); });
@override
_ErrorThumbnailState createState() => _ErrorThumbnailState();
}
class _ErrorThumbnailState extends State<ErrorThumbnail> {
Future<bool> _exists;
AvesEntry get entry => widget.entry;
double get extent => widget.extent;
@override
void initState() {
super.initState();
_exists = entry.path != null ? File(entry.path).exists() : SynchronousFuture(true);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const color = Colors.blueGrey;
return FutureBuilder<bool>(
future: _exists,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return SizedBox();
final exists = snapshot.data;
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
color: Colors.black, color: Colors.black,
child: Tooltip( child: Tooltip(
message: tooltip, message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
preferBelow: false, preferBelow: false,
child: Text( child: exists
? Text(
MimeUtils.displayType(entry.mimeType), MimeUtils.displayType(entry.mimeType),
style: TextStyle( style: TextStyle(
color: Colors.blueGrey, color: color,
fontSize: extent / 5, fontSize: extent / 5,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
)
: Icon(
AIcons.broken,
size: extent / 2,
color: color,
), ),
), ),
); );
});
} }
} }

View file

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

View file

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

View file

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

View file

@ -40,12 +40,12 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'), child: Text('SVG cache: ${PictureProvider.cache.count} items'),
), ),
SizedBox(width: 8), SizedBox(width: 8),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
PictureProvider.clearCache(); PictureProvider.cache.clear();
setState(() {}); setState(() {});
}, },

View file

@ -8,10 +8,10 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -58,9 +58,6 @@ class _AppDrawerState extends State<AppDrawer> {
albumListTile, albumListTile,
countryListTile, countryListTile,
tagListTile, tagListTile,
Divider(),
settingsTile,
aboutTile,
if (kDebugMode) ...[ if (kDebugMode) ...[
Divider(), Divider(),
debugTile, debugTile,
@ -92,8 +89,19 @@ class _AppDrawerState extends State<AppDrawer> {
} }
Widget _buildHeader(BuildContext context) { Widget _buildHeader(BuildContext context) {
Future<void> goTo(String routeName, WidgetBuilder pageBuilder) async {
Navigator.pop(context);
await Future.delayed(Durations.drawerTransitionAnimation);
await Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
));
}
return Container( return Container(
padding: EdgeInsets.all(16), padding: EdgeInsets.only(left: 16, top: 16, right: 16, bottom: 8),
color: Theme.of(context).accentColor, color: Theme.of(context).accentColor,
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
@ -119,6 +127,61 @@ class _AppDrawerState extends State<AppDrawer> {
], ],
), ),
), ),
SizedBox(height: 8),
OutlinedButtonTheme(
data: OutlinedButtonThemeData(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
overlayColor: MaterialStateProperty.all<Color>(Colors.white24),
),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => goTo(AboutPage.routeName, (_) => AboutPage()),
icon: Icon(AIcons.info),
label: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.aboutPageTitle),
FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
final badgeSize = 8.0 * MediaQuery.textScaleFactorOf(context);
return AnimatedOpacity(
duration: Durations.newsBadgeAnimation,
opacity: newVersion ? 1 : 0,
child: Padding(
padding: EdgeInsetsDirectional.only(start: 2),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.white70),
borderRadius: BorderRadius.circular(badgeSize),
),
child: Icon(
Icons.circle,
size: badgeSize,
color: Colors.red,
),
),
),
);
},
),
],
),
),
OutlinedButton.icon(
onPressed: () => goTo(SettingsPage.routeName, (_) => SettingsPage()),
icon: Icon(AIcons.settings),
label: Text(context.l10n.settingsPageTitle),
),
],
),
)
], ],
), ),
), ),
@ -198,29 +261,6 @@ class _AppDrawerState extends State<AppDrawer> {
pageBuilder: (_) => TagListPage(), pageBuilder: (_) => TagListPage(),
); );
Widget get settingsTile => NavTile(
icon: AIcons.settings,
title: context.l10n.settingsPageTitle,
topLevel: false,
routeName: SettingsPage.routeName,
pageBuilder: (_) => SettingsPage(),
);
Widget get aboutTile => FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
return NavTile(
icon: AIcons.info,
title: context.l10n.aboutPageTitle,
trailing: newVersion ? AboutNewsBadge() : null,
topLevel: false,
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
);
},
);
Widget get debugTile => NavTile( Widget get debugTile => NavTile(
icon: AIcons.debug, icon: AIcons.debug,
title: 'Debug', title: 'Debug',

View file

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,5 +1,4 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';

View file

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

View file

@ -5,6 +5,7 @@ import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
@ -168,13 +169,13 @@ 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 (multiPageInfo.pageCount > 1) { if (entry.isMotionPhoto) {
for (final page in multiPageInfo.pages) { await multiPageInfo.extractMotionPhotoVideo();
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
selection.add(pageEntry);
} }
if (multiPageInfo.pageCount > 1) {
selection.addAll(multiPageInfo.exportEntries);
} }
} else { } else {
selection.add(entry); selection.add(entry);
@ -183,7 +184,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length; final selectionCount = selection.length;
showOpReport<ExportOpEvent>( showOpReport<ExportOpEvent>(
context: context, context: context,
opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum), opStream: imageFileService.export(
selection,
mimeType: MimeTypes.jpeg,
destinationAlbum: destinationAlbum,
),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) { onDone: (processed) {
final movedOps = processed.where((e) => e.success); final movedOps = processed.where((e) => e.success);

View file

@ -3,27 +3,21 @@ import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/common/video/controller.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class MultiEntryScroller extends StatefulWidget { class MultiEntryScroller extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final PageController pageController; final PageController pageController;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final void Function(String uri) onViewDisposed; final void Function(String uri) onViewDisposed;
const MultiEntryScroller({ const MultiEntryScroller({
this.collection, this.collection,
this.pageController, this.pageController,
this.onPageChanged, this.onPageChanged,
this.videoControllers,
this.multiPageControllers,
this.onViewDisposed, this.onViewDisposed,
}); });
@ -50,17 +44,17 @@ 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 = _getMultiPageController(entry); final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { builder: (context, page, child) {
return _buildViewer(entry, page: multiPageInfo?.getByIndex(page)); return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
}, },
); );
}, },
@ -78,39 +72,30 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
); );
} }
Widget _buildViewer(AvesEntry entry, {SinglePageInfo page}) { Widget _buildViewer(AvesEntry mainEntry, {AvesEntry pageEntry}) {
return Selector<MediaQueryData, Size>( return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size, selector: (c, mq) => mq.size,
builder: (c, mqSize, child) { builder: (c, mqSize, child) {
return EntryPageView( return EntryPageView(
key: Key('imageview'), key: Key('imageview'),
mainEntry: entry, mainEntry: mainEntry,
page: page, pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize, viewportSize: mqSize,
videoControllers: widget.videoControllers, onDisposed: () => widget.onViewDisposed?.call(mainEntry.uri),
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
); );
}, },
); );
} }
MultiPageController _getMultiPageController(AvesEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class SingleEntryScroller extends StatefulWidget { class SingleEntryScroller extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
const SingleEntryScroller({ const SingleEntryScroller({
this.entry, this.entry,
this.videoControllers,
this.multiPageControllers,
}); });
@override @override
@ -118,24 +103,24 @@ class SingleEntryScroller extends StatefulWidget {
} }
class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin { class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin {
AvesEntry get entry => widget.entry; AvesEntry get mainEntry => widget.entry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
Widget child; Widget child;
if (entry.isMultipage) { if (mainEntry.isMultiPage) {
final multiPageController = _getMultiPageController(entry); final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { builder: (context, page, child) {
return _buildViewer(page: multiPageInfo?.getByIndex(page)); return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
}, },
); );
}, },
@ -150,24 +135,19 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
); );
} }
Widget _buildViewer({SinglePageInfo page}) { Widget _buildViewer({AvesEntry pageEntry}) {
return Selector<MediaQueryData, Size>( return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size, selector: (c, mq) => mq.size,
builder: (c, mqSize, child) { builder: (c, mqSize, child) {
return EntryPageView( return EntryPageView(
mainEntry: entry, mainEntry: mainEntry,
page: page, pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize, viewportSize: mqSize,
videoControllers: widget.videoControllers,
); );
}, },
); );
} }
MultiPageController _getMultiPageController(AvesEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }

View file

@ -3,21 +3,16 @@ import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart'; import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
import 'package:aves/widgets/viewer/info/info_page.dart'; import 'package:aves/widgets/viewer/info/info_page.dart';
import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:tuple/tuple.dart';
class ViewerVerticalPageView extends StatefulWidget { class ViewerVerticalPageView extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final ValueNotifier<AvesEntry> entryNotifier; final ValueNotifier<AvesEntry> entryNotifier;
final List<Tuple2<String, AvesVideoController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final PageController horizontalPager, verticalPager; final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested; final VoidCallback onImagePageRequested;
@ -26,8 +21,6 @@ class ViewerVerticalPageView extends StatefulWidget {
const ViewerVerticalPageView({ const ViewerVerticalPageView({
@required this.collection, @required this.collection,
@required this.entryNotifier, @required this.entryNotifier,
@required this.videoControllers,
@required this.multiPageControllers,
@required this.verticalPager, @required this.verticalPager,
@required this.horizontalPager, @required this.horizontalPager,
@required this.onVerticalPageChanged, @required this.onVerticalPageChanged,
@ -92,14 +85,10 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
collection: collection, collection: collection,
pageController: widget.horizontalPager, pageController: widget.horizontalPager,
onPageChanged: widget.onHorizontalPageChanged, onPageChanged: widget.onHorizontalPageChanged,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
onViewDisposed: widget.onViewDisposed, onViewDisposed: widget.onViewDisposed,
) )
: SingleEntryScroller( : SingleEntryScroller(
entry: entry, entry: entry,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
), ),
NotificationListener( NotificationListener(
onNotification: (notification) { onNotification: (notification) {
@ -152,6 +141,9 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
} else { } else {
Navigator.pop(context); Navigator.pop(context);
} }
// needed to refresh when entry changes but the page does not (e.g. on page deletion)
setState(() {});
} }
// when the entry image itself changed (e.g. after rotation) // when the entry image itself changed (e.g. after rotation)

View file

@ -2,7 +2,10 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EntryViewerPage extends StatelessWidget { class EntryViewerPage extends StatelessWidget {
static const routeName = '/viewer'; static const routeName = '/viewer';
@ -20,10 +23,18 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: EntryViewerStack( body: Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: EntryViewerStack(
collection: collection, collection: collection,
initialEntry: initialEntry, initialEntry: initialEntry,
), ),
),
),
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
), ),

View file

@ -1,7 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
@ -11,18 +13,18 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/common/video/fijkplayer.dart';
import 'package:aves/widgets/viewer/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/bottom.dart'; import 'package:aves/widgets/viewer/overlay/bottom/common.dart';
import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/panorama.dart';
import 'package:aves/widgets/viewer/overlay/top.dart'; import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/overlay/video.dart'; import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -57,8 +59,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Animation<Offset> _bottomOverlayOffset; Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding; EdgeInsets _frozenViewInsets, _frozenViewPadding;
EntryActionDelegate _actionDelegate; EntryActionDelegate _actionDelegate;
final List<Tuple2<String, AvesVideoController>> _videoControllers = [];
final List<Tuple2<String, MultiPageController>> _multiPageControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = []; final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final ValueNotifier<HeroInfo> _heroInfoNotifier = ValueNotifier(null); final ValueNotifier<HeroInfo> _heroInfoNotifier = ValueNotifier(null);
@ -108,7 +108,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());
@ -128,10 +128,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void dispose() { void dispose() {
_overlayAnimationController.dispose(); _overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange); _overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
_videoControllers.clear();
_multiPageControllers.forEach((kv) => kv.item2.dispose());
_multiPageControllers.clear();
_verticalPager.removeListener(_onVerticalPageControllerChange); _verticalPager.removeListener(_onVerticalPageControllerChange);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_unregisterWidget(widget); _unregisterWidget(widget);
@ -198,8 +194,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
ViewerVerticalPageView( ViewerVerticalPageView(
collection: collection, collection: collection,
entryNotifier: _entryNotifier, entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
multiPageControllers: _multiPageControllers,
verticalPager: _verticalPager, verticalPager: _verticalPager,
horizontalPager: _horizontalPager, horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged, onVerticalPageChanged: _onVerticalPageChanged,
@ -226,21 +220,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Widget _buildTopOverlay() { Widget _buildTopOverlay() {
final child = ValueListenableBuilder<AvesEntry>( final child = ValueListenableBuilder<AvesEntry>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, entry, child) { builder: (context, mainEntry, child) {
if (entry == null) return SizedBox.shrink(); if (mainEntry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry); final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == mainEntry.uri, orElse: () => null)?.item2;
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return ViewerTopOverlay( return ViewerTopOverlay(
entry: entry, mainEntry: mainEntry,
scale: _topOverlayScale, scale: _topOverlayScale,
canToggleFavourite: hasCollection, canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), onActionSelected: (action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
_actionDelegate.onActionSelected(context, targetEntry, action);
},
viewStateNotifier: viewStateNotifier, viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
); );
}, },
); );
@ -262,24 +266,42 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
builder: (context, entry, child) { builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink(); if (entry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry); Widget _buildExtraBottomOverlay(AvesEntry pageEntry) {
// a 360 video is both a video and a panorama but only the video controls are displayed
Widget extraBottomOverlay; if (pageEntry.isVideo) {
if (entry.isVideo) { return Selector<VideoConductor, AvesVideoController>(
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; selector: (context, vc) => vc.getController(pageEntry),
if (videoController != null) { builder: (context, videoController, child) => VideoControlOverlay(
extraBottomOverlay = VideoControlOverlay( entry: pageEntry,
entry: entry,
controller: videoController, controller: videoController,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
),
); );
} } else if (pageEntry.is360) {
} else if (entry.is360) { return PanoramaOverlay(
extraBottomOverlay = PanoramaOverlay( entry: pageEntry,
entry: entry,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
); );
} }
return null;
}
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
final extraBottomOverlay = multiPageController != null
? StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
},
);
})
: _buildExtraBottomOverlay(entry);
final child = Column( final child = Column(
children: [ children: [
@ -335,10 +357,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
return bottomOverlay; return bottomOverlay;
} }
MultiPageController _getMultiPageController(AvesEntry entry) {
return entry.isMultipage ? _multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
}
void _onVerticalPageControllerChange() { void _onVerticalPageControllerChange() {
_verticalScrollNotifier.notifyListeners(); _verticalScrollNotifier.notifyListeners();
} }
@ -405,7 +423,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
@ -416,8 +434,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() {
@ -494,68 +512,92 @@ 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) {
_initViewSpecificController<AvesVideoController>( await _initVideoController(entry);
uri,
_videoControllers,
() => IjkPlayerAvesVideoController(entry),
(_) => _.dispose(),
);
if (settings.enableVideoAutoPlay) {
_playVideo();
}
}
if (entry.isMultipage) {
_initViewSpecificController<MultiPageController>(
uri,
_multiPageControllers,
() => MultiPageController(entry),
(_) => _.dispose(),
);
}
setState(() {});
}
Future<void> _playVideo() async {
await Future.delayed(Duration(milliseconds: 300));
final entry = _entryNotifier.value;
if (entry == null) return;
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
if (videoController != null) {
if (videoController.isPlayable) {
await videoController.play();
} else {
await videoController.setDataSource(entry.uri);
} }
if (entry.isMultiPage) {
await _initMultiPageController(entry);
} }
} }
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) { void _initViewStateController(AvesEntry entry) {
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); final uri = entry.uri;
var controller = _viewStateNotifiers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controller != null) { if (controller != null) {
controllers.remove(controller); _viewStateNotifiers.remove(controller);
} else { } else {
controller = Tuple2(uri, builder()); controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
} }
controllers.insert(0, controller); _viewStateNotifiers.insert(0, controller);
while (controllers.length > 3) { while (_viewStateNotifiers.length > 3) {
disposer?.call(controllers.removeLast().item2); _viewStateNotifiers.removeLast().item2.dispose();
} }
} }
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (settings.enableVideoAutoPlay) {
await _playVideo(controller, () => entry == _entryNotifier.value);
}
}
Future<void> _initMultiPageController(AvesEntry entry) async {
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {});
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
final videoPageEntries = multiPageInfo.videoPageEntries;
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
Future<void> _onPageChange() async {
await _pauseVideoControllers();
if (settings.enableVideoAutoPlay) {
final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page);
if (pageInfo.isVideo) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
}
}
}
multiPageController.pageNotifier.addListener(_onPageChange);
await _onPageChange();
}
}
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)
// so we play after a delay for increased stability
await Future.delayed(Duration(milliseconds: 300) * timeDilation);
await videoController.play();
// playing controllers are paused when the entry changes,
// but the controller may still be preparing (not yet playing) when this happens
// so we make sure the current entry is still the same to keep playing
if (!isCurrent()) {
await videoController.pause();
}
}
Future<void> _pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
} }

View file

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

View file

@ -7,7 +7,7 @@ import 'package:aves/widgets/viewer/info/maps/scale_layer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -71,7 +71,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
options: MapOptions( options: MapOptions(
center: widget.latLng, center: widget.latLng,
zoom: widget.initialZoom, zoom: widget.initialZoom,
interactive: false, interactiveFlags: InteractiveFlag.none,
), ),
mapController: _mapController, mapController: _mapController,
children: [ children: [

View file

@ -31,8 +31,8 @@ class ScaleLayerWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mapState = MapState.of(context); final mapState = MapState.maybeOf(context);
return ScaleLayer(options, mapState, mapState.onMoved); return mapState != null ? ScaleLayer(options, mapState, mapState.onMoved) : SizedBox();
} }
} }

View file

@ -1,7 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong2/latlong.dart';
LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis var mSemiMajorAxis = 6378137.0; //WGS84 major axis

View file

@ -121,11 +121,14 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async { Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
Map fields; Map fields;
switch (notification.source) { switch (notification.source) {
case EmbeddedDataSource.motionPhotoVideo:
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
break;
case EmbeddedDataSource.videoCover: case EmbeddedDataSource.videoCover:
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri); fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
break; break;
case EmbeddedDataSource.xmp: case EmbeddedDataSource.xmp:
fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
break; break;
} }
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {

View file

@ -158,7 +158,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
}).toList(); }).toList();
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) { if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
directories.addAll(await _getStreamDirectories()); directories.addAll(await _getStreamDirectories());
} }
@ -193,6 +193,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
String getTypeText(Map stream) { String getTypeText(Map stream) {
final type = stream[Keys.streamType] ?? StreamTypes.unknown; final type = stream[Keys.streamType] ?? StreamTypes.unknown;
switch (type) { switch (type) {
case StreamTypes.attachment:
return 'Attachment';
case StreamTypes.audio: case StreamTypes.audio:
return 'Audio'; return 'Audio';
case StreamTypes.metadata: case StreamTypes.metadata:
@ -209,8 +211,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
} }
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>(); final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList(); final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
final knownStreams = allStreams.whereNot(unknownStreams.contains); final knownStreams = allStreams.whereNot(attachmentStreams.contains);
// display known streams as separate directories (e.g. video, audio, subs) // display known streams as separate directories (e.g. video, audio, subs)
if (knownStreams.isNotEmpty) { if (knownStreams.isNotEmpty) {
@ -228,18 +230,18 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
} }
} }
// display unknown streams as attachments (e.g. fonts) // group attachments by format (e.g. TTF fonts)
if (unknownStreams.isNotEmpty) { if (attachmentStreams.isNotEmpty) {
final unknownCodecCount = <String, List<String>>{}; final formatCount = <String, List<String>>{};
for (final stream in unknownStreams) { for (final stream in attachmentStreams) {
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase(); final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
if (!unknownCodecCount.containsKey(codec)) { if (!formatCount.containsKey(codec)) {
unknownCodecCount[codec] = []; formatCount[codec] = [];
} }
unknownCodecCount[codec].add(stream[Keys.filename]); formatCount[codec].add(stream[Keys.filename]);
} }
if (unknownCodecCount.isNotEmpty) { if (formatCount.isNotEmpty) {
final rawTags = unknownCodecCount.map((key, value) { final rawTags = formatCount.map((key, value) {
final count = value.length; final count = value.length;
// remove duplicate names, so number of displayed names may not match displayed count // remove duplicate names, so number of displayed names may not match displayed count
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase); final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);

View file

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

View file

@ -4,21 +4,61 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class XmpNamespace { class XmpNamespace {
final String namespace; final String namespace;
final Map<String, String> rawProps;
const XmpNamespace(this.namespace); const XmpNamespace(this.namespace, this.rawProps);
factory XmpNamespace.create(String namespace, Map<String, String> rawProps) {
switch (namespace) {
case XmpBasicNamespace.ns:
return XmpBasicNamespace(rawProps);
case XmpExifNamespace.ns:
return XmpExifNamespace(rawProps);
case XmpGAudioNamespace.ns:
return XmpGAudioNamespace(rawProps);
case XmpGCameraNamespace.ns:
return XmpGCameraNamespace(rawProps);
case XmpGDepthNamespace.ns:
return XmpGDepthNamespace(rawProps);
case XmpGImageNamespace.ns:
return XmpGImageNamespace(rawProps);
case XmpIptcCoreNamespace.ns:
return XmpIptcCoreNamespace(rawProps);
case XmpMgwRegionsNamespace.ns:
return XmpMgwRegionsNamespace(rawProps);
case XmpMMNamespace.ns:
return XmpMMNamespace(rawProps);
case XmpNoteNamespace.ns:
return XmpNoteNamespace(rawProps);
case XmpPhotoshopNamespace.ns:
return XmpPhotoshopNamespace(rawProps);
case XmpTiffNamespace.ns:
return XmpTiffNamespace(rawProps);
default:
return XmpNamespace(namespace, rawProps);
}
}
String get displayTitle => XMP.namespaces[namespace] ?? namespace; String get displayTitle => XMP.namespaces[namespace] ?? namespace;
List<Widget> buildNamespaceSection({ Map<String, String> get buildProps => rawProps;
@required List<MapEntry<String, String>> rawProps,
}) { List<Widget> buildNamespaceSection() {
final props = rawProps final props = buildProps
.entries
.map((kv) { .map((kv) {
final prop = XmpProp(kv.key, kv.value); final prop = XmpProp(kv.key, kv.value);
return extractData(prop) ? null : prop; return extractData(prop) ? null : prop;

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
class XmpExifNamespace extends XmpNamespace { class XmpExifNamespace extends XmpNamespace {
static const ns = 'exif'; static const ns = 'exif';
XmpExifNamespace() : super(ns); XmpExifNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'Exif'; String get displayTitle => 'Exif';

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
abstract class XmpGoogleNamespace extends XmpNamespace { abstract class XmpGoogleNamespace extends XmpNamespace {
XmpGoogleNamespace(String ns) : super(ns); XmpGoogleNamespace(String ns, Map<String, String> rawProps) : super(ns, rawProps);
List<Tuple2<String, String>> get dataProps; List<Tuple2<String, String>> get dataProps;
@ -34,7 +34,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
class XmpGAudioNamespace extends XmpGoogleNamespace { class XmpGAudioNamespace extends XmpGoogleNamespace {
static const ns = 'GAudio'; static const ns = 'GAudio';
XmpGAudioNamespace() : super(ns); XmpGAudioNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@ -46,7 +46,7 @@ class XmpGAudioNamespace extends XmpGoogleNamespace {
class XmpGDepthNamespace extends XmpGoogleNamespace { class XmpGDepthNamespace extends XmpGoogleNamespace {
static const ns = 'GDepth'; static const ns = 'GDepth';
XmpGDepthNamespace() : super(ns); XmpGDepthNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
List<Tuple2<String, String>> get dataProps => [ List<Tuple2<String, String>> get dataProps => [
@ -61,7 +61,7 @@ class XmpGDepthNamespace extends XmpGoogleNamespace {
class XmpGImageNamespace extends XmpGoogleNamespace { class XmpGImageNamespace extends XmpGoogleNamespace {
static const ns = 'GImage'; static const ns = 'GImage';
XmpGImageNamespace() : super(ns); XmpGImageNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@ -69,3 +69,35 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
@override @override
String get displayTitle => 'Google Image'; String get displayTitle => 'Google Image';
} }
class XmpGCameraNamespace extends XmpNamespace {
static const ns = 'GCamera';
static const videoOffsetKey = 'GCamera:MicroVideoOffset';
static const videoDataKey = 'Data';
bool _isMotionPhoto;
XmpGCameraNamespace(Map<String, String> rawProps) : super(ns, rawProps) {
_isMotionPhoto = rawProps.keys.any((key) => key == videoOffsetKey);
}
@override
Map<String, String> get buildProps {
return _isMotionPhoto
? Map.fromEntries({
MapEntry(videoDataKey, '[skipped]'),
...rawProps.entries,
})
: rawProps;
}
@override
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) {
return {
videoDataKey: InfoLinkHandler(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context),
),
};
}
}

View file

@ -9,7 +9,7 @@ class XmpIptcCoreNamespace extends XmpNamespace {
final creatorContactInfo = <String, String>{}; final creatorContactInfo = <String, String>{};
XmpIptcCoreNamespace() : super(ns); XmpIptcCoreNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'IPTC Core'; String get displayTitle => 'IPTC Core';

View file

@ -12,7 +12,7 @@ class XmpMgwRegionsNamespace extends XmpNamespace {
final dimensions = <String, String>{}; final dimensions = <String, String>{};
final regionList = <int, Map<String, String>>{}; final regionList = <int, Map<String, String>>{};
XmpMgwRegionsNamespace() : super(ns); XmpMgwRegionsNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'Regions'; String get displayTitle => 'Regions';

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
class XmpPhotoshopNamespace extends XmpNamespace { class XmpPhotoshopNamespace extends XmpNamespace {
static const ns = 'photoshop'; static const ns = 'photoshop';
XmpPhotoshopNamespace() : super(ns); XmpPhotoshopNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'Photoshop'; String get displayTitle => 'Photoshop';

View file

@ -8,7 +8,7 @@ class XmpTiffNamespace extends XmpNamespace {
@override @override
String get displayTitle => 'TIFF'; String get displayTitle => 'TIFF';
XmpTiffNamespace() : super(ns); XmpTiffNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String formatValue(XmpProp prop) { String formatValue(XmpProp prop) {

View file

@ -14,7 +14,7 @@ class XmpBasicNamespace extends XmpNamespace {
final thumbnails = <int, Map<String, String>>{}; final thumbnails = <int, Map<String, String>>{};
XmpBasicNamespace() : super(ns); XmpBasicNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'Basic'; String get displayTitle => 'Basic';
@ -61,7 +61,7 @@ class XmpMMNamespace extends XmpNamespace {
final ingredients = <int, Map<String, String>>{}; final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{}; final pantry = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns); XmpMMNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
String get displayTitle => 'Media Management'; String get displayTitle => 'Media Management';
@ -114,7 +114,7 @@ class XmpNoteNamespace extends XmpNamespace {
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
static const hasExtendedXmp = '$ns:HasExtendedXMP'; static const hasExtendedXmp = '$ns:HasExtendedXMP';
XmpNoteNamespace() : super(ns); XmpNoteNamespace(Map<String, String> rawProps) : super(ns, rawProps);
@override @override
bool extractData(XmpProp prop) { bool extractData(XmpProp prop) {

View file

@ -4,13 +4,6 @@ import 'package:aves/model/entry.dart';
import 'package:aves/ref/xmp.dart'; import 'package:aves/ref/xmp.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -36,40 +29,13 @@ class _XmpDirTileState extends State<XmpDirTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of( final sections = groupBy(widget.tags.entries, (kv) {
groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key; final fullKey = kv.key;
final i = fullKey.indexOf(XMP.propNamespaceSeparator); final i = fullKey.indexOf(XMP.propNamespaceSeparator);
final namespace = i == -1 ? '' : fullKey.substring(0, i); final namespace = i == -1 ? '' : fullKey.substring(0, i);
switch (namespace) { return namespace;
case XmpBasicNamespace.ns: }).entries.map((kv) => XmpNamespace.create(kv.key, Map.fromEntries(kv.value))).toList()
return XmpBasicNamespace(); ..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
case XmpExifNamespace.ns:
return XmpExifNamespace();
case XmpGAudioNamespace.ns:
return XmpGAudioNamespace();
case XmpGDepthNamespace.ns:
return XmpGDepthNamespace();
case XmpGImageNamespace.ns:
return XmpGImageNamespace();
case XmpIptcCoreNamespace.ns:
return XmpIptcCoreNamespace();
case XmpMgwRegionsNamespace.ns:
return XmpMgwRegionsNamespace();
case XmpMMNamespace.ns:
return XmpMMNamespace();
case XmpNoteNamespace.ns:
return XmpNoteNamespace();
case XmpPhotoshopNamespace.ns:
return XmpPhotoshopNamespace();
case XmpTiffNamespace.ns:
return XmpTiffNamespace();
default:
return XmpNamespace(namespace);
}
}),
(a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
);
return AvesExpansionTile( return AvesExpansionTile(
title: 'XMP', title: 'XMP',
expandedNotifier: widget.expandedNotifier, expandedNotifier: widget.expandedNotifier,
@ -79,11 +45,7 @@ class _XmpDirTileState extends State<XmpDirTile> {
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries children: sections.expand((section) => section.buildNamespaceSection()).toList(),
.expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
))
.toList(),
), ),
), ),
], ],

View file

@ -28,7 +28,7 @@ class OpenTempEntryNotification extends Notification {
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}'; String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
} }
enum EmbeddedDataSource { videoCover, xmp } enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
class OpenEmbeddedDataNotification extends Notification { class OpenEmbeddedDataNotification extends Notification {
final EmbeddedDataSource source; final EmbeddedDataSource source;
@ -41,6 +41,10 @@ class OpenEmbeddedDataNotification extends Notification {
this.mimeType, this.mimeType,
}); });
factory OpenEmbeddedDataNotification.motionPhotoVideo() => OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.motionPhotoVideo,
);
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private( factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.videoCover, source: EmbeddedDataSource.videoCover,
); );

View file

@ -1,29 +0,0 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageController extends ChangeNotifier {
Future<MultiPageInfo> info;
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageController(AvesEntry entry) {
info = metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index;
return value;
});
}
int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page;
@override
void dispose() {
pageNotifier.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,31 @@
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
class MultiPageConductor {
final List<MultiPageController> _controllers = [];
static const maxControllerCount = 3;
Future<void> dispose() async {
await Future.forEach(_controllers, (controller) => controller.dispose());
_controllers.clear();
}
MultiPageController getOrCreateController(AvesEntry entry) {
var controller = getController(entry);
if (controller != null) {
_controllers.remove(controller);
} else {
controller = MultiPageController(entry);
}
_controllers.insert(0, controller);
while (_controllers.length > maxControllerCount) {
_controllers.removeLast().dispose();
}
return controller;
}
MultiPageController getController(AvesEntry entry) {
return _controllers.firstWhere((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId, orElse: () => null);
}
}

View file

@ -0,0 +1,39 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageController {
final AvesEntry entry;
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageInfo _info;
final StreamController<MultiPageInfo> _infoStreamController = StreamController.broadcast();
Stream<MultiPageInfo> get infoStream => _infoStreamController.stream;
MultiPageInfo get info => _info;
int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page;
MultiPageController(this.entry) {
metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index;
_info = value;
_infoStreamController.add(_info);
});
}
void dispose() {
pageNotifier.dispose();
}
@override
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}';
}

View file

@ -11,9 +11,9 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart';
import 'package:decorated_icon/decorated_icon.dart'; import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -102,7 +102,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent( Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
mainEntry: _lastEntry, mainEntry: _lastEntry,
page: multiPageInfo?.getByIndex(page), pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry,
details: _lastDetails, details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth, availableWidth: availableWidth,
@ -111,10 +111,10 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
if (multiPageController == null) return _buildContent(); if (multiPageController == null) return _buildContent();
return FutureBuilder<MultiPageInfo>( return StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { builder: (context, page, child) {
@ -138,8 +138,7 @@ const double _interRowPadding = 2.0;
const double _subRowMinWidth = 300.0; const double _subRowMinWidth = 300.0;
class _BottomOverlayContent extends AnimatedWidget { class _BottomOverlayContent extends AnimatedWidget {
final AvesEntry mainEntry, entry; final AvesEntry mainEntry, pageEntry;
final SinglePageInfo page;
final OverlayMetadata details; final OverlayMetadata details;
final String position; final String position;
final double availableWidth; final double availableWidth;
@ -150,13 +149,18 @@ class _BottomOverlayContent extends AnimatedWidget {
_BottomOverlayContent({ _BottomOverlayContent({
Key key, Key key,
this.mainEntry, this.mainEntry,
this.page, this.pageEntry,
this.details, this.details,
this.position, this.position,
this.availableWidth, this.availableWidth,
this.multiPageController, this.multiPageController,
}) : entry = mainEntry.getPageEntry(page), }) : super(
super(key: key, listenable: mainEntry.metadataChangeNotifier); key: key,
listenable: Listenable.merge([
mainEntry.metadataChangeNotifier,
pageEntry.metadataChangeNotifier,
]),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -178,13 +182,12 @@ 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,
children: [ children: [
MultiPageOverlay( MultiPageOverlay(
mainEntry: mainEntry,
controller: multiPageController, controller: multiPageController,
availableWidth: availableWidth, availableWidth: availableWidth,
), ),
@ -204,7 +207,7 @@ class _BottomOverlayContent extends AnimatedWidget {
final infoMaxWidth = availableWidth - infoPadding.horizontal; final infoMaxWidth = availableWidth - infoPadding.horizontal;
final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth;
final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth;
final positionTitle = _PositionTitleRow(entry: entry, collectionPosition: position, multiPageController: multiPageController); final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails; final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Padding( return Padding(
@ -223,7 +226,7 @@ class _BottomOverlayContent extends AnimatedWidget {
Container( Container(
width: subRowWidth, width: subRowWidth,
child: _DateRow( child: _DateRow(
entry: entry, entry: pageEntry,
multiPageController: multiPageController, multiPageController: multiPageController,
)), )),
_buildDuoShootingRow(subRowWidth, hasShootingDetails), _buildDuoShootingRow(subRowWidth, hasShootingDetails),
@ -235,7 +238,7 @@ class _BottomOverlayContent extends AnimatedWidget {
padding: EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth, width: subRowWidth,
child: _DateRow( child: _DateRow(
entry: entry, entry: pageEntry,
multiPageController: multiPageController, multiPageController: multiPageController,
), ),
), ),
@ -251,10 +254,10 @@ class _BottomOverlayContent extends AnimatedWidget {
switchInCurve: Curves.easeInOutCubic, switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition, transitionBuilder: _soloTransition,
child: entry.hasGps child: pageEntry.hasGps
? Container( ? Container(
padding: EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry), child: _LocationRow(entry: pageEntry),
) )
: SizedBox.shrink(), : SizedBox.shrink(),
); );
@ -354,10 +357,10 @@ class _PositionTitleRow extends StatelessWidget {
if (multiPageController == null) return toText(); if (multiPageController == null) return toText();
return FutureBuilder<MultiPageInfo>( return StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
String pagePosition; String pagePosition;
if (multiPageInfo != null) { if (multiPageInfo != null) {
// page count may be 0 when we know an entry to have multiple pages // page count may be 0 when we know an entry to have multiple pages

View file

@ -1,26 +1,22 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MultiPageOverlay extends StatefulWidget { class MultiPageOverlay extends StatefulWidget {
final AvesEntry mainEntry;
final MultiPageController controller; final MultiPageController controller;
final double availableWidth; final double availableWidth;
MultiPageOverlay({ const MultiPageOverlay({
Key key, Key key,
@required this.mainEntry,
@required this.controller, @required this.controller,
@required this.availableWidth, @required this.availableWidth,
}) : assert(mainEntry.isMultipage), }) : assert(controller != null),
assert(controller != null),
super(key: key); super(key: key);
@override @override
@ -31,12 +27,11 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
final _cancellableNotifier = ValueNotifier(true); final _cancellableNotifier = ValueNotifier(true);
ScrollController _scrollController; ScrollController _scrollController;
bool _syncScroll = true; bool _syncScroll = true;
int _initControllerPage;
static const double extent = 48; static const double extent = 48;
static const double separatorWidth = 2; static const double separatorWidth = 2;
AvesEntry get mainEntry => widget.mainEntry;
MultiPageController get controller => widget.controller; MultiPageController get controller => widget.controller;
double get availableWidth => widget.availableWidth; double get availableWidth => widget.availableWidth;
@ -64,10 +59,26 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
} }
void _registerWidget() { void _registerWidget() {
final page = controller.page ?? 0; _initControllerPage = controller.page;
final scrollOffset = pageToScrollOffset(page); final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0);
_scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange); _scrollController.addListener(_onScrollChange);
if (_initControllerPage == null) {
_correctDefaultPageScroll();
}
}
// correct scroll offset to match default page
// if default page was unknown when the scroll controller was created
void _correctDefaultPageScroll() async {
await controller.infoStream.first;
if (_initControllerPage == null) {
_initControllerPage = controller.page;
if (_initControllerPage != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) => _goToPage(_initControllerPage));
}
}
} }
void _unregisterWidget() { void _unregisterWidget() {
@ -83,39 +94,30 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
return ThumbnailTheme( return ThumbnailTheme(
extent: extent, extent: extent,
child: FutureBuilder<MultiPageInfo>( showLocation: false,
future: controller.info, child: StreamBuilder<MultiPageInfo>(
stream: controller.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = controller.info;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); final pageCount = multiPageInfo?.pageCount ?? 0;
if (multiPageInfo.uri != mainEntry.uri) return SizedBox();
return SizedBox( return SizedBox(
height: extent, height: extent,
child: ListView.separated( child: ListView.separated(
key: ValueKey(mainEntry), key: ValueKey(multiPageInfo),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: _scrollController, controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`, // default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0 // but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; if (index == 0 || index == pageCount + 1) return horizontalMargin;
final page = index - 1; final page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return Stack( return Stack(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () async { onTap: () => _goToPage(page),
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
child: DecoratedThumbnail( child: DecoratedThumbnail(
entry: pageEntry, entry: pageEntry,
extent: extent, extent: extent,
@ -139,7 +141,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
}, },
separatorBuilder: (context, index) => separator, separatorBuilder: (context, index) => separator,
itemCount: multiPageInfo.pageCount + 2, itemCount: pageCount + 2,
), ),
); );
}, },
@ -147,6 +149,17 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
} }
Future<void> _goToPage(int page) async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
}
void _onScrollChange() { void _onScrollChange() {
if (_syncScroll) { if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset); controller.page = scrollOffsetToPage(_scrollController.offset);

View file

@ -8,9 +8,9 @@ import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class VideoControlOverlay extends StatefulWidget { class VideoControlOverlay extends StatefulWidget {
@ -34,7 +34,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
bool _playingOnDragStart = false; bool _playingOnDragStart = false;
AnimationController _playPauseAnimation; AnimationController _playPauseAnimation;
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
double _seekTargetPercent;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -42,9 +41,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
AvesVideoController get controller => widget.controller; AvesVideoController get controller => widget.controller;
bool get isPlayable => controller.isPlayable; Stream<VideoStatus> get statusStream => controller?.statusStream ?? Stream.value(VideoStatus.idle);
bool get isPlaying => controller.isPlaying; Stream<int> get positionStream => controller?.positionStream ?? Stream.value(0);
bool get isPlaying => controller?.isPlaying ?? false;
@override @override
void initState() { void initState() {
@ -71,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
@ -84,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,
@ -160,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(),
@ -173,12 +176,15 @@ 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(value: progress); return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey[700],
);
}), }),
), ),
], ],
@ -190,33 +196,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
} }
void _onStatusChange(VideoStatus status) { void _onStatusChange(VideoStatus status) {
if (status == VideoStatus.playing && _seekTargetPercent != null) {
_seekFromTarget();
}
_updatePlayPauseIcon();
}
Future<void> _togglePlayPause() async {
if (isPlaying) {
await controller.pause();
} else {
await _play();
}
}
Future<void> _play() async {
if (isPlayable) {
await controller.play();
} else {
await controller.setDataSource(entry.uri);
}
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
void _updatePlayPauseIcon() {
final status = _playPauseAnimation.status; final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward(); _playPauseAnimation.forward();
@ -225,28 +204,23 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
} }
} }
Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) {
await controller.pause();
} else {
await controller.play();
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
}
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);
_seekTargetPercent = (localPosition.dx / box.size.width); await controller.seekToProgress(localPosition.dx / box.size.width);
if (isPlayable) {
await _seekFromTarget();
} else {
// controller duration is not set yet, so we use the expected duration instead
final seekTargetMillis = (entry.durationMillis * _seekTargetPercent).toInt();
await controller.setDataSource(entry.uri, startMillis: seekTargetMillis);
_seekTargetPercent = null;
}
}
Future _seekFromTarget() async {
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
// so we make sure the video info is up to date first
if (controller.duration != null) {
await controller.seekToProgress(_seekTargetPercent);
_seekTargetPercent = null;
}
} }
} }

View file

@ -1,50 +1,27 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class Minimap extends StatelessWidget { class Minimap extends StatelessWidget {
final AvesEntry mainEntry; final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
final Size size; final Size size;
static const defaultSize = Size(96, 96); static const defaultSize = Size(96, 96);
const Minimap({ const Minimap({
@required this.mainEntry, @required this.entry,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.multiPageController,
this.size = defaultSize, this.size = defaultSize,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IgnorePointer( return IgnorePointer(
child: multiPageController != null child: ValueListenableBuilder<ViewState>(
? FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = mainEntry.getPageEntry(multiPageInfo?.getByIndex(page));
return _buildForEntrySize(pageEntry);
},
);
})
: _buildForEntrySize(mainEntry),
);
}
Widget _buildForEntrySize(AvesEntry entry) {
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier, valueListenable: viewStateNotifier,
builder: (context, viewState, child) { builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize; final viewportSize = viewState.viewportSize;
@ -62,7 +39,8 @@ class Minimap extends StatelessWidget {
size: size, size: size,
), ),
); );
}); }),
);
} }
} }

View file

@ -1,13 +1,14 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/minimap.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart';
import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/state.dart';
@ -17,26 +18,24 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ViewerTopOverlay extends StatelessWidget { class ViewerTopOverlay extends StatelessWidget {
final AvesEntry entry; final AvesEntry mainEntry;
final Animation<double> scale; final Animation<double> scale;
final EdgeInsets viewInsets, viewPadding; final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected; final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite; final bool canToggleFavourite;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
static const double padding = 8; static const double padding = 8;
const ViewerTopOverlay({ const ViewerTopOverlay({
Key key, Key key,
@required this.entry, @required this.mainEntry,
@required this.scale, @required this.scale,
@required this.canToggleFavourite, @required this.canToggleFavourite,
@required this.viewInsets, @required this.viewInsets,
@required this.viewPadding, @required this.viewPadding,
@required this.onActionSelected, @required this.onActionSelected,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.multiPageController,
}) : super(key: key); }) : super(key: key);
@override @override
@ -49,6 +48,67 @@ class ViewerTopOverlay extends StatelessWidget {
selector: (c, mq) => mq.size.width - mq.padding.horizontal, selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mqWidth, child) { builder: (c, mqWidth, child) {
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2; final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
Widget child;
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
return child ??= _buildOverlay(availableCount, mainEntry);
},
),
),
);
}
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry pageEntry}) {
pageEntry ??= mainEntry;
bool _canDo(EntryAction action) {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
return targetEntry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return !targetEntry.isVideo;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.share:
case EntryAction.info:
case EntryAction.open:
case EntryAction.edit:
case EntryAction.setAs:
return true;
case EntryAction.debug:
return kDebugMode;
}
return false;
}
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList(); final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
@ -57,7 +117,8 @@ class ViewerTopOverlay extends StatelessWidget {
inAppActions: inAppActions, inAppActions: inAppActions,
externalAppActions: externalAppActions, externalAppActions: externalAppActions,
scale: scale, scale: scale,
entry: entry, mainEntry: mainEntry,
pageEntry: pageEntry,
onActionSelected: onActionSelected, onActionSelected: onActionSelected,
); );
@ -70,57 +131,20 @@ class ViewerTopOverlay extends StatelessWidget {
FadeTransition( FadeTransition(
opacity: scale, opacity: scale,
child: Minimap( child: Minimap(
mainEntry: entry, entry: pageEntry,
viewStateNotifier: viewStateNotifier, viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
), ),
) )
], ],
) )
: buttonRow; : buttonRow;
},
),
),
);
}
bool _canDo(EntryAction action) {
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
return entry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return !entry.isVideo;
case EntryAction.openMap:
return entry.hasGps;
case EntryAction.viewSource:
return entry.isSvg;
case EntryAction.share:
case EntryAction.info:
case EntryAction.open:
case EntryAction.edit:
case EntryAction.setAs:
return true;
case EntryAction.debug:
return kDebugMode;
}
return false;
} }
} }
class _TopOverlayRow extends StatelessWidget { class _TopOverlayRow extends StatelessWidget {
final List<EntryAction> quickActions; final List<EntryAction> quickActions, inAppActions, externalAppActions;
final List<EntryAction> inAppActions;
final List<EntryAction> externalAppActions;
final Animation<double> scale; final Animation<double> scale;
final AvesEntry entry; final AvesEntry mainEntry, pageEntry;
final Function(EntryAction value) onActionSelected; final Function(EntryAction value) onActionSelected;
const _TopOverlayRow({ const _TopOverlayRow({
@ -129,7 +153,8 @@ class _TopOverlayRow extends StatelessWidget {
@required this.inAppActions, @required this.inAppActions,
@required this.externalAppActions, @required this.externalAppActions,
@required this.scale, @required this.scale,
@required this.entry, @required this.mainEntry,
@required this.pageEntry,
@required this.onActionSelected, @required this.onActionSelected,
}) : super(key: key); }) : super(key: key);
@ -151,7 +176,7 @@ class _TopOverlayRow extends StatelessWidget {
key: Key('entry-menu-button'), key: Key('entry-menu-button'),
itemBuilder: (context) => [ itemBuilder: (context) => [
...inAppActions.map((action) => _buildPopupMenuItem(context, action)), ...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
PopupMenuDivider(), PopupMenuDivider(),
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (kDebugMode) ...[ if (kDebugMode) ...[
@ -175,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: entry, entry: mainEntry,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -219,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget {
// in app actions // in app actions
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: entry, entry: mainEntry,
isMenuItem: true, isMenuItem: true,
); );
break; break;

View file

@ -47,17 +47,18 @@ 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) { final pageCount = multiPageInfo.pageCount;
if (pageCount > 1) {
final streamController = StreamController<AvesEntry>.broadcast(); final streamController = StreamController<AvesEntry>.broadcast();
showOpReport<AvesEntry>( showOpReport<AvesEntry>(
context: context, context: context,
opStream: streamController.stream, opStream: streamController.stream,
itemCount: multiPageInfo.pageCount, itemCount: pageCount,
); );
for (final page in multiPageInfo.pages) { for (var page = 0; page < pageCount; page++) {
final pageEntry = entry.getPageEntry(page); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
_addPdfPage(await _buildPageImage(pageEntry)); _addPdfPage(await _buildPageImage(pageEntry));
streamController.sink.add(pageEntry); streamController.sink.add(pageEntry);
} }

Some files were not shown because too many files have changed in this diff Show more