viewer: multitrack HEIF support

This commit is contained in:
Thibault Deckers 2021-01-19 10:24:31 +09:00
parent 4690fac4f6
commit 9956d6521c
27 changed files with 440 additions and 211 deletions

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.util.Log 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.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface 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) { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
if (it > 1) flags = flags or MASK_IS_MULTIPAGE if (it > 1) flags = flags or MASK_IS_MULTIPAGE
} }
@ -525,8 +528,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (mimeType == MimeTypes.TIFF) { if (mimeType == MimeTypes.TIFF) {
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> { fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
return hashMapOf( return hashMapOf(
"width" to options.outWidth, KEY_MIME_TYPE to mimeType,
"height" to options.outHeight, KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight,
) )
} }
getTiffPageInfo(uri, 0)?.let { first -> getTiffPageInfo(uri, 0)?.let { first ->
@ -536,6 +540,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) } 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<String, Any>(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) result.success(pages)
} }
@ -619,7 +653,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap -> exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { 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) private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata // catalog metadata & page info
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_FLAGS = "flags" 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_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" 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_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -13,11 +13,13 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.MultiTrackThumbnail
import deckers.thibault.aves.decoder.TiffThumbnail import deckers.thibault.aves.decoder.TiffThumbnail
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes 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.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor(
private val isFlipped: Boolean, private val isFlipped: Boolean,
width: Int?, width: Int?,
height: Int?, height: Int?,
page: Int?, private val page: Int?,
private val defaultSize: Int, private val defaultSize: Int,
private val result: MethodChannel.Result, private val result: MethodChannel.Result,
) { ) {
private val uri: Uri = Uri.parse(uri) private val uri: Uri = Uri.parse(uri)
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize 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 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() { fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor(
var exception: Exception? = null var exception: Exception? = null
try { 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. // Fetch low quality thumbnails when size is not specified.
// As of Android R, the Media Store content resolver may return a thumbnail // As of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped, // that is automatically rotated according to EXIF orientation, but not flipped,
@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor(
.load(VideoThumbnail(context, uri)) .load(VideoThumbnail(context, uri))
.submit(width, height) .submit(width, height)
} else { } 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) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)

View file

@ -9,11 +9,13 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackThumbnail
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide 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 uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val rotationDegrees = arguments["rotationDegrees"] as Int val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean val isFlipped = arguments["isFlipped"] as Boolean
val page = arguments["page"] as Int val page = arguments["page"] as Int?
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", 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) streamTiffImage(uri, page)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter // 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 { } else {
// to be decoded by Flutter // to be decoded by Flutter
streamImageAsIs(uri) 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) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(uri) .load(model)
.submit() .submit()
try { try {
var bitmap = target.get() 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 val resolver = activity.contentResolver
try { try {
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd() val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
@ -166,7 +174,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
return return
} }
val options = TiffBitmapFactory.Options().apply { val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page inDirectoryNumber = page ?: 0
} }
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) { if (bitmap != null) {

View file

@ -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<MultiTrackThumbnail, InputStream> {
override fun buildLoadData(model: MultiTrackThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackThumbnailFetcher(model, width, height))
}
override fun handles(MultiTrackThumbnail: MultiTrackThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<MultiTrackThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackThumbnail, InputStream> = MultiTrackThumbnailLoader()
override fun teardown() {}
}
}
internal class MultiTrackThumbnailFetcher(val model: MultiTrackThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
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> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
companion object {
private val LOG_TAG = LogUtils.createTag(MultiTrackThumbnailFetcher::class.java)
}
}

View file

@ -9,7 +9,7 @@ object MimeTypes {
private const val BMP = "image/bmp" private const val BMP = "image/bmp"
const val GIF = "image/gif" const val GIF = "image/gif"
const val HEIC = "image/heic" 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 ICO = "image/x-icon"
private const val JPEG = "image/jpeg" private const val JPEG = "image/jpeg"
private const val PNG = "image/png" private const val PNG = "image/png"
@ -41,10 +41,9 @@ object MimeTypes {
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
fun isMultimedia(mimeType: String?) = when (mimeType) { fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
HEIC, HEIF -> true
else -> isVideo(mimeType) fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
}
fun isRaw(mimeType: String): Boolean { fun isRaw(mimeType: String): Boolean {
return when (mimeType) { return when (mimeType) {

View file

@ -23,7 +23,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { 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<RegionProviderKey> {
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final page = key.page;
try { try {
final bytes = await ImageFileService.getRegion( final bytes = await ImageFileService.getRegion(
uri, uri,
@ -40,7 +41,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
key.sampleSize, key.sampleSize,
key.regionRect, key.regionRect,
key.imageSize, key.imageSize,
page: key.page, page: page,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes == null) {
@ -49,7 +50,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$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.mimeType,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.page = 0, this.page,
@required this.sampleSize, @required this.sampleSize,
@required this.regionRect, @required this.regionRect,
@required this.imageSize, @required this.imageSize,
@ -93,7 +94,6 @@ class RegionProviderKey {
// but the entry attributes may change over time // but the entry attributes may change over time
factory RegionProviderKey.fromEntry( factory RegionProviderKey.fromEntry(
ImageEntry entry, { ImageEntry entry, {
int page = 0,
@required int sampleSize, @required int sampleSize,
@required Rectangle<int> rect, @required Rectangle<int> rect,
}) { }) {
@ -102,7 +102,7 @@ class RegionProviderKey {
mimeType: entry.mimeType, mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
page: page, page: entry.page,
sampleSize: sampleSize, sampleSize: sampleSize,
regionRect: rect, regionRect: rect,
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()), imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),

View file

@ -24,7 +24,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { 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<ThumbnailProviderKey> {
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final page = key.page;
try { try {
final bytes = await ImageFileService.getThumbnail( final bytes = await ImageFileService.getThumbnail(
uri, uri,
@ -41,7 +42,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
key.isFlipped, key.isFlipped,
key.extent, key.extent,
key.extent, key.extent,
page: key.page, page: page,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes == null) {
@ -50,7 +51,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$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.dateModifiedSecs,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.page = 0, this.page,
this.extent = 0, this.extent = 0,
this.scale = 1, this.scale = 1,
}) : assert(uri != null), }) : assert(uri != null),
@ -88,7 +89,7 @@ class ThumbnailProviderKey {
// do not store the entry as it is, because the key should be constant // do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time // 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( return ThumbnailProviderKey(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
@ -96,7 +97,7 @@ class ThumbnailProviderKey {
dateModifiedSecs: entry.dateModifiedSecs ?? -1, dateModifiedSecs: entry.dateModifiedSecs ?? -1,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
page: page, page: entry.page,
extent: extent, extent: extent,
); );
} }

View file

@ -15,7 +15,7 @@ class UriImage extends ImageProvider<UriImage> {
const UriImage({ const UriImage({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
this.page = 0, this.page,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped, @required this.isFlipped,
this.expectedContentLength, this.expectedContentLength,
@ -37,7 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
scale: key.scale, scale: key.scale,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription('uri=$uri, mimeType=$mimeType'); yield ErrorDescription('uri=$uri, page=$page, mimeType=$mimeType');
}, },
); );
} }
@ -66,7 +66,7 @@ class UriImage extends ImageProvider<UriImage> {
return await decode(bytes); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$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 { } finally {
unawaited(chunkEvents.close()); unawaited(chunkEvents.close());
} }

View file

@ -12,14 +12,12 @@ class EntryCache {
int oldRotationDegrees, int oldRotationDegrees,
bool oldIsFlipped, bool oldIsFlipped,
) async { ) async {
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them // TODO TLAD provide page parameter for multipage items, if someday image editing features are added for them
const page = 0;
// evict fullscreen image // evict fullscreen image
await UriImage( await UriImage(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
page: page,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
).evict(); ).evict();
@ -31,7 +29,6 @@ class EntryCache {
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
page: page,
)).evict(); )).evict();
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents) // evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
@ -44,7 +41,6 @@ class EntryCache {
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
page: page,
extent: extent, extent: extent,
)).evict()); )).evict());
} }

View file

@ -24,7 +24,7 @@ import '../ref/mime_types.dart';
class ImageEntry { class ImageEntry {
String uri; String uri;
String _path, _directory, _filename, _extension; String _path, _directory, _filename, _extension;
int contentId; int page, contentId;
final String sourceMimeType; final String sourceMimeType;
int width; int width;
int height; int height;
@ -47,6 +47,7 @@ class ImageEntry {
this.uri, this.uri,
String path, String path,
this.contentId, this.contentId,
this.page,
this.sourceMimeType, this.sourceMimeType,
@required this.width, @required this.width,
@required this.height, @required this.height,
@ -93,6 +94,35 @@ class ImageEntry {
return copied; 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 // from DB or platform source entry
factory ImageEntry.fromMap(Map map) { factory ImageEntry.fromMap(Map map) {
return ImageEntry( return ImageEntry(
@ -243,18 +273,9 @@ class ImageEntry {
static const ratioSeparator = '\u2236'; static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 '; static const resolutionSeparator = ' \u00D7 ';
String getResolutionText({MultiPageInfo multiPageInfo, int page}) { String get resolutionText {
int w; final ws = width ?? '?';
int h; final hs = height ?? '?';
if (multiPageInfo != null && page != null) {
final pageInfo = multiPageInfo.pages[page];
w = pageInfo?.width;
h = pageInfo?.height;
}
w ??= width;
h ??= height;
final ws = w ?? '?';
final hs = h ?? '?';
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
} }
@ -274,17 +295,10 @@ class ImageEntry {
return isPortrait ? height / width : width / height; return isPortrait ? height / width : width / height;
} }
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) { Size get displaySize {
int w; final w = width.toDouble();
int h; final h = height.toDouble();
if (multiPageInfo != null && page != null) { return isPortrait ? Size(h, w) : Size(w, h);
final pageInfo = multiPageInfo.pages[page];
w = pageInfo?.width;
h = pageInfo?.height;
}
w ??= width;
h ??= height;
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
} }
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;

View file

@ -68,17 +68,19 @@ class CatalogMetadata {
} }
CatalogMetadata copyWith({ CatalogMetadata copyWith({
@required int contentId, int contentId,
String mimeType,
bool isMultipage,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
mimeType: mimeType, mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis, dateMillis: dateMillis,
isAnimated: isAnimated, isAnimated: isAnimated,
isFlipped: isFlipped, isFlipped: isFlipped,
isGeotiff: isGeotiff, isGeotiff: isGeotiff,
is360: is360, is360: is360,
isMultipage: isMultipage, isMultipage: isMultipage ?? this.isMultipage,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
@ -169,7 +171,7 @@ class AddressDetails {
}); });
AddressDetails copyWith({ AddressDetails copyWith({
@required int contentId, int contentId,
}) { }) {
return AddressDetails( return AddressDetails(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,

View file

@ -1,24 +1,6 @@
import 'package:aves/model/image_entry.dart';
import 'package:flutter/foundation.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 { class MultiPageInfo {
final Map<int, SinglePageInfo> pages; final Map<int, SinglePageInfo> pages;
@ -40,3 +22,65 @@ class MultiPageInfo {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}'; 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,
);
}

View file

@ -74,7 +74,7 @@ class ImageFileService {
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
bool isFlipped, { bool isFlipped, {
int page = 0, int page,
int expectedContentLength, int expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback onBytesReceived,
}) { }) {
@ -87,7 +87,7 @@ class ImageFileService {
'mimeType': mimeType, 'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0, 'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false, 'isFlipped': isFlipped ?? false,
'page': page ?? 0, 'page': page,
}).listen( }).listen(
(data) { (data) {
final chunk = data as Uint8List; final chunk = data as Uint8List;
@ -125,7 +125,7 @@ class ImageFileService {
int sampleSize, int sampleSize,
Rectangle<int> regionRect, Rectangle<int> regionRect,
Size imageSize, { Size imageSize, {
int page = 0, int page,
Object taskKey, Object taskKey,
int priority, int priority,
}) { }) {

View file

@ -19,7 +19,7 @@ class RasterImageThumbnail extends StatefulWidget {
Key key, Key key,
@required this.entry, @required this.entry,
@required this.extent, @required this.extent,
this.page = 0, this.page,
this.isScrollingNotifier, this.isScrollingNotifier,
this.heroTag, this.heroTag,
}) : super(key: key); }) : super(key: key);
@ -33,8 +33,6 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
int get page => widget.page;
double get extent => widget.extent; double get extent => widget.extent;
Object get heroTag => widget.heroTag; Object get heroTag => widget.heroTag;
@ -79,11 +77,11 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
if (!entry.canDecode) return; if (!entry.canDecode) return;
_fastThumbnailProvider = ThumbnailProvider( _fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, page: page), ThumbnailProviderKey.fromEntry(entry),
); );
if (!entry.isVideo) { if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider( _sizedThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent), ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
); );
} }
} }
@ -153,7 +151,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
final imageProvider = UriImage( final imageProvider = UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
page: page, page: entry.page,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,

View file

@ -29,7 +29,7 @@ class VectorImageThumbnail extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final availableSize = constraints.biggest; 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 offset = fitSize / 2 - availableSize / 2;
final child = DecoratedBox( final child = DecoratedBox(
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),

View file

@ -80,7 +80,7 @@ class ViewerDebugPage extends StatelessWidget {
'isFlipped': '${entry.isFlipped}', 'isFlipped': '${entry.isFlipped}',
'portrait': '${entry.isPortrait}', 'portrait': '${entry.isPortrait}',
'displayAspectRatio': '${entry.displayAspectRatio}', 'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.getDisplaySize()}', 'displaySize': '${entry.displaySize}',
}), }),
Divider(), Divider(),
InfoRowGroup({ InfoRowGroup({

View file

@ -79,10 +79,10 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
); );
} }
EntryPageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page = 0}) { EntryPageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page}) {
return EntryPageView( return EntryPageView(
key: Key('imageview'), key: Key('imageview'),
entry: entry, mainEntry: entry,
multiPageInfo: multiPageInfo, multiPageInfo: multiPageInfo,
page: page, page: page,
heroTag: widget.collection.heroTag(entry), heroTag: widget.collection.heroTag(entry),
@ -150,9 +150,9 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
); );
} }
EntryPageView _buildViewer({MultiPageInfo multiPageInfo, int page = 0}) { EntryPageView _buildViewer({MultiPageInfo multiPageInfo, int page}) {
return EntryPageView( return EntryPageView(
entry: entry, mainEntry: entry,
multiPageInfo: multiPageInfo, multiPageInfo: multiPageInfo,
page: page, page: page,
onTap: (_) => widget.onTap?.call(), onTap: (_) => widget.onTap?.call(),

View file

@ -30,7 +30,7 @@ class BasicSection extends StatelessWidget {
bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -97,15 +97,32 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
_lastDetails = snapshot.data; _lastDetails = snapshot.data;
_lastEntry = entry; _lastEntry = entry;
} }
return _lastEntry == null if (_lastEntry == null) return SizedBox.shrink();
? SizedBox.shrink()
: _BottomOverlayContent( Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
entry: _lastEntry, mainEntry: _lastEntry,
multiPageInfo: multiPageInfo,
page: page,
details: _lastDetails, details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth, availableWidth: availableWidth,
multiPageController: multiPageController, multiPageController: multiPageController,
); );
if (multiPageController == null) return _buildContent();
return FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
return ValueListenableBuilder<int>(
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; const double _subRowMinWidth = 300.0;
class _BottomOverlayContent extends AnimatedWidget { class _BottomOverlayContent extends AnimatedWidget {
final ImageEntry entry; final ImageEntry mainEntry, entry;
final MultiPageInfo multiPageInfo;
final int page;
final OverlayMetadata details; final OverlayMetadata details;
final String position; final String position;
final double availableWidth; final double availableWidth;
@ -131,12 +150,15 @@ class _BottomOverlayContent extends AnimatedWidget {
_BottomOverlayContent({ _BottomOverlayContent({
Key key, Key key,
this.entry, this.mainEntry,
this.multiPageInfo,
this.page,
this.details, this.details,
this.position, this.position,
this.availableWidth, this.availableWidth,
this.multiPageController, this.multiPageController,
}) : super(key: key, listenable: entry.metadataChangeNotifier); }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page),
super(key: key, listenable: mainEntry.metadataChangeNotifier);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -158,13 +180,13 @@ class _BottomOverlayContent extends AnimatedWidget {
infoColumn = _buildInfoColumn(orientation); infoColumn = _buildInfoColumn(orientation);
} }
if (multiPageController != null) { if (mainEntry.isMultipage && multiPageController != null) {
infoColumn = Column( infoColumn = Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MultiPageOverlay( MultiPageOverlay(
entry: entry, mainEntry: mainEntry,
controller: multiPageController, controller: multiPageController,
availableWidth: availableWidth, availableWidth: availableWidth,
), ),
@ -340,12 +362,7 @@ class _PositionTitleRow extends StatelessWidget {
// page count may be 0 when we know an entry to have multiple pages // page count may be 0 when we know an entry to have multiple pages
// but fail to get information about these pages // but fail to get information about these pages
final missingInfo = pageCount == 0; final missingInfo = pageCount == 0;
return ValueListenableBuilder<int>( return toText(pagePosition: missingInfo ? null : '${(entry.page ?? 0) + 1}/${pageCount ?? '?'}');
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return toText(pagePosition: missingInfo ? null : '${page + 1}/${pageCount ?? '?'}');
},
);
}, },
); );
} }
@ -364,40 +381,14 @@ class _DateRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.overlayUnknown; 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<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return toText(multiPageInfo: multiPageInfo, page: page);
},
);
},
);
} else {
resolutionText = toText();
}
return Row( return Row(
children: [ children: [
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
Expanded(flex: 2, child: resolutionText), Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)),
], ],
); );
} }

View file

@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class Minimap extends StatelessWidget { class Minimap extends StatelessWidget {
final ImageEntry entry; final ImageEntry mainEntry;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController; final MultiPageController multiPageController;
final Size size; final Size size;
@ -16,7 +16,7 @@ class Minimap extends StatelessWidget {
static const defaultSize = Size(96, 96); static const defaultSize = Size(96, 96);
const Minimap({ const Minimap({
@required this.entry, @required this.mainEntry,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.multiPageController, @required this.multiPageController,
this.size = defaultSize, this.size = defaultSize,
@ -34,11 +34,12 @@ class Minimap extends StatelessWidget {
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { 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),
); );
} }

View file

@ -3,22 +3,25 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/multipage.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MultiPageOverlay extends StatefulWidget { class MultiPageOverlay extends StatefulWidget {
final ImageEntry entry; final ImageEntry mainEntry;
final MultiPageController controller; final MultiPageController controller;
final double availableWidth; final double availableWidth;
const MultiPageOverlay({ MultiPageOverlay({
Key key, Key key,
@required this.entry, @required this.mainEntry,
@required this.controller, @required this.controller,
@required this.availableWidth, @required this.availableWidth,
}) : super(key: key); }) : assert(mainEntry.isMultipage),
assert(controller != null),
super(key: key);
@override @override
_MultiPageOverlayState createState() => _MultiPageOverlayState(); _MultiPageOverlayState createState() => _MultiPageOverlayState();
@ -31,7 +34,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
static const double extent = 48; static const double extent = 48;
static const double separatorWidth = 2; static const double separatorWidth = 2;
ImageEntry get entry => widget.entry; ImageEntry get mainEntry => widget.mainEntry;
MultiPageController get controller => widget.controller; MultiPageController get controller => widget.controller;
@ -97,7 +100,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
width: availableWidth, width: availableWidth,
height: extent, height: extent,
child: ListView.separated( child: ListView.separated(
key: ValueKey(entry), key: ValueKey(mainEntry),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: _scrollController, controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`, // default padding in scroll direction matches `MediaQuery.viewPadding`,
@ -106,6 +109,8 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
final page = index - 1; final page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page);
return GestureDetector( return GestureDetector(
onTap: () async { onTap: () async {
_syncScroll = false; _syncScroll = false;
@ -117,15 +122,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
_syncScroll = true; _syncScroll = true;
}, },
child: Container( child: _buildPageThumbnail(pageEntry),
width: extent,
height: extent,
child: RasterImageThumbnail(
entry: entry,
extent: extent,
page: page,
),
),
); );
}, },
separatorBuilder: (context, index) => separator, separatorBuilder: (context, index) => separator,
@ -165,6 +162,35 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
} }
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() { void _onScrollChange() {
if (_syncScroll) { if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset); controller.page = scrollOffsetToPage(_scrollController.offset);

View file

@ -86,7 +86,7 @@ class ViewerTopOverlay extends StatelessWidget {
FadeTransition( FadeTransition(
opacity: scale, opacity: scale,
child: Minimap( child: Minimap(
entry: entry, mainEntry: entry,
viewStateNotifier: viewStateNotifier, viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController, multiPageController: multiPageController,
), ),

View file

@ -16,12 +16,10 @@ class PanoramaPage extends StatefulWidget {
static const routeName = '/viewer/panorama'; static const routeName = '/viewer/panorama';
final ImageEntry entry; final ImageEntry entry;
final int page;
final PanoramaInfo info; final PanoramaInfo info;
const PanoramaPage({ const PanoramaPage({
@required this.entry, @required this.entry,
this.page = 0,
@required this.info, @required this.info,
}); });
@ -77,7 +75,7 @@ class _PanoramaPageState extends State<PanoramaPage> {
image: UriImage( image: UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
page: widget.page, page: entry.page,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,

View file

@ -57,7 +57,7 @@ class EntryPrinter {
return pages; return pages;
} }
Future<pdf.Widget> _buildPageImage({page = 0}) async { Future<pdf.Widget> _buildPageImage({int page}) async {
final uri = entry.uri; final uri = entry.uri;
final mimeType = entry.mimeType; final mimeType = entry.mimeType;
final rotationDegrees = entry.rotationDegrees; final rotationDegrees = entry.rotationDegrees;

View file

@ -36,16 +36,17 @@ class EntryPageView extends StatefulWidget {
static const minScale = ScaleLevel(ref: ScaleReference.contained); static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0); static const maxScale = ScaleLevel(factor: 2.0);
const EntryPageView({ EntryPageView({
Key key, Key key,
@required this.entry, ImageEntry mainEntry,
this.multiPageInfo, this.multiPageInfo,
this.page = 0, this.page,
this.heroTag, this.heroTag,
@required this.onTap, @required this.onTap,
@required this.videoControllers, @required this.videoControllers,
this.onDisposed, this.onDisposed,
}) : super(key: key); }) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page) ?? mainEntry,
super(key: key);
@override @override
_EntryPageViewState createState() => _EntryPageViewState(); _EntryPageViewState createState() => _EntryPageViewState();
@ -58,14 +59,8 @@ class _EntryPageViewState extends State<EntryPageView> {
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
MultiPageInfo get multiPageInfo => widget.multiPageInfo;
int get page => widget.page;
MagnifierTapCallback get onTap => widget.onTap; MagnifierTapCallback get onTap => widget.onTap;
Size get pageDisplaySize => entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -86,7 +81,7 @@ class _EntryPageViewState extends State<EntryPageView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child; Widget child;
if (entry.isVideo) { if (entry.isVideo) {
if (entry.width > 0 && entry.height > 0) { if (!entry.displaySize.isEmpty) {
child = _buildVideoView(); child = _buildVideoView();
} }
} else if (entry.isSvg) { } else if (entry.isSvg) {
@ -109,15 +104,13 @@ class _EntryPageViewState extends State<EntryPageView> {
Widget _buildRasterView() { Widget _buildRasterView() {
return Magnifier( return Magnifier(
// key includes size and orientation to refresh when the image is rotated // 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( child: TiledImageView(
entry: entry, entry: entry,
multiPageInfo: multiPageInfo,
page: page,
viewStateNotifier: _viewStateNotifier, viewStateNotifier: _viewStateNotifier,
errorBuilder: (context, error, stackTrace) => ErrorView(onTap: () => onTap?.call(null)), errorBuilder: (context, error, stackTrace) => ErrorView(onTap: () => onTap?.call(null)),
), ),
childSize: pageDisplaySize, childSize: entry.displaySize,
controller: _magnifierController, controller: _magnifierController,
maxScale: EntryPageView.maxScale, maxScale: EntryPageView.maxScale,
minScale: EntryPageView.minScale, minScale: EntryPageView.minScale,
@ -139,7 +132,7 @@ class _EntryPageViewState extends State<EntryPageView> {
colorFilter: colorFilter, colorFilter: colorFilter,
), ),
), ),
childSize: pageDisplaySize, childSize: entry.displaySize,
controller: _magnifierController, controller: _magnifierController,
minScale: EntryPageView.minScale, minScale: EntryPageView.minScale,
initialScale: EntryPageView.initialScale, initialScale: EntryPageView.initialScale,
@ -149,7 +142,7 @@ class _EntryPageViewState extends State<EntryPageView> {
if (background == EntryBackground.checkered) { if (background == EntryBackground.checkered) {
child = VectorViewCheckeredBackground( child = VectorViewCheckeredBackground(
displaySize: pageDisplaySize, displaySize: entry.displaySize,
viewStateNotifier: _viewStateNotifier, viewStateNotifier: _viewStateNotifier,
child: child, child: child,
); );
@ -166,7 +159,7 @@ class _EntryPageViewState extends State<EntryPageView> {
controller: videoController, controller: videoController,
) )
: SizedBox(), : SizedBox(),
childSize: pageDisplaySize, childSize: entry.displaySize,
controller: _magnifierController, controller: _magnifierController,
maxScale: EntryPageView.maxScale, maxScale: EntryPageView.maxScale,
minScale: EntryPageView.minScale, minScale: EntryPageView.minScale,

View file

@ -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/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.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/entry_background.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
@ -17,15 +16,11 @@ import 'package:tuple/tuple.dart';
class TiledImageView extends StatefulWidget { class TiledImageView extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final MultiPageInfo multiPageInfo;
final int page;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final ImageErrorWidgetBuilder errorBuilder; final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({ const TiledImageView({
@required this.entry, @required this.entry,
this.multiPageInfo,
this.page = 0,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.errorBuilder, @required this.errorBuilder,
}); });
@ -46,17 +41,15 @@ class _TiledImageViewState extends State<TiledImageView> {
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
int get page => widget.page;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier; ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent; 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 // 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 // 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 { ImageProvider get fullImageProvider {
if (useTiles) { if (useTiles) {
@ -76,7 +69,6 @@ class _TiledImageViewState extends State<TiledImageView> {
)?.item2; )?.item2;
return RegionProvider(RegionProviderKey.fromEntry( return RegionProvider(RegionProviderKey.fromEntry(
entry, entry,
page: page,
sampleSize: _maxSampleSize, sampleSize: _maxSampleSize,
rect: regionRect, rect: regionRect,
)); ));
@ -84,7 +76,7 @@ class _TiledImageViewState extends State<TiledImageView> {
return UriImage( return UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
page: page, page: entry.page,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
@ -98,7 +90,7 @@ class _TiledImageViewState extends State<TiledImageView> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page); _displaySize = entry.displaySize;
_fullImageListener = ImageStreamListener(_onFullImageCompleted); _fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!useTiles) _registerFullImage(); if (!useTiles) _registerFullImage();
} }
@ -109,7 +101,7 @@ class _TiledImageViewState extends State<TiledImageView> {
final oldViewState = oldWidget.viewStateNotifier.value; final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.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; _isTilingInitialized = false;
_fullImageLoaded.value = false; _fullImageLoaded.value = false;
_unregisterFullImage(); _unregisterFullImage();
@ -278,7 +270,6 @@ class _TiledImageViewState extends State<TiledImageView> {
if (rects != null) { if (rects != null) {
tiles.add(RegionTile( tiles.add(RegionTile(
entry: entry, entry: entry,
page: page,
tileRect: rects.item1, tileRect: rects.item1,
regionRect: rects.item2, regionRect: rects.item2,
sampleSize: sampleSize, sampleSize: sampleSize,
@ -347,7 +338,6 @@ class _TiledImageViewState extends State<TiledImageView> {
class RegionTile extends StatefulWidget { class RegionTile extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final int page;
// `tileRect` uses Flutter view coordinates // `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates // `regionRect` uses the raw image pixel coordinates
@ -357,7 +347,6 @@ class RegionTile extends StatefulWidget {
const RegionTile({ const RegionTile({
@required this.entry, @required this.entry,
@required this.page,
@required this.tileRect, @required this.tileRect,
@required this.regionRect, @required this.regionRect,
@required this.sampleSize, @required this.sampleSize,
@ -406,7 +395,6 @@ class _RegionTileState extends State<RegionTile> {
_provider = RegionProvider(RegionProviderKey.fromEntry( _provider = RegionProvider(RegionProviderKey.fromEntry(
entry, entry,
page: widget.page,
sampleSize: widget.sampleSize, sampleSize: widget.sampleSize,
rect: widget.regionRect, rect: widget.regionRect,
)); ));

View file

@ -101,7 +101,7 @@ class _AvesVideoState extends State<AvesVideo> {
image: UriImage( image: UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
page: 0, page: entry.page,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,