From a6b99e7c2a7be787f0fc8bebf912f47c6172c6ed Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 22 Jan 2021 13:42:17 +0900 Subject: [PATCH] multipage: open with default track --- .../aves/channel/calls/ImageFileHandler.kt | 8 ++-- .../aves/channel/calls/MetadataHandler.kt | 19 ++++++--- .../aves/channel/calls/ThumbnailFetcher.kt | 16 ++++---- .../channel/streams/ImageByteStreamHandler.kt | 12 +++--- .../decoder/MultiTrackImageGlideModule.kt | 6 +-- .../thibault/aves/metadata/MultiTrackMedia.kt | 31 ++++++++------- lib/image_providers/region_provider.dart | 18 ++++----- lib/image_providers/thumbnail_provider.dart | 18 ++++----- lib/image_providers/uri_image_provider.dart | 16 ++++---- lib/model/entry.dart | 16 +++----- lib/model/entry_cache.dart | 10 ++--- lib/model/entry_images.dart | 6 +-- lib/model/multipage.dart | 39 +++++++++++-------- lib/services/app_shortcut_service.dart | 2 +- lib/services/image_file_service.dart | 12 +++--- lib/services/metadata_service.dart | 5 ++- lib/widgets/collection/thumbnail/raster.dart | 2 - lib/widgets/viewer/entry_scroller.dart | 10 ++--- lib/widgets/viewer/multipage.dart | 12 ++++-- lib/widgets/viewer/overlay/bottom.dart | 29 ++++++++------ lib/widgets/viewer/overlay/minimap.dart | 2 +- lib/widgets/viewer/overlay/multipage.dart | 5 ++- lib/widgets/viewer/printer.dart | 4 +- .../viewer/visual/entry_page_view.dart | 8 ++-- 24 files changed, 163 insertions(+), 143 deletions(-) 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 82edbd66c..43a8ff81d 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,7 +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 pageId = call.argument("pageId") val defaultSizeDip = call.argument("defaultSizeDip") if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) { @@ -76,7 +76,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { isFlipped, width = (widthDip * density).roundToInt(), height = (heightDip * density).roundToInt(), - page = page, + pageId = pageId, defaultSize = (defaultSizeDip * density).roundToInt(), result, ).fetch() @@ -85,7 +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 pageId = call.argument("pageId") val sampleSize = call.argument("sampleSize") val x = call.argument("regionX") val y = call.argument("regionY") @@ -105,7 +105,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { uri, sampleSize, regionRect, - page = page ?: 0, + page = pageId ?: 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 e1d693a83..2a5355ba8 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 @@ -524,20 +524,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return } - val pages = HashMap() + val pages = ArrayList>() if (mimeType == MimeTypes.TIFF) { - fun toMap(options: TiffBitmapFactory.Options): Map { + fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap { return hashMapOf( + KEY_PAGE to page, KEY_MIME_TYPE to mimeType, KEY_WIDTH to options.outWidth, KEY_HEIGHT to options.outHeight, ) } getTiffPageInfo(uri, 0)?.let { first -> - pages[0] = toMap(first) + pages.add(toMap(0, first)) val pageCount = first.outDirectoryCount for (i in 1 until pageCount) { - getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) } + getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) } } } } else if (isHeifLike(mimeType)) { @@ -556,14 +557,18 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { 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) + val page = hashMapOf( + KEY_PAGE to i, + KEY_MIME_TYPE to trackMime, + ) + format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 } 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 + pages.add(page) } } catch (e: Exception) { Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e) @@ -778,7 +783,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" private const val KEY_HEIGHT = "height" private const val KEY_WIDTH = "width" + private const val KEY_PAGE = "page" private const val KEY_TRACK_ID = "trackId" + private const val KEY_IS_DEFAULT = "isDefault" private const val KEY_DURATION = "durationMillis" private const val MASK_IS_ANIMATED = 1 shl 0 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 d3c4d4605..f5d027d32 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 @@ -34,7 +34,7 @@ class ThumbnailFetcher internal constructor( private val isFlipped: Boolean, width: Int?, height: Int?, - private val page: Int?, + private val pageId: Int?, private val defaultSize: Int, private val result: MethodChannel.Result, ) { @@ -42,7 +42,7 @@ class ThumbnailFetcher internal constructor( 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 tiffFetch = mimeType == MimeTypes.TIFF - private val multiTrackFetch = isHeifLike(mimeType) && page != null + private val multiTrackFetch = isHeifLike(mimeType) && pageId != null private val customFetch = tiffFetch || multiTrackFetch fun fetch() { @@ -114,7 +114,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-$page")) + .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId")) .override(width, height) val target = if (isVideo(mimeType)) { @@ -125,11 +125,11 @@ class ThumbnailFetcher internal constructor( .load(VideoThumbnail(context, uri)) .submit(width, height) } else { - val model: Any = if (tiffFetch) { - TiffThumbnail(context, uri, page ?: 0) - } else if (multiTrackFetch) { - MultiTrackImage(context, uri, page ?: 0) - } else uri + val model: Any = when { + tiffFetch -> TiffThumbnail(context, uri, pageId ?: 0) + multiTrackFetch -> MultiTrackImage(context, uri, pageId) + 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 8a73fa1a2..28022a627 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 @@ -86,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 pageId = arguments["pageId"] as Int? if (mimeType == null || uri == null) { error("streamImage-args", "failed because of missing arguments", null) @@ -97,10 +97,10 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen if (isVideo(mimeType)) { streamVideoByGlide(uri) } else if (mimeType == MimeTypes.TIFF) { - streamTiffImage(uri, page) + streamTiffImage(uri, pageId) } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { // decode exotic format on platform side, then encode it in portable format for Flutter - streamImageByGlide(uri, page, mimeType, rotationDegrees, isFlipped) + streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped) } else { // to be decoded by Flutter streamImageAsIs(uri) @@ -116,9 +116,9 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } } - private fun streamImageByGlide(uri: Uri, page: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { - val model: Any = if (isHeifLike(mimeType) && page != null) { - MultiTrackImage(activity, uri, page) + private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { + val model: Any = if (isHeifLike(mimeType) && pageId != null) { + MultiTrackImage(activity, uri, pageId) } else { uri } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt index 36b2d544a..6a53d5709 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt @@ -28,7 +28,7 @@ class MultiTrackImageGlideModule : LibraryGlideModule() { } } -class MultiTrackImage(val context: Context, val uri: Uri, val trackIndex: Int) +class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?) internal class MultiTrackThumbnailLoader : ModelLoader { override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { @@ -53,9 +53,9 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int val context = model.context val uri = model.uri - val trackIndex = model.trackIndex + val trackId = model.trackId - val bitmap = MultiTrackMedia.getImage(context, uri, trackIndex) + val bitmap = MultiTrackMedia.getImage(context, uri, trackId) if (bitmap == null) { callback.onLoadFailed(Exception("null bitmap")) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt index afc7f4976..8ac8dad05 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt @@ -16,14 +16,17 @@ object MultiTrackMedia { private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java) @RequiresApi(Build.VERSION_CODES.P) - fun getImage(context: Context, uri: Uri, trackIndex: Int): Bitmap? { - val imageIndex = trackIndexToImageIndex(context, uri, trackIndex) ?: return null - + fun getImage(context: Context, uri: Uri, trackId: Int?): Bitmap? { val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return null try { - return retriever.getImageAtIndex(imageIndex) + return if (trackId != null) { + val imageIndex = trackIdToImageIndex(context, uri, trackId) ?: return null + retriever.getImageAtIndex(imageIndex) + } else { + retriever.primaryImage + } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract image from uri=$uri trackIndex=$trackIndex imageIndex=$imageIndex", e) + Log.w(LOG_TAG, "failed to extract image from uri=$uri trackId=$trackId", e) } finally { // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs retriever.release() @@ -31,21 +34,23 @@ object MultiTrackMedia { return null } - private fun trackIndexToImageIndex(context: Context, uri: Uri, trackIndex: Int): Int? { + private fun trackIdToImageIndex(context: Context, uri: Uri, trackId: 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++ + var imageIndex = 0 + for (i in 0 until trackCount) { + val trackFormat = extractor.getTrackFormat(i) + if (trackId == trackFormat.getInteger(MediaFormat.KEY_TRACK_ID)) { + return imageIndex + } + if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) { + imageIndex++ } - return imageIndex } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackIndex=$trackIndex", e) + Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackId=$trackId", e) } finally { extractor.release() } diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index 5d41855c4..3563d9419 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -22,7 +22,7 @@ class RegionProvider extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, region=${key.region}'); + yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}'); }, ); } @@ -30,7 +30,7 @@ class RegionProvider extends ImageProvider { Future _loadAsync(RegionProviderKey key, DecoderCallback decode) async { final uri = key.uri; final mimeType = key.mimeType; - final page = key.page; + final pageId = key.pageId; try { final bytes = await ImageFileService.getRegion( uri, @@ -40,7 +40,7 @@ class RegionProvider extends ImageProvider { key.sampleSize, key.region, key.imageSize, - page: page, + pageId: pageId, taskKey: key, ); if (bytes == null) { @@ -49,7 +49,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 (page $page)'); + throw StateError('$mimeType region decoding failed (page $pageId)'); } } @@ -66,7 +66,7 @@ class RegionProviderKey { // do not store the entry as it is, because the key should be constant // but the entry attributes may change over time final String uri, mimeType; - final int page, rotationDegrees, sampleSize; + final int pageId, rotationDegrees, sampleSize; final bool isFlipped; final Rectangle region; final Size imageSize; @@ -75,7 +75,7 @@ class RegionProviderKey { const RegionProviderKey({ @required this.uri, @required this.mimeType, - @required this.page, + @required this.pageId, @required this.rotationDegrees, @required this.isFlipped, @required this.sampleSize, @@ -94,14 +94,14 @@ class RegionProviderKey { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.page == page && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale; + return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale; } @override int get hashCode => hashValues( uri, mimeType, - page, + pageId, rotationDegrees, isFlipped, sampleSize, @@ -111,5 +111,5 @@ class RegionProviderKey { ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, page=$page, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}'; } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 02b4ac2f2..fac117ab4 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -23,7 +23,7 @@ class ThumbnailProvider extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, extent=${key.extent}'); + yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}'); }, ); } @@ -31,12 +31,12 @@ class ThumbnailProvider extends ImageProvider { Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { final uri = key.uri; final mimeType = key.mimeType; - final page = key.page; + final pageId = key.pageId; try { final bytes = await ImageFileService.getThumbnail( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, rotationDegrees: key.rotationDegrees, isFlipped: key.isFlipped, dateModifiedSecs: key.dateModifiedSecs, @@ -49,7 +49,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 (page $page)'); + throw StateError('$mimeType decoding failed (page $pageId)'); } } @@ -66,7 +66,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 final String uri, mimeType; - final int page, rotationDegrees; + final int pageId, rotationDegrees; final bool isFlipped; final int dateModifiedSecs; final double extent, scale; @@ -74,7 +74,7 @@ class ThumbnailProviderKey { const ThumbnailProviderKey({ @required this.uri, @required this.mimeType, - @required this.page, + @required this.pageId, @required this.rotationDegrees, @required this.isFlipped, @required this.dateModifiedSecs, @@ -91,14 +91,14 @@ class ThumbnailProviderKey { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.page == page && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale; + return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale; } @override int get hashCode => hashValues( uri, mimeType, - page, + pageId, rotationDegrees, isFlipped, dateModifiedSecs, @@ -107,5 +107,5 @@ class ThumbnailProviderKey { ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, page=$page, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 4e2d3f46c..6c3f9615e 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -8,14 +8,14 @@ import 'package:pedantic/pedantic.dart'; class UriImage extends ImageProvider { final String uri, mimeType; - final int page, rotationDegrees, expectedContentLength; + final int pageId, rotationDegrees, expectedContentLength; final bool isFlipped; final double scale; const UriImage({ @required this.uri, @required this.mimeType, - @required this.page, + @required this.pageId, @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, page=$page, mimeType=$mimeType'); + yield ErrorDescription('uri=$uri, pageId=$pageId, mimeType=$mimeType'); }, ); } @@ -51,7 +51,7 @@ class UriImage extends ImageProvider { mimeType, rotationDegrees, isFlipped, - page: page, + pageId: pageId, expectedContentLength: expectedContentLength, onBytesReceived: (cumulative, total) { chunkEvents.add(ImageChunkEvent( @@ -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 (page $page)'); + throw StateError('$mimeType decoding failed (page $pageId)'); } finally { unawaited(chunkEvents.close()); } @@ -75,7 +75,7 @@ class UriImage extends ImageProvider { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale; + return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.pageId == pageId && other.scale == scale; } @override @@ -84,10 +84,10 @@ class UriImage extends ImageProvider { mimeType, rotationDegrees, isFlipped, - page, + pageId, scale, ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}'; } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 6d3588236..b15b634ba 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -24,7 +24,7 @@ import '../ref/mime_types.dart'; class AvesEntry { String uri; String _path, _directory, _filename, _extension; - int page, contentId; + int pageId, contentId; final String sourceMimeType; int width; int height; @@ -49,7 +49,7 @@ class AvesEntry { this.uri, String path, this.contentId, - this.page, + this.pageId, this.sourceMimeType, @required this.width, @required this.height, @@ -96,18 +96,14 @@ class AvesEntry { return copied; } - AvesEntry getPageEntry({ - @required MultiPageInfo multiPageInfo, - @required int page, - }) { - final pageInfo = (multiPageInfo?.pages ?? {})[page]; + AvesEntry getPageEntry(SinglePageInfo pageInfo) { if (pageInfo == null) return this; return AvesPageEntry( pageInfo: pageInfo, uri: uri, path: path, contentId: contentId, - page: page, + pageId: pageInfo.pageId, sourceMimeType: sourceMimeType, width: width, height: height, @@ -168,7 +164,7 @@ class AvesEntry { } @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; set path(String path) { _path = path; @@ -227,7 +223,7 @@ class AvesEntry { MimeTypes.srw, ].contains(mimeType) && !isAnimated && - page == null; + pageId == null; bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff; diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index 291cac72f..1e794f202 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -12,14 +12,14 @@ class EntryCache { int oldRotationDegrees, bool oldIsFlipped, ) async { - // TODO TLAD provide page parameter for multipage items, if someday image editing features are added for them - int page; + // TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them + int pageId; // evict fullscreen image await UriImage( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, ).evict(); @@ -28,7 +28,7 @@ class EntryCache { await ThumbnailProvider(ThumbnailProviderKey( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, @@ -41,7 +41,7 @@ class EntryCache { (extent) => ThumbnailProvider(ThumbnailProviderKey( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, isFlipped: oldIsFlipped, diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 85bd93828..72ab8493e 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -20,7 +20,7 @@ extension ExtraAvesEntry on AvesEntry { return ThumbnailProviderKey( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, rotationDegrees: rotationDegrees, isFlipped: isFlipped, dateModifiedSecs: dateModifiedSecs ?? -1, @@ -34,7 +34,7 @@ extension ExtraAvesEntry on AvesEntry { return RegionProviderKey( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, rotationDegrees: rotationDegrees, isFlipped: isFlipped, sampleSize: sampleSize, @@ -46,7 +46,7 @@ extension ExtraAvesEntry on AvesEntry { UriImage get uriImage => UriImage( uri: uri, mimeType: mimeType, - page: page, + pageId: pageId, rotationDegrees: rotationDegrees, isFlipped: isFlipped, expectedContentLength: sizeBytes, diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index e61bc0f8a..a9f4d6222 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -2,7 +2,7 @@ import 'package:aves/model/entry.dart'; import 'package:flutter/foundation.dart'; class MultiPageInfo { - final Map pages; + final List pages; int get pageCount => pages.length; @@ -10,44 +10,49 @@ class 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); + factory MultiPageInfo.fromPageMaps(List pageMaps) { + return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList()); } + SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null); + + SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null); + @override String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}'; } class SinglePageInfo { + final int index, pageId; final String mimeType; - final int width, height; - final int trackId, durationMillis; + final bool isDefault; + final int width, height, durationMillis; SinglePageInfo({ + this.index, + this.pageId, this.mimeType, + this.isDefault, this.width, this.height, - this.trackId, this.durationMillis, }); factory SinglePageInfo.fromMap(Map map) { + final index = map['page'] as int; return SinglePageInfo( + index: index, + pageId: map['trackId'] as int ?? index, mimeType: map['mimeType'] as String, - width: map['width'] as int, - height: map['height'] as int, - trackId: map['trackId'] as int, + isDefault: map['isDefault'] as bool ?? false, + width: map['width'] as int ?? 0, + height: map['height'] as int ?? 0, durationMillis: map['durationMillis'] as int, ); } @override - String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, width=$width, height=$height, trackId=$trackId, durationMillis=$durationMillis}'; + String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}'; } class AvesPageEntry extends AvesEntry { @@ -58,7 +63,7 @@ class AvesPageEntry extends AvesEntry { String uri, String path, int contentId, - int page, + int pageId, String sourceMimeType, int width, int height, @@ -72,7 +77,7 @@ class AvesPageEntry extends AvesEntry { uri: uri, path: path, contentId: contentId, - page: page, + pageId: pageId, sourceMimeType: pageInfo.mimeType ?? sourceMimeType, width: pageInfo.width ?? width, height: pageInfo.height ?? height, diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index a358b5413..02a3f76f6 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -33,7 +33,7 @@ class AppShortcutService { iconBytes = await ImageFileService.getThumbnail( uri: entry.uri, mimeType: entry.mimeType, - page: entry.page, + pageId: entry.pageId, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, dateModifiedSecs: entry.dateModifiedSecs, diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index af68d8bd9..8e94d0d59 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -89,7 +89,7 @@ class ImageFileService { String mimeType, int rotationDegrees, bool isFlipped, { - int page, + int pageId, int expectedContentLength, BytesReceivedCallback onBytesReceived, }) { @@ -102,7 +102,7 @@ class ImageFileService { 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, 'isFlipped': isFlipped ?? false, - 'page': page, + 'pageId': pageId, }).listen( (data) { final chunk = data as Uint8List; @@ -140,7 +140,7 @@ class ImageFileService { int sampleSize, Rectangle regionRect, Size imageSize, { - int page, + int pageId, Object taskKey, int priority, }) { @@ -150,7 +150,7 @@ class ImageFileService { final result = await platform.invokeMethod('getRegion', { 'uri': uri, 'mimeType': mimeType, - 'page': page, + 'pageId': pageId, 'sampleSize': sampleSize, 'regionX': regionRect.left, 'regionY': regionRect.top, @@ -174,7 +174,7 @@ class ImageFileService { @required String uri, @required String mimeType, @required int rotationDegrees, - @required int page, + @required int pageId, @required bool isFlipped, @required int dateModifiedSecs, @required double extent, @@ -195,7 +195,7 @@ class ImageFileService { 'isFlipped': isFlipped, 'widthDip': extent, 'heightDip': extent, - 'page': page, + 'pageId': pageId, 'defaultSizeDip': thumbnailDefaultSize, }); return result as Uint8List; diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index bb058efda..e4a2e0b90 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -87,8 +87,9 @@ class MetadataService { final result = await platform.invokeMethod('getMultiPageInfo', { 'mimeType': entry.mimeType, 'uri': entry.uri, - }) as Map; - return MultiPageInfo.fromMap(result); + }); + final pageMaps = (result as List).cast(); + return MultiPageInfo.fromPageMaps(pageMaps); } on PlatformException catch (e) { debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index dcfb8a786..5a37c8367 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; class RasterImageThumbnail extends StatefulWidget { final AvesEntry entry; final double extent; - final int page; final ValueNotifier isScrollingNotifier; final Object heroTag; @@ -17,7 +16,6 @@ class RasterImageThumbnail extends StatefulWidget { Key key, @required this.entry, @required this.extent, - this.page, this.isScrollingNotifier, this.heroTag, }) : super(key: key); diff --git a/lib/widgets/viewer/entry_scroller.dart b/lib/widgets/viewer/entry_scroller.dart index d4ec97c0e..7927597a4 100644 --- a/lib/widgets/viewer/entry_scroller.dart +++ b/lib/widgets/viewer/entry_scroller.dart @@ -62,7 +62,7 @@ class _MultiEntryScrollerState extends State with AutomaticK return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - return _buildViewer(entry, multiPageInfo: multiPageInfo, page: page); + return _buildViewer(entry, page: multiPageInfo?.getByIndex(page)); }, ); }, @@ -80,14 +80,13 @@ class _MultiEntryScrollerState extends State with AutomaticK ); } - Widget _buildViewer(AvesEntry entry, {MultiPageInfo multiPageInfo, int page}) { + Widget _buildViewer(AvesEntry entry, {SinglePageInfo page}) { return Selector( selector: (c, mq) => mq.size, builder: (c, mqSize, child) { return EntryPageView( key: Key('imageview'), mainEntry: entry, - multiPageInfo: multiPageInfo, page: page, viewportSize: mqSize, heroTag: widget.collection.heroTag(entry), @@ -142,7 +141,7 @@ class _SingleEntryScrollerState extends State with Automati return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - return _buildViewer(multiPageInfo: multiPageInfo, page: page); + return _buildViewer(page: multiPageInfo?.getByIndex(page)); }, ); }, @@ -157,13 +156,12 @@ class _SingleEntryScrollerState extends State with Automati ); } - Widget _buildViewer({MultiPageInfo multiPageInfo, int page}) { + Widget _buildViewer({SinglePageInfo page}) { return Selector( selector: (c, mq) => mq.size, builder: (c, mqSize, child) { return EntryPageView( mainEntry: entry, - multiPageInfo: multiPageInfo, page: page, viewportSize: mqSize, onTap: (_) => widget.onTap?.call(), diff --git a/lib/widgets/viewer/multipage.dart b/lib/widgets/viewer/multipage.dart index 7bc74b9ac..9fae90a5b 100644 --- a/lib/widgets/viewer/multipage.dart +++ b/lib/widgets/viewer/multipage.dart @@ -7,10 +7,16 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class MultiPageController extends ChangeNotifier { - final Future info; - final ValueNotifier pageNotifier = ValueNotifier(0); + Future info; + final ValueNotifier pageNotifier = ValueNotifier(null); - MultiPageController(AvesEntry entry) : info = MetadataService.getMultiPageInfo(entry); + MultiPageController(AvesEntry entry) { + info = MetadataService.getMultiPageInfo(entry).then((value) { + final defaultPage = value.pages.firstWhere((page) => page.isDefault, orElse: () => null); + pageNotifier.value = defaultPage?.index ?? 0; + return value; + }); + } int get page => pageNotifier.value; diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 24e7bd2fe..c1a498a25 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -101,8 +101,7 @@ class _ViewerBottomOverlayState extends State { Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent( mainEntry: _lastEntry, - multiPageInfo: multiPageInfo, - page: page, + page: multiPageInfo?.getByIndex(page), details: _lastDetails, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, availableWidth: availableWidth, @@ -139,8 +138,7 @@ const double _subRowMinWidth = 300.0; class _BottomOverlayContent extends AnimatedWidget { final AvesEntry mainEntry, entry; - final MultiPageInfo multiPageInfo; - final int page; + final SinglePageInfo page; final OverlayMetadata details; final String position; final double availableWidth; @@ -151,13 +149,12 @@ class _BottomOverlayContent extends AnimatedWidget { _BottomOverlayContent({ Key key, this.mainEntry, - this.multiPageInfo, this.page, this.details, this.position, this.availableWidth, this.multiPageController, - }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page), + }) : entry = mainEntry.getPageEntry(page), super(key: key, listenable: mainEntry.metadataChangeNotifier); @override @@ -342,6 +339,8 @@ class _PositionTitleRow extends StatelessWidget { bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null; + static const separator = ' • '; + @override Widget build(BuildContext context) { Text toText({String pagePosition}) => Text( @@ -349,7 +348,7 @@ class _PositionTitleRow extends StatelessWidget { if (collectionPosition != null) collectionPosition, if (pagePosition != null) pagePosition, if (title != null) title, - ].join(' • '), + ].join(separator), strutStyle: Constants.overflowStrutStyle); if (multiPageController == null) return toText(); @@ -358,11 +357,17 @@ class _PositionTitleRow extends StatelessWidget { future: multiPageController.info, builder: (context, snapshot) { final multiPageInfo = snapshot.data; - final pageCount = multiPageInfo?.pageCount; - // 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 toText(pagePosition: missingInfo ? null : '${(entry.page ?? 0) + 1}/${pageCount ?? '?'}'); + String pagePosition; + if (multiPageInfo != null) { + // page count may be 0 when we know an entry to have multiple pages + // but fail to get information about these pages + final pageCount = multiPageInfo.pageCount; + if (pageCount > 0) { + final page = multiPageInfo.getById(entry.pageId); + pagePosition = '${(page?.index ?? 0) + 1}/$pageCount'; + } + } + return toText(pagePosition: pagePosition); }, ); } diff --git a/lib/widgets/viewer/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart index 657aff590..794de067a 100644 --- a/lib/widgets/viewer/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -34,7 +34,7 @@ class Minimap extends StatelessWidget { return ValueListenableBuilder( valueListenable: multiPageController.pageNotifier, builder: (context, page, child) { - final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page); + final pageEntry = mainEntry.getPageEntry(multiPageInfo?.getByIndex(page)); return _buildForEntrySize(pageEntry.displaySize); }, ); diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index b379a350e..b1568b837 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -62,7 +62,8 @@ class _MultiPageOverlayState extends State { } void _registerWidget() { - final scrollOffset = pageToScrollOffset(controller.page); + final page = controller.page ?? 0; + final scrollOffset = pageToScrollOffset(page); _scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController.addListener(_onScrollChange); } @@ -108,7 +109,7 @@ 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); + final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); return GestureDetector( onTap: () async { diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index d3ab1a35c..bab2dc5a7 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -46,8 +46,8 @@ class EntryPrinter { if (entry.isMultipage) { final multiPageInfo = await MetadataService.getMultiPageInfo(entry); if (multiPageInfo.pageCount > 1) { - for (final kv in multiPageInfo.pages.entries) { - final pageEntry = entry.getPageEntry(multiPageInfo: multiPageInfo, page: kv.key); + for (final page in multiPageInfo.pages) { + final pageEntry = entry.getPageEntry(page); _addPdfPage(await _buildPageImage(pageEntry)); } } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index fcc8f4e70..30b4f3830 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -24,8 +24,7 @@ import 'package:tuple/tuple.dart'; class EntryPageView extends StatefulWidget { final AvesEntry entry; - final MultiPageInfo multiPageInfo; - final int page; + final SinglePageInfo page; final Size viewportSize; final Object heroTag; final MagnifierTapCallback onTap; @@ -37,14 +36,13 @@ class EntryPageView extends StatefulWidget { EntryPageView({ Key key, AvesEntry mainEntry, - this.multiPageInfo, this.page, this.viewportSize, this.heroTag, @required this.onTap, @required this.videoControllers, this.onDisposed, - }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page) ?? mainEntry, + }) : entry = mainEntry.getPageEntry(page) ?? mainEntry, super(key: key); @override @@ -198,7 +196,7 @@ class _EntryPageViewState extends State { }) { return Magnifier( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${entry.pageId}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), controller: _magnifierController, childSize: entry.displaySize, minScale: minScale,