Merge branch 'develop'
This commit is contained in:
commit
f0b87f39c3
117 changed files with 2251 additions and 1363 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -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
|
||||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -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
|
||||||
|
|
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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
|
||||||
|
|
Binary file not shown.
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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() {}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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? {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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? {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -99,6 +99,8 @@
|
||||||
"@filterTagEmptyLabel": {},
|
"@filterTagEmptyLabel": {},
|
||||||
"filterTypeAnimatedLabel": "Animated",
|
"filterTypeAnimatedLabel": "Animated",
|
||||||
"@filterTypeAnimatedLabel": {},
|
"@filterTypeAnimatedLabel": {},
|
||||||
|
"filterTypeMotionPhotoLabel": "Motion Photo",
|
||||||
|
"@filterTypeMotionPhotoLabel": {},
|
||||||
"filterTypePanoramaLabel": "Panorama",
|
"filterTypePanoramaLabel": "Panorama",
|
||||||
"@filterTypePanoramaLabel": {},
|
"@filterTypePanoramaLabel": {},
|
||||||
"filterTypeSphericalVideoLabel": "360° Video",
|
"filterTypeSphericalVideoLabel": "360° Video",
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"filterLocationEmptyLabel": "장소 없음",
|
"filterLocationEmptyLabel": "장소 없음",
|
||||||
"filterTagEmptyLabel": "태그 없음",
|
"filterTagEmptyLabel": "태그 없음",
|
||||||
"filterTypeAnimatedLabel": "애니메이션",
|
"filterTypeAnimatedLabel": "애니메이션",
|
||||||
|
"filterTypeMotionPhotoLabel": "모션 포토",
|
||||||
"filterTypePanoramaLabel": "파노라마",
|
"filterTypePanoramaLabel": "파노라마",
|
||||||
"filterTypeSphericalVideoLabel": "360° 동영상",
|
"filterTypeSphericalVideoLabel": "360° 동영상",
|
||||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ class TypeFilter extends CollectionFilter {
|
||||||
|
|
||||||
static const _animated = 'animated'; // subset of `image/gif` and `image/webp`
|
static const _animated = 'animated'; // subset of `image/gif` and `image/webp`
|
||||||
static const _geotiff = 'geotiff'; // subset of `image/tiff`
|
static const _geotiff = 'geotiff'; // subset of `image/tiff`
|
||||||
|
static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg`
|
||||||
static const _panorama = 'panorama'; // subset of images
|
static const _panorama = 'panorama'; // subset of images
|
||||||
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ class TypeFilter extends CollectionFilter {
|
||||||
|
|
||||||
static final animated = TypeFilter._private(_animated);
|
static final animated = TypeFilter._private(_animated);
|
||||||
static final geotiff = TypeFilter._private(_geotiff);
|
static final geotiff = TypeFilter._private(_geotiff);
|
||||||
|
static final motionPhoto = TypeFilter._private(_motionPhoto);
|
||||||
static final panorama = TypeFilter._private(_panorama);
|
static final panorama = TypeFilter._private(_panorama);
|
||||||
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
|
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
|
||||||
|
|
||||||
|
@ -27,13 +29,17 @@ class TypeFilter extends CollectionFilter {
|
||||||
_test = (entry) => entry.isAnimated;
|
_test = (entry) => entry.isAnimated;
|
||||||
_icon = AIcons.animated;
|
_icon = AIcons.animated;
|
||||||
break;
|
break;
|
||||||
|
case _motionPhoto:
|
||||||
|
_test = (entry) => entry.isMotionPhoto;
|
||||||
|
_icon = AIcons.motionPhoto;
|
||||||
|
break;
|
||||||
case _panorama:
|
case _panorama:
|
||||||
_test = (entry) => entry.isImage && entry.is360;
|
_test = (entry) => entry.isImage && entry.is360;
|
||||||
_icon = AIcons.threesixty;
|
_icon = AIcons.threeSixty;
|
||||||
break;
|
break;
|
||||||
case _sphericalVideo:
|
case _sphericalVideo:
|
||||||
_test = (entry) => entry.isVideo && entry.is360;
|
_test = (entry) => entry.isVideo && entry.is360;
|
||||||
_icon = AIcons.threesixty;
|
_icon = AIcons.threeSixty;
|
||||||
break;
|
break;
|
||||||
case _geotiff:
|
case _geotiff:
|
||||||
_test = (entry) => entry.isGeotiff;
|
_test = (entry) => entry.isGeotiff;
|
||||||
|
@ -64,6 +70,8 @@ class TypeFilter extends CollectionFilter {
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
case _animated:
|
case _animated:
|
||||||
return context.l10n.filterTypeAnimatedLabel;
|
return context.l10n.filterTypeAnimatedLabel;
|
||||||
|
case _motionPhoto:
|
||||||
|
return context.l10n.filterTypeMotionPhotoLabel;
|
||||||
case _panorama:
|
case _panorama:
|
||||||
return context.l10n.filterTypePanoramaLabel;
|
return context.l10n.filterTypePanoramaLabel;
|
||||||
case _sphericalVideo:
|
case _sphericalVideo:
|
||||||
|
|
|
@ -29,7 +29,7 @@ class DateMetadata {
|
||||||
|
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
final int contentId, dateMillis;
|
final int contentId, dateMillis;
|
||||||
final bool isAnimated, isGeotiff, is360, isMultipage;
|
final bool isAnimated, isGeotiff, is360, isMultiPage;
|
||||||
bool isFlipped;
|
bool isFlipped;
|
||||||
int rotationDegrees;
|
int rotationDegrees;
|
||||||
final String mimeType, xmpSubjects, xmpTitleDescription;
|
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||||
|
@ -41,7 +41,7 @@ class CatalogMetadata {
|
||||||
static const _isFlippedMask = 1 << 1;
|
static const _isFlippedMask = 1 << 1;
|
||||||
static const _isGeotiffMask = 1 << 2;
|
static const _isGeotiffMask = 1 << 2;
|
||||||
static const _is360Mask = 1 << 3;
|
static const _is360Mask = 1 << 3;
|
||||||
static const _isMultipageMask = 1 << 4;
|
static const _isMultiPageMask = 1 << 4;
|
||||||
|
|
||||||
CatalogMetadata({
|
CatalogMetadata({
|
||||||
this.contentId,
|
this.contentId,
|
||||||
|
@ -51,7 +51,7 @@ class CatalogMetadata {
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
this.isGeotiff = false,
|
this.isGeotiff = false,
|
||||||
this.is360 = false,
|
this.is360 = false,
|
||||||
this.isMultipage = false,
|
this.isMultiPage = false,
|
||||||
this.rotationDegrees,
|
this.rotationDegrees,
|
||||||
this.xmpSubjects,
|
this.xmpSubjects,
|
||||||
this.xmpTitleDescription,
|
this.xmpTitleDescription,
|
||||||
|
@ -70,7 +70,8 @@ class CatalogMetadata {
|
||||||
CatalogMetadata copyWith({
|
CatalogMetadata copyWith({
|
||||||
int contentId,
|
int contentId,
|
||||||
String mimeType,
|
String mimeType,
|
||||||
bool isMultipage,
|
bool isMultiPage,
|
||||||
|
int rotationDegrees,
|
||||||
}) {
|
}) {
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
||||||
|
@ -80,8 +81,8 @@ class CatalogMetadata {
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
isGeotiff: isGeotiff,
|
isGeotiff: isGeotiff,
|
||||||
is360: is360,
|
is360: is360,
|
||||||
isMultipage: isMultipage ?? this.isMultipage,
|
isMultiPage: isMultiPage ?? this.isMultiPage,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
|
||||||
xmpSubjects: xmpSubjects,
|
xmpSubjects: xmpSubjects,
|
||||||
xmpTitleDescription: xmpTitleDescription,
|
xmpTitleDescription: xmpTitleDescription,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
|
@ -99,7 +100,7 @@ class CatalogMetadata {
|
||||||
isFlipped: flags & _isFlippedMask != 0,
|
isFlipped: flags & _isFlippedMask != 0,
|
||||||
isGeotiff: flags & _isGeotiffMask != 0,
|
isGeotiff: flags & _isGeotiffMask != 0,
|
||||||
is360: flags & _is360Mask != 0,
|
is360: flags & _is360Mask != 0,
|
||||||
isMultipage: flags & _isMultipageMask != 0,
|
isMultiPage: flags & _isMultiPageMask != 0,
|
||||||
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
|
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
|
||||||
rotationDegrees: map['rotationDegrees'],
|
rotationDegrees: map['rotationDegrees'],
|
||||||
xmpSubjects: map['xmpSubjects'] ?? '',
|
xmpSubjects: map['xmpSubjects'] ?? '',
|
||||||
|
@ -113,7 +114,7 @@ class CatalogMetadata {
|
||||||
'contentId': contentId,
|
'contentId': contentId,
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
'dateMillis': dateMillis,
|
'dateMillis': dateMillis,
|
||||||
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultipage ? _isMultipageMask : 0),
|
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0),
|
||||||
'rotationDegrees': rotationDegrees,
|
'rotationDegrees': rotationDegrees,
|
||||||
'xmpSubjects': xmpSubjects,
|
'xmpSubjects': xmpSubjects,
|
||||||
'xmpTitleDescription': xmpTitleDescription,
|
'xmpTitleDescription': xmpTitleDescription,
|
||||||
|
@ -122,7 +123,7 @@ class CatalogMetadata {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultipage=$isMultipage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class OverlayMetadata {
|
class OverlayMetadata {
|
||||||
|
|
|
@ -1,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);
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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',
|
||||||
|
|
82
lib/services/embedded_data_service.dart
Normal file
82
lib/services/embedded_data_service.dart
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
abstract class EmbeddedDataService {
|
||||||
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformEmbeddedDataService implements EmbeddedDataService {
|
||||||
|
static const platform = MethodChannel('deckers.thibault/aves/embedded');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
});
|
||||||
|
return (result as List).cast<Uint8List>();
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
'displayName': '${entry.bestTitle} • Video',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
|
||||||
|
'uri': entry.uri,
|
||||||
|
'displayName': '${entry.bestTitle} • Cover',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
'displayName': '${entry.bestTitle} • $propPath',
|
||||||
|
'propPath': propPath,
|
||||||
|
'propMimeType': propMimeType,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
AnimatedImageIcon()
|
AnimatedImageIcon()
|
||||||
else ...[
|
else ...[
|
||||||
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) RawIcon(),
|
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) RawIcon(),
|
||||||
if (entry.isMultipage) MultipageIcon(),
|
if (entry.isMultiPage) MultiPageIcon(entry: entry),
|
||||||
if (entry.isGeotiff) GeotiffIcon(),
|
if (entry.isGeotiff) GeotiffIcon(),
|
||||||
if (entry.is360) SphericalImageIcon(),
|
if (entry.is360) SphericalImageIcon(),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ThumbnailTheme extends StatelessWidget {
|
class ThumbnailTheme extends StatelessWidget {
|
||||||
final double extent;
|
final double extent;
|
||||||
|
final bool showLocation;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const ThumbnailTheme({
|
const ThumbnailTheme({
|
||||||
@required this.extent,
|
@required this.extent,
|
||||||
|
this.showLocation,
|
||||||
@required this.child,
|
@required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -22,7 +24,7 @@ class ThumbnailTheme extends StatelessWidget {
|
||||||
return ThumbnailThemeData(
|
return ThumbnailThemeData(
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
showLocation: settings.showThumbnailLocation,
|
showLocation: showLocation ?? settings.showThumbnailLocation,
|
||||||
showRaw: settings.showThumbnailRaw,
|
showRaw: settings.showThumbnailRaw,
|
||||||
showVideoDuration: settings.showThumbnailVideoDuration,
|
showVideoDuration: settings.showThumbnailVideoDuration,
|
||||||
);
|
);
|
||||||
|
|
|
@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
|
||||||
final thumbnailTheme = context.watch<ThumbnailThemeData>();
|
final thumbnailTheme = context.watch<ThumbnailThemeData>();
|
||||||
final showDuration = thumbnailTheme.showVideoDuration;
|
final showDuration = thumbnailTheme.showVideoDuration;
|
||||||
Widget child = OverlayIcon(
|
Widget child = OverlayIcon(
|
||||||
icon: entry.is360 ? AIcons.threesixty : AIcons.play,
|
icon: entry.is360 ? AIcons.threeSixty : AIcons.play,
|
||||||
size: thumbnailTheme.iconSize,
|
size: thumbnailTheme.iconSize,
|
||||||
text: showDuration ? entry.durationText : null,
|
text: showDuration ? entry.durationText : null,
|
||||||
iconScale: entry.is360 && showDuration ? .9 : 1,
|
iconScale: entry.is360 && showDuration ? .9 : 1,
|
||||||
|
@ -72,7 +72,7 @@ class SphericalImageIcon extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return OverlayIcon(
|
return OverlayIcon(
|
||||||
icon: AIcons.threesixty,
|
icon: AIcons.threeSixty,
|
||||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -102,13 +102,18 @@ class RawIcon extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MultipageIcon extends StatelessWidget {
|
class MultiPageIcon extends StatelessWidget {
|
||||||
const MultipageIcon({Key key}) : super(key: key);
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
const MultiPageIcon({
|
||||||
|
Key key,
|
||||||
|
this.entry,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return OverlayIcon(
|
return OverlayIcon(
|
||||||
icon: AIcons.multipage,
|
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
|
||||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||||
iconScale: .8,
|
iconScale: .8,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(() {});
|
||||||
},
|
},
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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')) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
31
lib/widgets/viewer/multipage/conductor.dart
Normal file
31
lib/widgets/viewer/multipage/conductor.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
39
lib/widgets/viewer/multipage/controller.dart
Normal file
39
lib/widgets/viewer/multipage/controller.dart
Normal 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}';
|
||||||
|
}
|
|
@ -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
|
|
@ -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);
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue