diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 261e9dcb3..534f5c520 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -90,19 +90,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { return } - try { - MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> - val videoStartOffset = sizeBytes - videoSizeBytes - StorageUtils.openInputStream(context, uri)?.let { input -> - input.skip(videoStartOffset) - copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input) - } - return + 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) } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract video from motion photo", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract video from motion photo", e) + return } 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 file = File.createTempFile("aves", extension, context.cacheDir).apply { deleteOnExit() - outputStream().use { outputStream -> - embeddedByteStream.use { inputStream -> - inputStream.copyTo(outputStream) + outputStream().use { output -> + embeddedByteStream.use { input -> + input.copyTo(output) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 5ed49e3d1..ff76d43b0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -184,7 +184,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] 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) return } @@ -195,7 +196,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { 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 onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 13b425bf4..573777615 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -47,6 +47,7 @@ import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText 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.model.FieldMap import deckers.thibault.aves.utils.LogUtils @@ -371,7 +372,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } // 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 } } catch (e: XMPException) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index d977c1316..01a4785b1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -114,8 +114,8 @@ class RegionFetcher internal constructor( val bitmap = target.get() val tempFile = File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() - outputStream().use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + outputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output) } } return Uri.fromFile(tempFile) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index f0f2c1f93..98f80393f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -129,11 +129,11 @@ object Metadata { if (previewFile == null) { previewFile = File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() - outputStream().use { outputStream -> - StorageUtils.openInputStream(context, uri)?.use { inputStream -> + outputStream().use { output -> + StorageUtils.openInputStream(context, uri)?.use { input -> val b = ByteArray(previewSize) - inputStream.read(b, 0, previewSize) - outputStream.write(b) + input.read(b, 0, previewSize) + output.write(b) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 63ce5f0e4..eb9597b7d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -135,13 +135,19 @@ object MultiPage { } fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input) - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - var offsetFromEnd: Long? = null - dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } - return offsetFromEnd + 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 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 278e18b34..d1d24bd87 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -77,6 +77,19 @@ object XMP { // 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 { // Google try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 9c1e3f725..f5337cf97 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -17,20 +17,18 @@ import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.TiffImage +import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap -import deckers.thibault.aves.utils.BitmapUtils -import deckers.thibault.aves.utils.BmpWriter -import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage 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.getDocumentFile import deckers.thibault.aves.utils.UriUtils.tryParseId +import java.io.ByteArrayInputStream import java.io.File import java.io.FileNotFoundException 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)) { callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) return @@ -245,16 +243,44 @@ abstract class ImageProvider { return } - // copy original file to a temporary file for editing - val editablePath = copyFileToTemp(originalDocumentFile, path) - if (editablePath == null) { - callback.onFailure(Exception("failed to create a temporary file for path=$path")) - 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 + originalDocumentFile.openInputStream().use { imageInput -> + imageInput.copyTo(output) + } + } + } + } catch (e: Exception) { + callback.onFailure(e) + return + } } val newFields = HashMap() 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)` // in that case we explicitly set it to `normal` first // because ExifInterface fails to rotate an image with undefined orientation @@ -270,8 +296,12 @@ abstract class ImageProvider { } exif.saveAttributes() + if (videoBytes != null) { + // append motion photo video, if any + editableFile.appendBytes(videoBytes!!) + } // 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["isFlipped"] = exif.isFlipped @@ -300,7 +330,7 @@ abstract class ImageProvider { // as of androidx.exifinterface:exifinterface:1.3.0 private fun canEditExif(mimeType: String): Boolean { return when (mimeType) { - "image/jpeg", "image/png", "image/webp" -> true + MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true else -> false } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 88a0627d9..dbcac15a7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -11,13 +11,11 @@ import android.provider.DocumentsContract import android.provider.MediaStore import android.text.TextUtils import android.util.Log -import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import java.io.File import java.io.FileNotFoundException -import java.io.IOException import java.io.InputStream import java.util.* 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? { var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 2b92511dc..04e4865e1 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -44,6 +44,12 @@ class EntryActions { EntryAction.setAs, EntryAction.openMap, ]; + + static const pageActions = [ + EntryAction.rotateCCW, + EntryAction.rotateCW, + EntryAction.flip, + ]; } extension ExtraEntryAction on EntryAction { diff --git a/lib/model/entry.dart b/lib/model/entry.dart index a43ac9247..f0f87b4f2 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -4,7 +4,6 @@ import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/service_policy.dart'; @@ -43,7 +42,7 @@ class AvesEntry { final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); // TODO TLAD make it dynamic if it depends on OS/lib versions - static const List undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd]; + static const List undecodable = [MimeTypes.art, MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd]; AvesEntry({ this.uri, @@ -97,37 +96,6 @@ class AvesEntry { 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 factory AvesEntry.fromMap(Map map) { return AvesEntry( diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 73c9837c9..bc92ef4d7 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -5,20 +5,21 @@ import 'package:flutter/foundation.dart'; class MultiPageInfo { final AvesEntry mainEntry; - final List pages; + final List _pages; + final Map _pageEntries = {}; - int get pageCount => pages.length; + int get pageCount => _pages.length; MultiPageInfo({ @required this.mainEntry, - this.pages, - }) { - if (pages.isNotEmpty) { - pages.sort(); + List pages, + }) : _pages = pages { + if (_pages.isNotEmpty) { + _pages.sort(); // make sure there is a page marked as default if (defaultPage == null) { - final firstPage = pages.removeAt(0); - pages.insert(0, firstPage.copyWith(isDefault: true)); + final firstPage = _pages.removeAt(0); + _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 get videoPageEntries => _pages.where((page) => page.isVideo).map(_getPageEntry).toSet(); + + List get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList(); Future 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) { final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry); - final extractedUri = fields != null ? fields['uri'] as String : null; - if (extractedUri != null) { - final pageIndex = pages.indexOf(videoPage); - pages.removeAt(pageIndex); - pages.insert( + if (fields != null) { + final pageIndex = _pages.indexOf(videoPage); + _pages.removeAt(pageIndex); + _pages.insert( pageIndex, 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 - String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}'; + String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$_pages}'; } class SinglePageInfo implements Comparable { @@ -78,6 +126,8 @@ class SinglePageInfo implements Comparable { SinglePageInfo copyWith({ bool isDefault, String uri, + int rotationDegrees, + int durationMillis, }) { return SinglePageInfo( index: index, @@ -87,8 +137,8 @@ class SinglePageInfo implements Comparable { mimeType: mimeType, width: width, height: height, - rotationDegrees: rotationDegrees, - durationMillis: durationMillis, + rotationDegrees: rotationDegrees ?? this.rotationDegrees, + durationMillis: durationMillis ?? this.durationMillis, ); } diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index c82db0619..cf1d08ec0 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -12,13 +12,14 @@ class MimeTypes { static const tiff = 'image/tiff'; 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 arw = 'image/x-sony-arw'; static const cr2 = 'image/x-canon-cr2'; static const crw = 'image/x-canon-crw'; static const dcr = 'image/x-kodak-dcr'; - static const djvu = 'image/vnd.djvu'; static const dng = 'image/x-adobe-dng'; static const erf = 'image/x-epson-erf'; static const k25 = 'image/x-kodak-k25'; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 2ac874501..4bf6238e8 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -103,6 +103,7 @@ class PlatformImageFileService implements ImageFileService { 'rotationDegrees': entry.rotationDegrees, 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, + 'sizeBytes': entry.sizeBytes, }; } diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index d3d2add74..cf2ff7d53 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -175,10 +175,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix await multiPageInfo.extractMotionPhotoVideo(); } if (multiPageInfo.pageCount > 1) { - for (final page in multiPageInfo.pages) { - final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false); - selection.add(pageEntry); - } + selection.addAll(multiPageInfo.exportEntries); } } else { selection.add(entry); diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 80d16a974..96dcd75cc 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -47,14 +47,14 @@ class _MultiEntryScrollerState extends State with AutomaticK if (entry.isMultiPage) { final multiPageController = context.read().getController(entry); if (multiPageController != null) { - child = FutureBuilder( - future: multiPageController.info, + child = StreamBuilder( + stream: multiPageController.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; + final multiPageInfo = multiPageController.info; return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, 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 with AutomaticK ); } - Widget _buildViewer(AvesEntry entry, {SinglePageInfo page}) { + Widget _buildViewer(AvesEntry mainEntry, {AvesEntry pageEntry}) { return Selector( selector: (c, mq) => mq.size, builder: (c, mqSize, child) { return EntryPageView( key: Key('imageview'), - mainEntry: entry, - page: page, + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, 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 with AutomaticKeepAliveClientMixin { - AvesEntry get entry => widget.entry; + AvesEntry get mainEntry => widget.entry; @override Widget build(BuildContext context) { super.build(context); Widget child; - if (entry.isMultiPage) { - final multiPageController = context.read().getController(entry); + if (mainEntry.isMultiPage) { + final multiPageController = context.read().getController(mainEntry); if (multiPageController != null) { - child = FutureBuilder( - future: multiPageController.info, + child = StreamBuilder( + stream: multiPageController.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; + final multiPageInfo = multiPageController.info; return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - return _buildViewer(page: multiPageInfo?.getByIndex(page)); + return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page)); }, ); }, @@ -135,13 +135,13 @@ class _SingleEntryScrollerState extends State with Automati ); } - Widget _buildViewer({SinglePageInfo page}) { + Widget _buildViewer({AvesEntry pageEntry}) { return Selector( selector: (c, mq) => mq.size, builder: (c, mqSize, child) { return EntryPageView( - mainEntry: entry, - page: page, + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, viewportSize: mqSize, ); }, diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index bb3ccf7c3..03aa7df4a 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/multipage.dart'; @@ -219,17 +220,30 @@ class _EntryViewerStackState extends State with SingleTickerPr Widget _buildTopOverlay() { final child = ValueListenableBuilder( valueListenable: _entryNotifier, - builder: (context, entry, child) { - if (entry == null) return SizedBox.shrink(); + builder: (context, mainEntry, child) { + 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( - entry: entry, + mainEntry: mainEntry, scale: _topOverlayScale, canToggleFavourite: hasCollection, viewInsets: _frozenViewInsets, 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().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, ); }, @@ -274,15 +288,15 @@ class _EntryViewerStackState extends State with SingleTickerPr final multiPageController = entry.isMultiPage ? context.read().getController(entry) : null; final extraBottomOverlay = multiPageController != null - ? FutureBuilder( - future: multiPageController.info, + ? StreamBuilder( + stream: multiPageController.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; + final multiPageInfo = multiPageController.info; if (multiPageInfo == null) return SizedBox.shrink(); return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - final pageEntry = entry.getPageEntry(multiPageInfo?.getByIndex(page)); + final pageEntry = multiPageInfo.getPageEntryByIndex(page); return _buildExtraBottomOverlay(pageEntry) ?? SizedBox(); }, ); @@ -538,13 +552,12 @@ class _EntryViewerStackState extends State with SingleTickerPr final multiPageController = context.read().getOrCreateController(entry); setState(() {}); - final multiPageInfo = await multiPageController.info; + final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first; if (entry.isMotionPhoto) { await multiPageInfo.extractMotionPhotoVideo(); } - final pages = multiPageInfo.pages; - final videoPageEntries = pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet(); + final videoPageEntries = multiPageInfo.videoPageEntries; if (videoPageEntries.isNotEmpty) { // init video controllers for all pages that could need it final videoConductor = context.read(); @@ -557,8 +570,9 @@ class _EntryViewerStackState extends State with SingleTickerPr final page = multiPageController.page; final pageInfo = multiPageInfo.getByIndex(page); if (pageInfo.isVideo) { - final pageEntry = entry.getPageEntry(pageInfo); + final pageEntry = multiPageInfo.getPageEntryByIndex(page); final pageVideoController = videoConductor.getController(pageEntry); + assert(pageVideoController != null); await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page); } } diff --git a/lib/widgets/viewer/multipage/controller.dart b/lib/widgets/viewer/multipage/controller.dart index d3a81154c..00224f8f5 100644 --- a/lib/widgets/viewer/multipage/controller.dart +++ b/lib/widgets/viewer/multipage/controller.dart @@ -6,25 +6,34 @@ import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class MultiPageController extends ChangeNotifier { +class MultiPageController { final AvesEntry entry; - Future info; final ValueNotifier pageNotifier = ValueNotifier(null); - MultiPageController(this.entry) { - info = metadataService.getMultiPageInfo(entry).then((value) { - pageNotifier.value = value.defaultPage.index; - return value; - }); - } + MultiPageInfo _info; + + final StreamController _infoStreamController = StreamController.broadcast(); + + Stream get infoStream => _infoStreamController.stream; + + MultiPageInfo get info => _info; int get page => pageNotifier.value; 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() { pageNotifier.dispose(); - super.dispose(); } + + @override + String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}'; } diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 14e102118..ae870bd3b 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -102,7 +102,7 @@ class _ViewerBottomOverlayState extends State { Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent( mainEntry: _lastEntry, - page: multiPageInfo?.getByIndex(page), + pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry, details: _lastDetails, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, availableWidth: availableWidth, @@ -111,10 +111,10 @@ class _ViewerBottomOverlayState extends State { if (multiPageController == null) return _buildContent(); - return FutureBuilder( - future: multiPageController.info, + return StreamBuilder( + stream: multiPageController.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; + final multiPageInfo = multiPageController.info; return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { @@ -138,8 +138,7 @@ const double _interRowPadding = 2.0; const double _subRowMinWidth = 300.0; class _BottomOverlayContent extends AnimatedWidget { - final AvesEntry mainEntry, entry; - final SinglePageInfo page; + final AvesEntry mainEntry, pageEntry; final OverlayMetadata details; final String position; final double availableWidth; @@ -150,13 +149,18 @@ class _BottomOverlayContent extends AnimatedWidget { _BottomOverlayContent({ Key key, this.mainEntry, - this.page, + this.pageEntry, this.details, this.position, this.availableWidth, this.multiPageController, - }) : entry = mainEntry.getPageEntry(page), - super(key: key, listenable: mainEntry.metadataChangeNotifier); + }) : super( + key: key, + listenable: Listenable.merge([ + mainEntry.metadataChangeNotifier, + pageEntry.metadataChangeNotifier, + ]), + ); @override Widget build(BuildContext context) { @@ -184,7 +188,6 @@ class _BottomOverlayContent extends AnimatedWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MultiPageOverlay( - mainEntry: mainEntry, controller: multiPageController, availableWidth: availableWidth, ), @@ -204,7 +207,7 @@ class _BottomOverlayContent extends AnimatedWidget { final infoMaxWidth = availableWidth - infoPadding.horizontal; final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; 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; return Padding( @@ -223,7 +226,7 @@ class _BottomOverlayContent extends AnimatedWidget { Container( width: subRowWidth, child: _DateRow( - entry: entry, + entry: pageEntry, multiPageController: multiPageController, )), _buildDuoShootingRow(subRowWidth, hasShootingDetails), @@ -235,7 +238,7 @@ class _BottomOverlayContent extends AnimatedWidget { padding: EdgeInsets.only(top: _interRowPadding), width: subRowWidth, child: _DateRow( - entry: entry, + entry: pageEntry, multiPageController: multiPageController, ), ), @@ -251,10 +254,10 @@ class _BottomOverlayContent extends AnimatedWidget { switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: _soloTransition, - child: entry.hasGps + child: pageEntry.hasGps ? Container( padding: EdgeInsets.only(top: _interRowPadding), - child: _LocationRow(entry: entry), + child: _LocationRow(entry: pageEntry), ) : SizedBox.shrink(), ); @@ -354,10 +357,10 @@ class _PositionTitleRow extends StatelessWidget { if (multiPageController == null) return toText(); - return FutureBuilder( - future: multiPageController.info, + return StreamBuilder( + stream: multiPageController.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; + final multiPageInfo = multiPageController.info; String pagePosition; if (multiPageInfo != null) { // page count may be 0 when we know an entry to have multiple pages diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index 85608adc1..0ac68df62 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; @@ -10,17 +9,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class MultiPageOverlay extends StatefulWidget { - final AvesEntry mainEntry; final MultiPageController controller; final double availableWidth; - MultiPageOverlay({ + const MultiPageOverlay({ Key key, - @required this.mainEntry, @required this.controller, @required this.availableWidth, - }) : assert(mainEntry.isMultiPage), - assert(controller != null), + }) : assert(controller != null), super(key: key); @override @@ -31,12 +27,11 @@ class _MultiPageOverlayState extends State { final _cancellableNotifier = ValueNotifier(true); ScrollController _scrollController; bool _syncScroll = true; + int _initControllerPage; static const double extent = 48; static const double separatorWidth = 2; - AvesEntry get mainEntry => widget.mainEntry; - MultiPageController get controller => widget.controller; double get availableWidth => widget.availableWidth; @@ -64,10 +59,26 @@ class _MultiPageOverlayState extends State { } void _registerWidget() { - final page = controller.page ?? 0; - final scrollOffset = pageToScrollOffset(page); + _initControllerPage = controller.page; + final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0); _scrollController = ScrollController(initialScrollOffset: scrollOffset); _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() { @@ -84,39 +95,29 @@ class _MultiPageOverlayState extends State { return ThumbnailTheme( extent: extent, showLocation: false, - child: FutureBuilder( - future: controller.info, + child: StreamBuilder( + stream: controller.infoStream, builder: (context, snapshot) { - final multiPageInfo = snapshot.data; - if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); - if (multiPageInfo.mainEntry != mainEntry) return SizedBox(); + final multiPageInfo = controller.info; + final pageCount = multiPageInfo?.pageCount ?? 0; return SizedBox( height: extent, child: ListView.separated( - key: ValueKey(mainEntry), + key: ValueKey(multiPageInfo), scrollDirection: Axis.horizontal, controller: _scrollController, // default padding in scroll direction matches `MediaQuery.viewPadding`, // but we already accommodate for it, so make sure horizontal padding is 0 padding: EdgeInsets.zero, 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 pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); + final pageEntry = multiPageInfo.getPageEntryByIndex(page); return Stack( children: [ GestureDetector( - onTap: () async { - _syncScroll = false; - controller.page = page; - await _scrollController.animateTo( - pageToScrollOffset(page), - duration: Durations.viewerOverlayPageScrollAnimation, - curve: Curves.easeOutCubic, - ); - _syncScroll = true; - }, + onTap: () => _goToPage(page), child: DecoratedThumbnail( entry: pageEntry, extent: extent, @@ -140,7 +141,7 @@ class _MultiPageOverlayState extends State { ); }, separatorBuilder: (context, index) => separator, - itemCount: multiPageInfo.pageCount + 2, + itemCount: pageCount + 2, ), ); }, @@ -148,6 +149,17 @@ class _MultiPageOverlayState extends State { ); } + Future _goToPage(int page) async { + _syncScroll = false; + controller.page = page; + await _scrollController.animateTo( + pageToScrollOffset(page), + duration: Durations.viewerOverlayPageScrollAnimation, + curve: Curves.easeOutCubic, + ); + _syncScroll = true; + } + void _onScrollChange() { if (_syncScroll) { controller.page = scrollOffsetToPage(_scrollController.offset); diff --git a/lib/widgets/viewer/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart index 27fafb0f2..0f1431e39 100644 --- a/lib/widgets/viewer/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -1,68 +1,46 @@ import 'dart:math'; 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:flutter/foundation.dart'; import 'package:flutter/material.dart'; class Minimap extends StatelessWidget { - final AvesEntry mainEntry; + final AvesEntry entry; final ValueNotifier viewStateNotifier; - final MultiPageController multiPageController; final Size size; static const defaultSize = Size(96, 96); const Minimap({ - @required this.mainEntry, + @required this.entry, @required this.viewStateNotifier, - @required this.multiPageController, this.size = defaultSize, }); @override Widget build(BuildContext context) { return IgnorePointer( - child: multiPageController != null - ? FutureBuilder( - future: multiPageController.info, - builder: (context, snapshot) { - final multiPageInfo = snapshot.data; - if (multiPageInfo == null) return SizedBox.shrink(); - return ValueListenableBuilder( - 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( - 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, + child: ValueListenableBuilder( + 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, ), - size: size, - ), - ); - }); + ); + }), + ); } } diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index e70aa5f67..e04854eb6 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,6 +1,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; @@ -17,7 +18,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; class ViewerTopOverlay extends StatelessWidget { - final AvesEntry entry; + final AvesEntry mainEntry; final Animation scale; final EdgeInsets viewInsets, viewPadding; final Function(EntryAction value) onActionSelected; @@ -28,7 +29,7 @@ class ViewerTopOverlay extends StatelessWidget { const ViewerTopOverlay({ Key key, - @required this.entry, + @required this.mainEntry, @required this.scale, @required this.canToggleFavourite, @required this.viewInsets, @@ -47,78 +48,103 @@ class ViewerTopOverlay extends StatelessWidget { selector: (c, mq) => mq.size.width - mq.padding.horizontal, builder: (c, mqWidth, child) { 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 - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buttonRow, - SizedBox(height: 8), - FadeTransition( - opacity: scale, - child: Minimap( - mainEntry: entry, - viewStateNotifier: viewStateNotifier, - multiPageController: entry.isMultiPage ? context.read().getController(entry) : null, - ), - ) - ], - ) - : buttonRow; + Widget child; + if (mainEntry.isMultiPage) { + final multiPageController = context.read().getController(mainEntry); + if (multiPageController != null) { + child = StreamBuilder( + stream: multiPageController.infoStream, + builder: (context, snapshot) { + final multiPageInfo = multiPageController.info; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page)); + }, + ); + }, + ); + } + } + + return child ??= _buildOverlay(availableCount, mainEntry); }, ), ), ); } - 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; + 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; } - 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 { - final List quickActions; - final List inAppActions; - final List externalAppActions; + final List quickActions, inAppActions, externalAppActions; final Animation scale; - final AvesEntry entry; + final AvesEntry mainEntry, pageEntry; final Function(EntryAction value) onActionSelected; const _TopOverlayRow({ @@ -127,7 +153,8 @@ class _TopOverlayRow extends StatelessWidget { @required this.inAppActions, @required this.externalAppActions, @required this.scale, - @required this.entry, + @required this.mainEntry, + @required this.pageEntry, @required this.onActionSelected, }) : super(key: key); @@ -149,7 +176,7 @@ class _TopOverlayRow extends StatelessWidget { key: Key('entry-menu-button'), itemBuilder: (context) => [ ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), + if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), PopupMenuDivider(), ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), if (kDebugMode) ...[ @@ -173,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget { switch (action) { case EntryAction.toggleFavourite: child = _FavouriteToggler( - entry: entry, + entry: mainEntry, onPressed: onPressed, ); break; @@ -217,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget { // in app actions case EntryAction.toggleFavourite: child = _FavouriteToggler( - entry: entry, + entry: mainEntry, isMenuItem: true, ); break; diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index 4ee21bfda..da21d2174 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -49,15 +49,16 @@ class EntryPrinter with FeedbackMixin { if (entry.isMultiPage && !entry.isMotionPhoto) { final multiPageInfo = await metadataService.getMultiPageInfo(entry); - if (multiPageInfo.pageCount > 1) { + final pageCount = multiPageInfo.pageCount; + if (pageCount > 1) { final streamController = StreamController.broadcast(); showOpReport( context: context, opStream: streamController.stream, - itemCount: multiPageInfo.pageCount, + itemCount: pageCount, ); - for (final page in multiPageInfo.pages) { - final pageEntry = entry.getPageEntry(page); + for (var page = 0; page < pageCount; page++) { + final pageEntry = multiPageInfo.getPageEntryByIndex(page); _addPdfPage(await _buildPageImage(pageEntry)); streamController.sink.add(pageEntry); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 2c33fe868..92f960cea 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/entry.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/enums.dart'; import 'package:aves/model/settings/settings.dart'; @@ -30,22 +29,19 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; class EntryPageView extends StatefulWidget { - final AvesEntry mainEntry; - final AvesEntry entry; - final SinglePageInfo page; + final AvesEntry mainEntry, pageEntry; final Size viewportSize; final VoidCallback onDisposed; static const decorationCheckSize = 20.0; - EntryPageView({ + const EntryPageView({ Key key, this.mainEntry, - this.page, + this.pageEntry, this.viewportSize, this.onDisposed, - }) : entry = mainEntry.getPageEntry(page) ?? mainEntry, - super(key: key); + }) : super(key: key); @override _EntryPageViewState createState() => _EntryPageViewState(); @@ -58,7 +54,7 @@ class _EntryPageViewState extends State { AvesEntry get mainEntry => widget.mainEntry; - AvesEntry get entry => widget.entry; + AvesEntry get entry => widget.pageEntry; Size get viewportSize => widget.viewportSize; @@ -76,7 +72,7 @@ class _EntryPageViewState extends State { void didUpdateWidget(covariant EntryPageView 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, // in effect locking the zoom & position when browsing entry pages of the same size _unregisterWidget();