From a121d21ca20cf8bfb406463dced19672f8b70df3 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 11 Jan 2021 15:11:05 +0900 Subject: [PATCH] #11 viewer: multipage TIFF support --- .../aves/channel/calls/DebugHandler.kt | 6 +- .../aves/channel/calls/ImageFileHandler.kt | 5 +- .../aves/channel/calls/MetadataHandler.kt | 59 ++-- .../aves/channel/calls/ThumbnailFetcher.kt | 12 +- .../channel/streams/ImageByteStreamHandler.kt | 61 ++-- .../channel/streams/ImageOpStreamHandler.kt | 24 +- .../streams/MediaStoreStreamHandler.kt | 19 +- .../streams/StorageAccessStreamHandler.kt | 19 +- .../aves/decoder/TiffThumbnailGlideModule.kt | 5 +- lib/image_providers/region_provider.dart | 12 +- lib/image_providers/thumbnail_provider.dart | 12 +- lib/image_providers/uri_image_provider.dart | 25 +- lib/model/entry_cache.dart | 6 + lib/model/image_entry.dart | 31 +- lib/model/multipage.dart | 42 +++ lib/services/image_file_service.dart | 7 +- lib/services/metadata_service.dart | 14 + lib/theme/durations.dart | 9 +- lib/widgets/collection/app_bar.dart | 2 +- lib/widgets/collection/filter_bar.dart | 2 +- .../collection/grid/list_section_layout.dart | 1 - lib/widgets/collection/thumbnail/raster.dart | 11 +- lib/widgets/collection/thumbnail/vector.dart | 2 +- .../collection/thumbnail_collection.dart | 2 +- .../common/basic/multi_cross_fader.dart | 2 +- lib/widgets/common/fx/sweeper.dart | 2 +- lib/widgets/common/fx/transition_image.dart | 2 +- .../common/identity/aves_filter_chip.dart | 2 +- lib/widgets/common/magnifier/magnifier.dart | 18 +- .../fullscreen/entry_action_delegate.dart | 2 + lib/widgets/fullscreen/fullscreen_body.dart | 36 ++- .../fullscreen/fullscreen_debug_page.dart | 2 +- lib/widgets/fullscreen/image_page.dart | 95 ++++++- lib/widgets/fullscreen/image_view.dart | 23 +- .../fullscreen/info/basic_section.dart | 10 +- lib/widgets/fullscreen/info/info_page.dart | 2 +- .../fullscreen/info/location_section.dart | 2 +- .../fullscreen/info/maps/google_map.dart | 2 +- .../fullscreen/info/maps/leaflet_map.dart | 2 +- lib/widgets/fullscreen/info/maps/marker.dart | 2 +- .../info/metadata/metadata_section.dart | 2 +- .../fullscreen/multipage_controller.dart | 24 ++ lib/widgets/fullscreen/overlay/bottom.dart | 265 ++++++++++++------ lib/widgets/fullscreen/overlay/minimap.dart | 53 ++-- lib/widgets/fullscreen/overlay/multipage.dart | 177 ++++++++++++ lib/widgets/fullscreen/overlay/top.dart | 8 +- lib/widgets/fullscreen/overlay/video.dart | 2 +- lib/widgets/fullscreen/panorama_page.dart | 8 +- lib/widgets/fullscreen/tiled_view.dart | 44 ++- lib/widgets/fullscreen/video_view.dart | 3 +- lib/widgets/search/search_page.dart | 2 +- 51 files changed, 919 insertions(+), 261 deletions(-) create mode 100644 lib/model/multipage.dart create mode 100644 lib/widgets/fullscreen/multipage_controller.dart create mode 100644 lib/widgets/fullscreen/overlay/multipage.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 1119e62e3..22e4c84ca 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -251,7 +251,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { TiffBitmapFactory.decodeFileDescriptor(fd, options) metadataMap["0"] = tiffOptionsToMap(options) val dirCount = options.outDirectoryCount - for (i in 1 until dirCount) { + for (page in 1 until dirCount) { fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() if (fd == null) { result.error("getTiffStructure-fd", "failed to get file descriptor", null) @@ -259,10 +259,10 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } options = TiffBitmapFactory.Options().apply { inJustDecodeBounds = true - inDirectoryNumber = i + inDirectoryNumber = page } TiffBitmapFactory.decodeFileDescriptor(fd, options) - metadataMap["$i"] = tiffOptionsToMap(options) + metadataMap["$page"] = tiffOptionsToMap(options) } result.success(metadataMap) } catch (e: Exception) { 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 5d8555a5c..de0ee5245 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 @@ -58,6 +58,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val isFlipped = call.argument("isFlipped") val widthDip = call.argument("widthDip") val heightDip = call.argument("heightDip") + val page = call.argument("page") val defaultSizeDip = call.argument("defaultSizeDip") if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) { @@ -75,6 +76,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { isFlipped, width = (widthDip * density).roundToInt(), height = (heightDip * density).roundToInt(), + page = page, defaultSize = (defaultSizeDip * density).roundToInt(), result, ).fetch() @@ -83,6 +85,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { private fun getRegion(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } val mimeType = call.argument("mimeType") + val page = call.argument("page") val sampleSize = call.argument("sampleSize") val x = call.argument("regionX") val y = call.argument("regionY") @@ -102,7 +105,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { uri, sampleSize, regionRect, - page = 0, + page = page ?: 0, result, ) else -> regionFetcher.fetch( 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 689391991..f2e0960a8 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 @@ -11,10 +11,7 @@ import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader import com.drew.lang.Rational -import com.drew.metadata.exif.ExifDirectoryBase -import com.drew.metadata.exif.ExifIFD0Directory -import com.drew.metadata.exif.ExifSubIFDDirectory -import com.drew.metadata.exif.GpsDirectory +import com.drew.metadata.exif.* import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory @@ -72,6 +69,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) } "getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) } + "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) } "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) } @@ -109,7 +107,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[dirName] = dirMap // tags - if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) { + if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { dirMap.putAll(dir.tags.map { val name = if (it.hasTagName()) { it.tagName @@ -397,7 +395,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (mimeType == MimeTypes.TIFF && getTiffDirCount(uri) > 1) flags = flags or MASK_IS_MULTIPAGE + if (mimeType == MimeTypes.TIFF && isMultiPageTiff(uri)) flags = flags or MASK_IS_MULTIPAGE metadataMap[KEY_FLAGS] = flags } @@ -514,6 +512,33 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getMultiPageInfo-args", "failed because of missing arguments", null) + return + } + + val pages = HashMap() + if (mimeType == MimeTypes.TIFF) { + fun toMap(options: TiffBitmapFactory.Options): Map { + return hashMapOf( + "width" to options.outWidth, + "height" to options.outHeight, + ) + } + getTiffPageInfo(uri, 0)?.let { first -> + pages[0] = toMap(first) + val pageCount = first.outDirectoryCount + for (i in 1 until pageCount) { + getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) } + } + } + } + result.success(pages) + } + private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { @@ -642,23 +667,25 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) } - private fun getTiffDirCount(uri: Uri): Int { - var dirCount = 1 + private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1 + + private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? { try { val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() if (fd == null) { Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri") - } else { - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - } - TiffBitmapFactory.decodeFileDescriptor(fd, options) - dirCount = options.outDirectoryCount + return null } + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + inDirectoryNumber = page + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + return options } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get TIFF dir count for uri=$uri", e) + Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e) } - return dirCount + return null } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt index 0f1b4c31e..3728fa209 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt @@ -32,12 +32,14 @@ class ThumbnailFetcher internal constructor( private val isFlipped: Boolean, width: Int?, height: Int?, + page: Int?, private val defaultSize: Int, private val result: MethodChannel.Result, ) { - val uri: Uri = Uri.parse(uri) - val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize - val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize + private val uri: Uri = Uri.parse(uri) + private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize + private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize + private val page = page ?: 0 fun fetch() { var bitmap: Bitmap? = null @@ -108,7 +110,7 @@ class ThumbnailFetcher internal constructor( // add signature to ignore cache for images which got modified but kept the same URI var options = RequestOptions() .format(DecodeFormat.PREFER_RGB_565) - .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width")) + .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page")) .override(width, height) val target = if (isVideo(mimeType)) { @@ -119,7 +121,7 @@ class ThumbnailFetcher internal constructor( .load(VideoThumbnail(context, uri)) .submit(width, height) } else { - val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri + val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri Glide.with(context) .asBitmap() .apply(options) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index d14ab3a4e..b08332540 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.net.Uri import android.os.Handler import android.os.Looper +import android.util.Log import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -11,6 +12,7 @@ import com.bumptech.glide.request.RequestOptions import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isVideo @@ -39,15 +41,33 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen override fun onCancel(o: Any) {} private fun success(bytes: ByteArray) { - handler.post { eventSink.success(bytes) } + handler.post { + try { + eventSink.success(bytes) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) { - handler.post { eventSink.error(errorCode, errorMessage, errorDetails) } + handler.post { + try { + eventSink.error(errorCode, errorMessage, errorDetails) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private fun endOfStream() { - handler.post { eventSink.endOfStream() } + handler.post { + try { + eventSink.endOfStream() + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } // Supported image formats: @@ -64,6 +84,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) } val rotationDegrees = arguments["rotationDegrees"] as Int val isFlipped = arguments["isFlipped"] as Boolean + val page = arguments["page"] as Int if (mimeType == null || uri == null) { error("streamImage-args", "failed because of missing arguments", null) @@ -74,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen if (isVideo(mimeType)) { streamVideoByGlide(uri) } else if (mimeType == MimeTypes.TIFF) { - streamTiffImage(uri) + streamTiffImage(uri, page) } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { // decode exotic format on platform side, then encode it in portable format for Flutter streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped) @@ -139,34 +160,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamTiffImage(uri: Uri, page: Int = 0) { val resolver = activity.contentResolver try { - var fd = resolver.openFileDescriptor(uri, "r")?.detachFd() + val fd = resolver.openFileDescriptor(uri, "r")?.detachFd() if (fd == null) { error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null) return } - var options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true + val options = TiffBitmapFactory.Options().apply { + inDirectoryNumber = page } - TiffBitmapFactory.decodeFileDescriptor(fd, options) - val dirCount = options.outDirectoryCount - - // TODO TLAD handle multipage TIFF - if (dirCount > page) { - fd = resolver.openFileDescriptor(uri, "r")?.detachFd() - if (fd == null) { - error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null) - return - } - options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = false - inDirectoryNumber = page - } - val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) - if (bitmap != null) { - success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) - } else { - error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) - } + val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) + if (bitmap != null) { + success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) + } else { + error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) } } catch (e: Exception) { error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e)) @@ -192,6 +198,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } companion object { + private val LOG_TAG = LogUtils.createTag(ImageByteStreamHandler::class.java) const val CHANNEL = "deckers.thibault/aves/imagebytestream" const val bufferSize = 2 shl 17 // 256kB diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index ead0d7ade..fe759f7c5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -51,15 +51,33 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: // {String uri, bool success, [Map newFields]} private fun success(result: Map) { - handler.post { eventSink.success(result) } + handler.post { + try { + eventSink.success(result) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) { - handler.post { eventSink.error(errorCode, errorMessage, errorDetails) } + handler.post { + try { + eventSink.error(errorCode, errorMessage, errorDetails) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private fun endOfStream() { - handler.post { eventSink.endOfStream() } + handler.post { + try { + eventSink.endOfStream() + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private suspend fun move() { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 311589a9a..f2892cfb2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -3,8 +3,10 @@ package deckers.thibault.aves.channel.streams import android.content.Context import android.os.Handler import android.os.Looper +import android.util.Log import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.MediaStoreImageProvider +import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers @@ -34,11 +36,23 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E override fun onCancel(arguments: Any?) {} private fun success(result: FieldMap) { - handler.post { eventSink.success(result) } + handler.post { + try { + eventSink.success(result) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private fun endOfStream() { - handler.post { eventSink.endOfStream() } + handler.post { + try { + eventSink.endOfStream() + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } private suspend fun fetchAll() { @@ -47,6 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E } companion object { + private val LOG_TAG = LogUtils.createTag(MediaStoreStreamHandler::class.java) const val CHANNEL = "deckers.thibault/aves/mediastorestream" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 98d66f4e1..77321a004 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -3,6 +3,8 @@ package deckers.thibault.aves.channel.streams import android.app.Activity import android.os.Handler import android.os.Looper +import android.util.Log +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -30,15 +32,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? override fun onCancel(arguments: Any?) {} private fun success(result: Boolean) { - handler.post { eventSink.success(result) } + handler.post { + try { + eventSink.success(result) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } endOfStream() } private fun endOfStream() { - handler.post { eventSink.endOfStream() } + handler.post { + try { + eventSink.endOfStream() + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } } companion object { + private val LOG_TAG = LogUtils.createTag(StorageAccessStreamHandler::class.java) const val CHANNEL = "deckers.thibault/aves/storageaccessstream" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt index 4ee15e9b4..050f42cca 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt @@ -26,7 +26,7 @@ class TiffThumbnailGlideModule : LibraryGlideModule() { } } -class TiffThumbnail(val context: Context, val uri: Uri) +class TiffThumbnail(val context: Context, val uri: Uri, val page: Int) internal class TiffThumbnailLoader : ModelLoader { override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { @@ -46,6 +46,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va override fun loadData(priority: Priority, callback: DataCallback) { val context = model.context val uri = model.uri + val page = model.page // determine sample size var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() @@ -56,6 +57,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va var sampleSize = 1 var options = TiffBitmapFactory.Options().apply { inJustDecodeBounds = true + inDirectoryNumber = page } TiffBitmapFactory.decodeFileDescriptor(fd, options) val imageWidth = options.outWidth @@ -74,6 +76,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va } options = TiffBitmapFactory.Options().apply { inJustDecodeBounds = false + inDirectoryNumber = page inSampleSize = sampleSize } val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index d1a21313d..ce08b6967 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -40,6 +40,7 @@ class RegionProvider extends ImageProvider { key.sampleSize, key.regionRect, key.imageSize, + page: key.page, taskKey: key, ); if (bytes == null) { @@ -63,7 +64,7 @@ class RegionProvider extends ImageProvider { class RegionProviderKey { final String uri, mimeType; - final int rotationDegrees, sampleSize; + final int rotationDegrees, sampleSize, page; final bool isFlipped; final Rectangle regionRect; final Size imageSize; @@ -74,6 +75,7 @@ class RegionProviderKey { @required this.mimeType, @required this.rotationDegrees, @required this.isFlipped, + this.page = 0, @required this.sampleSize, @required this.regionRect, @required this.imageSize, @@ -91,6 +93,7 @@ class RegionProviderKey { // but the entry attributes may change over time factory RegionProviderKey.fromEntry( ImageEntry entry, { + int page = 0, @required int sampleSize, @required Rectangle rect, }) { @@ -99,6 +102,7 @@ class RegionProviderKey { mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, + page: page, sampleSize: sampleSize, regionRect: rect, imageSize: Size(entry.width.toDouble(), entry.height.toDouble()), @@ -108,7 +112,7 @@ class RegionProviderKey { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale; + return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale; } @override @@ -117,7 +121,7 @@ class RegionProviderKey { mimeType, rotationDegrees, isFlipped, - mimeType, + page, sampleSize, regionRect, imageSize, @@ -125,5 +129,5 @@ class RegionProviderKey { ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}'; } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index e0cebca55..62546bbae 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -41,6 +41,7 @@ class ThumbnailProvider extends ImageProvider { key.isFlipped, key.extent, key.extent, + page: key.page, taskKey: key, ); if (bytes == null) { @@ -64,7 +65,7 @@ class ThumbnailProvider extends ImageProvider { class ThumbnailProviderKey { final String uri, mimeType; - final int dateModifiedSecs, rotationDegrees; + final int dateModifiedSecs, rotationDegrees, page; final bool isFlipped; final double extent, scale; @@ -74,6 +75,7 @@ class ThumbnailProviderKey { @required this.dateModifiedSecs, @required this.rotationDegrees, @required this.isFlipped, + this.page = 0, this.extent = 0, this.scale = 1, }) : assert(uri != null), @@ -86,7 +88,7 @@ class ThumbnailProviderKey { // do not store the entry as it is, because the key should be constant // but the entry attributes may change over time - factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) { + factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) { return ThumbnailProviderKey( uri: entry.uri, mimeType: entry.mimeType, @@ -94,6 +96,7 @@ class ThumbnailProviderKey { dateModifiedSecs: entry.dateModifiedSecs ?? -1, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, + page: page, extent: extent, ); } @@ -101,7 +104,7 @@ class ThumbnailProviderKey { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale; + return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale; } @override @@ -111,10 +114,11 @@ class ThumbnailProviderKey { dateModifiedSecs, rotationDegrees, isFlipped, + page, extent, scale, ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 1368f890c..5290913f9 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -7,9 +7,15 @@ import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; class UriImage extends ImageProvider { + final String uri, mimeType; + final int page, rotationDegrees, expectedContentLength; + final bool isFlipped; + final double scale; + const UriImage({ @required this.uri, @required this.mimeType, + this.page = 0, @required this.rotationDegrees, @required this.isFlipped, this.expectedContentLength, @@ -17,11 +23,6 @@ class UriImage extends ImageProvider { }) : assert(uri != null), assert(scale != null); - final String uri, mimeType; - final int rotationDegrees, expectedContentLength; - final bool isFlipped; - final double scale; - @override Future obtainKey(ImageConfiguration configuration) { return SynchronousFuture(this); @@ -50,6 +51,7 @@ class UriImage extends ImageProvider { mimeType, rotationDegrees, isFlipped, + page: page, expectedContentLength: expectedContentLength, onBytesReceived: (cumulative, total) { chunkEvents.add(ImageChunkEvent( @@ -73,12 +75,19 @@ class UriImage extends ImageProvider { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is UriImage && other.uri == uri && other.scale == scale; + return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale; } @override - int get hashCode => hashValues(uri, scale); + int get hashCode => hashValues( + uri, + mimeType, + rotationDegrees, + isFlipped, + page, + scale, + ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}'; } diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index b0ef230cc..a940821e0 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -12,10 +12,14 @@ class EntryCache { int oldRotationDegrees, bool oldIsFlipped, ) async { + // TODO TLAD revisit this for multipage items, if someday image editing features are added for them + const page = 0; + // evict fullscreen image await UriImage( uri: uri, mimeType: mimeType, + page: page, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, ).evict(); @@ -27,6 +31,7 @@ class EntryCache { dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, + page: page, )).evict(); // evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents) @@ -39,6 +44,7 @@ class EntryCache { dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, + page: page, extent: extent, )).evict()); } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 87a11cc9e..1d1e6f697 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; @@ -242,10 +243,19 @@ class ImageEntry { static const ratioSeparator = '\u2236'; static const resolutionSeparator = ' \u00D7 '; - String get resolutionText { - final w = width ?? '?'; - final h = height ?? '?'; - return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h'; + String getResolutionText({MultiPageInfo multiPageInfo, int page}) { + int w; + int h; + if (multiPageInfo != null && page != null) { + final pageInfo = multiPageInfo.pages[page]; + w = pageInfo?.width; + h = pageInfo?.height; + } + w ??= width; + h ??= height; + final ws = w ?? '?'; + final hs = h ?? '?'; + return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; } String get aspectRatioText { @@ -264,7 +274,18 @@ class ImageEntry { return isPortrait ? height / width : width / height; } - Size get displaySize => isPortrait ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble()); + Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) { + int w; + int h; + if (multiPageInfo != null && page != null) { + final pageInfo = multiPageInfo.pages[page]; + w = pageInfo?.width; + h = pageInfo?.height; + } + w ??= width; + h ??= height; + return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble()); + } int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart new file mode 100644 index 000000000..7ca616792 --- /dev/null +++ b/lib/model/multipage.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +class SinglePageInfo { + final int width, height; + + SinglePageInfo({ + this.width, + this.height, + }); + + factory SinglePageInfo.fromMap(Map map) { + return SinglePageInfo( + width: map['width'] as int, + height: map['height'] as int, + ); + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}'; +} + +class MultiPageInfo { + final Map pages; + + int get pageCount => pages.length; + + MultiPageInfo({ + this.pages, + }); + + factory MultiPageInfo.fromMap(Map map) { + final pages = {}; + map.keys.forEach((key) { + final index = key as int; + pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key])); + }); + return MultiPageInfo(pages: pages); + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}'; +} diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 71d9d9bb1..5ad41ffb2 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -74,6 +74,7 @@ class ImageFileService { String mimeType, int rotationDegrees, bool isFlipped, { + int page = 0, int expectedContentLength, BytesReceivedCallback onBytesReceived, }) { @@ -86,6 +87,7 @@ class ImageFileService { 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, 'isFlipped': isFlipped ?? false, + 'page': page ?? 0, }).listen( (data) { final chunk = data as Uint8List; @@ -123,6 +125,7 @@ class ImageFileService { int sampleSize, Rectangle regionRect, Size imageSize, { + int page = 0, Object taskKey, int priority, }) { @@ -132,6 +135,7 @@ class ImageFileService { final result = await platform.invokeMethod('getRegion', { 'uri': uri, 'mimeType': mimeType, + 'page': page, 'sampleSize': sampleSize, 'regionX': regionRect.left, 'regionY': regionRect.top, @@ -159,6 +163,7 @@ class ImageFileService { bool isFlipped, double width, double height, { + int page, Object taskKey, int priority, }) { @@ -176,6 +181,7 @@ class ImageFileService { 'isFlipped': isFlipped, 'widthDip': width, 'heightDip': height, + 'page': page, 'defaultSizeDip': thumbnailDefaultSize, }); return result as Uint8List; @@ -217,7 +223,6 @@ class ImageFileService { } static Stream move(Iterable entries, {@required bool copy, @required String destinationAlbum}) { - debugPrint('move ${entries.length} entries'); try { return opChannel.receiveBroadcastStream({ 'op': 'move', diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 268bf1c17..930713b29 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -80,6 +81,19 @@ class MetadataService { return null; } + static Future getMultiPageInfo(ImageEntry entry) async { + try { + final result = await platform.invokeMethod('getMultiPageInfo', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }) as Map; + return MultiPageInfo.fromMap(result); + } on PlatformException catch (e) { + debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } + static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 4dc281fdc..59a0f2a64 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -29,10 +29,11 @@ class Durations { // search animations static const filterRowExpandAnimation = Duration(milliseconds: 300); - // fullscreen animations - static const fullscreenPageAnimation = Duration(milliseconds: 300); - static const fullscreenOverlayAnimation = Duration(milliseconds: 200); - static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150); + // viewer animations + static const viewerPageAnimation = Duration(milliseconds: 300); + static const viewerOverlayAnimation = Duration(milliseconds: 200); + static const viewerOverlayChangeAnimation = Duration(milliseconds: 150); + static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200); // info static const mapStyleSwitchAnimation = Duration(milliseconds: 300); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 2beeb4360..091f38a24 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -68,7 +68,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } @override - void didUpdateWidget(CollectionAppBar oldWidget) { + void didUpdateWidget(covariant CollectionAppBar oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 712f21fa3..230e1cfea 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -29,7 +29,7 @@ class _FilterBarState extends State { CollectionFilter _userRemovedFilter; @override - void didUpdateWidget(FilterBar oldWidget) { + void didUpdateWidget(covariant FilterBar oldWidget) { super.didUpdateWidget(oldWidget); final current = widget.filters; final existing = oldWidget.filters; diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart index 368b3bbeb..08069dcb6 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -33,7 +33,6 @@ class SectionedListLayoutProvider extends StatelessWidget { } SectionedListLayout _updateLayouts(BuildContext context) { -// debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent'); final sectionLayouts = []; final showHeaders = collection.showHeaders; final source = collection.source; diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 68471ae2b..39be15767 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; class ThumbnailRasterImage extends StatefulWidget { final ImageEntry entry; final double extent; + final int page; final ValueNotifier isScrollingNotifier; final Object heroTag; @@ -18,6 +19,7 @@ class ThumbnailRasterImage extends StatefulWidget { Key key, @required this.entry, @required this.extent, + this.page = 0, this.isScrollingNotifier, this.heroTag, }) : super(key: key); @@ -31,6 +33,8 @@ class _ThumbnailRasterImageState extends State { ImageEntry get entry => widget.entry; + int get page => widget.page; + double get extent => widget.extent; Object get heroTag => widget.heroTag; @@ -47,7 +51,7 @@ class _ThumbnailRasterImageState extends State { } @override - void didUpdateWidget(ThumbnailRasterImage oldWidget) { + void didUpdateWidget(covariant ThumbnailRasterImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.entry != entry) { _unregisterWidget(oldWidget); @@ -75,11 +79,11 @@ class _ThumbnailRasterImageState extends State { if (!entry.canDecode) return; _fastThumbnailProvider = ThumbnailProvider( - ThumbnailProviderKey.fromEntry(entry), + ThumbnailProviderKey.fromEntry(entry, page: page), ); if (!entry.isVideo) { _sizedThumbnailProvider = ThumbnailProvider( - ThumbnailProviderKey.fromEntry(entry, extent: requestExtent), + ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent), ); } } @@ -149,6 +153,7 @@ class _ThumbnailRasterImageState extends State { final imageProvider = UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index 384fae2e8..5b05bff6c 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -29,7 +29,7 @@ class ThumbnailVectorImage extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final availableSize = constraints.biggest; - final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination; + final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination; final offset = fitSize / 2 - availableSize / 2; final child = DecoratedBox( decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index d58c2a8ca..28adb4b24 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -173,7 +173,7 @@ class _CollectionScrollViewState extends State { } @override - void didUpdateWidget(CollectionScrollView oldWidget) { + void didUpdateWidget(covariant CollectionScrollView oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart index 5c14dd458..22d75f822 100644 --- a/lib/widgets/common/basic/multi_cross_fader.dart +++ b/lib/widgets/common/basic/multi_cross_fader.dart @@ -30,7 +30,7 @@ class _MultiCrossFaderState extends State { } @override - void didUpdateWidget(MultiCrossFader oldWidget) { + void didUpdateWidget(covariant MultiCrossFader oldWidget) { super.didUpdateWidget(oldWidget); if (_first == oldWidget.child) { _second = widget.child; diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index 9a44bc053..0e9be29ab 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -57,7 +57,7 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { } @override - void didUpdateWidget(Sweeper oldWidget) { + void didUpdateWidget(covariant Sweeper oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index 1f1117206..2a62162b7 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -57,7 +57,7 @@ class _TransitionImageState extends State { } @override - void didUpdateWidget(TransitionImage oldWidget) { + void didUpdateWidget(covariant TransitionImage oldWidget) { super.didUpdateWidget(oldWidget); if (_isListeningToStream) { _imageStream.removeListener(_getListener()); diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 5351522a1..6d6b9a092 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -66,7 +66,7 @@ class _AvesFilterChipState extends State { } @override - void didUpdateWidget(AvesFilterChip oldWidget) { + void didUpdateWidget(covariant AvesFilterChip oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.filter != filter) { _initColorLoader(); diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index f5e18a5d3..a4ee16699 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -58,19 +58,14 @@ class Magnifier extends StatefulWidget { } class _MagnifierState extends State { - Size _childSize; - bool _controlledController; MagnifierController _controller; - void _setChildSize(Size childSize) { - _childSize = childSize.isEmpty ? null : childSize; - } + Size get childSize => widget.childSize; @override void initState() { super.initState(); - _setChildSize(widget.childSize); if (widget.controller == null) { _controlledController = true; _controller = MagnifierController(); @@ -81,12 +76,8 @@ class _MagnifierState extends State { } @override - void didUpdateWidget(Magnifier oldWidget) { - if (oldWidget.childSize != widget.childSize && widget.childSize != null) { - setState(() { - _setChildSize(widget.childSize); - }); - } + void didUpdateWidget(covariant Magnifier oldWidget) { + super.didUpdateWidget(oldWidget); if (widget.controller == null) { if (!_controlledController) { _controlledController = true; @@ -96,7 +87,6 @@ class _MagnifierState extends State { _controlledController = false; _controller = widget.controller; } - super.didUpdateWidget(oldWidget); } @override @@ -116,7 +106,7 @@ class _MagnifierState extends State { widget.maxScale ?? ScaleLevel(factor: double.infinity), widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained), constraints.biggest, - _childSize ?? constraints.biggest, + widget.childSize?.isEmpty == true ? constraints.biggest: widget.childSize, )); return MagnifierCore( diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index b6102bf1d..16e736901 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -109,6 +109,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { UriImage( uri: uri, mimeType: mimeType, + // TODO TLAD multipage print + page: 0, rotationDegrees: rotationDegrees, isFlipped: isFlipped, ), diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index e7b3791bd..d2e5a23ac 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -14,6 +14,7 @@ import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; import 'package:aves/widgets/fullscreen/overlay/panorama.dart'; import 'package:aves/widgets/fullscreen/overlay/top.dart'; @@ -54,6 +55,7 @@ class FullscreenBodyState extends State with SingleTickerProvide EdgeInsets _frozenViewInsets, _frozenViewPadding; EntryActionDelegate _actionDelegate; final List> _videoControllers = []; + final List> _multiPageControllers = []; final List>> _viewStateNotifiers = []; CollectionLens get collection => widget.collection; @@ -78,7 +80,7 @@ class FullscreenBodyState extends State with SingleTickerProvide _horizontalPager = PageController(initialPage: _currentHorizontalPage); _verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange); _overlayAnimationController = AnimationController( - duration: Durations.fullscreenOverlayAnimation, + duration: Durations.viewerOverlayAnimation, vsync: this, ); _topOverlayScale = CurvedAnimation( @@ -110,7 +112,7 @@ class FullscreenBodyState extends State with SingleTickerProvide } @override - void didUpdateWidget(FullscreenBody oldWidget) { + void didUpdateWidget(covariant FullscreenBody oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -122,6 +124,8 @@ class FullscreenBodyState extends State with SingleTickerProvide _overlayVisible.removeListener(_onOverlayVisibleChange); _videoControllers.forEach((kv) => kv.item2.dispose()); _videoControllers.clear(); + _multiPageControllers.forEach((kv) => kv.item2.dispose()); + _multiPageControllers.clear(); _verticalPager.removeListener(_onVerticalPageControllerChange); WidgetsBinding.instance.removeObserver(this); _unregisterWidget(widget); @@ -170,6 +174,7 @@ class FullscreenBodyState extends State with SingleTickerProvide collection: collection, entryNotifier: _entryNotifier, videoControllers: _videoControllers, + multiPageControllers: _multiPageControllers, verticalPager: _verticalPager, horizontalPager: _horizontalPager, onVerticalPageChanged: _onVerticalPageChanged, @@ -196,6 +201,9 @@ class FullscreenBodyState extends State with SingleTickerProvide valueListenable: _entryNotifier, builder: (context, entry, child) { if (entry == null) return SizedBox.shrink(); + + final multiPageController = _getMultiPageController(entry); + final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; return FullscreenTopOverlay( entry: entry, @@ -205,6 +213,7 @@ class FullscreenBodyState extends State with SingleTickerProvide viewPadding: _frozenViewPadding, onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), viewStateNotifier: viewStateNotifier, + multiPageController: multiPageController, ); }, ); @@ -226,6 +235,8 @@ class FullscreenBodyState extends State with SingleTickerProvide builder: (context, entry, child) { if (entry == null) return SizedBox.shrink(); + final multiPageController = _getMultiPageController(entry); + Widget extraBottomOverlay; if (entry.isVideo) { final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; @@ -259,6 +270,7 @@ class FullscreenBodyState extends State with SingleTickerProvide showPosition: hasCollection, viewInsets: _frozenViewInsets, viewPadding: _frozenViewPadding, + multiPageController: multiPageController, ), ), ], @@ -296,6 +308,10 @@ class FullscreenBodyState extends State with SingleTickerProvide return bottomOverlay; } + MultiPageController _getMultiPageController(ImageEntry entry) { + return entry.isMultipage ? _multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null; + } + void _onVerticalPageControllerChange() { _verticalScrollNotifier.notifyListeners(); } @@ -315,7 +331,7 @@ class FullscreenBodyState extends State with SingleTickerProvide Future _goToVerticalPage(int page) { return _verticalPager.animateToPage( page, - duration: Durations.fullscreenPageAnimation, + duration: Durations.viewerPageAnimation, curve: Curves.easeInOut, ); } @@ -428,6 +444,14 @@ class FullscreenBodyState extends State with SingleTickerProvide (_) => _.dispose(), ); } + if (entry.isMultipage) { + _initViewSpecificController( + uri, + _multiPageControllers, + () => MultiPageController(entry), + (_) => _.dispose(), + ); + } setState(() {}); } @@ -452,6 +476,7 @@ class FullscreenVerticalPageView extends StatefulWidget { final CollectionLens collection; final ValueNotifier entryNotifier; final List> videoControllers; + final List> multiPageControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImageTap, onImagePageRequested; @@ -461,6 +486,7 @@ class FullscreenVerticalPageView extends StatefulWidget { @required this.collection, @required this.entryNotifier, @required this.videoControllers, + @required this.multiPageControllers, @required this.verticalPager, @required this.horizontalPager, @required this.onVerticalPageChanged, @@ -492,7 +518,7 @@ class _FullscreenVerticalPageViewState extends State } @override - void didUpdateWidget(FullscreenVerticalPageView oldWidget) { + void didUpdateWidget(covariant FullscreenVerticalPageView oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -528,12 +554,14 @@ class _FullscreenVerticalPageViewState extends State onTap: widget.onImageTap, onPageChanged: widget.onHorizontalPageChanged, videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, onViewDisposed: widget.onViewDisposed, ) : SingleImagePage( entry: entry, onTap: widget.onImageTap, videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, ), NotificationListener( onNotification: (notification) { diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart index 0abf248a0..8371b67e3 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -80,7 +80,7 @@ class FullscreenDebugPage extends StatelessWidget { 'isFlipped': '${entry.isFlipped}', 'portrait': '${entry.isPortrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', - 'displaySize': '${entry.displaySize}', + 'displaySize': '${entry.getDisplaySize()}', }), Divider(), InfoRowGroup({ diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 7046661e5..80c195355 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,8 +1,10 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:tuple/tuple.dart'; @@ -13,6 +15,7 @@ class MultiImagePage extends StatefulWidget { final ValueChanged onPageChanged; final VoidCallback onTap; final List> videoControllers; + final List> multiPageControllers; final void Function(String uri) onViewDisposed; const MultiImagePage({ @@ -21,6 +24,7 @@ class MultiImagePage extends StatefulWidget { this.onPageChanged, this.onTap, this.videoControllers, + this.multiPageControllers, this.onViewDisposed, }); @@ -45,15 +49,29 @@ class MultiImagePageState extends State with AutomaticKeepAliveC onPageChanged: widget.onPageChanged, itemBuilder: (context, index) { final entry = entries[index]; + + Widget child; + if (entry.isMultipage) { + final multiPageController = _getMultiPageController(entry); + if (multiPageController != null) { + child = FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return _buildViewer(entry, multiPageInfo: multiPageInfo, page: page); + }, + ); + }, + ); + } + } + child ??= _buildViewer(entry); + return ClipRect( - child: ImageView( - key: Key('imageview'), - entry: entry, - heroTag: widget.collection.heroTag(entry), - onTap: (_) => widget.onTap?.call(), - videoControllers: widget.videoControllers, - onDisposed: () => widget.onViewDisposed?.call(entry.uri), - ), + child: child, ); }, itemCount: entries.length, @@ -61,6 +79,23 @@ class MultiImagePageState extends State with AutomaticKeepAliveC ); } + ImageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page = 0}) { + return ImageView( + key: Key('imageview'), + entry: entry, + multiPageInfo: multiPageInfo, + page: page, + heroTag: widget.collection.heroTag(entry), + onTap: (_) => widget.onTap?.call(), + videoControllers: widget.videoControllers, + onDisposed: () => widget.onViewDisposed?.call(entry.uri), + ); + } + + MultiPageController _getMultiPageController(ImageEntry entry) { + return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; + } + @override bool get wantKeepAlive => true; } @@ -69,11 +104,13 @@ class SingleImagePage extends StatefulWidget { final ImageEntry entry; final VoidCallback onTap; final List> videoControllers; + final List> multiPageControllers; const SingleImagePage({ this.entry, this.onTap, this.videoControllers, + this.multiPageControllers, }); @override @@ -81,20 +118,52 @@ class SingleImagePage extends StatefulWidget { } class SingleImagePageState extends State with AutomaticKeepAliveClientMixin { + ImageEntry get entry => widget.entry; + @override Widget build(BuildContext context) { super.build(context); + Widget child; + if (entry.isMultipage) { + final multiPageController = _getMultiPageController(entry); + if (multiPageController != null) { + child = FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return _buildViewer(multiPageInfo: multiPageInfo, page: page); + }, + ); + }, + ); + } + } + child ??= _buildViewer(); + return MagnifierGestureDetectorScope( axis: [Axis.vertical], - child: ImageView( - entry: widget.entry, - onTap: (_) => widget.onTap?.call(), - videoControllers: widget.videoControllers, - ), + child: child, ); } + ImageView _buildViewer({MultiPageInfo multiPageInfo, int page = 0}) { + return ImageView( + entry: entry, + multiPageInfo: multiPageInfo, + page: page, + onTap: (_) => widget.onTap?.call(), + videoControllers: widget.videoControllers, + ); + } + + MultiPageController _getMultiPageController(ImageEntry entry) { + return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index b3c5e1ada..6641a8f8a 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; @@ -23,6 +24,8 @@ import 'package:tuple/tuple.dart'; class ImageView extends StatefulWidget { final ImageEntry entry; + final MultiPageInfo multiPageInfo; + final int page; final Object heroTag; final MagnifierTapCallback onTap; final List> videoControllers; @@ -33,6 +36,8 @@ class ImageView extends StatefulWidget { const ImageView({ Key key, @required this.entry, + this.multiPageInfo, + this.page = 0, this.heroTag, @required this.onTap, @required this.videoControllers, @@ -54,8 +59,14 @@ class _ImageViewState extends State { ImageEntry get entry => widget.entry; + MultiPageInfo get multiPageInfo => widget.multiPageInfo; + + int get page => widget.page; + MagnifierTapCallback get onTap => widget.onTap; + Size get pageDisplaySize => entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page); + @override void initState() { super.initState(); @@ -99,13 +110,15 @@ class _ImageViewState extends State { Widget _buildRasterView() { return Magnifier( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), child: TiledImageView( entry: entry, + multiPageInfo: multiPageInfo, + page: page, viewStateNotifier: _viewStateNotifier, errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), ), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, maxScale: maxScale, minScale: minScale, @@ -127,7 +140,7 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, minScale: minScale, initialScale: initialScale, @@ -145,7 +158,7 @@ class _ImageViewState extends State { final side = viewportSize.shortestSide; final checkSize = side / ((side / ImageView.decorationCheckSize).round()); - final viewSize = entry.displaySize * viewState.scale; + final viewSize = pageDisplaySize * viewState.scale; final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; final offset = ((decorationSize - viewportSize) as Offset) / 2; @@ -181,7 +194,7 @@ class _ImageViewState extends State { controller: videoController, ) : SizedBox(), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, maxScale: maxScale, minScale: minScale, diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index c14b92088..8f4847a2a 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -26,12 +26,16 @@ class BasicSection extends StatelessWidget { @required this.onFilter, }) : super(key: key); + int get megaPixels => entry.megaPixels; + + bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0; + + String get rasterResolutionText => '${entry.getResolutionText()}${showMegaPixels ? ' ($megaPixels MP)' : ''}'; + @override Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.infoUnknown; - final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; - final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) @@ -46,7 +50,7 @@ class BasicSection extends StatelessWidget { 'Title': title, 'Date': dateText, if (entry.isVideo) ..._buildVideoRows(), - if (!entry.isSvg) 'Resolution': resolutionText, + if (!entry.isSvg) 'Resolution': rasterResolutionText, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, 'URI': uri, if (path != null) 'Path': path, diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 8117cdea2..f8d9fdf38 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -99,7 +99,7 @@ class InfoPageState extends State { BackUpNotification().dispatch(context); _scrollController.animateTo( 0, - duration: Durations.fullscreenPageAnimation, + duration: Durations.viewerPageAnimation, curve: Curves.easeInOut, ); } diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index e6d1475d7..5e97e5cb4 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -51,7 +51,7 @@ class _LocationSectionState extends State with TickerProviderSt } @override - void didUpdateWidget(LocationSection oldWidget) { + void didUpdateWidget(covariant LocationSection oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/fullscreen/info/maps/google_map.dart b/lib/widgets/fullscreen/info/maps/google_map.dart index 2d70b77f1..748d96db0 100644 --- a/lib/widgets/fullscreen/info/maps/google_map.dart +++ b/lib/widgets/fullscreen/info/maps/google_map.dart @@ -41,7 +41,7 @@ class EntryGoogleMapState extends State with AutomaticKeepAliveC } @override - void didUpdateWidget(EntryGoogleMap oldWidget) { + void didUpdateWidget(covariant EntryGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); if (widget.latLng != oldWidget.latLng && _controller != null) { _controller.moveCamera(CameraUpdate.newLatLng(widget.latLng)); diff --git a/lib/widgets/fullscreen/info/maps/leaflet_map.dart b/lib/widgets/fullscreen/info/maps/leaflet_map.dart index 84c8bc203..3f1d56cfd 100644 --- a/lib/widgets/fullscreen/info/maps/leaflet_map.dart +++ b/lib/widgets/fullscreen/info/maps/leaflet_map.dart @@ -35,7 +35,7 @@ class EntryLeafletMapState extends State with AutomaticKeepAliv final MapController _mapController = MapController(); @override - void didUpdateWidget(EntryLeafletMap oldWidget) { + void didUpdateWidget(covariant EntryLeafletMap oldWidget) { super.didUpdateWidget(oldWidget); if (widget.latLng != oldWidget.latLng && _mapController != null) { _mapController.move(widget.latLng, settings.infoMapZoom); diff --git a/lib/widgets/fullscreen/info/maps/marker.dart b/lib/widgets/fullscreen/info/maps/marker.dart index a640ae731..daae72f50 100644 --- a/lib/widgets/fullscreen/info/maps/marker.dart +++ b/lib/widgets/fullscreen/info/maps/marker.dart @@ -116,7 +116,7 @@ class MarkerPointerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + bool shouldRepaint(CustomPainter oldDelegate) => false; } // generate bitmap from widget, for Google Maps diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index 96a6475b5..9a2290b4d 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -52,7 +52,7 @@ class _MetadataSectionSliverState extends State with Auto } @override - void didUpdateWidget(MetadataSectionSliver oldWidget) { + void didUpdateWidget(covariant MetadataSectionSliver oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/fullscreen/multipage_controller.dart b/lib/widgets/fullscreen/multipage_controller.dart new file mode 100644 index 000000000..004ff7a49 --- /dev/null +++ b/lib/widgets/fullscreen/multipage_controller.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MultiPageController extends ChangeNotifier { + final Future info; + final ValueNotifier pageNotifier = ValueNotifier(0); + + MultiPageController(ImageEntry entry) : info = MetadataService.getMultiPageInfo(entry); + + int get page => pageNotifier.value; + + set page(int page) => pageNotifier.value = page; + + @override + void dispose() { + pageNotifier.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index b4d87a4cf..762f995e8 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/metadata_service.dart'; @@ -9,7 +10,9 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; +import 'package:aves/widgets/fullscreen/overlay/multipage.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -21,6 +24,7 @@ class FullscreenBottomOverlay extends StatefulWidget { final int index; final bool showPosition; final EdgeInsets viewInsets, viewPadding; + final MultiPageController multiPageController; const FullscreenBottomOverlay({ Key key, @@ -29,6 +33,7 @@ class FullscreenBottomOverlay extends StatefulWidget { @required this.showPosition, this.viewInsets, this.viewPadding, + @required this.multiPageController, }) : super(key: key); @override @@ -40,8 +45,6 @@ class _FullscreenBottomOverlayState extends State { ImageEntry _lastEntry; OverlayMetadata _lastDetails; - static const innerPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); - ImageEntry get entry { final entries = widget.entries; final index = widget.index; @@ -55,7 +58,7 @@ class _FullscreenBottomOverlayState extends State { } @override - void didUpdateWidget(FullscreenBottomOverlay oldWidget) { + void didUpdateWidget(covariant FullscreenBottomOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (entry != _lastEntry) { _initDetailLoader(); @@ -68,46 +71,41 @@ class _FullscreenBottomOverlayState extends State { @override Widget build(BuildContext context) { - return IgnorePointer( - child: BlurredRect( - child: Selector>( - selector: (c, mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding), - builder: (c, mq, child) { - final mqWidth = mq.item1; - final mqViewInsets = mq.item2; - final mqViewPadding = mq.item3; + return BlurredRect( + child: Selector>( + selector: (c, mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding), + builder: (c, mq, child) { + final mqWidth = mq.item1; + final mqViewInsets = mq.item2; + final mqViewPadding = mq.item3; - final viewInsets = widget.viewInsets ?? mqViewInsets; - final viewPadding = widget.viewPadding ?? mqViewPadding; - final overlayContentMaxWidth = mqWidth - viewPadding.horizontal - innerPadding.horizontal; + final viewInsets = widget.viewInsets ?? mqViewInsets; + final viewPadding = widget.viewPadding ?? mqViewPadding; + final availableWidth = mqWidth - viewPadding.horizontal; - return Container( - color: kOverlayBackgroundColor, - padding: viewInsets + viewPadding.copyWith(top: 0), - child: FutureBuilder( - future: _detailLoader, - builder: (futureContext, snapshot) { - if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - _lastDetails = snapshot.data; - _lastEntry = entry; - } - return _lastEntry == null - ? SizedBox.shrink() - : Padding( - // keep padding inside `FutureBuilder` so that overlay takes no space until data is ready - padding: innerPadding, - child: _FullscreenBottomOverlayContent( - entry: _lastEntry, - details: _lastDetails, - position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, - maxWidth: overlayContentMaxWidth, - ), - ); - }, - ), - ); - }, - ), + return Container( + color: kOverlayBackgroundColor, + padding: viewInsets + viewPadding.copyWith(top: 0), + child: FutureBuilder( + future: _detailLoader, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { + _lastDetails = snapshot.data; + _lastEntry = entry; + } + return _lastEntry == null + ? SizedBox.shrink() + : _BottomOverlayContent( + entry: _lastEntry, + details: _lastDetails, + position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, + availableWidth: availableWidth, + multiPageController: widget.multiPageController, + ); + }, + ), + ); + }, ), ); } @@ -118,22 +116,28 @@ const double _iconSize = 16.0; const double _interRowPadding = 2.0; const double _subRowMinWidth = 300.0; -class _FullscreenBottomOverlayContent extends AnimatedWidget { +class _BottomOverlayContent extends AnimatedWidget { final ImageEntry entry; final OverlayMetadata details; final String position; - final double maxWidth; + final double availableWidth; + final MultiPageController multiPageController; - _FullscreenBottomOverlayContent({ + static const infoPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); + + _BottomOverlayContent({ Key key, this.entry, this.details, this.position, - this.maxWidth, + this.availableWidth, + this.multiPageController, }) : super(key: key, listenable: entry.metadataChangeNotifier); @override Widget build(BuildContext context) { + final infoMaxWidth = availableWidth - infoPadding.horizontal; + return DefaultTextStyle( style: Theme.of(context).textTheme.bodyText2.copyWith( shadows: [Constants.embossShadow], @@ -142,43 +146,69 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget { overflow: TextOverflow.fade, maxLines: 1, child: SizedBox( - width: maxWidth, + width: availableWidth, child: Selector( selector: (c, mq) => mq.orientation, builder: (c, orientation, child) { - final twoColumns = orientation == Orientation.landscape && maxWidth / 2 > _subRowMinWidth; - final subRowWidth = twoColumns ? min(_subRowMinWidth, maxWidth / 2) : maxWidth; - final positionTitle = [ - if (position != null) position, - if (entry.bestTitle != null) entry.bestTitle, - ].join(' • '); + 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 hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle), - _buildSoloLocationRow(), - if (twoColumns) - Padding( - padding: EdgeInsets.only(top: _interRowPadding), - child: Row( - children: [ - Container(width: subRowWidth, child: _DateRow(entry)), - _buildDuoShootingRow(subRowWidth, hasShootingDetails), - ], + + Widget infoColumn = Padding( + padding: infoPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (positionTitle.isNotEmpty) positionTitle, + _buildSoloLocationRow(), + if (twoColumns) + Padding( + padding: EdgeInsets.only(top: _interRowPadding), + child: Row( + children: [ + Container( + width: subRowWidth, + child: _DateRow( + entry: entry, + multiPageController: multiPageController, + )), + _buildDuoShootingRow(subRowWidth, hasShootingDetails), + ], + ), + ) + else ...[ + Container( + padding: EdgeInsets.only(top: _interRowPadding), + width: subRowWidth, + child: _DateRow( + entry: entry, + multiPageController: multiPageController, + ), ), - ) - else ...[ - Container( - padding: EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _DateRow(entry), - ), - _buildSoloShootingRow(subRowWidth, hasShootingDetails), + _buildSoloShootingRow(subRowWidth, hasShootingDetails), + ], ], - ], + ), ); + + if (multiPageController != null) { + infoColumn = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MultiPageOverlay( + entry: entry, + controller: multiPageController, + availableWidth: availableWidth, + ), + infoColumn, + ], + ); + } + + return infoColumn; }, ), ), @@ -186,7 +216,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget { } Widget _buildSoloLocationRow() => AnimatedSwitcher( - duration: Durations.fullscreenOverlayChangeAnimation, + duration: Durations.viewerOverlayChangeAnimation, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: _soloTransition, @@ -199,7 +229,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget { ); Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( - duration: Durations.fullscreenOverlayChangeAnimation, + duration: Durations.viewerOverlayChangeAnimation, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: _soloTransition, @@ -213,7 +243,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget { ); Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( - duration: Durations.fullscreenOverlayChangeAnimation, + duration: Durations.viewerOverlayChangeAnimation, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: (child, animation) => FadeTransition( @@ -264,21 +294,96 @@ class _LocationRow extends AnimatedWidget { } } +class _PositionTitleRow extends StatelessWidget { + final ImageEntry entry; + final String collectionPosition; + final MultiPageController multiPageController; + + const _PositionTitleRow({ + @required this.entry, + @required this.collectionPosition, + @required this.multiPageController, + }); + + String get title => entry.bestTitle; + + bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null; + + @override + Widget build(BuildContext context) { + Text toText({String pagePosition}) => Text( + [ + if (collectionPosition != null) collectionPosition, + if (pagePosition != null) pagePosition, + if (title != null) title, + ].join(' • '), + strutStyle: Constants.overflowStrutStyle); + + if (multiPageController == null) return toText(); + + return FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + final pageCount = multiPageInfo?.pageCount ?? '?'; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return toText(pagePosition: '${page + 1}/$pageCount'); + }, + ); + }, + ); + } +} + class _DateRow extends StatelessWidget { final ImageEntry entry; + final MultiPageController multiPageController; - const _DateRow(this.entry); + const _DateRow({ + @required this.entry, + @required this.multiPageController, + }); @override Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown; + + Text toText({MultiPageInfo multiPageInfo, int page}) => Text( + entry.isSvg + ? entry.aspectRatioText + : entry.getResolutionText( + multiPageInfo: multiPageInfo, + page: page, + ), + strutStyle: Constants.overflowStrutStyle, + ); + + Widget resolutionText; + if (multiPageController != null) { + resolutionText = FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return toText(multiPageInfo: multiPageInfo, page: page); + }, + ); + }, + ); + } else { + resolutionText = toText(); + } return Row( children: [ DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), + Expanded(flex: 2, child: resolutionText), ], ); } diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart index 561cc3971..75f9edae2 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -1,13 +1,16 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class Minimap extends StatelessWidget { final ImageEntry entry; final ValueNotifier viewStateNotifier; + final MultiPageController multiPageController; final Size size; static const defaultSize = Size(96, 96); @@ -15,30 +18,48 @@ class Minimap extends StatelessWidget { const Minimap({ @required this.entry, @required this.viewStateNotifier, + @required this.multiPageController, this.size = defaultSize, }); @override Widget build(BuildContext context) { return IgnorePointer( - child: ValueListenableBuilder( - valueListenable: viewStateNotifier, - builder: (context, viewState, child) { - final viewportSize = viewState.viewportSize; - if (viewportSize == null) return SizedBox.shrink(); - return CustomPaint( - painter: MinimapPainter( - viewportSize: viewportSize, - entrySize: entry.displaySize, - viewCenterOffset: viewState.position, - viewScale: viewState.scale, - minimapBorderColor: Colors.white30, - ), - size: size, - ); - }), + 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) { + return _buildForEntrySize(entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page)); + }, + ); + }) + : _buildForEntrySize(entry.getDisplaySize()), ); } + + Widget _buildForEntrySize(Size entrySize) { + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return SizedBox.shrink(); + return CustomPaint( + painter: MinimapPainter( + viewportSize: viewportSize, + entrySize: entrySize, + viewCenterOffset: viewState.position, + viewScale: viewState.scale, + minimapBorderColor: Colors.white30, + ), + size: size, + ); + }); + } } class MinimapPainter extends CustomPainter { diff --git a/lib/widgets/fullscreen/overlay/multipage.dart b/lib/widgets/fullscreen/overlay/multipage.dart new file mode 100644 index 000000000..cf451e1d1 --- /dev/null +++ b/lib/widgets/fullscreen/overlay/multipage.dart @@ -0,0 +1,177 @@ +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/collection/thumbnail/raster.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MultiPageOverlay extends StatefulWidget { + final ImageEntry entry; + final MultiPageController controller; + final double availableWidth; + + const MultiPageOverlay({ + Key key, + @required this.entry, + @required this.controller, + @required this.availableWidth, + }) : super(key: key); + + @override + _MultiPageOverlayState createState() => _MultiPageOverlayState(); +} + +class _MultiPageOverlayState extends State { + ScrollController _scrollController; + bool _syncScroll = true; + + static const double extent = 48; + static const double separatorWidth = 2; + + ImageEntry get entry => widget.entry; + + MultiPageController get controller => widget.controller; + + double get availableWidth => widget.availableWidth; + + @override + void initState() { + super.initState(); + _registerWidget(); + } + + @override + void didUpdateWidget(covariant MultiPageOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != controller) { + _unregisterWidget(); + _registerWidget(); + } + } + + @override + void dispose() { + _unregisterWidget(); + super.dispose(); + } + + void _registerWidget() { + final scrollOffset = pageToScrollOffset(controller.page); + _scrollController = ScrollController(initialScrollOffset: scrollOffset); + _scrollController.addListener(_onScrollChange); + } + + void _unregisterWidget() { + _scrollController.removeListener(_onScrollChange); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + final marginWidth = max(0, (availableWidth - extent) / 2 - separatorWidth); + final horizontalMargin = SizedBox(width: marginWidth); + final separator = SizedBox(width: separatorWidth); + final shade = IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black38, + ), + ), + ); + + return FutureBuilder( + future: controller.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox.shrink(); + return Container( + height: extent + separatorWidth * 2, + child: Stack( + children: [ + Positioned( + top: separatorWidth, + width: availableWidth, + height: extent, + child: ListView.separated( + key: ValueKey(entry), + 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; + final page = index - 1; + return GestureDetector( + onTap: () async { + _syncScroll = false; + controller.page = page; + await _scrollController.animateTo( + pageToScrollOffset(page), + duration: Durations.viewerOverlayPageChooserAnimation, + curve: Curves.easeOutCubic, + ); + _syncScroll = true; + }, + child: Container( + width: extent, + height: extent, + child: ThumbnailRasterImage( + entry: entry, + extent: extent, + page: page, + ), + ), + ); + }, + separatorBuilder: (context, index) => separator, + itemCount: multiPageInfo.pageCount + 2, + ), + ), + Positioned( + left: 0, + top: separatorWidth, + width: marginWidth + separatorWidth, + height: extent, + child: shade, + ), + Positioned( + top: separatorWidth, + right: 0, + width: marginWidth + separatorWidth, + height: extent, + child: shade, + ), + Positioned( + top: 0, + width: availableWidth, + height: separatorWidth, + child: shade, + ), + Positioned( + bottom: 0, + width: availableWidth, + height: separatorWidth, + child: shade, + ), + ], + ), + ); + }, + ); + } + + void _onScrollChange() { + if (_syncScroll) { + controller.page = scrollOffsetToPage(_scrollController.offset); + } + } + + double pageToScrollOffset(int page) => page * (extent + separatorWidth); + + int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round(); +} diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 5626c06f5..cc494e5ad 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -9,6 +9,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:aves/widgets/fullscreen/multipage_controller.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:aves/widgets/fullscreen/overlay/minimap.dart'; import 'package:flutter/foundation.dart'; @@ -24,6 +25,7 @@ class FullscreenTopOverlay extends StatelessWidget { final Function(EntryAction value) onActionSelected; final bool canToggleFavourite; final ValueNotifier viewStateNotifier; + final MultiPageController multiPageController; static const double padding = 8; @@ -39,7 +41,8 @@ class FullscreenTopOverlay extends StatelessWidget { @required this.viewInsets, @required this.viewPadding, @required this.onActionSelected, - this.viewStateNotifier, + @required this.viewStateNotifier, + @required this.multiPageController, }) : super(key: key); @override @@ -85,6 +88,7 @@ class FullscreenTopOverlay extends StatelessWidget { child: Minimap( entry: entry, viewStateNotifier: viewStateNotifier, + multiPageController: multiPageController, ), ) ], @@ -320,7 +324,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { } @override - void didUpdateWidget(_FavouriteToggler oldWidget) { + void didUpdateWidget(covariant _FavouriteToggler oldWidget) { super.didUpdateWidget(oldWidget); _onChanged(); } diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 1e0b20dbf..cf049f65d 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -65,7 +65,7 @@ class VideoControlOverlayState extends State with SingleTic } @override - void didUpdateWidget(VideoControlOverlay oldWidget) { + void didUpdateWidget(covariant VideoControlOverlay oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/fullscreen/panorama_page.dart b/lib/widgets/fullscreen/panorama_page.dart index d7f18b491..e9a2ad2ab 100644 --- a/lib/widgets/fullscreen/panorama_page.dart +++ b/lib/widgets/fullscreen/panorama_page.dart @@ -8,7 +8,12 @@ class PanoramaPage extends StatelessWidget { final ImageEntry entry; - const PanoramaPage({@required this.entry}); + final int page; + + const PanoramaPage({ + @required this.entry, + this.page = 0, + }); @override Widget build(BuildContext context) { @@ -18,6 +23,7 @@ class PanoramaPage extends StatelessWidget { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index c64fcea6f..2e8f71429 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -4,6 +4,7 @@ import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; @@ -15,11 +16,15 @@ import 'package:tuple/tuple.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; + final MultiPageInfo multiPageInfo; + final int page; final ValueNotifier viewStateNotifier; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, + this.multiPageInfo, + this.page = 0, @required this.viewStateNotifier, @required this.errorBuilder, }); @@ -29,6 +34,7 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { + Size _displaySize; bool _isTilingInitialized = false; int _maxSampleSize; double _tileSide; @@ -39,19 +45,21 @@ class _TiledImageViewState extends State { ImageEntry get entry => widget.entry; + int get page => widget.page; + ValueNotifier get viewStateNotifier => widget.viewStateNotifier; bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent; bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096); - ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); + ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry, page: page)); ImageProvider get fullImageProvider { if (useTiles) { assert(_isTilingInitialized); - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); + final displayWidth = _displaySize.width.round(); + final displayHeight = _displaySize.height.round(); final viewState = viewStateNotifier.value; final regionRect = _getTileRects( x: 0, @@ -62,9 +70,10 @@ class _TiledImageViewState extends State { displayHeight: displayHeight, scale: viewState.scale, viewRect: _getViewRect(viewState, displayWidth, displayHeight), - ).item2; + )?.item2; return RegionProvider(RegionProviderKey.fromEntry( entry, + page: page, sampleSize: _maxSampleSize, rect: regionRect, )); @@ -72,6 +81,7 @@ class _TiledImageViewState extends State { return UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, @@ -85,17 +95,18 @@ class _TiledImageViewState extends State { @override void initState() { super.initState(); + _displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page); _fullImageListener = ImageStreamListener(_onFullImageCompleted); if (!useTiles) _registerFullImage(); } @override - void didUpdateWidget(TiledImageView oldWidget) { + void didUpdateWidget(covariant TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); final oldViewState = oldWidget.viewStateNotifier.value; final viewState = widget.viewStateNotifier.value; - if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize || oldWidget.page != page) { _isTilingInitialized = false; _fullImageLoaded.value = false; _unregisterFullImage(); @@ -135,7 +146,7 @@ class _TiledImageViewState extends State { if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize); return SizedBox.fromSize( - size: entry.displaySize * viewState.scale, + size: _displaySize * viewState.scale, child: Stack( alignment: Alignment.center, children: [ @@ -147,7 +158,7 @@ class _TiledImageViewState extends State { image: fullImageProvider, gaplessPlayback: true, errorBuilder: widget.errorBuilder, - width: (entry.displaySize * viewState.scale).width, + width: (_displaySize * viewState.scale).width, fit: BoxFit.contain, filterQuality: FilterQuality.medium, ), @@ -159,10 +170,9 @@ class _TiledImageViewState extends State { } void _initTiling(Size viewportSize) { - final displaySize = entry.displaySize; _tileSide = viewportSize.shortestSide * scaleFactor; // scale for initial state `contained` - final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height); + final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height); _maxSampleSize = _sampleSizeForScale(containedScale); final rotationDegrees = entry.rotationDegrees; @@ -173,7 +183,7 @@ class _TiledImageViewState extends State { ..translate(entry.width / 2.0, entry.height / 2.0) ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-displaySize.width / 2.0, -displaySize.height / 2.0); + ..translate(-_displaySize.width / 2.0, -_displaySize.height / 2.0); } _isTilingInitialized = true; _registerFullImage(); @@ -203,7 +213,7 @@ class _TiledImageViewState extends State { final viewportSize = viewState.viewportSize; assert(viewportSize != null); - final viewSize = entry.displaySize * viewState.scale; + final viewSize = _displaySize * viewState.scale; final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position; final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; @@ -236,8 +246,8 @@ class _TiledImageViewState extends State { List _getTiles(ViewState viewState) { if (!_isTilingInitialized) return []; - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); + final displayWidth = _displaySize.width.round(); + final displayHeight = _displaySize.height.round(); final viewRect = _getViewRect(viewState, displayWidth, displayHeight); final scale = viewState.scale; @@ -265,6 +275,7 @@ class _TiledImageViewState extends State { if (rects != null) { tiles.add(RegionTile( entry: entry, + page: page, tileRect: rects.item1, regionRect: rects.item2, sampleSize: sampleSize, @@ -333,6 +344,7 @@ class _TiledImageViewState extends State { class RegionTile extends StatefulWidget { final ImageEntry entry; + final int page; // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates @@ -342,6 +354,7 @@ class RegionTile extends StatefulWidget { const RegionTile({ @required this.entry, + @required this.page, @required this.tileRect, @required this.regionRect, @required this.sampleSize, @@ -363,7 +376,7 @@ class _RegionTileState extends State { } @override - void didUpdateWidget(RegionTile oldWidget) { + void didUpdateWidget(covariant RegionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) { _unregisterWidget(oldWidget); @@ -390,6 +403,7 @@ class _RegionTileState extends State { _provider = RegionProvider(RegionProviderKey.fromEntry( entry, + page: widget.page, sampleSize: widget.sampleSize, rect: widget.regionRect, )); diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 7007ebb22..f45bda841 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -34,7 +34,7 @@ class AvesVideoState extends State { } @override - void didUpdateWidget(AvesVideo oldWidget) { + void didUpdateWidget(covariant AvesVideo oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -101,6 +101,7 @@ class AvesVideoState extends State { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: 0, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index e0e464e51..ef3c3e933 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -52,7 +52,7 @@ class _SearchPageState extends State { } @override - void didUpdateWidget(SearchPage oldWidget) { + void didUpdateWidget(covariant SearchPage oldWidget) { super.didUpdateWidget(oldWidget); if (widget.delegate != oldWidget.delegate) { oldWidget.delegate.queryTextController.removeListener(_onQueryChanged);