SVG migration: thumbnails

This commit is contained in:
Thibault Deckers 2021-06-29 11:32:54 +09:00
parent e76376be91
commit 92178ca409
13 changed files with 186 additions and 160 deletions

View file

@ -108,6 +108,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack

View file

@ -13,11 +13,13 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgThumbnail
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.SVG
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
@ -41,9 +43,10 @@ class ThumbnailFetcher internal constructor(
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 svgFetch = mimeType == SVG
private val tiffFetch = mimeType == MimeTypes.TIFF
private val multiTrackFetch = isHeic(mimeType) && pageId != null
private val customFetch = tiffFetch || multiTrackFetch
private val customFetch = svgFetch || tiffFetch || multiTrackFetch
suspend fun fetch() {
var bitmap: Bitmap? = null
@ -124,6 +127,7 @@ class ThumbnailFetcher internal constructor(
.submit(width, height)
} else {
val model: Any = when {
svgFetch -> SvgThumbnail(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> uri

View file

@ -0,0 +1,80 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.utils.StorageUtils
import kotlin.math.ceil
@GlideModule
class SvgGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(SvgThumbnail::class.java, Bitmap::class.java, SvgLoader.Factory())
}
}
class SvgThumbnail(val context: Context, val uri: Uri)
internal class SvgLoader : ModelLoader<SvgThumbnail, Bitmap> {
override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height))
}
override fun handles(model: SvgThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<SvgThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgThumbnail, Bitmap> = SvgLoader()
override fun teardown() {}
}
}
internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val context = model.context
val uri = model.uri
StorageUtils.openInputStream(context, uri)?.use { input ->
try {
SVG.getFromInputStream(input)?.let { svg ->
val svgWidth = svg.documentWidth
val svgHeight = svg.documentHeight
val bitmapWidth = if (svgWidth > 0) ceil(svgWidth).toInt() else width
val bitmapHeight = if (svgHeight > 0) ceil(svgHeight).toInt() else height
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
svg.renderToCanvas(canvas)
callback.onDataReady(bitmap)
}
} catch (ex: SVGParseException) {
callback.onLoadFailed(ex)
}
}
}
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -59,7 +59,7 @@ object MimeTypes {
// returns whether the specified MIME type represents
// a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
BMP, GIF, ICO, PNG, TIFF, WEBP -> true
BMP, GIF, ICO, PNG, SVG, TIFF, WEBP -> true
else -> false
}

View file

@ -4,7 +4,6 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
@ -245,9 +244,6 @@ class PlatformImageFileService implements ImageFileService {
Object? taskKey,
int? priority,
}) {
if (mimeType == MimeTypes.svg) {
return Future.sync(() => Uint8List(0));
}
return servicePolicy.call(
() async {
try {

View file

@ -41,6 +41,12 @@ class Constants {
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
),
Dependency(
name: 'AndroidSVG',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/BigBadaboom/androidsvg/blob/master/LICENSE',
sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
),
Dependency(
name: 'Android-TiffBitmapFactory (Aves fork)',
license: 'MIT',

View file

@ -1,8 +1,7 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:flutter/material.dart';
@ -35,13 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final heroTag = hashValues(collection?.id, entry);
final isSvg = entry.isSvg;
var child = isSvg
? VectorImageThumbnail(
entry: entry,
extent: imageExtent,
heroTag: heroTag,
)
: RasterImageThumbnail(
Widget child = ThumbnailImage(
entry: entry,
extent: imageExtent,
cancellableNotifier: cancellableNotifier,

View file

@ -4,41 +4,46 @@ import 'dart:ui';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/collection/thumbnail/error.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class RasterImageThumbnail extends StatefulWidget {
class ThumbnailImage extends StatefulWidget {
final AvesEntry entry;
final double extent;
final BoxFit fit;
final BoxFit? fit;
final bool showLoadingBackground;
final ValueNotifier<bool>? cancellableNotifier;
final Object? heroTag;
const RasterImageThumbnail({
const ThumbnailImage({
Key? key,
required this.entry,
required this.extent,
this.fit = BoxFit.cover,
this.fit,
this.showLoadingBackground = true,
this.cancellableNotifier,
this.heroTag,
}) : super(key: key);
@override
_RasterImageThumbnailState createState() => _RasterImageThumbnailState();
_ThumbnailImageState createState() => _ThumbnailImageState();
}
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
class _ThumbnailImageState extends State<ThumbnailImage> {
final _providers = <_ConditionalImageProvider>[];
_ProviderStream? _currentProviderStream;
ImageInfo? _lastImageInfo;
Object? _lastException;
late final ImageStreamListener _streamListener;
late DisposableBuildContext<State<RasterImageThumbnail>> _scrollAwareContext;
late DisposableBuildContext<State<ThumbnailImage>> _scrollAwareContext;
AvesEntry get entry => widget.entry;
@ -48,12 +53,12 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
void initState() {
super.initState();
_streamListener = ImageStreamListener(_onImageLoad, onError: _onError);
_scrollAwareContext = DisposableBuildContext<State<RasterImageThumbnail>>(this);
_scrollAwareContext = DisposableBuildContext<State<ThumbnailImage>>(this);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant RasterImageThumbnail oldWidget) {
void didUpdateWidget(covariant ThumbnailImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) {
_unregisterWidget(oldWidget);
@ -68,12 +73,12 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
super.dispose();
}
void _registerWidget(RasterImageThumbnail widget) {
void _registerWidget(ThumbnailImage widget) {
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
_initProvider();
}
void _unregisterWidget(RasterImageThumbnail widget) {
void _unregisterWidget(ThumbnailImage widget) {
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
_pauseProvider();
_currentProviderStream?.stopListening();
@ -87,6 +92,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
_lastException = null;
_providers.clear();
_providers.addAll([
if (!entry.isSvg)
_ConditionalImageProvider(
ScrollAwareImageProvider(
context: _scrollAwareContext,
@ -152,14 +158,14 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
}
}
Color? _backgroundColor;
Color? _loadingBackgroundColor;
Color get backgroundColor {
if (_backgroundColor == null) {
Color get loadingBackgroundColor {
if (_loadingBackgroundColor == null) {
final rgb = 0x30 + entry.uri.hashCode % 0x20;
_backgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb);
_loadingBackgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb);
}
return _backgroundColor!;
return _loadingBackgroundColor!;
}
@override
@ -173,21 +179,54 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
// use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions
// and have more control when chaining image providers
final fit = widget.fit ?? (entry.isSvg ? BoxFit.contain : BoxFit.cover);
final imageInfo = _lastImageInfo;
final image = imageInfo == null
? Container(
color: widget.showLoadingBackground ? backgroundColor : Colors.transparent,
color: widget.showLoadingBackground ? loadingBackgroundColor : Colors.transparent,
width: extent,
height: extent,
)
: RawImage(
: Selector<Settings, EntryBackground>(
selector: (context, s) => entry.isSvg ? s.vectorBackground : s.rasterBackground,
builder: (context, background, child) {
final backgroundColor = background.isColor ? background.color : null;
if (background == EntryBackground.checkered) {
return LayoutBuilder(
builder: (context, constraints) {
final availableSize = constraints.biggest;
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
final offset = (fitSize / 2 - availableSize / 2) as Offset;
final child = CustomPaint(
painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
child: RawImage(
image: imageInfo.image,
debugImageLabel: imageInfo.debugLabel,
width: fitSize.width,
height: fitSize.height,
scale: imageInfo.scale,
fit: BoxFit.cover,
),
);
// the thumbnail is centered for correct decoration sizing
// when constraints are tight during hero animation
return constraints.isTight ? Center(child: child) : child;
},
);
}
return RawImage(
image: imageInfo.image,
debugImageLabel: imageInfo.debugLabel,
width: extent,
height: extent,
scale: imageInfo.scale,
fit: widget.fit,
color: backgroundColor,
colorBlendMode: BlendMode.dstOver,
fit: fit,
);
});
return widget.heroTag != null
? Hero(

View file

@ -1,75 +0,0 @@
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
class VectorImageThumbnail extends StatelessWidget {
final AvesEntry entry;
final double extent;
final Object? heroTag;
const VectorImageThumbnail({
Key? key,
required this.entry,
required this.extent,
this.heroTag,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final child = Selector<Settings, EntryBackground>(
selector: (context, s) => s.vectorBackground,
builder: (context, background, child) {
const fit = BoxFit.contain;
if (background == EntryBackground.checkered) {
return LayoutBuilder(
builder: (context, constraints) {
final availableSize = constraints.biggest;
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
final offset = (fitSize / 2 - availableSize / 2) as Offset;
final child = CustomPaint(
painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
width: fitSize.width,
height: fitSize.height,
fit: fit,
),
);
// the thumbnail is centered for correct decoration sizing
// when constraints are tight during hero animation
return constraints.isTight ? Center(child: child) : child;
},
);
}
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
return SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
colorFilter: colorFilter,
),
width: extent,
height: extent,
fit: fit,
);
},
);
return heroTag != null
? Hero(
tag: heroTag!,
transitionOnUserGestures: true,
child: child,
)
: child;
}
}

View file

@ -2,8 +2,7 @@ import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
@ -114,12 +113,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
child: SizedBox(
width: extent,
height: extent,
child: entry.isSvg
? VectorImageThumbnail(
entry: entry,
extent: extent,
)
: RasterImageThumbnail(
child: ThumbnailImage(
entry: entry,
extent: extent,
),

View file

@ -13,8 +13,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
@ -85,12 +84,7 @@ class DecoratedFilterChip extends StatelessWidget {
final entry = coverEntry ?? source.coverEntry(filter);
final backgroundImage = entry == null
? Container(color: Colors.white)
: entry.isSvg
? VectorImageThumbnail(
entry: entry,
extent: extent,
)
: RasterImageThumbnail(
: ThumbnailImage(
entry: entry,
extent: thumbnailExtent,
);

View file

@ -2,8 +2,7 @@ import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
@ -30,12 +29,7 @@ class ImageMarker extends StatelessWidget {
@override
Widget build(BuildContext context) {
final thumbnail = entry.isSvg
? VectorImageThumbnail(
entry: entry,
extent: extent,
)
: RasterImageThumbnail(
final thumbnail = ThumbnailImage(
entry: entry,
extent: extent,
);

View file

@ -6,7 +6,7 @@ import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:aves/widgets/common/magnifier/magnifier.dart';
@ -232,7 +232,7 @@ class _EntryPageViewState extends State<EntryPageView> {
duration: Durations.viewerVideoPlayerTransition,
child: GestureDetector(
onTap: _onTap,
child: RasterImageThumbnail(
child: ThumbnailImage(
entry: entry,
extent: context.select<MediaQueryData, double>((mq) => mq.size.shortestSide),
fit: BoxFit.contain,