viewer: multitrack HEIF support
This commit is contained in:
parent
4690fac4f6
commit
9956d6521c
27 changed files with 440 additions and 211 deletions
|
@ -1,6 +1,8 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
|
@ -45,6 +47,7 @@ import deckers.thibault.aves.utils.BitmapUtils
|
|||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
|
@ -430,7 +433,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) {
|
||||
if (isHeifLike(mimeType)) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
||||
}
|
||||
|
@ -525,8 +528,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (mimeType == MimeTypes.TIFF) {
|
||||
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
|
||||
return hashMapOf(
|
||||
"width" to options.outWidth,
|
||||
"height" to options.outHeight,
|
||||
KEY_MIME_TYPE to mimeType,
|
||||
KEY_WIDTH to options.outWidth,
|
||||
KEY_HEIGHT to options.outHeight,
|
||||
)
|
||||
}
|
||||
getTiffPageInfo(uri, 0)?.let { first ->
|
||||
|
@ -536,6 +540,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
|
||||
}
|
||||
}
|
||||
} else if (isHeifLike(mimeType)) {
|
||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getInteger(key))
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getLong(key))
|
||||
}
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
extractor.setDataSource(context, uri, null)
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
try {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
|
||||
val page = hashMapOf<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)
|
||||
}
|
||||
|
@ -619,7 +653,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) }
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -733,7 +767,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
|
||||
// catalog metadata
|
||||
// catalog metadata & page info
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||
private const val KEY_FLAGS = "flags"
|
||||
|
@ -742,6 +776,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private const val KEY_LONGITUDE = "longitude"
|
||||
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
||||
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
|
||||
private const val KEY_HEIGHT = "height"
|
||||
private const val KEY_WIDTH = "width"
|
||||
private const val KEY_TRACK_ID = "trackId"
|
||||
private const val KEY_DURATION = "durationMillis"
|
||||
|
||||
private const val MASK_IS_ANIMATED = 1 shl 0
|
||||
private const val MASK_IS_FLIPPED = 1 shl 1
|
||||
|
|
|
@ -13,11 +13,13 @@ import com.bumptech.glide.load.DecodeFormat
|
|||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.decoder.MultiTrackThumbnail
|
||||
import deckers.thibault.aves.decoder.TiffThumbnail
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
|
@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor(
|
|||
private val isFlipped: Boolean,
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
page: Int?,
|
||||
private val page: Int?,
|
||||
private val defaultSize: Int,
|
||||
private val result: MethodChannel.Result,
|
||||
) {
|
||||
private val uri: Uri = Uri.parse(uri)
|
||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||
private val page = page ?: 0
|
||||
private val tiffFetch = mimeType == MimeTypes.TIFF
|
||||
private val multiTrackFetch = isHeifLike(mimeType) && page != null
|
||||
private val customFetch = tiffFetch || multiTrackFetch
|
||||
|
||||
fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
|
@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor(
|
|||
var exception: Exception? = null
|
||||
|
||||
try {
|
||||
if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
// Fetch low quality thumbnails when size is not specified.
|
||||
// As of Android R, the Media Store content resolver may return a thumbnail
|
||||
// that is automatically rotated according to EXIF orientation, but not flipped,
|
||||
|
@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor(
|
|||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri
|
||||
val model: Any = if (tiffFetch) {
|
||||
TiffThumbnail(context, uri, page ?: 0)
|
||||
} else if (multiTrackFetch) {
|
||||
MultiTrackThumbnail(context, uri, page ?: 0)
|
||||
} else uri
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
|
|
|
@ -9,11 +9,13 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.MultiTrackThumbnail
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
|
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||
val isFlipped = arguments["isFlipped"] as Boolean
|
||||
val page = arguments["page"] as Int
|
||||
val page = arguments["page"] as Int?
|
||||
|
||||
if (mimeType == null || uri == null) {
|
||||
error("streamImage-args", "failed because of missing arguments", null)
|
||||
|
@ -98,7 +100,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
streamTiffImage(uri, page)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
|
||||
streamImageByGlide(uri, page, mimeType, rotationDegrees, isFlipped)
|
||||
} else {
|
||||
// to be decoded by Flutter
|
||||
streamImageAsIs(uri)
|
||||
|
@ -114,11 +116,17 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
}
|
||||
|
||||
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
private fun streamImageByGlide(uri: Uri, page: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
val model: Any = if (isHeifLike(mimeType) && page != null) {
|
||||
MultiTrackThumbnail(activity, uri, page)
|
||||
} else {
|
||||
uri
|
||||
}
|
||||
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(uri)
|
||||
.load(model)
|
||||
.submit()
|
||||
try {
|
||||
var bitmap = target.get()
|
||||
|
@ -157,7 +165,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
}
|
||||
|
||||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
||||
private fun streamTiffImage(uri: Uri, page: Int?) {
|
||||
val resolver = activity.contentResolver
|
||||
try {
|
||||
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
|
@ -166,7 +174,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
return
|
||||
}
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inDirectoryNumber = page
|
||||
inDirectoryNumber = page ?: 0
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap != null) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ object MimeTypes {
|
|||
private const val BMP = "image/bmp"
|
||||
const val GIF = "image/gif"
|
||||
const val HEIC = "image/heic"
|
||||
const val HEIF = "image/heif"
|
||||
private const val HEIF = "image/heif"
|
||||
private const val ICO = "image/x-icon"
|
||||
private const val JPEG = "image/jpeg"
|
||||
private const val PNG = "image/png"
|
||||
|
@ -41,10 +41,9 @@ object MimeTypes {
|
|||
|
||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||
|
||||
fun isMultimedia(mimeType: String?) = when (mimeType) {
|
||||
HEIC, HEIF -> true
|
||||
else -> isVideo(mimeType)
|
||||
}
|
||||
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
||||
|
||||
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
|
||||
|
||||
fun isRaw(mimeType: String): Boolean {
|
||||
return when (mimeType) {
|
||||
|
|
|
@ -23,7 +23,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
|
||||
yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, regionRect=${key.regionRect}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
|
||||
final uri = key.uri;
|
||||
final mimeType = key.mimeType;
|
||||
final page = key.page;
|
||||
try {
|
||||
final bytes = await ImageFileService.getRegion(
|
||||
uri,
|
||||
|
@ -40,7 +41,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
key.sampleSize,
|
||||
key.regionRect,
|
||||
key.imageSize,
|
||||
page: key.page,
|
||||
page: page,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -49,7 +50,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||
throw StateError('$mimeType region decoding failed');
|
||||
throw StateError('$mimeType region decoding failed (page $page)');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,7 +76,7 @@ class RegionProviderKey {
|
|||
@required this.mimeType,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
this.page,
|
||||
@required this.sampleSize,
|
||||
@required this.regionRect,
|
||||
@required this.imageSize,
|
||||
|
@ -93,7 +94,6 @@ class RegionProviderKey {
|
|||
// but the entry attributes may change over time
|
||||
factory RegionProviderKey.fromEntry(
|
||||
ImageEntry entry, {
|
||||
int page = 0,
|
||||
@required int sampleSize,
|
||||
@required Rectangle<int> rect,
|
||||
}) {
|
||||
|
@ -102,7 +102,7 @@ class RegionProviderKey {
|
|||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
page: entry.page,
|
||||
sampleSize: sampleSize,
|
||||
regionRect: rect,
|
||||
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
|
||||
|
|
|
@ -24,7 +24,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
|
||||
yield ErrorDescription('uri=${key.uri}, page=${key.page}, mimeType=${key.mimeType}, extent=${key.extent}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
||||
final uri = key.uri;
|
||||
final mimeType = key.mimeType;
|
||||
final page = key.page;
|
||||
try {
|
||||
final bytes = await ImageFileService.getThumbnail(
|
||||
uri,
|
||||
|
@ -41,7 +42,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
key.isFlipped,
|
||||
key.extent,
|
||||
key.extent,
|
||||
page: key.page,
|
||||
page: page,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -50,7 +51,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
||||
throw StateError('$mimeType decoding failed');
|
||||
throw StateError('$mimeType decoding failed (page $page)');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,7 +76,7 @@ class ThumbnailProviderKey {
|
|||
@required this.dateModifiedSecs,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
this.page,
|
||||
this.extent = 0,
|
||||
this.scale = 1,
|
||||
}) : assert(uri != null),
|
||||
|
@ -88,7 +89,7 @@ class ThumbnailProviderKey {
|
|||
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
|
||||
return ThumbnailProviderKey(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
@ -96,7 +97,7 @@ class ThumbnailProviderKey {
|
|||
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
page: entry.page,
|
||||
extent: extent,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
const UriImage({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
this.page = 0,
|
||||
this.page,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.expectedContentLength,
|
||||
|
@ -37,7 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
scale: key.scale,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=$uri, mimeType=$mimeType');
|
||||
yield ErrorDescription('uri=$uri, page=$page, mimeType=$mimeType');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||
throw StateError('$mimeType decoding failed');
|
||||
throw StateError('$mimeType decoding failed (page $page)');
|
||||
} finally {
|
||||
unawaited(chunkEvents.close());
|
||||
}
|
||||
|
|
|
@ -12,14 +12,12 @@ class EntryCache {
|
|||
int oldRotationDegrees,
|
||||
bool oldIsFlipped,
|
||||
) async {
|
||||
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them
|
||||
const page = 0;
|
||||
// TODO TLAD provide page parameter for multipage items, if someday image editing features are added for them
|
||||
|
||||
// evict fullscreen image
|
||||
await UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
page: page,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
).evict();
|
||||
|
@ -31,7 +29,6 @@ class EntryCache {
|
|||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
)).evict();
|
||||
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
|
@ -44,7 +41,6 @@ class EntryCache {
|
|||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
extent: extent,
|
||||
)).evict());
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import '../ref/mime_types.dart';
|
|||
class ImageEntry {
|
||||
String uri;
|
||||
String _path, _directory, _filename, _extension;
|
||||
int contentId;
|
||||
int page, contentId;
|
||||
final String sourceMimeType;
|
||||
int width;
|
||||
int height;
|
||||
|
@ -47,6 +47,7 @@ class ImageEntry {
|
|||
this.uri,
|
||||
String path,
|
||||
this.contentId,
|
||||
this.page,
|
||||
this.sourceMimeType,
|
||||
@required this.width,
|
||||
@required this.height,
|
||||
|
@ -93,6 +94,35 @@ class ImageEntry {
|
|||
return copied;
|
||||
}
|
||||
|
||||
ImageEntry getPageEntry({
|
||||
@required MultiPageInfo multiPageInfo,
|
||||
@required int page,
|
||||
}) {
|
||||
final pageInfo = (multiPageInfo?.pages ?? {})[page];
|
||||
if (pageInfo == null) return this;
|
||||
return AvesPageEntry(
|
||||
pageInfo: pageInfo,
|
||||
uri: uri,
|
||||
path: path,
|
||||
contentId: contentId,
|
||||
page: page,
|
||||
sourceMimeType: sourceMimeType,
|
||||
width: width,
|
||||
height: height,
|
||||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
isMultipage: false,
|
||||
)
|
||||
..addressDetails = _addressDetails?.copyWith();
|
||||
}
|
||||
|
||||
// from DB or platform source entry
|
||||
factory ImageEntry.fromMap(Map map) {
|
||||
return ImageEntry(
|
||||
|
@ -243,18 +273,9 @@ class ImageEntry {
|
|||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
String getResolutionText({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
final ws = w ?? '?';
|
||||
final hs = h ?? '?';
|
||||
String get resolutionText {
|
||||
final ws = width ?? '?';
|
||||
final hs = height ?? '?';
|
||||
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||
}
|
||||
|
||||
|
@ -274,17 +295,10 @@ class ImageEntry {
|
|||
return isPortrait ? height / width : width / height;
|
||||
}
|
||||
|
||||
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
|
||||
Size get displaySize {
|
||||
final w = width.toDouble();
|
||||
final h = height.toDouble();
|
||||
return isPortrait ? Size(h, w) : Size(w, h);
|
||||
}
|
||||
|
||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||
|
|
|
@ -68,17 +68,19 @@ class CatalogMetadata {
|
|||
}
|
||||
|
||||
CatalogMetadata copyWith({
|
||||
@required int contentId,
|
||||
int contentId,
|
||||
String mimeType,
|
||||
bool isMultipage,
|
||||
}) {
|
||||
return CatalogMetadata(
|
||||
contentId: contentId ?? this.contentId,
|
||||
mimeType: mimeType,
|
||||
mimeType: mimeType ?? this.mimeType,
|
||||
dateMillis: dateMillis,
|
||||
isAnimated: isAnimated,
|
||||
isFlipped: isFlipped,
|
||||
isGeotiff: isGeotiff,
|
||||
is360: is360,
|
||||
isMultipage: isMultipage,
|
||||
isMultipage: isMultipage ?? this.isMultipage,
|
||||
rotationDegrees: rotationDegrees,
|
||||
xmpSubjects: xmpSubjects,
|
||||
xmpTitleDescription: xmpTitleDescription,
|
||||
|
@ -169,7 +171,7 @@ class AddressDetails {
|
|||
});
|
||||
|
||||
AddressDetails copyWith({
|
||||
@required int contentId,
|
||||
int contentId,
|
||||
}) {
|
||||
return AddressDetails(
|
||||
contentId: contentId ?? this.contentId,
|
||||
|
|
|
@ -1,24 +1,6 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SinglePageInfo {
|
||||
final int width, height;
|
||||
|
||||
SinglePageInfo({
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
factory SinglePageInfo.fromMap(Map map) {
|
||||
return SinglePageInfo(
|
||||
width: map['width'] as int,
|
||||
height: map['height'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
|
||||
}
|
||||
|
||||
class MultiPageInfo {
|
||||
final Map<int, SinglePageInfo> pages;
|
||||
|
||||
|
@ -40,3 +22,65 @@ class MultiPageInfo {
|
|||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
|
||||
}
|
||||
|
||||
class SinglePageInfo {
|
||||
final String mimeType;
|
||||
final int width, height;
|
||||
final int trackId, durationMillis;
|
||||
|
||||
SinglePageInfo({
|
||||
this.mimeType,
|
||||
this.width,
|
||||
this.height,
|
||||
this.trackId,
|
||||
this.durationMillis,
|
||||
});
|
||||
|
||||
factory SinglePageInfo.fromMap(Map map) {
|
||||
return SinglePageInfo(
|
||||
mimeType: map['mimeType'] as String,
|
||||
width: map['width'] as int,
|
||||
height: map['height'] as int,
|
||||
trackId: map['trackId'] as int,
|
||||
durationMillis: map['durationMillis'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, width=$width, height=$height, trackId=$trackId, durationMillis=$durationMillis}';
|
||||
}
|
||||
|
||||
class AvesPageEntry extends ImageEntry {
|
||||
final SinglePageInfo pageInfo;
|
||||
|
||||
AvesPageEntry({
|
||||
@required this.pageInfo,
|
||||
String uri,
|
||||
String path,
|
||||
int contentId,
|
||||
int page,
|
||||
String sourceMimeType,
|
||||
int width,
|
||||
int height,
|
||||
int sourceRotationDegrees,
|
||||
int sizeBytes,
|
||||
String sourceTitle,
|
||||
int dateModifiedSecs,
|
||||
int sourceDateTakenMillis,
|
||||
int durationMillis,
|
||||
}) : super(
|
||||
uri: uri,
|
||||
path: path,
|
||||
contentId: contentId,
|
||||
page: page,
|
||||
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
|
||||
width: pageInfo.width ?? width,
|
||||
height: pageInfo.height ?? height,
|
||||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? durationMillis,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -74,7 +74,7 @@ class ImageFileService {
|
|||
String mimeType,
|
||||
int rotationDegrees,
|
||||
bool isFlipped, {
|
||||
int page = 0,
|
||||
int page,
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
}) {
|
||||
|
@ -87,7 +87,7 @@ class ImageFileService {
|
|||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
'page': page ?? 0,
|
||||
'page': page,
|
||||
}).listen(
|
||||
(data) {
|
||||
final chunk = data as Uint8List;
|
||||
|
@ -125,7 +125,7 @@ class ImageFileService {
|
|||
int sampleSize,
|
||||
Rectangle<int> regionRect,
|
||||
Size imageSize, {
|
||||
int page = 0,
|
||||
int page,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
|
|
|
@ -19,7 +19,7 @@ class RasterImageThumbnail extends StatefulWidget {
|
|||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
this.page = 0,
|
||||
this.page,
|
||||
this.isScrollingNotifier,
|
||||
this.heroTag,
|
||||
}) : super(key: key);
|
||||
|
@ -33,8 +33,6 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
int get page => widget.page;
|
||||
|
||||
double get extent => widget.extent;
|
||||
|
||||
Object get heroTag => widget.heroTag;
|
||||
|
@ -79,11 +77,11 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
if (!entry.canDecode) return;
|
||||
|
||||
_fastThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page),
|
||||
ThumbnailProviderKey.fromEntry(entry),
|
||||
);
|
||||
if (!entry.isVideo) {
|
||||
_sizedThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
|
||||
ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -153,7 +151,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
final imageProvider = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: page,
|
||||
page: entry.page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
|
|
|
@ -29,7 +29,7 @@ class VectorImageThumbnail extends StatelessWidget {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableSize = constraints.biggest;
|
||||
final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination;
|
||||
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||
final offset = fitSize / 2 - availableSize / 2;
|
||||
final child = DecoratedBox(
|
||||
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
|
||||
|
|
|
@ -80,7 +80,7 @@ class ViewerDebugPage extends StatelessWidget {
|
|||
'isFlipped': '${entry.isFlipped}',
|
||||
'portrait': '${entry.isPortrait}',
|
||||
'displayAspectRatio': '${entry.displayAspectRatio}',
|
||||
'displaySize': '${entry.getDisplaySize()}',
|
||||
'displaySize': '${entry.displaySize}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
|
|
|
@ -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(
|
||||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
mainEntry: entry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
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(
|
||||
entry: entry,
|
||||
mainEntry: entry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
|
|
|
@ -30,7 +30,7 @@ class BasicSection extends StatelessWidget {
|
|||
|
||||
bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0;
|
||||
|
||||
String get rasterResolutionText => '${entry.getResolutionText()}${showMegaPixels ? ' ($megaPixels MP)' : ''}';
|
||||
String get rasterResolutionText => '${entry.resolutionText}${showMegaPixels ? ' ($megaPixels MP)' : ''}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -97,15 +97,32 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
_lastDetails = snapshot.data;
|
||||
_lastEntry = entry;
|
||||
}
|
||||
return _lastEntry == null
|
||||
? SizedBox.shrink()
|
||||
: _BottomOverlayContent(
|
||||
entry: _lastEntry,
|
||||
details: _lastDetails,
|
||||
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
|
||||
availableWidth: availableWidth,
|
||||
multiPageController: multiPageController,
|
||||
);
|
||||
if (_lastEntry == null) return SizedBox.shrink();
|
||||
|
||||
Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
|
||||
mainEntry: _lastEntry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
details: _lastDetails,
|
||||
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
|
||||
availableWidth: availableWidth,
|
||||
multiPageController: multiPageController,
|
||||
);
|
||||
|
||||
if (multiPageController == null) return _buildContent();
|
||||
|
||||
return FutureBuilder<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;
|
||||
|
||||
class _BottomOverlayContent extends AnimatedWidget {
|
||||
final ImageEntry entry;
|
||||
final ImageEntry mainEntry, entry;
|
||||
final MultiPageInfo multiPageInfo;
|
||||
final int page;
|
||||
final OverlayMetadata details;
|
||||
final String position;
|
||||
final double availableWidth;
|
||||
|
@ -131,12 +150,15 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
|
||||
_BottomOverlayContent({
|
||||
Key key,
|
||||
this.entry,
|
||||
this.mainEntry,
|
||||
this.multiPageInfo,
|
||||
this.page,
|
||||
this.details,
|
||||
this.position,
|
||||
this.availableWidth,
|
||||
this.multiPageController,
|
||||
}) : super(key: key, listenable: entry.metadataChangeNotifier);
|
||||
}) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page),
|
||||
super(key: key, listenable: mainEntry.metadataChangeNotifier);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -158,13 +180,13 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
infoColumn = _buildInfoColumn(orientation);
|
||||
}
|
||||
|
||||
if (multiPageController != null) {
|
||||
if (mainEntry.isMultipage && multiPageController != null) {
|
||||
infoColumn = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MultiPageOverlay(
|
||||
entry: entry,
|
||||
mainEntry: mainEntry,
|
||||
controller: multiPageController,
|
||||
availableWidth: availableWidth,
|
||||
),
|
||||
|
@ -340,12 +362,7 @@ class _PositionTitleRow extends StatelessWidget {
|
|||
// page count may be 0 when we know an entry to have multiple pages
|
||||
// but fail to get information about these pages
|
||||
final missingInfo = pageCount == 0;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return toText(pagePosition: missingInfo ? null : '${page + 1}/${pageCount ?? '?'}');
|
||||
},
|
||||
);
|
||||
return toText(pagePosition: missingInfo ? null : '${(entry.page ?? 0) + 1}/${pageCount ?? '?'}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -364,40 +381,14 @@ class _DateRow extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final date = entry.bestDate;
|
||||
final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown;
|
||||
final resolutionText = entry.isSvg ? entry.aspectRatioText : entry.resolutionText;
|
||||
|
||||
Text toText({MultiPageInfo multiPageInfo, int page}) => Text(
|
||||
entry.isSvg
|
||||
? entry.aspectRatioText
|
||||
: entry.getResolutionText(
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
),
|
||||
strutStyle: Constants.overflowStrutStyle,
|
||||
);
|
||||
|
||||
Widget resolutionText;
|
||||
if (multiPageController != null) {
|
||||
resolutionText = FutureBuilder<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(
|
||||
children: [
|
||||
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(flex: 2, child: resolutionText),
|
||||
Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class Minimap extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final ImageEntry mainEntry;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
final MultiPageController multiPageController;
|
||||
final Size size;
|
||||
|
@ -16,7 +16,7 @@ class Minimap extends StatelessWidget {
|
|||
static const defaultSize = Size(96, 96);
|
||||
|
||||
const Minimap({
|
||||
@required this.entry,
|
||||
@required this.mainEntry,
|
||||
@required this.viewStateNotifier,
|
||||
@required this.multiPageController,
|
||||
this.size = defaultSize,
|
||||
|
@ -34,11 +34,12 @@ class Minimap extends StatelessWidget {
|
|||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildForEntrySize(entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page));
|
||||
final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page);
|
||||
return _buildForEntrySize(pageEntry.displaySize);
|
||||
},
|
||||
);
|
||||
})
|
||||
: _buildForEntrySize(entry.getDisplaySize()),
|
||||
: _buildForEntrySize(mainEntry.displaySize),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,22 +3,25 @@ import 'dart:math';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||
import 'package:aves/widgets/viewer/multipage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MultiPageOverlay extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final ImageEntry mainEntry;
|
||||
final MultiPageController controller;
|
||||
final double availableWidth;
|
||||
|
||||
const MultiPageOverlay({
|
||||
MultiPageOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.mainEntry,
|
||||
@required this.controller,
|
||||
@required this.availableWidth,
|
||||
}) : super(key: key);
|
||||
}) : assert(mainEntry.isMultipage),
|
||||
assert(controller != null),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
_MultiPageOverlayState createState() => _MultiPageOverlayState();
|
||||
|
@ -31,7 +34,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
static const double extent = 48;
|
||||
static const double separatorWidth = 2;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
ImageEntry get mainEntry => widget.mainEntry;
|
||||
|
||||
MultiPageController get controller => widget.controller;
|
||||
|
||||
|
@ -97,7 +100,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
width: availableWidth,
|
||||
height: extent,
|
||||
child: ListView.separated(
|
||||
key: ValueKey(entry),
|
||||
key: ValueKey(mainEntry),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: _scrollController,
|
||||
// default padding in scroll direction matches `MediaQuery.viewPadding`,
|
||||
|
@ -106,6 +109,8 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
itemBuilder: (context, index) {
|
||||
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
|
||||
final page = index - 1;
|
||||
final pageEntry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
_syncScroll = false;
|
||||
|
@ -117,15 +122,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
);
|
||||
_syncScroll = true;
|
||||
},
|
||||
child: Container(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
page: page,
|
||||
),
|
||||
),
|
||||
child: _buildPageThumbnail(pageEntry),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => separator,
|
||||
|
@ -165,6 +162,35 @@ class _MultiPageOverlayState extends State<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() {
|
||||
if (_syncScroll) {
|
||||
controller.page = scrollOffsetToPage(_scrollController.offset);
|
||||
|
|
|
@ -86,7 +86,7 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
FadeTransition(
|
||||
opacity: scale,
|
||||
child: Minimap(
|
||||
entry: entry,
|
||||
mainEntry: entry,
|
||||
viewStateNotifier: viewStateNotifier,
|
||||
multiPageController: multiPageController,
|
||||
),
|
||||
|
|
|
@ -16,12 +16,10 @@ class PanoramaPage extends StatefulWidget {
|
|||
static const routeName = '/viewer/panorama';
|
||||
|
||||
final ImageEntry entry;
|
||||
final int page;
|
||||
final PanoramaInfo info;
|
||||
|
||||
const PanoramaPage({
|
||||
@required this.entry,
|
||||
this.page = 0,
|
||||
@required this.info,
|
||||
});
|
||||
|
||||
|
@ -77,7 +75,7 @@ class _PanoramaPageState extends State<PanoramaPage> {
|
|||
image: UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: widget.page,
|
||||
page: entry.page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
|
|
|
@ -57,7 +57,7 @@ class EntryPrinter {
|
|||
return pages;
|
||||
}
|
||||
|
||||
Future<pdf.Widget> _buildPageImage({page = 0}) async {
|
||||
Future<pdf.Widget> _buildPageImage({int page}) async {
|
||||
final uri = entry.uri;
|
||||
final mimeType = entry.mimeType;
|
||||
final rotationDegrees = entry.rotationDegrees;
|
||||
|
|
|
@ -36,16 +36,17 @@ class EntryPageView extends StatefulWidget {
|
|||
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
static const maxScale = ScaleLevel(factor: 2.0);
|
||||
|
||||
const EntryPageView({
|
||||
EntryPageView({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
ImageEntry mainEntry,
|
||||
this.multiPageInfo,
|
||||
this.page = 0,
|
||||
this.page,
|
||||
this.heroTag,
|
||||
@required this.onTap,
|
||||
@required this.videoControllers,
|
||||
this.onDisposed,
|
||||
}) : super(key: key);
|
||||
}) : entry = mainEntry.getPageEntry(multiPageInfo: multiPageInfo, page: page) ?? mainEntry,
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
_EntryPageViewState createState() => _EntryPageViewState();
|
||||
|
@ -58,14 +59,8 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
MultiPageInfo get multiPageInfo => widget.multiPageInfo;
|
||||
|
||||
int get page => widget.page;
|
||||
|
||||
MagnifierTapCallback get onTap => widget.onTap;
|
||||
|
||||
Size get pageDisplaySize => entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -86,7 +81,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
if (entry.isVideo) {
|
||||
if (entry.width > 0 && entry.height > 0) {
|
||||
if (!entry.displaySize.isEmpty) {
|
||||
child = _buildVideoView();
|
||||
}
|
||||
} else if (entry.isSvg) {
|
||||
|
@ -109,15 +104,13 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
Widget _buildRasterView() {
|
||||
return Magnifier(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
key: ValueKey('${entry.page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
child: TiledImageView(
|
||||
entry: entry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
errorBuilder: (context, error, stackTrace) => ErrorView(onTap: () => onTap?.call(null)),
|
||||
),
|
||||
childSize: pageDisplaySize,
|
||||
childSize: entry.displaySize,
|
||||
controller: _magnifierController,
|
||||
maxScale: EntryPageView.maxScale,
|
||||
minScale: EntryPageView.minScale,
|
||||
|
@ -139,7 +132,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
colorFilter: colorFilter,
|
||||
),
|
||||
),
|
||||
childSize: pageDisplaySize,
|
||||
childSize: entry.displaySize,
|
||||
controller: _magnifierController,
|
||||
minScale: EntryPageView.minScale,
|
||||
initialScale: EntryPageView.initialScale,
|
||||
|
@ -149,7 +142,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
|
||||
if (background == EntryBackground.checkered) {
|
||||
child = VectorViewCheckeredBackground(
|
||||
displaySize: pageDisplaySize,
|
||||
displaySize: entry.displaySize,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
child: child,
|
||||
);
|
||||
|
@ -166,7 +159,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
controller: videoController,
|
||||
)
|
||||
: SizedBox(),
|
||||
childSize: pageDisplaySize,
|
||||
childSize: entry.displaySize,
|
||||
controller: _magnifierController,
|
||||
maxScale: EntryPageView.maxScale,
|
||||
minScale: EntryPageView.minScale,
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/image_providers/region_provider.dart';
|
|||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
|
@ -17,15 +16,11 @@ import 'package:tuple/tuple.dart';
|
|||
|
||||
class TiledImageView extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final MultiPageInfo multiPageInfo;
|
||||
final int page;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
final ImageErrorWidgetBuilder errorBuilder;
|
||||
|
||||
const TiledImageView({
|
||||
@required this.entry,
|
||||
this.multiPageInfo,
|
||||
this.page = 0,
|
||||
@required this.viewStateNotifier,
|
||||
@required this.errorBuilder,
|
||||
});
|
||||
|
@ -46,17 +41,15 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
int get page => widget.page;
|
||||
|
||||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||||
|
||||
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
|
||||
|
||||
// as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved
|
||||
// so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution
|
||||
bool get useTiles => entry.canTile && (entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page).longestSide > 4096 || entry.is360);
|
||||
bool get useTiles => entry.canTile && (entry.displaySize.longestSide > 4096 || entry.is360);
|
||||
|
||||
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry, page: page));
|
||||
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
|
||||
|
||||
ImageProvider get fullImageProvider {
|
||||
if (useTiles) {
|
||||
|
@ -76,7 +69,6 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
)?.item2;
|
||||
return RegionProvider(RegionProviderKey.fromEntry(
|
||||
entry,
|
||||
page: page,
|
||||
sampleSize: _maxSampleSize,
|
||||
rect: regionRect,
|
||||
));
|
||||
|
@ -84,7 +76,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
return UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: page,
|
||||
page: entry.page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
|
@ -98,7 +90,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page);
|
||||
_displaySize = entry.displaySize;
|
||||
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||
if (!useTiles) _registerFullImage();
|
||||
}
|
||||
|
@ -109,7 +101,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
|
||||
final oldViewState = oldWidget.viewStateNotifier.value;
|
||||
final viewState = widget.viewStateNotifier.value;
|
||||
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize || oldWidget.page != page) {
|
||||
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
|
||||
_isTilingInitialized = false;
|
||||
_fullImageLoaded.value = false;
|
||||
_unregisterFullImage();
|
||||
|
@ -278,7 +270,6 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
if (rects != null) {
|
||||
tiles.add(RegionTile(
|
||||
entry: entry,
|
||||
page: page,
|
||||
tileRect: rects.item1,
|
||||
regionRect: rects.item2,
|
||||
sampleSize: sampleSize,
|
||||
|
@ -347,7 +338,6 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
|
||||
class RegionTile extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final int page;
|
||||
|
||||
// `tileRect` uses Flutter view coordinates
|
||||
// `regionRect` uses the raw image pixel coordinates
|
||||
|
@ -357,7 +347,6 @@ class RegionTile extends StatefulWidget {
|
|||
|
||||
const RegionTile({
|
||||
@required this.entry,
|
||||
@required this.page,
|
||||
@required this.tileRect,
|
||||
@required this.regionRect,
|
||||
@required this.sampleSize,
|
||||
|
@ -406,7 +395,6 @@ class _RegionTileState extends State<RegionTile> {
|
|||
|
||||
_provider = RegionProvider(RegionProviderKey.fromEntry(
|
||||
entry,
|
||||
page: widget.page,
|
||||
sampleSize: widget.sampleSize,
|
||||
rect: widget.regionRect,
|
||||
));
|
||||
|
|
|
@ -101,7 +101,7 @@ class _AvesVideoState extends State<AvesVideo> {
|
|||
image: UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: 0,
|
||||
page: entry.page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
|
|
Loading…
Reference in a new issue