full decoding: use raw image descriptor in Flutter on decoded bytes from Android

This commit is contained in:
Thibault Deckers 2025-03-02 23:29:00 +01:00
parent 152b942f57
commit b224709c5d
12 changed files with 191 additions and 128 deletions

View file

@ -8,8 +8,8 @@ import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getDecodedBytes
import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MemoryUtils
@ -81,11 +81,13 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return return
} }
val decoded = arguments["decoded"] as Boolean
val mimeType = arguments["mimeType"] as String? val mimeType = arguments["mimeType"] as String?
val uri = (arguments["uri"] as String?)?.toUri() val uri = (arguments["uri"] as String?)?.toUri()
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong() val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean val isFlipped = arguments["isFlipped"] as Boolean
val isAnimated = arguments["isAnimated"] as Boolean
val pageId = arguments["pageId"] as Int? val pageId = arguments["pageId"] as Int?
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
@ -94,19 +96,31 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return return
} }
if (isVideo(mimeType)) { if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
streamVideoByGlide(uri, mimeType, sizeBytes)
} else if (!canDecodeWithFlutter(mimeType, pageId, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
} else {
// to be decoded by Flutter // to be decoded by Flutter
streamImageAsIs(uri, mimeType, sizeBytes) streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
} else if (isVideo(mimeType)) {
streamVideoByGlide(
uri = uri,
mimeType = mimeType,
sizeBytes = sizeBytes,
decoded = decoded,
)
} else {
streamImageByGlide(
uri = uri,
pageId = pageId,
mimeType = mimeType,
sizeBytes = sizeBytes,
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
decoded = decoded,
)
} }
endOfStream() endOfStream()
} }
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) { private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
if (!MemoryUtils.canAllocate(sizeBytes)) { if (!MemoryUtils.canAllocate(sizeBytes)) {
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null) error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
return return
@ -126,6 +140,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
sizeBytes: Long?, sizeBytes: Long?,
rotationDegrees: Int, rotationDegrees: Int,
isFlipped: Boolean, isFlipped: Boolean,
decoded: Boolean,
) { ) {
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
@ -139,11 +154,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
} }
if (bitmap != null) { if (bitmap != null) {
val recycle = false val recycle = false
val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType) val bytes = if (decoded) {
var bytes = bitmap.getEncodedBytes(canHaveAlpha, recycle = recycle) bitmap.getDecodedBytes(recycle)
if (bytes != null && bytes.isEmpty()) { } else {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getEncodedBytes(canHaveAlpha, recycle = recycle) bitmap.getEncodedBytes(canHaveAlpha = MimeTypes.canHaveAlpha(mimeType), recycle = recycle)
} }
if (MemoryUtils.canAllocate(sizeBytes)) { if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes) success(bytes)
} else { } else {
@ -159,7 +175,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
} }
} }
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) { private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?, decoded: Boolean) {
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
@ -168,7 +184,13 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
try { try {
val bitmap = withContext(Dispatchers.IO) { target.get() } val bitmap = withContext(Dispatchers.IO) { target.get() }
if (bitmap != null) { if (bitmap != null) {
val bytes = bitmap.getEncodedBytes(canHaveAlpha = false, recycle = false) val recycle = false
val bytes = if (decoded) {
bitmap.getDecodedBytes(recycle)
} else {
bitmap.getEncodedBytes(canHaveAlpha = false, recycle = false)
}
if (MemoryUtils.canAllocate(sizeBytes)) { if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes) success(bytes)
} else { } else {

View file

@ -17,6 +17,7 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import androidx.core.graphics.scale
@GlideModule @GlideModule
class TiffGlideModule : LibraryGlideModule() { class TiffGlideModule : LibraryGlideModule() {
@ -96,7 +97,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
dstWidth = width dstWidth = width
dstHeight = (width / aspectRatio).toInt() dstHeight = (width / aspectRatio).toInt()
} }
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true)) callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
} else { } else {
callback.onDataReady(bitmap) callback.onDataReady(bitmap)
} }

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -20,7 +21,6 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes
import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -28,45 +28,54 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.IOException
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt import kotlin.math.roundToInt
@GlideModule @GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() { class VideoThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory()) registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
} }
} }
class VideoThumbnail(val context: Context, val uri: Uri) class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> { internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> { override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height)) return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
} }
override fun handles(model: VideoThumbnail): Boolean = true override fun handles(model: VideoThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> { internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader() override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
override fun teardown() {} override fun teardown() {}
} }
} }
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> { internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) { override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
ioScope.launch { ioScope.launch {
val retriever = openMetadataRetriever(model.context, model.uri) val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever == null) { if (retriever == null) {
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}")) callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
} else { } else {
try { try {
var bytes = retriever.embeddedPicture var bitmap: Bitmap? = null
if (bytes == null) {
retriever.embeddedPicture?.let { bytes ->
try {
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
} catch (e: IOException) {
// ignore
}
}
if (bitmap == null) {
// there is no consistent strategy across devices to match // there is no consistent strategy across devices to match
// the thumbnails returned by the content resolver / Media Store // the thumbnails returned by the content resolver / Media Store
// so we derive one in an arbitrary way // so we derive one in an arbitrary way
@ -111,7 +120,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
} }
// the returned frame is already rotated according to the video metadata // the returned frame is already rotated according to the video metadata
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val pixelCount = dstWidth * dstHeight val pixelCount = dstWidth * dstHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig()) val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) { if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
@ -134,13 +143,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
retriever.getFrameAtTime(timeMicros, option) retriever.getFrameAtTime(timeMicros, option)
} }
} }
bytes = frame?.getEncodedBytes(canHaveAlpha = false, recycle = false)
} }
if (bytes != null) { if (bitmap == null) {
callback.onDataReady(ByteArrayInputStream(bytes)) callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
} else { } else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame")) callback.onDataReady(bitmap)
} }
} catch (e: Exception) { } catch (e: Exception) {
callback.onLoadFailed(e) callback.onLoadFailed(e)
@ -175,7 +183,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
// cannot cancel // cannot cancel
override fun cancel() {} override fun cancel() {}
override fun getDataClass(): Class<InputStream> = InputStream::class.java override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL override fun getDataSource(): DataSource = DataSource.LOCAL
} }

View file

@ -139,39 +139,6 @@ object BitmapUtils {
return null return null
} }
// On some devices, RGBA_1010102 config can be displayed directly from the hardware buffer,
// but the native image decoder cannot convert RGBA_1010102 to another config like ARGB_8888,
// so we manually check the config and convert the pixels as a fallback mechanism.
fun tryPixelFormatConversion(bitmap: Bitmap): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && bitmap.config == Bitmap.Config.RGBA_1010102) {
val byteCount = bitmap.byteCount
if (MemoryUtils.canAllocate(byteCount)) {
val bytes = ByteBuffer.allocate(byteCount).apply {
bitmap.copyPixelsToBuffer(this)
rewind()
}.array()
val srcColorSpace = bitmap.colorSpace
if (srcColorSpace != null) {
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
rgba1010102toArgb8888(bytes, connector)
val hasAlpha = false
return createBitmap(
bitmap.width,
bitmap.height,
Bitmap.Config.ARGB_8888,
hasAlpha = hasAlpha,
colorSpace = dstColorSpace,
).apply {
copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}
}
}
}
return null
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun argb8888toArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) { private fun argb8888toArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
// unpacking from ARGB_8888 and packing to ARGB_8888 // unpacking from ARGB_8888 and packing to ARGB_8888

View file

@ -84,11 +84,11 @@ object MimeTypes {
else -> false else -> false
} }
// as of Flutter v3.16.4, with additional custom handling for SVG // as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
fun canDecodeWithFlutter(mimeType: String, pageId: Int?, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { // while handling still PNG and JPEG on Android for color space and config conversion
fun canDecodeWithFlutter(mimeType: String, isAnimated: Boolean) = when (mimeType) {
GIF, WEBP, BMP, WBMP, ICO, SVG -> true GIF, WEBP, BMP, WBMP, ICO, SVG -> true
JPEG -> (pageId ?: 0) == 0 JPEG, PNG -> isAnimated
PNG -> (rotationDegrees ?: 0) == 0 && !(isFlipped ?: false)
else -> false else -> false
} }

View file

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/media_fetch_service.dart';
import 'package:aves_report/aves_report.dart'; import 'package:aves_report/aves_report.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -11,11 +13,11 @@ import 'package:flutter/widgets.dart';
class UriImage extends ImageProvider<UriImage> with EquatableMixin { class UriImage extends ImageProvider<UriImage> with EquatableMixin {
final String uri, mimeType; final String uri, mimeType;
final int? pageId, rotationDegrees, sizeBytes; final int? pageId, rotationDegrees, sizeBytes;
final bool isFlipped; final bool isFlipped, isAnimated;
final double scale; final double scale;
@override @override
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, scale]; List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, isAnimated, scale];
const UriImage({ const UriImage({
required this.uri, required this.uri,
@ -23,6 +25,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
required this.pageId, required this.pageId,
required this.rotationDegrees, required this.rotationDegrees,
required this.isFlipped, required this.isFlipped,
required this.isAnimated,
this.sizeBytes, this.sizeBytes,
this.scale = 1.0, this.scale = 1.0,
}); });
@ -46,29 +49,60 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
); );
} }
// as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
// while handling still PNG and JPEG on Android for color space and config conversion
bool _canDecodeWithFlutter(String mimeType, bool isAnimated) {
switch(mimeType) {
case MimeTypes.gif:
case MimeTypes.webp:
case MimeTypes.bmp:
case MimeTypes.wbmp:
case MimeTypes.ico:
case MimeTypes.svg:
return true;
case MimeTypes.jpeg:
case MimeTypes.png:
return isAnimated;
default:
return false;
}
}
Future<ui.Codec> _loadAsync(UriImage key, ImageDecoderCallback decode, StreamController<ImageChunkEvent> chunkEvents) async { Future<ui.Codec> _loadAsync(UriImage key, ImageDecoderCallback decode, StreamController<ImageChunkEvent> chunkEvents) async {
assert(key == this); assert(key == this);
final request = ImageRequest(
uri,
mimeType,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
isAnimated: isAnimated,
pageId: pageId,
sizeBytes: sizeBytes,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
));
},
);
try { try {
final bytes = await mediaFetchService.getImage( if (_canDecodeWithFlutter(mimeType, isAnimated)) {
uri, // get original media bytes from platform, and rely on a codec instantiated by `ImageProvider`
mimeType, final bytes = await mediaFetchService.getEncodedImage(request);
rotationDegrees: rotationDegrees, if (bytes.isEmpty) {
isFlipped: isFlipped, throw UnreportedStateError('$uri ($mimeType) image loading failed');
pageId: pageId, }
sizeBytes: sizeBytes, final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
onBytesReceived: (cumulative, total) { return await decode(buffer);
chunkEvents.add(ImageChunkEvent( } else {
cumulativeBytesLoaded: cumulative, // get decoded media bytes from platform, and rely on a codec instantiated from raw bytes
expectedTotalBytes: total, final descriptor = await mediaFetchService.getDecodedImage(request);
)); if (descriptor == null) {
}, throw UnreportedStateError('$uri ($mimeType) image loading failed');
); }
if (bytes.isEmpty) { return descriptor.instantiateCodec();
throw UnreportedStateError('$uri ($mimeType) loading failed');
} }
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return await decode(buffer);
} catch (error) { } catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');

View file

@ -22,8 +22,9 @@ class EntryCache {
int? dateModifiedMillis, int? dateModifiedMillis,
int rotationDegrees, int rotationDegrees,
bool isFlipped, bool isFlipped,
bool isAnimated,
) async { ) async {
debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedMillis=$dateModifiedMillis, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped'); debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedMillis=$dateModifiedMillis, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, isAnimated=$isAnimated');
// TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them
int? pageId; int? pageId;
@ -35,6 +36,7 @@ class EntryCache {
pageId: pageId, pageId: pageId,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: isFlipped, isFlipped: isFlipped,
isAnimated: isAnimated,
).evict(); ).evict();
// evict low quality thumbnail (without specified extents) // evict low quality thumbnail (without specified extents)

View file

@ -484,7 +484,7 @@ class AvesEntry with AvesEntryBase {
bool oldIsFlipped, bool oldIsFlipped,
) async { ) async {
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped, isAnimated);
visualChangeNotifier.notify(); visualChangeNotifier.notify();
} }
} }

View file

@ -55,6 +55,7 @@ extension ExtraAvesEntryImages on AvesEntry {
pageId: pageId, pageId: pageId,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: isFlipped, isFlipped: isFlipped,
isAnimated: isAnimated,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
); );

View file

@ -13,6 +13,7 @@ class MimeTypes {
static const svg = 'image/svg+xml'; static const svg = 'image/svg+xml';
static const tiff = 'image/tiff'; static const tiff = 'image/tiff';
static const webp = 'image/webp'; static const webp = 'image/webp';
static const wbmp = 'image/vnd.wap.wbmp';
static const art = 'image/x-jg'; static const art = 'image/x-jg';
static const cdr = 'image/x-coreldraw'; static const cdr = 'image/x-coreldraw';

View file

@ -24,15 +24,9 @@ abstract class MediaFetchService {
BytesReceivedCallback? onBytesReceived, BytesReceivedCallback? onBytesReceived,
}); });
Future<Uint8List> getImage( Future<Uint8List> getEncodedImage(ImageRequest request);
String uri,
String mimeType, { Future<ui.ImageDescriptor?> getDecodedImage(ImageRequest request);
required int? rotationDegrees,
required bool isFlipped,
required int? pageId,
required int? sizeBytes,
BytesReceivedCallback? onBytesReceived,
});
// `rect`: region to decode, with coordinates in reference to `imageSize` // `rect`: region to decode, with coordinates in reference to `imageSize`
Future<ui.ImageDescriptor?> getRegion( Future<ui.ImageDescriptor?> getRegion(
@ -101,45 +95,52 @@ class PlatformMediaFetchService implements MediaFetchService {
required int? sizeBytes, required int? sizeBytes,
BytesReceivedCallback? onBytesReceived, BytesReceivedCallback? onBytesReceived,
}) => }) =>
getImage( getEncodedImage(
uri, ImageRequest(
mimeType, uri,
rotationDegrees: 0, mimeType,
isFlipped: false, rotationDegrees: 0,
pageId: null, isFlipped: false,
sizeBytes: sizeBytes, isAnimated: false,
onBytesReceived: onBytesReceived, pageId: null,
sizeBytes: sizeBytes,
onBytesReceived: onBytesReceived,
),
); );
@override @override
Future<Uint8List> getImage( Future<Uint8List> getEncodedImage(ImageRequest request) {
String uri, return getBytes(request, decoded: false);
String mimeType, { }
required int? rotationDegrees,
required bool isFlipped, @override
required int? pageId, Future<ui.ImageDescriptor?> getDecodedImage(ImageRequest request) async {
required int? sizeBytes, return getBytes(request, decoded: true).then(InteropDecoding.bytesToCodec);
BytesReceivedCallback? onBytesReceived, }
}) async {
Future<Uint8List> getBytes(ImageRequest request, {required bool decoded}) async {
final _onBytesReceived = request.onBytesReceived;
try { try {
final opCompleter = Completer<Uint8List>(); final opCompleter = Completer<Uint8List>();
final sink = OutputBuffer(); final sink = OutputBuffer();
var bytesReceived = 0; var bytesReceived = 0;
_byteStream.receiveBroadcastStream(<String, dynamic>{ _byteStream.receiveBroadcastStream(<String, dynamic>{
'uri': uri, 'uri': request.uri,
'mimeType': mimeType, 'mimeType': request.mimeType,
'sizeBytes': sizeBytes, 'sizeBytes': request.sizeBytes,
'rotationDegrees': rotationDegrees ?? 0, 'rotationDegrees': request.rotationDegrees ?? 0,
'isFlipped': isFlipped, 'isFlipped': request.isFlipped,
'pageId': pageId, 'isAnimated': request.isAnimated,
'pageId': request.pageId,
'decoded': decoded,
}).listen( }).listen(
(data) { (data) {
final chunk = data as Uint8List; final chunk = data as Uint8List;
sink.add(chunk); sink.add(chunk);
if (onBytesReceived != null) { if (_onBytesReceived != null) {
bytesReceived += chunk.length; bytesReceived += chunk.length;
try { try {
onBytesReceived(bytesReceived, sizeBytes); _onBytesReceived(bytesReceived, request.sizeBytes);
} catch (error, stack) { } catch (error, stack) {
opCompleter.completeError(error, stack); opCompleter.completeError(error, stack);
return; return;
@ -156,7 +157,7 @@ class PlatformMediaFetchService implements MediaFetchService {
// `await` here, so that `completeError` will be caught below // `await` here, so that `completeError` will be caught below
return await opCompleter.future; return await opCompleter.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (_isUnknownVisual(mimeType)) { if (_isUnknownVisual(request.mimeType)) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
} }
@ -313,3 +314,26 @@ class PlatformMediaFetchService implements MediaFetchService {
..._knownVideos, ..._knownVideos,
}; };
} }
@immutable
class ImageRequest {
final String uri;
final String mimeType;
final int? rotationDegrees;
final bool isFlipped;
final bool isAnimated;
final int? pageId;
final int? sizeBytes;
final BytesReceivedCallback? onBytesReceived;
const ImageRequest(
this.uri,
this.mimeType, {
required this.rotationDegrees,
required this.isFlipped,
required this.isAnimated,
required this.pageId,
required this.sizeBytes,
this.onBytesReceived,
});
}

View file

@ -6,6 +6,7 @@ import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/ref/upnp.dart'; import 'package:aves/ref/upnp.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/media_fetch_service.dart';
import 'package:aves/widgets/dialogs/cast_dialog.dart'; import 'package:aves/widgets/dialogs/cast_dialog.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dlna_dart/dlna.dart'; import 'package:dlna_dart/dlna.dart';
@ -108,14 +109,16 @@ mixin CastMixin {
Future<Response> _sendEntry(AvesEntry entry) async { Future<Response> _sendEntry(AvesEntry entry) async {
// TODO TLAD [cast] providing downscaled versions is suitable when properly serving with `MediaServer`, as the renderer can pick what is best // TODO TLAD [cast] providing downscaled versions is suitable when properly serving with `MediaServer`, as the renderer can pick what is best
final bytes = await mediaFetchService.getImage( final request = ImageRequest(
entry.uri, entry.uri,
entry.mimeType, entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: entry.isFlipped,
isAnimated: entry.isAnimated,
pageId: entry.pageId, pageId: entry.pageId,
sizeBytes: entry.sizeBytes, sizeBytes: entry.sizeBytes,
); );
final bytes = await mediaFetchService.getEncodedImage(request);
debugPrint('cast: send ${bytes.length} bytes for entry=$entry'); debugPrint('cast: send ${bytes.length} bytes for entry=$entry');
return Response.ok( return Response.ok(