motion photo: fixed rotate/flip
This commit is contained in:
parent
bec145b0ae
commit
63f7aa1199
24 changed files with 421 additions and 328 deletions
|
@ -90,19 +90,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
input.skip(videoStartOffset)
|
||||||
input.skip(videoStartOffset)
|
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input)
|
||||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
return
|
||||||
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
|
||||||
} catch (e: NoClassDefFoundError) {
|
|
||||||
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
|
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
|
||||||
|
@ -198,9 +192,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val extension = extensionFor(mimeType)
|
val extension = extensionFor(mimeType)
|
||||||
val file = File.createTempFile("aves", extension, context.cacheDir).apply {
|
val file = File.createTempFile("aves", extension, context.cacheDir).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
outputStream().use { outputStream ->
|
outputStream().use { output ->
|
||||||
embeddedByteStream.use { inputStream ->
|
embeddedByteStream.use { input ->
|
||||||
inputStream.copyTo(outputStream)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,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
|
||||||
}
|
}
|
||||||
|
@ -195,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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -47,6 +47,7 @@ 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.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -371,7 +372,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// identification of motion photo
|
// identification of motion photo
|
||||||
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
if (xmpMeta.isMotionPhoto()) {
|
||||||
flags = flags or MASK_IS_MULTIPAGE
|
flags = flags or MASK_IS_MULTIPAGE
|
||||||
}
|
}
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -129,11 +129,11 @@ object Metadata {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,13 +135,19 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
try {
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
var offsetFromEnd: Long? = null
|
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||||
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
var offsetFromEnd: Long? = null
|
||||||
return offsetFromEnd
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,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 {
|
||||||
|
|
|
@ -17,20 +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.BmpWriter
|
|
||||||
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.extensionFor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
|
||||||
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
|
||||||
|
@ -233,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
|
||||||
|
@ -245,16 +243,44 @@ abstract class ImageProvider {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy original file to a temporary file for editing
|
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
|
||||||
val editablePath = copyFileToTemp(originalDocumentFile, path)
|
var videoBytes: ByteArray? = null
|
||||||
if (editablePath == null) {
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
callback.onFailure(Exception("failed to create a temporary file for path=$path"))
|
deleteOnExit()
|
||||||
return
|
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
|
||||||
|
originalDocumentFile.openInputStream().use { imageInput ->
|
||||||
|
imageInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
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
|
||||||
|
@ -270,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
|
||||||
|
@ -300,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,13 +11,11 @@ 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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
@ -43,7 +42,7 @@ 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.psd];
|
||||||
|
|
||||||
AvesEntry({
|
AvesEntry({
|
||||||
this.uri,
|
this.uri,
|
||||||
|
@ -97,37 +96,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: pageInfo.uri ?? uri,
|
|
||||||
path: path,
|
|
||||||
contentId: contentId,
|
|
||||||
pageId: pageId,
|
|
||||||
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
|
|
||||||
width: pageInfo.width ?? width,
|
|
||||||
height: pageInfo.height ?? height,
|
|
||||||
sourceRotationDegrees: pageInfo.rotationDegrees ?? sourceRotationDegrees,
|
|
||||||
sizeBytes: sizeBytes,
|
|
||||||
sourceTitle: sourceTitle,
|
|
||||||
dateModifiedSecs: dateModifiedSecs,
|
|
||||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
|
||||||
durationMillis: pageInfo.durationMillis ?? durationMillis,
|
|
||||||
)
|
|
||||||
..catalogMetadata = _catalogMetadata?.copyWith(
|
|
||||||
mimeType: pageInfo.mimeType,
|
|
||||||
isMultiPage: false,
|
|
||||||
rotationDegrees: pageInfo.rotationDegrees,
|
|
||||||
)
|
|
||||||
..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(
|
||||||
|
|
|
@ -5,20 +5,21 @@ import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class MultiPageInfo {
|
class MultiPageInfo {
|
||||||
final AvesEntry mainEntry;
|
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.mainEntry,
|
@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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,31 +31,78 @@ class MultiPageInfo {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
Future<void> extractMotionPhotoVideo() async {
|
||||||
final videoPage = pages.firstWhere((page) => page.isVideo, orElse: () => null);
|
final videoPage = _pages.firstWhere((page) => page.isVideo, orElse: () => null);
|
||||||
if (videoPage != null && videoPage.uri == null) {
|
if (videoPage != null && videoPage.uri == null) {
|
||||||
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
|
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
|
||||||
final extractedUri = fields != null ? fields['uri'] as String : null;
|
if (fields != null) {
|
||||||
if (extractedUri != null) {
|
final pageIndex = _pages.indexOf(videoPage);
|
||||||
final pageIndex = pages.indexOf(videoPage);
|
_pages.removeAt(pageIndex);
|
||||||
pages.removeAt(pageIndex);
|
_pages.insert(
|
||||||
pages.insert(
|
|
||||||
pageIndex,
|
pageIndex,
|
||||||
videoPage.copyWith(
|
videoPage.copyWith(
|
||||||
uri: extractedUri,
|
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)}{mainEntry=$mainEntry, pages=$pages}';
|
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$_pages}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||||
|
@ -78,6 +126,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||||
SinglePageInfo copyWith({
|
SinglePageInfo copyWith({
|
||||||
bool isDefault,
|
bool isDefault,
|
||||||
String uri,
|
String uri,
|
||||||
|
int rotationDegrees,
|
||||||
|
int durationMillis,
|
||||||
}) {
|
}) {
|
||||||
return SinglePageInfo(
|
return SinglePageInfo(
|
||||||
index: index,
|
index: index,
|
||||||
|
@ -87,8 +137,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
|
||||||
durationMillis: durationMillis,
|
durationMillis: durationMillis ?? this.durationMillis,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,13 +12,14 @@ class MimeTypes {
|
||||||
static const tiff = 'image/tiff';
|
static const tiff = 'image/tiff';
|
||||||
static const webp = 'image/webp';
|
static const webp = 'image/webp';
|
||||||
|
|
||||||
|
static const art = 'image/x-jg';
|
||||||
|
static const djvu = 'image/vnd.djvu';
|
||||||
static const psd = 'image/vnd.adobe.photoshop';
|
static const psd = 'image/vnd.adobe.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';
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -175,10 +175,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
await multiPageInfo.extractMotionPhotoVideo();
|
await multiPageInfo.extractMotionPhotoVideo();
|
||||||
}
|
}
|
||||||
if (multiPageInfo.pageCount > 1) {
|
if (multiPageInfo.pageCount > 1) {
|
||||||
for (final page in multiPageInfo.pages) {
|
selection.addAll(multiPageInfo.exportEntries);
|
||||||
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
|
|
||||||
selection.add(pageEntry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selection.add(entry);
|
selection.add(entry);
|
||||||
|
|
|
@ -47,14 +47,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
||||||
if (entry.isMultiPage) {
|
if (entry.isMultiPage) {
|
||||||
final multiPageController = context.read<MultiPageConductor>().getController(entry);
|
final multiPageController = context.read<MultiPageConductor>().getController(entry);
|
||||||
if (multiPageController != null) {
|
if (multiPageController != null) {
|
||||||
child = FutureBuilder<MultiPageInfo>(
|
child = 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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -72,16 +72,16 @@ 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,
|
||||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
onDisposed: () => widget.onViewDisposed?.call(mainEntry.uri),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -103,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 = context.read<MultiPageConductor>().getController(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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -135,13 +135,13 @@ 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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
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/multipage.dart';
|
||||||
|
@ -219,17 +220,30 @@ 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 viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == mainEntry.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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -274,15 +288,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
|
|
||||||
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
|
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
|
||||||
final extraBottomOverlay = multiPageController != null
|
final extraBottomOverlay = multiPageController != null
|
||||||
? FutureBuilder<MultiPageInfo>(
|
? StreamBuilder<MultiPageInfo>(
|
||||||
future: multiPageController.info,
|
stream: multiPageController.infoStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final multiPageInfo = snapshot.data;
|
final multiPageInfo = multiPageController.info;
|
||||||
if (multiPageInfo == null) return SizedBox.shrink();
|
if (multiPageInfo == null) return SizedBox.shrink();
|
||||||
return ValueListenableBuilder<int>(
|
return ValueListenableBuilder<int>(
|
||||||
valueListenable: multiPageController.pageNotifier,
|
valueListenable: multiPageController.pageNotifier,
|
||||||
builder: (context, page, child) {
|
builder: (context, page, child) {
|
||||||
final pageEntry = entry.getPageEntry(multiPageInfo?.getByIndex(page));
|
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||||
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
|
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -538,13 +552,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
|
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
||||||
final multiPageInfo = await multiPageController.info;
|
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
|
||||||
if (entry.isMotionPhoto) {
|
if (entry.isMotionPhoto) {
|
||||||
await multiPageInfo.extractMotionPhotoVideo();
|
await multiPageInfo.extractMotionPhotoVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
final pages = multiPageInfo.pages;
|
final videoPageEntries = multiPageInfo.videoPageEntries;
|
||||||
final videoPageEntries = pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet();
|
|
||||||
if (videoPageEntries.isNotEmpty) {
|
if (videoPageEntries.isNotEmpty) {
|
||||||
// init video controllers for all pages that could need it
|
// init video controllers for all pages that could need it
|
||||||
final videoConductor = context.read<VideoConductor>();
|
final videoConductor = context.read<VideoConductor>();
|
||||||
|
@ -557,8 +570,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
final page = multiPageController.page;
|
final page = multiPageController.page;
|
||||||
final pageInfo = multiPageInfo.getByIndex(page);
|
final pageInfo = multiPageInfo.getByIndex(page);
|
||||||
if (pageInfo.isVideo) {
|
if (pageInfo.isVideo) {
|
||||||
final pageEntry = entry.getPageEntry(pageInfo);
|
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||||
final pageVideoController = videoConductor.getController(pageEntry);
|
final pageVideoController = videoConductor.getController(pageEntry);
|
||||||
|
assert(pageVideoController != null);
|
||||||
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
|
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,34 @@ import 'package:aves/services/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class MultiPageController extends ChangeNotifier {
|
class MultiPageController {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
Future<MultiPageInfo> info;
|
|
||||||
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
|
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
MultiPageController(this.entry) {
|
MultiPageInfo _info;
|
||||||
info = metadataService.getMultiPageInfo(entry).then((value) {
|
|
||||||
pageNotifier.value = value.defaultPage.index;
|
final StreamController<MultiPageInfo> _infoStreamController = StreamController.broadcast();
|
||||||
return value;
|
|
||||||
});
|
Stream<MultiPageInfo> get infoStream => _infoStreamController.stream;
|
||||||
}
|
|
||||||
|
MultiPageInfo get info => _info;
|
||||||
|
|
||||||
int get page => pageNotifier.value;
|
int get page => pageNotifier.value;
|
||||||
|
|
||||||
set page(int page) => pageNotifier.value = page;
|
set page(int page) => pageNotifier.value = page;
|
||||||
|
|
||||||
@override
|
MultiPageController(this.entry) {
|
||||||
|
metadataService.getMultiPageInfo(entry).then((value) {
|
||||||
|
pageNotifier.value = value.defaultPage.index;
|
||||||
|
_info = value;
|
||||||
|
_infoStreamController.add(_info);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
pageNotifier.dispose();
|
pageNotifier.dispose();
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
@ -184,7 +188,6 @@ class _BottomOverlayContent extends AnimatedWidget {
|
||||||
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,6 +1,5 @@
|
||||||
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';
|
||||||
|
@ -10,17 +9,14 @@ 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() {
|
||||||
|
@ -84,39 +95,29 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
return ThumbnailTheme(
|
return ThumbnailTheme(
|
||||||
extent: extent,
|
extent: extent,
|
||||||
showLocation: false,
|
showLocation: false,
|
||||||
child: FutureBuilder<MultiPageInfo>(
|
child: StreamBuilder<MultiPageInfo>(
|
||||||
future: controller.info,
|
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.mainEntry != mainEntry) 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,
|
||||||
|
@ -140,7 +141,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (context, index) => separator,
|
separatorBuilder: (context, index) => separator,
|
||||||
itemCount: multiPageInfo.pageCount + 2,
|
itemCount: pageCount + 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -148,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);
|
||||||
|
|
|
@ -1,68 +1,46 @@
|
||||||
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/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';
|
||||||
|
|
||||||
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>(
|
valueListenable: viewStateNotifier,
|
||||||
future: multiPageController.info,
|
builder: (context, viewState, child) {
|
||||||
builder: (context, snapshot) {
|
final viewportSize = viewState.viewportSize;
|
||||||
final multiPageInfo = snapshot.data;
|
if (viewportSize == null) return SizedBox.shrink();
|
||||||
if (multiPageInfo == null) return SizedBox.shrink();
|
return AnimatedBuilder(
|
||||||
return ValueListenableBuilder<int>(
|
animation: entry.imageChangeNotifier,
|
||||||
valueListenable: multiPageController.pageNotifier,
|
builder: (context, child) => CustomPaint(
|
||||||
builder: (context, page, child) {
|
painter: MinimapPainter(
|
||||||
final pageEntry = mainEntry.getPageEntry(multiPageInfo?.getByIndex(page));
|
viewportSize: viewportSize,
|
||||||
return _buildForEntrySize(pageEntry);
|
entrySize: entry.displaySize,
|
||||||
},
|
viewCenterOffset: viewState.position,
|
||||||
);
|
viewScale: viewState.scale,
|
||||||
})
|
minimapBorderColor: Colors.white30,
|
||||||
: _buildForEntrySize(mainEntry),
|
),
|
||||||
);
|
size: size,
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildForEntrySize(AvesEntry entry) {
|
|
||||||
return ValueListenableBuilder<ViewState>(
|
|
||||||
valueListenable: viewStateNotifier,
|
|
||||||
builder: (context, viewState, child) {
|
|
||||||
final viewportSize = viewState.viewportSize;
|
|
||||||
if (viewportSize == null) return SizedBox.shrink();
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: entry.imageChangeNotifier,
|
|
||||||
builder: (context, child) => CustomPaint(
|
|
||||||
painter: MinimapPainter(
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
entrySize: entry.displaySize,
|
|
||||||
viewCenterOffset: viewState.position,
|
|
||||||
viewScale: viewState.scale,
|
|
||||||
minimapBorderColor: Colors.white30,
|
|
||||||
),
|
),
|
||||||
size: size,
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
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';
|
||||||
|
@ -17,7 +18,7 @@ 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;
|
||||||
|
@ -28,7 +29,7 @@ class ViewerTopOverlay extends StatelessWidget {
|
||||||
|
|
||||||
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,
|
||||||
|
@ -47,78 +48,103 @@ 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;
|
||||||
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
|
|
||||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
|
||||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
|
||||||
final buttonRow = _TopOverlayRow(
|
|
||||||
quickActions: quickActions,
|
|
||||||
inAppActions: inAppActions,
|
|
||||||
externalAppActions: externalAppActions,
|
|
||||||
scale: scale,
|
|
||||||
entry: entry,
|
|
||||||
onActionSelected: onActionSelected,
|
|
||||||
);
|
|
||||||
|
|
||||||
return settings.showOverlayMinimap && viewStateNotifier != null
|
Widget child;
|
||||||
? Column(
|
if (mainEntry.isMultiPage) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||||
children: [
|
if (multiPageController != null) {
|
||||||
buttonRow,
|
child = StreamBuilder<MultiPageInfo>(
|
||||||
SizedBox(height: 8),
|
stream: multiPageController.infoStream,
|
||||||
FadeTransition(
|
builder: (context, snapshot) {
|
||||||
opacity: scale,
|
final multiPageInfo = multiPageController.info;
|
||||||
child: Minimap(
|
return ValueListenableBuilder<int>(
|
||||||
mainEntry: entry,
|
valueListenable: multiPageController.pageNotifier,
|
||||||
viewStateNotifier: viewStateNotifier,
|
builder: (context, page, child) {
|
||||||
multiPageController: entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null,
|
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||||
),
|
},
|
||||||
)
|
);
|
||||||
],
|
},
|
||||||
)
|
);
|
||||||
: buttonRow;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return child ??= _buildOverlay(availableCount, mainEntry);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _canDo(EntryAction action) {
|
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry pageEntry}) {
|
||||||
switch (action) {
|
pageEntry ??= mainEntry;
|
||||||
case EntryAction.toggleFavourite:
|
|
||||||
return canToggleFavourite;
|
bool _canDo(EntryAction action) {
|
||||||
case EntryAction.delete:
|
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
|
||||||
case EntryAction.rename:
|
switch (action) {
|
||||||
return entry.canEdit;
|
case EntryAction.toggleFavourite:
|
||||||
case EntryAction.rotateCCW:
|
return canToggleFavourite;
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.delete:
|
||||||
case EntryAction.flip:
|
case EntryAction.rename:
|
||||||
return entry.canRotateAndFlip;
|
return targetEntry.canEdit;
|
||||||
case EntryAction.export:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.print:
|
case EntryAction.rotateCW:
|
||||||
return !entry.isVideo;
|
case EntryAction.flip:
|
||||||
case EntryAction.openMap:
|
return targetEntry.canRotateAndFlip;
|
||||||
return entry.hasGps;
|
case EntryAction.export:
|
||||||
case EntryAction.viewSource:
|
case EntryAction.print:
|
||||||
return entry.isSvg;
|
return !targetEntry.isVideo;
|
||||||
case EntryAction.share:
|
case EntryAction.openMap:
|
||||||
case EntryAction.info:
|
return targetEntry.hasGps;
|
||||||
case EntryAction.open:
|
case EntryAction.viewSource:
|
||||||
case EntryAction.edit:
|
return targetEntry.isSvg;
|
||||||
case EntryAction.setAs:
|
case EntryAction.share:
|
||||||
return true;
|
case EntryAction.info:
|
||||||
case EntryAction.debug:
|
case EntryAction.open:
|
||||||
return kDebugMode;
|
case EntryAction.edit:
|
||||||
|
case EntryAction.setAs:
|
||||||
|
return true;
|
||||||
|
case EntryAction.debug:
|
||||||
|
return kDebugMode;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
|
||||||
|
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
||||||
|
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
||||||
|
final buttonRow = _TopOverlayRow(
|
||||||
|
quickActions: quickActions,
|
||||||
|
inAppActions: inAppActions,
|
||||||
|
externalAppActions: externalAppActions,
|
||||||
|
scale: scale,
|
||||||
|
mainEntry: mainEntry,
|
||||||
|
pageEntry: pageEntry,
|
||||||
|
onActionSelected: onActionSelected,
|
||||||
|
);
|
||||||
|
|
||||||
|
return settings.showOverlayMinimap && viewStateNotifier != null
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
buttonRow,
|
||||||
|
SizedBox(height: 8),
|
||||||
|
FadeTransition(
|
||||||
|
opacity: scale,
|
||||||
|
child: Minimap(
|
||||||
|
entry: pageEntry,
|
||||||
|
viewStateNotifier: viewStateNotifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: buttonRow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
|
@ -127,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);
|
||||||
|
|
||||||
|
@ -149,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) ...[
|
||||||
|
@ -173,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;
|
||||||
|
@ -217,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;
|
||||||
|
|
|
@ -49,15 +49,16 @@ class EntryPrinter with FeedbackMixin {
|
||||||
|
|
||||||
if (entry.isMultiPage && !entry.isMotionPhoto) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import 'dart:async';
|
||||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_images.dart';
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
|
||||||
import 'package:aves/model/settings/entry_background.dart';
|
import 'package:aves/model/settings/entry_background.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';
|
||||||
|
@ -30,22 +29,19 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EntryPageView extends StatefulWidget {
|
class EntryPageView extends StatefulWidget {
|
||||||
final AvesEntry mainEntry;
|
final AvesEntry mainEntry, pageEntry;
|
||||||
final AvesEntry entry;
|
|
||||||
final SinglePageInfo page;
|
|
||||||
final Size viewportSize;
|
final Size viewportSize;
|
||||||
final VoidCallback onDisposed;
|
final VoidCallback onDisposed;
|
||||||
|
|
||||||
static const decorationCheckSize = 20.0;
|
static const decorationCheckSize = 20.0;
|
||||||
|
|
||||||
EntryPageView({
|
const EntryPageView({
|
||||||
Key key,
|
Key key,
|
||||||
this.mainEntry,
|
this.mainEntry,
|
||||||
this.page,
|
this.pageEntry,
|
||||||
this.viewportSize,
|
this.viewportSize,
|
||||||
this.onDisposed,
|
this.onDisposed,
|
||||||
}) : entry = mainEntry.getPageEntry(page) ?? mainEntry,
|
}) : super(key: key);
|
||||||
super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_EntryPageViewState createState() => _EntryPageViewState();
|
_EntryPageViewState createState() => _EntryPageViewState();
|
||||||
|
@ -58,7 +54,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
|
|
||||||
AvesEntry get mainEntry => widget.mainEntry;
|
AvesEntry get mainEntry => widget.mainEntry;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.pageEntry;
|
||||||
|
|
||||||
Size get viewportSize => widget.viewportSize;
|
Size get viewportSize => widget.viewportSize;
|
||||||
|
|
||||||
|
@ -76,7 +72,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
void didUpdateWidget(covariant EntryPageView oldWidget) {
|
void didUpdateWidget(covariant EntryPageView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
if (oldWidget.entry.displaySize != entry.displaySize) {
|
if (oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) {
|
||||||
// do not reset the magnifier view state unless page dimensions change,
|
// do not reset the magnifier view state unless page dimensions change,
|
||||||
// in effect locking the zoom & position when browsing entry pages of the same size
|
// in effect locking the zoom & position when browsing entry pages of the same size
|
||||||
_unregisterWidget();
|
_unregisterWidget();
|
||||||
|
|
Loading…
Reference in a new issue