From 9956d6521c547ea3941b2b2a4ef5642616932b3d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 19 Jan 2021 10:24:31 +0900 Subject: [PATCH] viewer: multitrack HEIF support --- .../aves/channel/calls/MetadataHandler.kt | 48 ++++++- .../aves/channel/calls/ThumbnailFetcher.kt | 16 ++- .../channel/streams/ImageByteStreamHandler.kt | 20 ++- .../decoder/MultiTrackThumbnailGlideModule.kt | 124 ++++++++++++++++++ .../deckers/thibault/aves/utils/MimeTypes.kt | 9 +- lib/image_providers/region_provider.dart | 12 +- lib/image_providers/thumbnail_provider.dart | 13 +- lib/image_providers/uri_image_provider.dart | 6 +- lib/model/entry_cache.dart | 6 +- lib/model/image_entry.dart | 62 +++++---- lib/model/image_metadata.dart | 10 +- lib/model/multipage.dart | 82 +++++++++--- lib/services/image_file_service.dart | 6 +- lib/widgets/collection/thumbnail/raster.dart | 10 +- lib/widgets/collection/thumbnail/vector.dart | 2 +- lib/widgets/viewer/debug_page.dart | 2 +- lib/widgets/viewer/entry_scroller.dart | 8 +- lib/widgets/viewer/info/basic_section.dart | 2 +- lib/widgets/viewer/overlay/bottom.dart | 87 ++++++------ lib/widgets/viewer/overlay/minimap.dart | 9 +- lib/widgets/viewer/overlay/multipage.dart | 56 +++++--- lib/widgets/viewer/overlay/top.dart | 2 +- lib/widgets/viewer/panorama_page.dart | 4 +- lib/widgets/viewer/printer.dart | 2 +- .../viewer/visual/entry_page_view.dart | 29 ++-- lib/widgets/viewer/visual/raster.dart | 22 +--- lib/widgets/viewer/visual/video.dart | 2 +- 27 files changed, 440 insertions(+), 211 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackThumbnailGlideModule.kt 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 f0a95b2d6..e1d693a83 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 @@ -1,6 +1,8 @@ package deckers.thibault.aves.channel.calls import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri import android.util.Log @@ -45,6 +47,7 @@ import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface @@ -430,7 +433,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) { + if (isHeifLike(mimeType)) { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) { if (it > 1) flags = flags or MASK_IS_MULTIPAGE } @@ -525,8 +528,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (mimeType == MimeTypes.TIFF) { fun toMap(options: TiffBitmapFactory.Options): Map { return hashMapOf( - "width" to options.outWidth, - "height" to options.outHeight, + KEY_MIME_TYPE to mimeType, + KEY_WIDTH to options.outWidth, + KEY_HEIGHT to options.outHeight, ) } getTiffPageInfo(uri, 0)?.let { first -> @@ -536,6 +540,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) } } } + } else if (isHeifLike(mimeType)) { + fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { + if (this.containsKey(key)) save(this.getInteger(key)) + } + + fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) { + if (this.containsKey(key)) save(this.getLong(key)) + } + + val extractor = MediaExtractor() + extractor.setDataSource(context, uri, null) + for (i in 0 until extractor.trackCount) { + try { + val format = extractor.getTrackFormat(i) + format.getString(MediaFormat.KEY_MIME)?.let { mime -> + val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime + val page = hashMapOf(KEY_MIME_TYPE to trackMime) + format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it } + format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it } + format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } + if (isVideo(trackMime)) { + format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 } + } + pages[i] = page + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e) + } + } + extractor.release() } result.success(pages) } @@ -619,7 +653,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) exif.thumbnailBitmap?.let { bitmap -> TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { - it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) } + it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) } } } } @@ -733,7 +767,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" - // catalog metadata + // catalog metadata & page info private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_FLAGS = "flags" @@ -742,6 +776,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val KEY_LONGITUDE = "longitude" private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" + private const val KEY_HEIGHT = "height" + private const val KEY_WIDTH = "width" + private const val KEY_TRACK_ID = "trackId" + private const val KEY_DURATION = "durationMillis" private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 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 3728fa209..57fa44102 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 @@ -13,11 +13,13 @@ import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ObjectKey +import deckers.thibault.aves.decoder.MultiTrackThumbnail import deckers.thibault.aves.decoder.TiffThumbnail 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.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide @@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor( private val isFlipped: Boolean, width: Int?, height: Int?, - page: Int?, + private val page: Int?, private val defaultSize: Int, private val result: MethodChannel.Result, ) { 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 + private val tiffFetch = mimeType == MimeTypes.TIFF + private val multiTrackFetch = isHeifLike(mimeType) && page != null + private val customFetch = tiffFetch || multiTrackFetch fun fetch() { var bitmap: Bitmap? = null @@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor( var exception: Exception? = null try { - if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) { + if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) { // Fetch low quality thumbnails when size is not specified. // As of Android R, the Media Store content resolver may return a thumbnail // that is automatically rotated according to EXIF orientation, but not flipped, @@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor( .load(VideoThumbnail(context, uri)) .submit(width, height) } else { - val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri + val model: Any = if (tiffFetch) { + TiffThumbnail(context, uri, page ?: 0) + } else if (multiTrackFetch) { + MultiTrackThumbnail(context, uri, page ?: 0) + } 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 04087ca04..1f3a155de 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 @@ -9,11 +9,13 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions +import deckers.thibault.aves.decoder.MultiTrackThumbnail 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.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide @@ -84,7 +86,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 + val page = arguments["page"] as Int? if (mimeType == null || uri == null) { error("streamImage-args", "failed because of missing arguments", null) @@ -98,7 +100,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen 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) + streamImageByGlide(uri, page, mimeType, rotationDegrees, isFlipped) } else { // to be decoded by Flutter streamImageAsIs(uri) @@ -114,11 +116,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } } - private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { + private fun streamImageByGlide(uri: Uri, page: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { + val model: Any = if (isHeifLike(mimeType) && page != null) { + MultiTrackThumbnail(activity, uri, page) + } else { + uri + } + val target = Glide.with(activity) .asBitmap() .apply(glideOptions) - .load(uri) + .load(model) .submit() try { var bitmap = target.get() @@ -157,7 +165,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } } - private fun streamTiffImage(uri: Uri, page: Int = 0) { + private fun streamTiffImage(uri: Uri, page: Int?) { val resolver = activity.contentResolver try { val fd = resolver.openFileDescriptor(uri, "r")?.detachFd() @@ -166,7 +174,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen return } val options = TiffBitmapFactory.Options().apply { - inDirectoryNumber = page + inDirectoryNumber = page ?: 0 } val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) if (bitmap != null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackThumbnailGlideModule.kt new file mode 100644 index 000000000..3d96d8b61 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackThumbnailGlideModule.kt @@ -0,0 +1,124 @@ +package deckers.thibault.aves.decoder + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import android.os.Build +import android.util.Log +import com.bumptech.glide.Glide +import com.bumptech.glide.Priority +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.data.DataFetcher.DataCallback +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import com.bumptech.glide.module.LibraryGlideModule +import com.bumptech.glide.signature.ObjectKey +import deckers.thibault.aves.utils.BitmapUtils.getBytes +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.StorageUtils +import java.io.InputStream + + +@GlideModule +class MultiTrackThumbnailGlideModule : LibraryGlideModule() { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + registry.append(MultiTrackThumbnail::class.java, InputStream::class.java, MultiTrackThumbnailLoader.Factory()) + } +} + +class MultiTrackThumbnail(val context: Context, val uri: Uri, val trackIndex: Int) + +internal class MultiTrackThumbnailLoader : ModelLoader { + override fun buildLoadData(model: MultiTrackThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { + return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackThumbnailFetcher(model, width, height)) + } + + override fun handles(MultiTrackThumbnail: MultiTrackThumbnail): Boolean = true + + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MultiTrackThumbnailLoader() + + override fun teardown() {} + } +} + +internal class MultiTrackThumbnailFetcher(val model: MultiTrackThumbnail, val width: Int, val height: Int) : DataFetcher { + override fun loadData(priority: Priority, callback: DataCallback) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + callback.onLoadFailed(Exception("unsupported Android version")) + return + } + + val context = model.context + val uri = model.uri + val trackIndex = model.trackIndex + + val imageIndex = trackIndexToImageIndex(context, uri, trackIndex) + if (imageIndex == null) { + callback.onLoadFailed(Exception("no image index")) + return + } + + val bitmap: Bitmap? + + val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return + try { + bitmap = retriever.getImageAtIndex(imageIndex) + } catch (e: Exception) { + callback.onLoadFailed(e) + return + } finally { + // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs + retriever.release() + } + + if (bitmap == null) { + callback.onLoadFailed(Exception("null bitmap")) + } else { + callback.onDataReady(bitmap.getBytes()?.inputStream()) + } + } + + private fun trackIndexToImageIndex(context: Context, uri: Uri, trackIndex: Int): Int? { + val extractor = MediaExtractor() + try { + extractor.setDataSource(context, uri, null) + val trackCount = extractor.trackCount + if (trackIndex < trackCount) { + var imageIndex = 0 + for (i in 0 until trackIndex) { + val mimeType = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) + if (MimeTypes.isImage(mimeType)) imageIndex++ + } + return imageIndex + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackIndex=$trackIndex", e) + } finally { + extractor.release() + } + return null + } + + // already cleaned up in loadData and ByteArrayInputStream will be GC'd + override fun cleanup() {} + + // cannot cancel + override fun cancel() {} + + override fun getDataClass(): Class = InputStream::class.java + + override fun getDataSource(): DataSource = DataSource.LOCAL + + companion object { + private val LOG_TAG = LogUtils.createTag(MultiTrackThumbnailFetcher::class.java) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 9f7766d36..cb41fd458 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -9,7 +9,7 @@ object MimeTypes { private const val BMP = "image/bmp" const val GIF = "image/gif" const val HEIC = "image/heic" - const val HEIF = "image/heif" + private const val HEIF = "image/heif" private const val ICO = "image/x-icon" private const val JPEG = "image/jpeg" private const val PNG = "image/png" @@ -41,10 +41,9 @@ object MimeTypes { fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) - fun isMultimedia(mimeType: String?) = when (mimeType) { - HEIC, HEIF -> true - else -> isVideo(mimeType) - } + fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF) + + fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType) fun isRaw(mimeType: String): Boolean { return when (mimeType) { diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index ce08b6967..9170c8ec5 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -23,7 +23,7 @@ class RegionProvider extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}'); + yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, regionRect=${key.regionRect}'); }, ); } @@ -31,6 +31,7 @@ class RegionProvider extends ImageProvider { Future _loadAsync(RegionProviderKey key, DecoderCallback decode) async { final uri = key.uri; final mimeType = key.mimeType; + final page = key.page; try { final bytes = await ImageFileService.getRegion( uri, @@ -40,7 +41,7 @@ class RegionProvider extends ImageProvider { key.sampleSize, key.regionRect, key.imageSize, - page: key.page, + page: page, taskKey: key, ); if (bytes == null) { @@ -49,7 +50,7 @@ class RegionProvider extends ImageProvider { return await decode(bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); - throw StateError('$mimeType region decoding failed'); + throw StateError('$mimeType region decoding failed (page $page)'); } } @@ -75,7 +76,7 @@ class RegionProviderKey { @required this.mimeType, @required this.rotationDegrees, @required this.isFlipped, - this.page = 0, + this.page, @required this.sampleSize, @required this.regionRect, @required this.imageSize, @@ -93,7 +94,6 @@ class RegionProviderKey { // but the entry attributes may change over time factory RegionProviderKey.fromEntry( ImageEntry entry, { - int page = 0, @required int sampleSize, @required Rectangle rect, }) { @@ -102,7 +102,7 @@ class RegionProviderKey { mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, - page: page, + page: entry.page, sampleSize: sampleSize, regionRect: rect, imageSize: Size(entry.width.toDouble(), entry.height.toDouble()), diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 62546bbae..be69e5eca 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -24,7 +24,7 @@ class ThumbnailProvider extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${key.uri}, extent=${key.extent}'); + yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, extent=${key.extent}'); }, ); } @@ -32,6 +32,7 @@ class ThumbnailProvider extends ImageProvider { Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { final uri = key.uri; final mimeType = key.mimeType; + final page = key.page; try { final bytes = await ImageFileService.getThumbnail( uri, @@ -41,7 +42,7 @@ class ThumbnailProvider extends ImageProvider { key.isFlipped, key.extent, key.extent, - page: key.page, + page: page, taskKey: key, ); if (bytes == null) { @@ -50,7 +51,7 @@ class ThumbnailProvider extends ImageProvider { return await decode(bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error'); - throw StateError('$mimeType decoding failed'); + throw StateError('$mimeType decoding failed (page $page)'); } } @@ -75,7 +76,7 @@ class ThumbnailProviderKey { @required this.dateModifiedSecs, @required this.rotationDegrees, @required this.isFlipped, - this.page = 0, + this.page, this.extent = 0, this.scale = 1, }) : assert(uri != null), @@ -88,7 +89,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, {int page = 0, double extent = 0}) { + factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) { return ThumbnailProviderKey( uri: entry.uri, mimeType: entry.mimeType, @@ -96,7 +97,7 @@ class ThumbnailProviderKey { dateModifiedSecs: entry.dateModifiedSecs ?? -1, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, - page: page, + page: entry.page, extent: extent, ); } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 5290913f9..22749c5a1 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -15,7 +15,7 @@ class UriImage extends ImageProvider { const UriImage({ @required this.uri, @required this.mimeType, - this.page = 0, + this.page, @required this.rotationDegrees, @required this.isFlipped, this.expectedContentLength, @@ -37,7 +37,7 @@ class UriImage extends ImageProvider { scale: key.scale, chunkEvents: chunkEvents.stream, informationCollector: () sync* { - yield ErrorDescription('uri=$uri, mimeType=$mimeType'); + yield ErrorDescription('uri=$uri, page=$page, mimeType=$mimeType'); }, ); } @@ -66,7 +66,7 @@ class UriImage extends ImageProvider { return await decode(bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); - throw StateError('$mimeType decoding failed'); + throw StateError('$mimeType decoding failed (page $page)'); } finally { unawaited(chunkEvents.close()); } diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index a940821e0..f135107e9 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -12,14 +12,12 @@ 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; + // TODO TLAD provide page parameter for multipage items, if someday image editing features are added for them // evict fullscreen image await UriImage( uri: uri, mimeType: mimeType, - page: page, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, ).evict(); @@ -31,7 +29,6 @@ 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) @@ -44,7 +41,6 @@ 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 1d1e6f697..a275e8108 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -24,7 +24,7 @@ import '../ref/mime_types.dart'; class ImageEntry { String uri; String _path, _directory, _filename, _extension; - int contentId; + int page, contentId; final String sourceMimeType; int width; int height; @@ -47,6 +47,7 @@ class ImageEntry { this.uri, String path, this.contentId, + this.page, this.sourceMimeType, @required this.width, @required this.height, @@ -93,6 +94,35 @@ class ImageEntry { return copied; } + ImageEntry getPageEntry({ + @required MultiPageInfo multiPageInfo, + @required int page, + }) { + final pageInfo = (multiPageInfo?.pages ?? {})[page]; + if (pageInfo == null) return this; + return AvesPageEntry( + pageInfo: pageInfo, + uri: uri, + path: path, + contentId: contentId, + page: page, + sourceMimeType: sourceMimeType, + width: width, + height: height, + sourceRotationDegrees: sourceRotationDegrees, + sizeBytes: sizeBytes, + sourceTitle: sourceTitle, + dateModifiedSecs: dateModifiedSecs, + sourceDateTakenMillis: sourceDateTakenMillis, + durationMillis: durationMillis, + ) + ..catalogMetadata = _catalogMetadata?.copyWith( + mimeType: pageInfo.mimeType, + isMultipage: false, + ) + ..addressDetails = _addressDetails?.copyWith(); + } + // from DB or platform source entry factory ImageEntry.fromMap(Map map) { return ImageEntry( @@ -243,18 +273,9 @@ class ImageEntry { static const ratioSeparator = '\u2236'; static const resolutionSeparator = ' \u00D7 '; - 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 ?? '?'; + String get resolutionText { + final ws = width ?? '?'; + final hs = height ?? '?'; return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; } @@ -274,17 +295,10 @@ class ImageEntry { return isPortrait ? height / width : width / height; } - 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()); + Size get displaySize { + final w = width.toDouble(); + final h = height.toDouble(); + return isPortrait ? Size(h, w) : Size(w, h); } int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index ccbbad3c0..e750fbbb0 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -68,17 +68,19 @@ class CatalogMetadata { } CatalogMetadata copyWith({ - @required int contentId, + int contentId, + String mimeType, + bool isMultipage, }) { return CatalogMetadata( contentId: contentId ?? this.contentId, - mimeType: mimeType, + mimeType: mimeType ?? this.mimeType, dateMillis: dateMillis, isAnimated: isAnimated, isFlipped: isFlipped, isGeotiff: isGeotiff, is360: is360, - isMultipage: isMultipage, + isMultipage: isMultipage ?? this.isMultipage, rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -169,7 +171,7 @@ class AddressDetails { }); AddressDetails copyWith({ - @required int contentId, + int contentId, }) { return AddressDetails( contentId: contentId ?? this.contentId, diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 7ca616792..45af27276 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -1,24 +1,6 @@ +import 'package:aves/model/image_entry.dart'; 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; @@ -40,3 +22,65 @@ class MultiPageInfo { @override String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}'; } + +class SinglePageInfo { + final String mimeType; + final int width, height; + final int trackId, durationMillis; + + SinglePageInfo({ + this.mimeType, + this.width, + this.height, + this.trackId, + this.durationMillis, + }); + + factory SinglePageInfo.fromMap(Map map) { + return SinglePageInfo( + mimeType: map['mimeType'] as String, + width: map['width'] as int, + height: map['height'] as int, + trackId: map['trackId'] as int, + durationMillis: map['durationMillis'] as int, + ); + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, width=$width, height=$height, trackId=$trackId, durationMillis=$durationMillis}'; +} + +class AvesPageEntry extends ImageEntry { + final SinglePageInfo pageInfo; + + AvesPageEntry({ + @required this.pageInfo, + String uri, + String path, + int contentId, + int page, + String sourceMimeType, + int width, + int height, + int sourceRotationDegrees, + int sizeBytes, + String sourceTitle, + int dateModifiedSecs, + int sourceDateTakenMillis, + int durationMillis, + }) : super( + uri: uri, + path: path, + contentId: contentId, + page: page, + sourceMimeType: pageInfo.mimeType ?? sourceMimeType, + width: pageInfo.width ?? width, + height: pageInfo.height ?? height, + sourceRotationDegrees: sourceRotationDegrees, + sizeBytes: sizeBytes, + sourceTitle: sourceTitle, + dateModifiedSecs: dateModifiedSecs, + sourceDateTakenMillis: sourceDateTakenMillis, + durationMillis: pageInfo.durationMillis ?? durationMillis, + ); +} diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 5ad41ffb2..d69cff77b 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -74,7 +74,7 @@ class ImageFileService { String mimeType, int rotationDegrees, bool isFlipped, { - int page = 0, + int page, int expectedContentLength, BytesReceivedCallback onBytesReceived, }) { @@ -87,7 +87,7 @@ class ImageFileService { 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, 'isFlipped': isFlipped ?? false, - 'page': page ?? 0, + 'page': page, }).listen( (data) { final chunk = data as Uint8List; @@ -125,7 +125,7 @@ class ImageFileService { int sampleSize, Rectangle regionRect, Size imageSize, { - int page = 0, + int page, Object taskKey, int priority, }) { diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 660a1159d..65c473554 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -19,7 +19,7 @@ class RasterImageThumbnail extends StatefulWidget { Key key, @required this.entry, @required this.extent, - this.page = 0, + this.page, this.isScrollingNotifier, this.heroTag, }) : super(key: key); @@ -33,8 +33,6 @@ class _RasterImageThumbnailState extends State { ImageEntry get entry => widget.entry; - int get page => widget.page; - double get extent => widget.extent; Object get heroTag => widget.heroTag; @@ -79,11 +77,11 @@ class _RasterImageThumbnailState extends State { if (!entry.canDecode) return; _fastThumbnailProvider = ThumbnailProvider( - ThumbnailProviderKey.fromEntry(entry, page: page), + ThumbnailProviderKey.fromEntry(entry), ); if (!entry.isVideo) { _sizedThumbnailProvider = ThumbnailProvider( - ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent), + ThumbnailProviderKey.fromEntry(entry, extent: requestExtent), ); } } @@ -153,7 +151,7 @@ class _RasterImageThumbnailState extends State { final imageProvider = UriImage( uri: entry.uri, mimeType: entry.mimeType, - page: page, + page: entry.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 54cb811b9..5561f587b 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -29,7 +29,7 @@ class VectorImageThumbnail extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final availableSize = constraints.biggest; - final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination; + final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination; final offset = fitSize / 2 - availableSize / 2; final child = DecoratedBox( decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), diff --git a/lib/widgets/viewer/debug_page.dart b/lib/widgets/viewer/debug_page.dart index 21b2aa728..9fbe1403e 100644 --- a/lib/widgets/viewer/debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -80,7 +80,7 @@ class ViewerDebugPage extends StatelessWidget { 'isFlipped': '${entry.isFlipped}', 'portrait': '${entry.isPortrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', - 'displaySize': '${entry.getDisplaySize()}', + 'displaySize': '${entry.displaySize}', }), Divider(), InfoRowGroup({ diff --git a/lib/widgets/viewer/entry_scroller.dart b/lib/widgets/viewer/entry_scroller.dart index 58b799a9d..7f8e124b1 100644 --- a/lib/widgets/viewer/entry_scroller.dart +++ b/lib/widgets/viewer/entry_scroller.dart @@ -79,10 +79,10 @@ class _MultiEntryScrollerState extends State with AutomaticK ); } - EntryPageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page = 0}) { + EntryPageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page}) { return EntryPageView( key: Key('imageview'), - entry: entry, + mainEntry: entry, multiPageInfo: multiPageInfo, page: page, heroTag: widget.collection.heroTag(entry), @@ -150,9 +150,9 @@ class _SingleEntryScrollerState extends State with Automati ); } - EntryPageView _buildViewer({MultiPageInfo multiPageInfo, int page = 0}) { + EntryPageView _buildViewer({MultiPageInfo multiPageInfo, int page}) { return EntryPageView( - entry: entry, + mainEntry: entry, multiPageInfo: multiPageInfo, page: page, onTap: (_) => widget.onTap?.call(), diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index bab656c2b..db19c30c4 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -30,7 +30,7 @@ class BasicSection extends StatelessWidget { bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0; - String get rasterResolutionText => '${entry.getResolutionText()}${showMegaPixels ? ' ($megaPixels MP)' : ''}'; + String get rasterResolutionText => '${entry.resolutionText}${showMegaPixels ? ' ($megaPixels MP)' : ''}'; @override Widget build(BuildContext context) { diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 9863c3793..d6ba71003 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -97,15 +97,32 @@ class _ViewerBottomOverlayState extends State { _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: multiPageController, - ); + if (_lastEntry == null) return SizedBox.shrink(); + + Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent( + mainEntry: _lastEntry, + multiPageInfo: multiPageInfo, + page: page, + details: _lastDetails, + position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, + availableWidth: availableWidth, + multiPageController: multiPageController, + ); + + if (multiPageController == null) return _buildContent(); + + return FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return _buildContent(multiPageInfo: multiPageInfo, page: page); + }, + ); + }, + ); }, ), ); @@ -121,7 +138,9 @@ const double _interRowPadding = 2.0; const double _subRowMinWidth = 300.0; class _BottomOverlayContent extends AnimatedWidget { - final ImageEntry entry; + final ImageEntry mainEntry, entry; + final MultiPageInfo multiPageInfo; + final int page; final OverlayMetadata details; final String position; final double availableWidth; @@ -131,12 +150,15 @@ class _BottomOverlayContent extends AnimatedWidget { _BottomOverlayContent({ Key key, - this.entry, + this.mainEntry, + this.multiPageInfo, + this.page, this.details, this.position, this.availableWidth, this.multiPageController, - }) : super(key: key, listenable: entry.metadataChangeNotifier); + }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page), + super(key: key, listenable: mainEntry.metadataChangeNotifier); @override Widget build(BuildContext context) { @@ -158,13 +180,13 @@ class _BottomOverlayContent extends AnimatedWidget { infoColumn = _buildInfoColumn(orientation); } - if (multiPageController != null) { + if (mainEntry.isMultipage && multiPageController != null) { infoColumn = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ MultiPageOverlay( - entry: entry, + mainEntry: mainEntry, controller: multiPageController, availableWidth: availableWidth, ), @@ -340,12 +362,7 @@ class _PositionTitleRow extends StatelessWidget { // page count may be 0 when we know an entry to have multiple pages // but fail to get information about these pages final missingInfo = pageCount == 0; - return ValueListenableBuilder( - valueListenable: multiPageController.pageNotifier, - builder: (context, page, child) { - return toText(pagePosition: missingInfo ? null : '${page + 1}/${pageCount ?? '?'}'); - }, - ); + return toText(pagePosition: missingInfo ? null : '${(entry.page ?? 0) + 1}/${pageCount ?? '?'}'); }, ); } @@ -364,40 +381,14 @@ class _DateRow extends StatelessWidget { Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown; + final resolutionText = entry.isSvg ? entry.aspectRatioText : entry.resolutionText; - 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: resolutionText), + Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)), ], ); } diff --git a/lib/widgets/viewer/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart index ce9c6b101..0e1a3a808 100644 --- a/lib/widgets/viewer/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class Minimap extends StatelessWidget { - final ImageEntry entry; + final ImageEntry mainEntry; final ValueNotifier viewStateNotifier; final MultiPageController multiPageController; final Size size; @@ -16,7 +16,7 @@ class Minimap extends StatelessWidget { static const defaultSize = Size(96, 96); const Minimap({ - @required this.entry, + @required this.mainEntry, @required this.viewStateNotifier, @required this.multiPageController, this.size = defaultSize, @@ -34,11 +34,12 @@ class Minimap extends StatelessWidget { return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - return _buildForEntrySize(entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page)); + final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page); + return _buildForEntrySize(pageEntry.displaySize); }, ); }) - : _buildForEntrySize(entry.getDisplaySize()), + : _buildForEntrySize(mainEntry.displaySize), ); } diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index a5569967d..a3ae822d6 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -3,22 +3,25 @@ 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/overlay.dart'; import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/viewer/multipage.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class MultiPageOverlay extends StatefulWidget { - final ImageEntry entry; + final ImageEntry mainEntry; final MultiPageController controller; final double availableWidth; - const MultiPageOverlay({ + MultiPageOverlay({ Key key, - @required this.entry, + @required this.mainEntry, @required this.controller, @required this.availableWidth, - }) : super(key: key); + }) : assert(mainEntry.isMultipage), + assert(controller != null), + super(key: key); @override _MultiPageOverlayState createState() => _MultiPageOverlayState(); @@ -31,7 +34,7 @@ class _MultiPageOverlayState extends State { static const double extent = 48; static const double separatorWidth = 2; - ImageEntry get entry => widget.entry; + ImageEntry get mainEntry => widget.mainEntry; MultiPageController get controller => widget.controller; @@ -97,7 +100,7 @@ class _MultiPageOverlayState extends State { width: availableWidth, height: extent, child: ListView.separated( - key: ValueKey(entry), + key: ValueKey(mainEntry), scrollDirection: Axis.horizontal, controller: _scrollController, // default padding in scroll direction matches `MediaQuery.viewPadding`, @@ -106,6 +109,8 @@ class _MultiPageOverlayState extends State { itemBuilder: (context, index) { if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; final page = index - 1; + final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page); + return GestureDetector( onTap: () async { _syncScroll = false; @@ -117,15 +122,7 @@ class _MultiPageOverlayState extends State { ); _syncScroll = true; }, - child: Container( - width: extent, - height: extent, - child: RasterImageThumbnail( - entry: entry, - extent: extent, - page: page, - ), - ), + child: _buildPageThumbnail(pageEntry), ); }, separatorBuilder: (context, index) => separator, @@ -165,6 +162,35 @@ class _MultiPageOverlayState extends State { ); } + Widget _buildPageThumbnail(ImageEntry entry) { + Widget child = RasterImageThumbnail( + entry: entry, + extent: extent, + page: entry.page, + ); + + child = Stack( + alignment: Alignment.center, + children: [ + child, + Positioned( + bottom: 0, + left: 0, + child: ThumbnailEntryOverlay( + entry: entry, + extent: extent, + ), + ), + ], + ); + + return Container( + width: extent, + height: extent, + child: child, + ); + } + void _onScrollChange() { if (_syncScroll) { controller.page = scrollOffsetToPage(_scrollController.offset); diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index a3effb3cd..22815ea56 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -86,7 +86,7 @@ class ViewerTopOverlay extends StatelessWidget { FadeTransition( opacity: scale, child: Minimap( - entry: entry, + mainEntry: entry, viewStateNotifier: viewStateNotifier, multiPageController: multiPageController, ), diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index 2eb5d03fb..3a1b267dc 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -16,12 +16,10 @@ class PanoramaPage extends StatefulWidget { static const routeName = '/viewer/panorama'; final ImageEntry entry; - final int page; final PanoramaInfo info; const PanoramaPage({ @required this.entry, - this.page = 0, @required this.info, }); @@ -77,7 +75,7 @@ class _PanoramaPageState extends State { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, - page: widget.page, + page: entry.page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index 00824a099..f8f27fe6f 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -57,7 +57,7 @@ class EntryPrinter { return pages; } - Future _buildPageImage({page = 0}) async { + Future _buildPageImage({int page}) async { final uri = entry.uri; final mimeType = entry.mimeType; final rotationDegrees = entry.rotationDegrees; diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index ae4638de8..dc916af35 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -36,16 +36,17 @@ class EntryPageView extends StatefulWidget { static const minScale = ScaleLevel(ref: ScaleReference.contained); static const maxScale = ScaleLevel(factor: 2.0); - const EntryPageView({ + EntryPageView({ Key key, - @required this.entry, + ImageEntry mainEntry, this.multiPageInfo, - this.page = 0, + this.page, this.heroTag, @required this.onTap, @required this.videoControllers, this.onDisposed, - }) : super(key: key); + }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page) ?? mainEntry, + super(key: key); @override _EntryPageViewState createState() => _EntryPageViewState(); @@ -58,14 +59,8 @@ class _EntryPageViewState 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(); @@ -86,7 +81,7 @@ class _EntryPageViewState extends State { Widget build(BuildContext context) { Widget child; if (entry.isVideo) { - if (entry.width > 0 && entry.height > 0) { + if (!entry.displaySize.isEmpty) { child = _buildVideoView(); } } else if (entry.isSvg) { @@ -109,15 +104,13 @@ class _EntryPageViewState extends State { Widget _buildRasterView() { return Magnifier( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${entry.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) => ErrorView(onTap: () => onTap?.call(null)), ), - childSize: pageDisplaySize, + childSize: entry.displaySize, controller: _magnifierController, maxScale: EntryPageView.maxScale, minScale: EntryPageView.minScale, @@ -139,7 +132,7 @@ class _EntryPageViewState extends State { colorFilter: colorFilter, ), ), - childSize: pageDisplaySize, + childSize: entry.displaySize, controller: _magnifierController, minScale: EntryPageView.minScale, initialScale: EntryPageView.initialScale, @@ -149,7 +142,7 @@ class _EntryPageViewState extends State { if (background == EntryBackground.checkered) { child = VectorViewCheckeredBackground( - displaySize: pageDisplaySize, + displaySize: entry.displaySize, viewStateNotifier: _viewStateNotifier, child: child, ); @@ -166,7 +159,7 @@ class _EntryPageViewState extends State { controller: videoController, ) : SizedBox(), - childSize: pageDisplaySize, + childSize: entry.displaySize, controller: _magnifierController, maxScale: EntryPageView.maxScale, minScale: EntryPageView.minScale, diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index cba13a263..9b0262e23 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -4,7 +4,6 @@ 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'; @@ -17,15 +16,11 @@ 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, }); @@ -46,17 +41,15 @@ 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; // as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved // so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution - bool get useTiles => entry.canTile && (entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page).longestSide > 4096 || entry.is360); + bool get useTiles => entry.canTile && (entry.displaySize.longestSide > 4096 || entry.is360); - ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry, page: page)); + ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); ImageProvider get fullImageProvider { if (useTiles) { @@ -76,7 +69,6 @@ class _TiledImageViewState extends State { )?.item2; return RegionProvider(RegionProviderKey.fromEntry( entry, - page: page, sampleSize: _maxSampleSize, rect: regionRect, )); @@ -84,7 +76,7 @@ class _TiledImageViewState extends State { return UriImage( uri: entry.uri, mimeType: entry.mimeType, - page: page, + page: entry.page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, @@ -98,7 +90,7 @@ class _TiledImageViewState extends State { @override void initState() { super.initState(); - _displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page); + _displaySize = entry.displaySize; _fullImageListener = ImageStreamListener(_onFullImageCompleted); if (!useTiles) _registerFullImage(); } @@ -109,7 +101,7 @@ class _TiledImageViewState extends State { final oldViewState = oldWidget.viewStateNotifier.value; final viewState = widget.viewStateNotifier.value; - if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize || oldWidget.page != page) { + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { _isTilingInitialized = false; _fullImageLoaded.value = false; _unregisterFullImage(); @@ -278,7 +270,6 @@ class _TiledImageViewState extends State { if (rects != null) { tiles.add(RegionTile( entry: entry, - page: page, tileRect: rects.item1, regionRect: rects.item2, sampleSize: sampleSize, @@ -347,7 +338,6 @@ 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 @@ -357,7 +347,6 @@ class RegionTile extends StatefulWidget { const RegionTile({ @required this.entry, - @required this.page, @required this.tileRect, @required this.regionRect, @required this.sampleSize, @@ -406,7 +395,6 @@ class _RegionTileState extends State { _provider = RegionProvider(RegionProviderKey.fromEntry( entry, - page: widget.page, sampleSize: widget.sampleSize, rect: widget.regionRect, )); diff --git a/lib/widgets/viewer/visual/video.dart b/lib/widgets/viewer/visual/video.dart index 51c257072..66b67b1e2 100644 --- a/lib/widgets/viewer/visual/video.dart +++ b/lib/widgets/viewer/visual/video.dart @@ -101,7 +101,7 @@ class _AvesVideoState extends State { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, - page: 0, + page: entry.page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes,