#11 viewer: multipage TIFF support

This commit is contained in:
Thibault Deckers 2021-01-11 15:11:05 +09:00
parent 9ca5f7b492
commit a121d21ca2
51 changed files with 919 additions and 261 deletions

View file

@ -251,7 +251,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
val dirCount = options.outDirectoryCount
for (i in 1 until dirCount) {
for (page in 1 until dirCount) {
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
@ -259,10 +259,10 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = i
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["$i"] = tiffOptionsToMap(options)
metadataMap["$page"] = tiffOptionsToMap(options)
}
result.success(metadataMap)
} catch (e: Exception) {

View file

@ -58,6 +58,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val isFlipped = call.argument<Boolean>("isFlipped")
val widthDip = call.argument<Double>("widthDip")
val heightDip = call.argument<Double>("heightDip")
val page = call.argument<Int>("page")
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
@ -75,6 +76,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
isFlipped,
width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(),
page = page,
defaultSize = (defaultSizeDip * density).roundToInt(),
result,
).fetch()
@ -83,6 +85,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
val page = call.argument<Int>("page")
val sampleSize = call.argument<Int>("sampleSize")
val x = call.argument<Int>("regionX")
val y = call.argument<Int>("regionY")
@ -102,7 +105,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
uri,
sampleSize,
regionRect,
page = 0,
page = page ?: 0,
result,
)
else -> regionFetcher.fetch(

View file

@ -11,10 +11,7 @@ import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.exif.*
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory
@ -72,6 +69,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) }
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
@ -109,7 +107,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[dirName] = dirMap
// tags
if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) {
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) {
dirMap.putAll(dir.tags.map {
val name = if (it.hasTagName()) {
it.tagName
@ -397,7 +395,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (mimeType == MimeTypes.TIFF && getTiffDirCount(uri) > 1) flags = flags or MASK_IS_MULTIPAGE
if (mimeType == MimeTypes.TIFF && isMultiPageTiff(uri)) flags = flags or MASK_IS_MULTIPAGE
metadataMap[KEY_FLAGS] = flags
}
@ -514,6 +512,33 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getMultiPageInfo-args", "failed because of missing arguments", null)
return
}
val pages = HashMap<Int, Any>()
if (mimeType == MimeTypes.TIFF) {
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
return hashMapOf(
"width" to options.outWidth,
"height" to options.outHeight,
)
}
getTiffPageInfo(uri, 0)?.let { first ->
pages[0] = toMap(first)
val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) {
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
}
}
}
result.success(pages)
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
@ -642,23 +667,25 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
}
private fun getTiffDirCount(uri: Uri): Int {
var dirCount = 1
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
try {
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri")
} else {
return null
}
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
dirCount = options.outDirectoryCount
}
return options
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get TIFF dir count for uri=$uri", e)
Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e)
}
return dirCount
return null
}
companion object {

View file

@ -32,12 +32,14 @@ class ThumbnailFetcher internal constructor(
private val isFlipped: Boolean,
width: Int?,
height: Int?,
page: Int?,
private val defaultSize: Int,
private val result: MethodChannel.Result,
) {
val uri: Uri = Uri.parse(uri)
val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val uri: Uri = Uri.parse(uri)
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val page = page ?: 0
fun fetch() {
var bitmap: Bitmap? = null
@ -108,7 +110,7 @@ class ThumbnailFetcher internal constructor(
// add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width"))
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page"))
.override(width, height)
val target = if (isVideo(mimeType)) {
@ -119,7 +121,7 @@ class ThumbnailFetcher internal constructor(
.load(VideoThumbnail(context, uri))
.submit(width, height)
} else {
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri
Glide.with(context)
.asBitmap()
.apply(options)

View file

@ -4,6 +4,7 @@ import android.app.Activity
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -11,6 +12,7 @@ import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -39,15 +41,33 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
override fun onCancel(o: Any) {}
private fun success(bytes: ByteArray) {
handler.post { eventSink.success(bytes) }
handler.post {
try {
eventSink.success(bytes)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
handler.post {
try {
eventSink.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
handler.post {
try {
eventSink.endOfStream()
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
// Supported image formats:
@ -64,6 +84,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
val page = arguments["page"] as Int
if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", null)
@ -74,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) {
streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri)
streamTiffImage(uri, page)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
@ -139,26 +160,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamTiffImage(uri: Uri, page: Int = 0) {
val resolver = activity.contentResolver
try {
var fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val dirCount = options.outDirectoryCount
// TODO TLAD handle multipage TIFF
if (dirCount > page) {
fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
@ -167,7 +174,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
}
} catch (e: Exception) {
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
}
@ -192,6 +198,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
}
companion object {
private val LOG_TAG = LogUtils.createTag(ImageByteStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/imagebytestream"
const val bufferSize = 2 shl 17 // 256kB

View file

@ -51,15 +51,33 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
// {String uri, bool success, [Map<String, Object> newFields]}
private fun success(result: Map<String, *>) {
handler.post { eventSink.success(result) }
handler.post {
try {
eventSink.success(result)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
handler.post {
try {
eventSink.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
handler.post {
try {
eventSink.endOfStream()
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private suspend fun move() {

View file

@ -3,8 +3,10 @@ package deckers.thibault.aves.channel.streams
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
@ -34,11 +36,23 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
override fun onCancel(arguments: Any?) {}
private fun success(result: FieldMap) {
handler.post { eventSink.success(result) }
handler.post {
try {
eventSink.success(result)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
handler.post {
try {
eventSink.endOfStream()
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private suspend fun fetchAll() {
@ -47,6 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
}
companion object {
private val LOG_TAG = LogUtils.createTag(MediaStoreStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
}
}

View file

@ -3,6 +3,8 @@ package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
@ -30,15 +32,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
override fun onCancel(arguments: Any?) {}
private fun success(result: Boolean) {
handler.post { eventSink.success(result) }
handler.post {
try {
eventSink.success(result)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
endOfStream()
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
handler.post {
try {
eventSink.endOfStream()
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag(StorageAccessStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
}
}

View file

@ -26,7 +26,7 @@ class TiffThumbnailGlideModule : LibraryGlideModule() {
}
}
class TiffThumbnail(val context: Context, val uri: Uri)
class TiffThumbnail(val context: Context, val uri: Uri, val page: Int)
internal class TiffThumbnailLoader : ModelLoader<TiffThumbnail, InputStream> {
override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
@ -46,6 +46,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
val context = model.context
val uri = model.uri
val page = model.page
// determine sample size
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
@ -56,6 +57,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
var sampleSize = 1
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth
@ -74,6 +76,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = page
inSampleSize = sampleSize
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)

View file

@ -40,6 +40,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
key.sampleSize,
key.regionRect,
key.imageSize,
page: key.page,
taskKey: key,
);
if (bytes == null) {
@ -63,7 +64,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
class RegionProviderKey {
final String uri, mimeType;
final int rotationDegrees, sampleSize;
final int rotationDegrees, sampleSize, page;
final bool isFlipped;
final Rectangle<int> regionRect;
final Size imageSize;
@ -74,6 +75,7 @@ class RegionProviderKey {
@required this.mimeType,
@required this.rotationDegrees,
@required this.isFlipped,
this.page = 0,
@required this.sampleSize,
@required this.regionRect,
@required this.imageSize,
@ -91,6 +93,7 @@ class RegionProviderKey {
// but the entry attributes may change over time
factory RegionProviderKey.fromEntry(
ImageEntry entry, {
int page = 0,
@required int sampleSize,
@required Rectangle<int> rect,
}) {
@ -99,6 +102,7 @@ class RegionProviderKey {
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
page: page,
sampleSize: sampleSize,
regionRect: rect,
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
@ -108,7 +112,7 @@ class RegionProviderKey {
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
}
@override
@ -117,7 +121,7 @@ class RegionProviderKey {
mimeType,
rotationDegrees,
isFlipped,
mimeType,
page,
sampleSize,
regionRect,
imageSize,
@ -125,5 +129,5 @@ class RegionProviderKey {
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
}

View file

@ -41,6 +41,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
key.isFlipped,
key.extent,
key.extent,
page: key.page,
taskKey: key,
);
if (bytes == null) {
@ -64,7 +65,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
class ThumbnailProviderKey {
final String uri, mimeType;
final int dateModifiedSecs, rotationDegrees;
final int dateModifiedSecs, rotationDegrees, page;
final bool isFlipped;
final double extent, scale;
@ -74,6 +75,7 @@ class ThumbnailProviderKey {
@required this.dateModifiedSecs,
@required this.rotationDegrees,
@required this.isFlipped,
this.page = 0,
this.extent = 0,
this.scale = 1,
}) : assert(uri != null),
@ -86,7 +88,7 @@ class ThumbnailProviderKey {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
return ThumbnailProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
@ -94,6 +96,7 @@ class ThumbnailProviderKey {
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
page: page,
extent: extent,
);
}
@ -101,7 +104,7 @@ class ThumbnailProviderKey {
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale;
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
}
@override
@ -111,10 +114,11 @@ class ThumbnailProviderKey {
dateModifiedSecs,
rotationDegrees,
isFlipped,
page,
extent,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}';
}

View file

@ -7,9 +7,15 @@ import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType;
final int page, rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale;
const UriImage({
@required this.uri,
@required this.mimeType,
this.page = 0,
@required this.rotationDegrees,
@required this.isFlipped,
this.expectedContentLength,
@ -17,11 +23,6 @@ class UriImage extends ImageProvider<UriImage> {
}) : assert(uri != null),
assert(scale != null);
final String uri, mimeType;
final int rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale;
@override
Future<UriImage> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<UriImage>(this);
@ -50,6 +51,7 @@ class UriImage extends ImageProvider<UriImage> {
mimeType,
rotationDegrees,
isFlipped,
page: page,
expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(
@ -73,12 +75,19 @@ class UriImage extends ImageProvider<UriImage> {
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is UriImage && other.uri == uri && other.scale == scale;
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
}
@override
int get hashCode => hashValues(uri, scale);
int get hashCode => hashValues(
uri,
mimeType,
rotationDegrees,
isFlipped,
page,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}';
}

View file

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

View file

@ -4,6 +4,7 @@ import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart';
@ -242,10 +243,19 @@ class ImageEntry {
static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 ';
String get resolutionText {
final w = width ?? '?';
final h = height ?? '?';
return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h';
String getResolutionText({MultiPageInfo multiPageInfo, int page}) {
int w;
int h;
if (multiPageInfo != null && page != null) {
final pageInfo = multiPageInfo.pages[page];
w = pageInfo?.width;
h = pageInfo?.height;
}
w ??= width;
h ??= height;
final ws = w ?? '?';
final hs = h ?? '?';
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
}
String get aspectRatioText {
@ -264,7 +274,18 @@ class ImageEntry {
return isPortrait ? height / width : width / height;
}
Size get displaySize => isPortrait ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) {
int w;
int h;
if (multiPageInfo != null && page != null) {
final pageInfo = multiPageInfo.pages[page];
w = pageInfo?.width;
h = pageInfo?.height;
}
w ??= width;
h ??= height;
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
}
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;

42
lib/model/multipage.dart Normal file
View file

@ -0,0 +1,42 @@
import 'package:flutter/foundation.dart';
class SinglePageInfo {
final int width, height;
SinglePageInfo({
this.width,
this.height,
});
factory SinglePageInfo.fromMap(Map map) {
return SinglePageInfo(
width: map['width'] as int,
height: map['height'] as int,
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
}
class MultiPageInfo {
final Map<int, SinglePageInfo> pages;
int get pageCount => pages.length;
MultiPageInfo({
this.pages,
});
factory MultiPageInfo.fromMap(Map map) {
final pages = <int, SinglePageInfo>{};
map.keys.forEach((key) {
final index = key as int;
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key]));
});
return MultiPageInfo(pages: pages);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
}

View file

@ -74,6 +74,7 @@ class ImageFileService {
String mimeType,
int rotationDegrees,
bool isFlipped, {
int page = 0,
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
}) {
@ -86,6 +87,7 @@ class ImageFileService {
'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false,
'page': page ?? 0,
}).listen(
(data) {
final chunk = data as Uint8List;
@ -123,6 +125,7 @@ class ImageFileService {
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
int page = 0,
Object taskKey,
int priority,
}) {
@ -132,6 +135,7 @@ class ImageFileService {
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'page': page,
'sampleSize': sampleSize,
'regionX': regionRect.left,
'regionY': regionRect.top,
@ -159,6 +163,7 @@ class ImageFileService {
bool isFlipped,
double width,
double height, {
int page,
Object taskKey,
int priority,
}) {
@ -176,6 +181,7 @@ class ImageFileService {
'isFlipped': isFlipped,
'widthDip': width,
'heightDip': height,
'page': page,
'defaultSizeDip': thumbnailDefaultSize,
});
return result as Uint8List;
@ -217,7 +223,6 @@ class ImageFileService {
}
static Stream<MoveOpEvent> move(Iterable<ImageEntry> entries, {@required bool copy, @required String destinationAlbum}) {
debugPrint('move ${entries.length} entries');
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -80,6 +81,19 @@ class MetadataService {
return null;
}
static Future<MultiPageInfo> getMultiPageInfo(ImageEntry entry) async {
try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
}) as Map;
return MultiPageInfo.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{

View file

@ -29,10 +29,11 @@ class Durations {
// search animations
static const filterRowExpandAnimation = Duration(milliseconds: 300);
// fullscreen animations
static const fullscreenPageAnimation = Duration(milliseconds: 300);
static const fullscreenOverlayAnimation = Duration(milliseconds: 200);
static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150);
// viewer animations
static const viewerPageAnimation = Duration(milliseconds: 300);
static const viewerOverlayAnimation = Duration(milliseconds: 200);
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200);
// info
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);

View file

@ -68,7 +68,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
@override
void didUpdateWidget(CollectionAppBar oldWidget) {
void didUpdateWidget(covariant CollectionAppBar oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -29,7 +29,7 @@ class _FilterBarState extends State<FilterBar> {
CollectionFilter _userRemovedFilter;
@override
void didUpdateWidget(FilterBar oldWidget) {
void didUpdateWidget(covariant FilterBar oldWidget) {
super.didUpdateWidget(oldWidget);
final current = widget.filters;
final existing = oldWidget.filters;

View file

@ -33,7 +33,6 @@ class SectionedListLayoutProvider extends StatelessWidget {
}
SectionedListLayout _updateLayouts(BuildContext context) {
// debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent');
final sectionLayouts = <SectionLayout>[];
final showHeaders = collection.showHeaders;
final source = collection.source;

View file

@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
class ThumbnailRasterImage extends StatefulWidget {
final ImageEntry entry;
final double extent;
final int page;
final ValueNotifier<bool> isScrollingNotifier;
final Object heroTag;
@ -18,6 +19,7 @@ class ThumbnailRasterImage extends StatefulWidget {
Key key,
@required this.entry,
@required this.extent,
this.page = 0,
this.isScrollingNotifier,
this.heroTag,
}) : super(key: key);
@ -31,6 +33,8 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
ImageEntry get entry => widget.entry;
int get page => widget.page;
double get extent => widget.extent;
Object get heroTag => widget.heroTag;
@ -47,7 +51,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
}
@override
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
void didUpdateWidget(covariant ThumbnailRasterImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) {
_unregisterWidget(oldWidget);
@ -75,11 +79,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
if (!entry.canDecode) return;
_fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry),
ThumbnailProviderKey.fromEntry(entry, page: page),
);
if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
);
}
}
@ -149,6 +153,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
page: page,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,

View file

@ -29,7 +29,7 @@ class ThumbnailVectorImage extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final availableSize = constraints.biggest;
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination;
final offset = fitSize / 2 - availableSize / 2;
final child = DecoratedBox(
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),

View file

@ -173,7 +173,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
}
@override
void didUpdateWidget(CollectionScrollView oldWidget) {
void didUpdateWidget(covariant CollectionScrollView oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -30,7 +30,7 @@ class _MultiCrossFaderState extends State<MultiCrossFader> {
}
@override
void didUpdateWidget(MultiCrossFader oldWidget) {
void didUpdateWidget(covariant MultiCrossFader oldWidget) {
super.didUpdateWidget(oldWidget);
if (_first == oldWidget.child) {
_second = widget.child;

View file

@ -57,7 +57,7 @@ class _SweeperState extends State<Sweeper> with SingleTickerProviderStateMixin {
}
@override
void didUpdateWidget(Sweeper oldWidget) {
void didUpdateWidget(covariant Sweeper oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -57,7 +57,7 @@ class _TransitionImageState extends State<TransitionImage> {
}
@override
void didUpdateWidget(TransitionImage oldWidget) {
void didUpdateWidget(covariant TransitionImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isListeningToStream) {
_imageStream.removeListener(_getListener());

View file

@ -66,7 +66,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
}
@override
void didUpdateWidget(AvesFilterChip oldWidget) {
void didUpdateWidget(covariant AvesFilterChip oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filter != filter) {
_initColorLoader();

View file

@ -58,19 +58,14 @@ class Magnifier extends StatefulWidget {
}
class _MagnifierState extends State<Magnifier> {
Size _childSize;
bool _controlledController;
MagnifierController _controller;
void _setChildSize(Size childSize) {
_childSize = childSize.isEmpty ? null : childSize;
}
Size get childSize => widget.childSize;
@override
void initState() {
super.initState();
_setChildSize(widget.childSize);
if (widget.controller == null) {
_controlledController = true;
_controller = MagnifierController();
@ -81,12 +76,8 @@ class _MagnifierState extends State<Magnifier> {
}
@override
void didUpdateWidget(Magnifier oldWidget) {
if (oldWidget.childSize != widget.childSize && widget.childSize != null) {
setState(() {
_setChildSize(widget.childSize);
});
}
void didUpdateWidget(covariant Magnifier oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller == null) {
if (!_controlledController) {
_controlledController = true;
@ -96,7 +87,6 @@ class _MagnifierState extends State<Magnifier> {
_controlledController = false;
_controller = widget.controller;
}
super.didUpdateWidget(oldWidget);
}
@override
@ -116,7 +106,7 @@ class _MagnifierState extends State<Magnifier> {
widget.maxScale ?? ScaleLevel(factor: double.infinity),
widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained),
constraints.biggest,
_childSize ?? constraints.biggest,
widget.childSize?.isEmpty == true ? constraints.biggest: widget.childSize,
));
return MagnifierCore(

View file

@ -109,6 +109,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
UriImage(
uri: uri,
mimeType: mimeType,
// TODO TLAD multipage print
page: 0,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
),

View file

@ -14,6 +14,7 @@ import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
import 'package:aves/widgets/fullscreen/overlay/panorama.dart';
import 'package:aves/widgets/fullscreen/overlay/top.dart';
@ -54,6 +55,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
EdgeInsets _frozenViewInsets, _frozenViewPadding;
EntryActionDelegate _actionDelegate;
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
final List<Tuple2<String, MultiPageController>> _multiPageControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
CollectionLens get collection => widget.collection;
@ -78,7 +80,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
_verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange);
_overlayAnimationController = AnimationController(
duration: Durations.fullscreenOverlayAnimation,
duration: Durations.viewerOverlayAnimation,
vsync: this,
);
_topOverlayScale = CurvedAnimation(
@ -110,7 +112,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
}
@override
void didUpdateWidget(FullscreenBody oldWidget) {
void didUpdateWidget(covariant FullscreenBody oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
@ -122,6 +124,8 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
_videoControllers.clear();
_multiPageControllers.forEach((kv) => kv.item2.dispose());
_multiPageControllers.clear();
_verticalPager.removeListener(_onVerticalPageControllerChange);
WidgetsBinding.instance.removeObserver(this);
_unregisterWidget(widget);
@ -170,6 +174,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection,
entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
multiPageControllers: _multiPageControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
@ -196,6 +201,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
valueListenable: _entryNotifier,
builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry);
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return FullscreenTopOverlay(
entry: entry,
@ -205,6 +213,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
);
},
);
@ -226,6 +235,8 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
final multiPageController = _getMultiPageController(entry);
Widget extraBottomOverlay;
if (entry.isVideo) {
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
@ -259,6 +270,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
showPosition: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
multiPageController: multiPageController,
),
),
],
@ -296,6 +308,10 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
return bottomOverlay;
}
MultiPageController _getMultiPageController(ImageEntry entry) {
return entry.isMultipage ? _multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
}
void _onVerticalPageControllerChange() {
_verticalScrollNotifier.notifyListeners();
}
@ -315,7 +331,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Future<void> _goToVerticalPage(int page) {
return _verticalPager.animateToPage(
page,
duration: Durations.fullscreenPageAnimation,
duration: Durations.viewerPageAnimation,
curve: Curves.easeInOut,
);
}
@ -428,6 +444,14 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
(_) => _.dispose(),
);
}
if (entry.isMultipage) {
_initViewSpecificController<MultiPageController>(
uri,
_multiPageControllers,
() => MultiPageController(entry),
(_) => _.dispose(),
);
}
setState(() {});
}
@ -452,6 +476,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ValueNotifier<ImageEntry> entryNotifier;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested;
@ -461,6 +486,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
@required this.collection,
@required this.entryNotifier,
@required this.videoControllers,
@required this.multiPageControllers,
@required this.verticalPager,
@required this.horizontalPager,
@required this.onVerticalPageChanged,
@ -492,7 +518,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
}
@override
void didUpdateWidget(FullscreenVerticalPageView oldWidget) {
void didUpdateWidget(covariant FullscreenVerticalPageView oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
@ -528,12 +554,14 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
onTap: widget.onImageTap,
onPageChanged: widget.onHorizontalPageChanged,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
onViewDisposed: widget.onViewDisposed,
)
: SingleImagePage(
entry: entry,
onTap: widget.onImageTap,
videoControllers: widget.videoControllers,
multiPageControllers: widget.multiPageControllers,
),
NotificationListener(
onNotification: (notification) {

View file

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

View file

@ -1,8 +1,10 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:tuple/tuple.dart';
@ -13,6 +15,7 @@ class MultiImagePage extends StatefulWidget {
final ValueChanged<int> onPageChanged;
final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
final void Function(String uri) onViewDisposed;
const MultiImagePage({
@ -21,6 +24,7 @@ class MultiImagePage extends StatefulWidget {
this.onPageChanged,
this.onTap,
this.videoControllers,
this.multiPageControllers,
this.onViewDisposed,
});
@ -45,15 +49,29 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) {
final entry = entries[index];
Widget child;
if (entry.isMultipage) {
final multiPageController = _getMultiPageController(entry);
if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(entry, multiPageInfo: multiPageInfo, page: page);
},
);
},
);
}
}
child ??= _buildViewer(entry);
return ClipRect(
child: ImageView(
key: Key('imageview'),
entry: entry,
heroTag: widget.collection.heroTag(entry),
onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
),
child: child,
);
},
itemCount: entries.length,
@ -61,6 +79,23 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
);
}
ImageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page = 0}) {
return ImageView(
key: Key('imageview'),
entry: entry,
multiPageInfo: multiPageInfo,
page: page,
heroTag: widget.collection.heroTag(entry),
onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
);
}
MultiPageController _getMultiPageController(ImageEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override
bool get wantKeepAlive => true;
}
@ -69,11 +104,13 @@ class SingleImagePage extends StatefulWidget {
final ImageEntry entry;
final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final List<Tuple2<String, MultiPageController>> multiPageControllers;
const SingleImagePage({
this.entry,
this.onTap,
this.videoControllers,
this.multiPageControllers,
});
@override
@ -81,20 +118,52 @@ class SingleImagePage extends StatefulWidget {
}
class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliveClientMixin {
ImageEntry get entry => widget.entry;
@override
Widget build(BuildContext context) {
super.build(context);
Widget child;
if (entry.isMultipage) {
final multiPageController = _getMultiPageController(entry);
if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(multiPageInfo: multiPageInfo, page: page);
},
);
},
);
}
}
child ??= _buildViewer();
return MagnifierGestureDetectorScope(
axis: [Axis.vertical],
child: ImageView(
entry: widget.entry,
child: child,
);
}
ImageView _buildViewer({MultiPageInfo multiPageInfo, int page = 0}) {
return ImageView(
entry: entry,
multiPageInfo: multiPageInfo,
page: page,
onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers,
),
);
}
MultiPageController _getMultiPageController(ImageEntry entry) {
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
}
@override
bool get wantKeepAlive => true;
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
@ -23,6 +24,8 @@ import 'package:tuple/tuple.dart';
class ImageView extends StatefulWidget {
final ImageEntry entry;
final MultiPageInfo multiPageInfo;
final int page;
final Object heroTag;
final MagnifierTapCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
@ -33,6 +36,8 @@ class ImageView extends StatefulWidget {
const ImageView({
Key key,
@required this.entry,
this.multiPageInfo,
this.page = 0,
this.heroTag,
@required this.onTap,
@required this.videoControllers,
@ -54,8 +59,14 @@ class _ImageViewState extends State<ImageView> {
ImageEntry get entry => widget.entry;
MultiPageInfo get multiPageInfo => widget.multiPageInfo;
int get page => widget.page;
MagnifierTapCallback get onTap => widget.onTap;
Size get pageDisplaySize => entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page);
@override
void initState() {
super.initState();
@ -99,13 +110,15 @@ class _ImageViewState extends State<ImageView> {
Widget _buildRasterView() {
return Magnifier(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
key: ValueKey('${page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
child: TiledImageView(
entry: entry,
multiPageInfo: multiPageInfo,
page: page,
viewStateNotifier: _viewStateNotifier,
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
),
childSize: entry.displaySize,
childSize: pageDisplaySize,
controller: _magnifierController,
maxScale: maxScale,
minScale: minScale,
@ -127,7 +140,7 @@ class _ImageViewState extends State<ImageView> {
colorFilter: colorFilter,
),
),
childSize: entry.displaySize,
childSize: pageDisplaySize,
controller: _magnifierController,
minScale: minScale,
initialScale: initialScale,
@ -145,7 +158,7 @@ class _ImageViewState extends State<ImageView> {
final side = viewportSize.shortestSide;
final checkSize = side / ((side / ImageView.decorationCheckSize).round());
final viewSize = entry.displaySize * viewState.scale;
final viewSize = pageDisplaySize * viewState.scale;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
final offset = ((decorationSize - viewportSize) as Offset) / 2;
@ -181,7 +194,7 @@ class _ImageViewState extends State<ImageView> {
controller: videoController,
)
: SizedBox(),
childSize: entry.displaySize,
childSize: pageDisplaySize,
controller: _magnifierController,
maxScale: maxScale,
minScale: minScale,

View file

@ -26,12 +26,16 @@ class BasicSection extends StatelessWidget {
@required this.onFilter,
}) : super(key: key);
int get megaPixels => entry.megaPixels;
bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0;
String get rasterResolutionText => '${entry.getResolutionText()}${showMegaPixels ? ' ($megaPixels MP)' : ''}';
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.infoUnknown;
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
// TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
@ -46,7 +50,7 @@ class BasicSection extends StatelessWidget {
'Title': title,
'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText,
if (!entry.isSvg) 'Resolution': rasterResolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
'URI': uri,
if (path != null) 'Path': path,

View file

@ -99,7 +99,7 @@ class InfoPageState extends State<InfoPage> {
BackUpNotification().dispatch(context);
_scrollController.animateTo(
0,
duration: Durations.fullscreenPageAnimation,
duration: Durations.viewerPageAnimation,
curve: Curves.easeInOut,
);
}

View file

@ -51,7 +51,7 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
}
@override
void didUpdateWidget(LocationSection oldWidget) {
void didUpdateWidget(covariant LocationSection oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -41,7 +41,7 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
}
@override
void didUpdateWidget(EntryGoogleMap oldWidget) {
void didUpdateWidget(covariant EntryGoogleMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.latLng != oldWidget.latLng && _controller != null) {
_controller.moveCamera(CameraUpdate.newLatLng(widget.latLng));

View file

@ -35,7 +35,7 @@ class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliv
final MapController _mapController = MapController();
@override
void didUpdateWidget(EntryLeafletMap oldWidget) {
void didUpdateWidget(covariant EntryLeafletMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.latLng != oldWidget.latLng && _mapController != null) {
_mapController.move(widget.latLng, settings.infoMapZoom);

View file

@ -116,7 +116,7 @@ class MarkerPointerPainter extends CustomPainter {
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// generate bitmap from widget, for Google Maps

View file

@ -52,7 +52,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
@override
void didUpdateWidget(MetadataSectionSliver oldWidget) {
void didUpdateWidget(covariant MetadataSectionSliver oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -0,0 +1,24 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageController extends ChangeNotifier {
final Future<MultiPageInfo> info;
final ValueNotifier<int> pageNotifier = ValueNotifier(0);
MultiPageController(ImageEntry entry) : info = MetadataService.getMultiPageInfo(entry);
int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page;
@override
void dispose() {
pageNotifier.dispose();
super.dispose();
}
}

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart';
@ -9,7 +10,9 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:aves/widgets/fullscreen/overlay/multipage.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@ -21,6 +24,7 @@ class FullscreenBottomOverlay extends StatefulWidget {
final int index;
final bool showPosition;
final EdgeInsets viewInsets, viewPadding;
final MultiPageController multiPageController;
const FullscreenBottomOverlay({
Key key,
@ -29,6 +33,7 @@ class FullscreenBottomOverlay extends StatefulWidget {
@required this.showPosition,
this.viewInsets,
this.viewPadding,
@required this.multiPageController,
}) : super(key: key);
@override
@ -40,8 +45,6 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
ImageEntry _lastEntry;
OverlayMetadata _lastDetails;
static const innerPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8);
ImageEntry get entry {
final entries = widget.entries;
final index = widget.index;
@ -55,7 +58,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
}
@override
void didUpdateWidget(FullscreenBottomOverlay oldWidget) {
void didUpdateWidget(covariant FullscreenBottomOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (entry != _lastEntry) {
_initDetailLoader();
@ -68,8 +71,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: BlurredRect(
return BlurredRect(
child: Selector<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>(
selector: (c, mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding),
builder: (c, mq, child) {
@ -79,36 +81,32 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
final viewInsets = widget.viewInsets ?? mqViewInsets;
final viewPadding = widget.viewPadding ?? mqViewPadding;
final overlayContentMaxWidth = mqWidth - viewPadding.horizontal - innerPadding.horizontal;
final availableWidth = mqWidth - viewPadding.horizontal;
return Container(
color: kOverlayBackgroundColor,
padding: viewInsets + viewPadding.copyWith(top: 0),
child: FutureBuilder<OverlayMetadata>(
future: _detailLoader,
builder: (futureContext, snapshot) {
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
_lastDetails = snapshot.data;
_lastEntry = entry;
}
return _lastEntry == null
? SizedBox.shrink()
: Padding(
// keep padding inside `FutureBuilder` so that overlay takes no space until data is ready
padding: innerPadding,
child: _FullscreenBottomOverlayContent(
: _BottomOverlayContent(
entry: _lastEntry,
details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
maxWidth: overlayContentMaxWidth,
),
availableWidth: availableWidth,
multiPageController: widget.multiPageController,
);
},
),
);
},
),
),
);
}
}
@ -118,22 +116,28 @@ const double _iconSize = 16.0;
const double _interRowPadding = 2.0;
const double _subRowMinWidth = 300.0;
class _FullscreenBottomOverlayContent extends AnimatedWidget {
class _BottomOverlayContent extends AnimatedWidget {
final ImageEntry entry;
final OverlayMetadata details;
final String position;
final double maxWidth;
final double availableWidth;
final MultiPageController multiPageController;
_FullscreenBottomOverlayContent({
static const infoPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8);
_BottomOverlayContent({
Key key,
this.entry,
this.details,
this.position,
this.maxWidth,
this.availableWidth,
this.multiPageController,
}) : super(key: key, listenable: entry.metadataChangeNotifier);
@override
Widget build(BuildContext context) {
final infoMaxWidth = availableWidth - infoPadding.horizontal;
return DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: [Constants.embossShadow],
@ -142,29 +146,34 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
overflow: TextOverflow.fade,
maxLines: 1,
child: SizedBox(
width: maxWidth,
width: availableWidth,
child: Selector<MediaQueryData, Orientation>(
selector: (c, mq) => mq.orientation,
builder: (c, orientation, child) {
final twoColumns = orientation == Orientation.landscape && maxWidth / 2 > _subRowMinWidth;
final subRowWidth = twoColumns ? min(_subRowMinWidth, maxWidth / 2) : maxWidth;
final positionTitle = [
if (position != null) position,
if (entry.bestTitle != null) entry.bestTitle,
].join('');
final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth;
final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth;
final positionTitle = _PositionTitleRow(entry: entry, collectionPosition: position, multiPageController: multiPageController);
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Column(
Widget infoColumn = Padding(
padding: infoPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
if (positionTitle.isNotEmpty) positionTitle,
_buildSoloLocationRow(),
if (twoColumns)
Padding(
padding: EdgeInsets.only(top: _interRowPadding),
child: Row(
children: [
Container(width: subRowWidth, child: _DateRow(entry)),
Container(
width: subRowWidth,
child: _DateRow(
entry: entry,
multiPageController: multiPageController,
)),
_buildDuoShootingRow(subRowWidth, hasShootingDetails),
],
),
@ -173,12 +182,33 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
Container(
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _DateRow(entry),
child: _DateRow(
entry: entry,
multiPageController: multiPageController,
),
),
_buildSoloShootingRow(subRowWidth, hasShootingDetails),
],
],
),
);
if (multiPageController != null) {
infoColumn = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MultiPageOverlay(
entry: entry,
controller: multiPageController,
availableWidth: availableWidth,
),
infoColumn,
],
);
}
return infoColumn;
},
),
),
@ -186,7 +216,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
}
Widget _buildSoloLocationRow() => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
duration: Durations.viewerOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
@ -199,7 +229,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
);
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
duration: Durations.viewerOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
@ -213,7 +243,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
);
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
duration: Durations.viewerOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
@ -264,21 +294,96 @@ class _LocationRow extends AnimatedWidget {
}
}
class _PositionTitleRow extends StatelessWidget {
final ImageEntry entry;
final String collectionPosition;
final MultiPageController multiPageController;
const _PositionTitleRow({
@required this.entry,
@required this.collectionPosition,
@required this.multiPageController,
});
String get title => entry.bestTitle;
bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null;
@override
Widget build(BuildContext context) {
Text toText({String pagePosition}) => Text(
[
if (collectionPosition != null) collectionPosition,
if (pagePosition != null) pagePosition,
if (title != null) title,
].join(''),
strutStyle: Constants.overflowStrutStyle);
if (multiPageController == null) return toText();
return FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
final pageCount = multiPageInfo?.pageCount ?? '?';
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return toText(pagePosition: '${page + 1}/$pageCount');
},
);
},
);
}
}
class _DateRow extends StatelessWidget {
final ImageEntry entry;
final MultiPageController multiPageController;
const _DateRow(this.entry);
const _DateRow({
@required this.entry,
@required this.multiPageController,
});
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.overlayUnknown;
Text toText({MultiPageInfo multiPageInfo, int page}) => Text(
entry.isSvg
? entry.aspectRatioText
: entry.getResolutionText(
multiPageInfo: multiPageInfo,
page: page,
),
strutStyle: Constants.overflowStrutStyle,
);
Widget resolutionText;
if (multiPageController != null) {
resolutionText = FutureBuilder<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: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)),
Expanded(flex: 2, child: resolutionText),
],
);
}

View file

@ -1,13 +1,16 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class Minimap extends StatelessWidget {
final ImageEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
final Size size;
static const defaultSize = Size(96, 96);
@ -15,13 +18,32 @@ class Minimap extends StatelessWidget {
const Minimap({
@required this.entry,
@required this.viewStateNotifier,
@required this.multiPageController,
this.size = defaultSize,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: ValueListenableBuilder<ViewState>(
child: multiPageController != null
? FutureBuilder<MultiPageInfo>(
future: multiPageController.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildForEntrySize(entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page));
},
);
})
: _buildForEntrySize(entry.getDisplaySize()),
);
}
Widget _buildForEntrySize(Size entrySize) {
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
@ -29,15 +51,14 @@ class Minimap extends StatelessWidget {
return CustomPaint(
painter: MinimapPainter(
viewportSize: viewportSize,
entrySize: entry.displaySize,
entrySize: entrySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
);
}),
);
});
}
}

View file

@ -0,0 +1,177 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultiPageOverlay extends StatefulWidget {
final ImageEntry entry;
final MultiPageController controller;
final double availableWidth;
const MultiPageOverlay({
Key key,
@required this.entry,
@required this.controller,
@required this.availableWidth,
}) : super(key: key);
@override
_MultiPageOverlayState createState() => _MultiPageOverlayState();
}
class _MultiPageOverlayState extends State<MultiPageOverlay> {
ScrollController _scrollController;
bool _syncScroll = true;
static const double extent = 48;
static const double separatorWidth = 2;
ImageEntry get entry => widget.entry;
MultiPageController get controller => widget.controller;
double get availableWidth => widget.availableWidth;
@override
void initState() {
super.initState();
_registerWidget();
}
@override
void didUpdateWidget(covariant MultiPageOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != controller) {
_unregisterWidget();
_registerWidget();
}
}
@override
void dispose() {
_unregisterWidget();
super.dispose();
}
void _registerWidget() {
final scrollOffset = pageToScrollOffset(controller.page);
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange);
}
void _unregisterWidget() {
_scrollController.removeListener(_onScrollChange);
_scrollController.dispose();
}
@override
Widget build(BuildContext context) {
final marginWidth = max(0, (availableWidth - extent) / 2 - separatorWidth);
final horizontalMargin = SizedBox(width: marginWidth);
final separator = SizedBox(width: separatorWidth);
final shade = IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black38,
),
),
);
return FutureBuilder<MultiPageInfo>(
future: controller.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox.shrink();
return Container(
height: extent + separatorWidth * 2,
child: Stack(
children: [
Positioned(
top: separatorWidth,
width: availableWidth,
height: extent,
child: ListView.separated(
key: ValueKey(entry),
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
final page = index - 1;
return GestureDetector(
onTap: () async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageChooserAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
child: Container(
width: extent,
height: extent,
child: ThumbnailRasterImage(
entry: entry,
extent: extent,
page: page,
),
),
);
},
separatorBuilder: (context, index) => separator,
itemCount: multiPageInfo.pageCount + 2,
),
),
Positioned(
left: 0,
top: separatorWidth,
width: marginWidth + separatorWidth,
height: extent,
child: shade,
),
Positioned(
top: separatorWidth,
right: 0,
width: marginWidth + separatorWidth,
height: extent,
child: shade,
),
Positioned(
top: 0,
width: availableWidth,
height: separatorWidth,
child: shade,
),
Positioned(
bottom: 0,
width: availableWidth,
height: separatorWidth,
child: shade,
),
],
),
);
},
);
}
void _onScrollChange() {
if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset);
}
}
double pageToScrollOffset(int page) => page * (extent + separatorWidth);
int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round();
}

View file

@ -9,6 +9,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/multipage_controller.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:aves/widgets/fullscreen/overlay/minimap.dart';
import 'package:flutter/foundation.dart';
@ -24,6 +25,7 @@ class FullscreenTopOverlay extends StatelessWidget {
final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite;
final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
static const double padding = 8;
@ -39,7 +41,8 @@ class FullscreenTopOverlay extends StatelessWidget {
@required this.viewInsets,
@required this.viewPadding,
@required this.onActionSelected,
this.viewStateNotifier,
@required this.viewStateNotifier,
@required this.multiPageController,
}) : super(key: key);
@override
@ -85,6 +88,7 @@ class FullscreenTopOverlay extends StatelessWidget {
child: Minimap(
entry: entry,
viewStateNotifier: viewStateNotifier,
multiPageController: multiPageController,
),
)
],
@ -320,7 +324,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
}
@override
void didUpdateWidget(_FavouriteToggler oldWidget) {
void didUpdateWidget(covariant _FavouriteToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_onChanged();
}

View file

@ -65,7 +65,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
}
@override
void didUpdateWidget(VideoControlOverlay oldWidget) {
void didUpdateWidget(covariant VideoControlOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);

View file

@ -8,7 +8,12 @@ class PanoramaPage extends StatelessWidget {
final ImageEntry entry;
const PanoramaPage({@required this.entry});
final int page;
const PanoramaPage({
@required this.entry,
this.page = 0,
});
@override
Widget build(BuildContext context) {
@ -18,6 +23,7 @@ class PanoramaPage extends StatelessWidget {
image: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
page: page,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,

View file

@ -4,6 +4,7 @@ import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart';
@ -15,11 +16,15 @@ import 'package:tuple/tuple.dart';
class TiledImageView extends StatefulWidget {
final ImageEntry entry;
final MultiPageInfo multiPageInfo;
final int page;
final ValueNotifier<ViewState> viewStateNotifier;
final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({
@required this.entry,
this.multiPageInfo,
this.page = 0,
@required this.viewStateNotifier,
@required this.errorBuilder,
});
@ -29,6 +34,7 @@ class TiledImageView extends StatefulWidget {
}
class _TiledImageViewState extends State<TiledImageView> {
Size _displaySize;
bool _isTilingInitialized = false;
int _maxSampleSize;
double _tileSide;
@ -39,19 +45,21 @@ 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;
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry, page: page));
ImageProvider get fullImageProvider {
if (useTiles) {
assert(_isTilingInitialized);
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final displayWidth = _displaySize.width.round();
final displayHeight = _displaySize.height.round();
final viewState = viewStateNotifier.value;
final regionRect = _getTileRects(
x: 0,
@ -62,9 +70,10 @@ class _TiledImageViewState extends State<TiledImageView> {
displayHeight: displayHeight,
scale: viewState.scale,
viewRect: _getViewRect(viewState, displayWidth, displayHeight),
).item2;
)?.item2;
return RegionProvider(RegionProviderKey.fromEntry(
entry,
page: page,
sampleSize: _maxSampleSize,
rect: regionRect,
));
@ -72,6 +81,7 @@ class _TiledImageViewState extends State<TiledImageView> {
return UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
page: page,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
@ -85,17 +95,18 @@ class _TiledImageViewState extends State<TiledImageView> {
@override
void initState() {
super.initState();
_displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page);
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!useTiles) _registerFullImage();
}
@override
void didUpdateWidget(TiledImageView oldWidget) {
void didUpdateWidget(covariant TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget);
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize || oldWidget.page != page) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
@ -135,7 +146,7 @@ class _TiledImageViewState extends State<TiledImageView> {
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize);
return SizedBox.fromSize(
size: entry.displaySize * viewState.scale,
size: _displaySize * viewState.scale,
child: Stack(
alignment: Alignment.center,
children: [
@ -147,7 +158,7 @@ class _TiledImageViewState extends State<TiledImageView> {
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (entry.displaySize * viewState.scale).width,
width: (_displaySize * viewState.scale).width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
@ -159,10 +170,9 @@ class _TiledImageViewState extends State<TiledImageView> {
}
void _initTiling(Size viewportSize) {
final displaySize = entry.displaySize;
_tileSide = viewportSize.shortestSide * scaleFactor;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
_maxSampleSize = _sampleSizeForScale(containedScale);
final rotationDegrees = entry.rotationDegrees;
@ -173,7 +183,7 @@ class _TiledImageViewState extends State<TiledImageView> {
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
..translate(-_displaySize.width / 2.0, -_displaySize.height / 2.0);
}
_isTilingInitialized = true;
_registerFullImage();
@ -203,7 +213,7 @@ class _TiledImageViewState extends State<TiledImageView> {
final viewportSize = viewState.viewportSize;
assert(viewportSize != null);
final viewSize = entry.displaySize * viewState.scale;
final viewSize = _displaySize * viewState.scale;
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
@ -236,8 +246,8 @@ class _TiledImageViewState extends State<TiledImageView> {
List<Widget> _getTiles(ViewState viewState) {
if (!_isTilingInitialized) return [];
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final displayWidth = _displaySize.width.round();
final displayHeight = _displaySize.height.round();
final viewRect = _getViewRect(viewState, displayWidth, displayHeight);
final scale = viewState.scale;
@ -265,6 +275,7 @@ class _TiledImageViewState extends State<TiledImageView> {
if (rects != null) {
tiles.add(RegionTile(
entry: entry,
page: page,
tileRect: rects.item1,
regionRect: rects.item2,
sampleSize: sampleSize,
@ -333,6 +344,7 @@ class _TiledImageViewState extends State<TiledImageView> {
class RegionTile extends StatefulWidget {
final ImageEntry entry;
final int page;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
@ -342,6 +354,7 @@ class RegionTile extends StatefulWidget {
const RegionTile({
@required this.entry,
@required this.page,
@required this.tileRect,
@required this.regionRect,
@required this.sampleSize,
@ -363,7 +376,7 @@ class _RegionTileState extends State<RegionTile> {
}
@override
void didUpdateWidget(RegionTile oldWidget) {
void didUpdateWidget(covariant RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget);
@ -390,6 +403,7 @@ class _RegionTileState extends State<RegionTile> {
_provider = RegionProvider(RegionProviderKey.fromEntry(
entry,
page: widget.page,
sampleSize: widget.sampleSize,
rect: widget.regionRect,
));

View file

@ -34,7 +34,7 @@ class AvesVideoState extends State<AvesVideo> {
}
@override
void didUpdateWidget(AvesVideo oldWidget) {
void didUpdateWidget(covariant AvesVideo oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
@ -101,6 +101,7 @@ class AvesVideoState extends State<AvesVideo> {
image: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
page: 0,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,

View file

@ -52,7 +52,7 @@ class _SearchPageState extends State<SearchPage> {
}
@override
void didUpdateWidget(SearchPage oldWidget) {
void didUpdateWidget(covariant SearchPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.delegate != oldWidget.delegate) {
oldWidget.delegate.queryTextController.removeListener(_onQueryChanged);