SVG migration: viewer
This commit is contained in:
parent
92178ca409
commit
88d3fa7991
19 changed files with 654 additions and 160 deletions
|
@ -7,6 +7,7 @@ import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||||
|
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -113,6 +114,13 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
|
||||||
val regionRect = Rect(x, y, x + width, y + height)
|
val regionRect = Rect(x, y, x + width, y + height)
|
||||||
when (mimeType) {
|
when (mimeType) {
|
||||||
|
MimeTypes.SVG -> SvgRegionFetcher(activity).fetch(
|
||||||
|
uri = uri,
|
||||||
|
regionRect = regionRect,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
page = pageId ?: 0,
|
page = pageId ?: 0,
|
||||||
|
|
|
@ -124,9 +124,9 @@ class RegionFetcher internal constructor(
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private data class LastDecoderRef(
|
private data class LastDecoderRef(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val decoder: BitmapRegionDecoder,
|
val decoder: BitmapRegionDecoder,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.net.Uri
|
||||||
|
import com.caverock.androidsvg.PreserveAspectRatio
|
||||||
|
import com.caverock.androidsvg.RenderOptions
|
||||||
|
import com.caverock.androidsvg.SVG
|
||||||
|
import com.caverock.androidsvg.SVGParseException
|
||||||
|
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
class SvgRegionFetcher internal constructor(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private var lastSvgRef: LastSvgRef? = null
|
||||||
|
|
||||||
|
suspend fun fetch(
|
||||||
|
uri: Uri,
|
||||||
|
regionRect: Rect,
|
||||||
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
|
result: MethodChannel.Result,
|
||||||
|
) {
|
||||||
|
var currentSvgRef = lastSvgRef
|
||||||
|
if (currentSvgRef != null && currentSvgRef.uri != uri) {
|
||||||
|
currentSvgRef = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentSvgRef == null) {
|
||||||
|
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
try {
|
||||||
|
SVG.getFromInputStream(input)
|
||||||
|
} catch (ex: SVGParseException) {
|
||||||
|
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSvg == null) {
|
||||||
|
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newSvg.normalizeSize()
|
||||||
|
currentSvgRef = LastSvgRef(uri, newSvg)
|
||||||
|
}
|
||||||
|
val svg = currentSvgRef.svg
|
||||||
|
lastSvgRef = currentSvgRef
|
||||||
|
|
||||||
|
// we scale the requested region accordingly to the viewbox size
|
||||||
|
val viewBox = svg.documentViewBox
|
||||||
|
val svgWidth = viewBox.width()
|
||||||
|
val svgHeight = viewBox.height()
|
||||||
|
val xf = imageWidth / ceil(svgWidth)
|
||||||
|
val yf = imageHeight / ceil(svgHeight)
|
||||||
|
// some SVG paths do not respect the rendering viewbox and do not reach its edges
|
||||||
|
// so we render to a slightly larger bitmap, using a slightly larger viewbox,
|
||||||
|
// and crop that bitmap to the target region size
|
||||||
|
val bleedX = xf.toInt()
|
||||||
|
val bleedY = yf.toInt()
|
||||||
|
val effectiveRect = RectF(
|
||||||
|
(regionRect.left - bleedX) / xf,
|
||||||
|
(regionRect.top - bleedY) / yf,
|
||||||
|
(regionRect.right + bleedX) / xf,
|
||||||
|
(regionRect.bottom + bleedY) / yf,
|
||||||
|
)
|
||||||
|
|
||||||
|
val renderOptions = RenderOptions()
|
||||||
|
renderOptions.viewBox(effectiveRect.left, effectiveRect.top, effectiveRect.width(), effectiveRect.height())
|
||||||
|
renderOptions.preserveAspectRatio(PreserveAspectRatio.FULLSCREEN_START)
|
||||||
|
|
||||||
|
val targetBitmapWidth = regionRect.width()
|
||||||
|
val targetBitmapHeight = regionRect.height()
|
||||||
|
var bitmap = Bitmap.createBitmap(
|
||||||
|
targetBitmapWidth + bleedX * 2,
|
||||||
|
targetBitmapHeight + bleedY * 2,
|
||||||
|
Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
svg.renderToCanvas(canvas, renderOptions)
|
||||||
|
|
||||||
|
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
||||||
|
|
||||||
|
if (bitmap != null) {
|
||||||
|
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||||
|
} else {
|
||||||
|
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LastSvgRef(
|
||||||
|
val uri: Uri,
|
||||||
|
val svg: SVG,
|
||||||
|
)
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import com.bumptech.glide.module.LibraryGlideModule
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import com.caverock.androidsvg.SVG
|
import com.caverock.androidsvg.SVG
|
||||||
import com.caverock.androidsvg.SVGParseException
|
import com.caverock.androidsvg.SVGParseException
|
||||||
|
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
@ -52,11 +53,20 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
|
||||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
try {
|
try {
|
||||||
SVG.getFromInputStream(input)?.let { svg ->
|
SVG.getFromInputStream(input)?.let { svg ->
|
||||||
val svgWidth = svg.documentWidth
|
svg.normalizeSize()
|
||||||
val svgHeight = svg.documentHeight
|
val viewBox = svg.documentViewBox
|
||||||
|
val svgWidth = viewBox.width()
|
||||||
|
val svgHeight = viewBox.height()
|
||||||
|
|
||||||
val bitmapWidth = if (svgWidth > 0) ceil(svgWidth).toInt() else width
|
val bitmapWidth: Int
|
||||||
val bitmapHeight = if (svgHeight > 0) ceil(svgHeight).toInt() else height
|
val bitmapHeight: Int
|
||||||
|
if (width / height > svgWidth / svgHeight) {
|
||||||
|
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
||||||
|
bitmapHeight = height;
|
||||||
|
} else {
|
||||||
|
bitmapWidth = width
|
||||||
|
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||||
|
}
|
||||||
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import com.caverock.androidsvg.SVG
|
||||||
|
|
||||||
|
object SvgHelper {
|
||||||
|
fun SVG.normalizeSize() {
|
||||||
|
if (documentViewBox == null) {
|
||||||
|
setDocumentViewBox(0f, 0f, documentWidth, documentHeight)
|
||||||
|
}
|
||||||
|
setDocumentWidth("100%")
|
||||||
|
setDocumentHeight("100%")
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,7 +20,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
|
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
|
||||||
return MultiFrameImageStreamCompleter(
|
return MultiFrameImageStreamCompleter(
|
||||||
codec: _loadAsync(key, decode),
|
codec: _loadAsync(key, decode),
|
||||||
scale: key.scale,
|
scale: 1.0,
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
|
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
|
||||||
},
|
},
|
||||||
|
@ -71,7 +71,6 @@ class RegionProviderKey {
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
final Rectangle<int> region;
|
final Rectangle<int> region;
|
||||||
final Size imageSize;
|
final Size imageSize;
|
||||||
final double scale;
|
|
||||||
|
|
||||||
const RegionProviderKey({
|
const RegionProviderKey({
|
||||||
required this.uri,
|
required this.uri,
|
||||||
|
@ -82,13 +81,12 @@ class RegionProviderKey {
|
||||||
required this.sampleSize,
|
required this.sampleSize,
|
||||||
required this.region,
|
required this.region,
|
||||||
required this.imageSize,
|
required this.imageSize,
|
||||||
this.scale = 1.0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale;
|
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -101,9 +99,8 @@ class RegionProviderKey {
|
||||||
sampleSize,
|
sampleSize,
|
||||||
region,
|
region,
|
||||||
imageSize,
|
imageSize,
|
||||||
scale,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -435,8 +435,8 @@ class AvesEntry {
|
||||||
final size = await SvgMetadataService.getSize(this);
|
final size = await SvgMetadataService.getSize(this);
|
||||||
if (size != null) {
|
if (size != null) {
|
||||||
await _applyNewFields({
|
await _applyNewFields({
|
||||||
'width': size.width.round(),
|
'width': size.width.ceil(),
|
||||||
'height': size.height.round(),
|
'height': size.height.ceil(),
|
||||||
}, persist: persist);
|
}, persist: persist);
|
||||||
}
|
}
|
||||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||||
|
|
|
@ -27,21 +27,22 @@ extension ExtraAvesEntry on AvesEntry {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RegionProvider getRegion({required int sampleSize, Rectangle<int>? region}) {
|
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
|
||||||
return RegionProvider(_getRegionProviderKey(sampleSize, region));
|
return RegionProvider(RegionProviderKey(
|
||||||
}
|
|
||||||
|
|
||||||
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int>? region) {
|
|
||||||
return RegionProviderKey(
|
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees,
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
sampleSize: sampleSize,
|
sampleSize: sampleSize,
|
||||||
region: region ?? Rectangle<int>(0, 0, width, height),
|
region: Rectangle(
|
||||||
imageSize: Size(width.toDouble(), height.toDouble()),
|
(region.left * scale).round(),
|
||||||
);
|
(region.top * scale).round(),
|
||||||
|
(region.width * scale).round(),
|
||||||
|
(region.height * scale).round(),
|
||||||
|
),
|
||||||
|
imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
UriImage get uriImage => UriImage(
|
UriImage get uriImage => UriImage(
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
enum CoordinateFormat { dms, decimal }
|
enum CoordinateFormat { dms, decimal }
|
||||||
|
|
||||||
enum EntryBackground { black, white, transparent, checkered }
|
enum EntryBackground { black, white, checkered }
|
||||||
|
|
||||||
enum HomePageSetting { collection, albums }
|
enum HomePageSetting { collection, albums }
|
||||||
|
|
||||||
|
|
|
@ -297,7 +297,7 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
|
|
||||||
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values);
|
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.white, EntryBackground.values);
|
||||||
|
|
||||||
set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
|
set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
|
||||||
|
|
||||||
|
|
|
@ -26,12 +26,9 @@ class SvgMetadataService {
|
||||||
String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value;
|
String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value;
|
||||||
double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
|
double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
|
||||||
|
|
||||||
final width = tryParseWithoutUnit(getAttribute('width'));
|
// prefer the viewbox over the viewport to determine size
|
||||||
final height = tryParseWithoutUnit(getAttribute('height'));
|
|
||||||
if (width != null && height != null) {
|
|
||||||
return Size(width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// viewbox
|
||||||
final viewBox = getAttribute('viewBox');
|
final viewBox = getAttribute('viewBox');
|
||||||
if (viewBox != null) {
|
if (viewBox != null) {
|
||||||
final parts = viewBox.split(RegExp(r'[\s,]+'));
|
final parts = viewBox.split(RegExp(r'[\s,]+'));
|
||||||
|
@ -43,6 +40,13 @@ class SvgMetadataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewport
|
||||||
|
final width = tryParseWithoutUnit(getAttribute('width'));
|
||||||
|
final height = tryParseWithoutUnit(getAttribute('height'));
|
||||||
|
if (width != null && height != null) {
|
||||||
|
return Size(width, height);
|
||||||
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
final double _log2 = log(2);
|
|
||||||
const double _piOver180 = pi / 180.0;
|
const double _piOver180 = pi / 180.0;
|
||||||
|
|
||||||
double toDegrees(num radians) => radians / _piOver180;
|
double toDegrees(num radians) => radians / _piOver180;
|
||||||
|
|
||||||
double toRadians(num degrees) => degrees * _piOver180;
|
double toRadians(num degrees) => degrees * _piOver180;
|
||||||
|
|
||||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()) as int;
|
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
|
||||||
|
|
||||||
|
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();
|
||||||
|
|
||||||
double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||||
|
|
|
@ -21,8 +21,8 @@ class CheckeredPainter extends CustomPainter {
|
||||||
final dx = offset.dx % (checkSize * 2);
|
final dx = offset.dx % (checkSize * 2);
|
||||||
final dy = offset.dy % (checkSize * 2);
|
final dy = offset.dy % (checkSize * 2);
|
||||||
|
|
||||||
final xMax = size.width / checkSize;
|
final xMax = (size.width / checkSize).ceil();
|
||||||
final yMax = size.height / checkSize;
|
final yMax = (size.height / checkSize).ceil();
|
||||||
for (var x = -2; x < xMax; x++) {
|
for (var x = -2; x < xMax; x++) {
|
||||||
for (var y = -2; y < yMax; y++) {
|
for (var y = -2; y < yMax; y++) {
|
||||||
if ((x + y) % 2 == 0) {
|
if ((x + y) % 2 == 0) {
|
||||||
|
|
|
@ -41,29 +41,7 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
||||||
EntryBackground.white,
|
EntryBackground.white,
|
||||||
EntryBackground.black,
|
EntryBackground.black,
|
||||||
EntryBackground.checkered,
|
EntryBackground.checkered,
|
||||||
EntryBackground.transparent,
|
|
||||||
].map((selected) {
|
].map((selected) {
|
||||||
Widget? child;
|
|
||||||
switch (selected) {
|
|
||||||
case EntryBackground.transparent:
|
|
||||||
child = const Icon(
|
|
||||||
Icons.clear,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.white30,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case EntryBackground.checkered:
|
|
||||||
child = ClipOval(
|
|
||||||
child: CustomPaint(
|
|
||||||
painter: CheckeredPainter(
|
|
||||||
checkSize: radius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return DropdownMenuItem<EntryBackground>(
|
return DropdownMenuItem<EntryBackground>(
|
||||||
value: selected,
|
value: selected,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
@ -74,7 +52,15 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
||||||
border: AvesBorder.border,
|
border: AvesBorder.border,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: child,
|
child: selected == EntryBackground.checkered
|
||||||
|
? ClipOval(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: CheckeredPainter(
|
||||||
|
checkSize: radius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_images.dart';
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
@ -7,7 +6,6 @@ import 'package:aves/widgets/viewer/debug/db.dart';
|
||||||
import 'package:aves/widgets/viewer/debug/metadata.dart';
|
import 'package:aves/widgets/viewer/debug/metadata.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
@ -132,36 +130,30 @@ class ViewerDebugPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnailsTabView() {
|
Widget _buildThumbnailsTabView() {
|
||||||
final children = <Widget>[];
|
|
||||||
if (entry.isSvg) {
|
|
||||||
const extent = 128.0;
|
|
||||||
children.addAll([
|
|
||||||
const Text('SVG ($extent)'),
|
|
||||||
SvgPicture(
|
|
||||||
UriPicture(
|
|
||||||
uri: entry.uri,
|
|
||||||
mimeType: entry.mimeType,
|
|
||||||
),
|
|
||||||
width: extent,
|
|
||||||
height: extent,
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
children.addAll(
|
|
||||||
entry.cachedThumbnails.expand((provider) => [
|
|
||||||
Text('Raster (${provider.key.extent})'),
|
|
||||||
Center(
|
|
||||||
child: Image(
|
|
||||||
image: provider,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: children,
|
children: entry.cachedThumbnails
|
||||||
|
.expand((provider) => [
|
||||||
|
Text('Extent: ${provider.key.extent}'),
|
||||||
|
Center(
|
||||||
|
child: Image(
|
||||||
|
image: provider,
|
||||||
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||||
|
return Container(
|
||||||
|
foregroundDecoration: const BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(
|
||||||
|
color: Colors.amber,
|
||||||
|
width: .1,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
|
||||||
import 'package:aves/model/entry.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/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||||
|
@ -25,7 +22,6 @@ import 'package:aves/widgets/viewer/visual/vector.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/video.dart';
|
import 'package:aves/widgets/viewer/visual/video.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EntryPageView extends StatefulWidget {
|
class EntryPageView extends StatefulWidget {
|
||||||
|
@ -163,28 +159,19 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSvgView() {
|
Widget _buildSvgView() {
|
||||||
final background = settings.vectorBackground;
|
|
||||||
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
|
|
||||||
|
|
||||||
var child = _buildMagnifier(
|
var child = _buildMagnifier(
|
||||||
maxScale: const ScaleLevel(factor: double.infinity),
|
maxScale: const ScaleLevel(factor: 25),
|
||||||
scaleStateCycle: _vectorScaleStateCycle,
|
scaleStateCycle: _vectorScaleStateCycle,
|
||||||
child: SvgPicture(
|
applyScale: false,
|
||||||
UriPicture(
|
child: VectorImageView(
|
||||||
uri: entry.uri,
|
entry: entry,
|
||||||
mimeType: entry.mimeType,
|
viewStateNotifier: _viewStateNotifier,
|
||||||
colorFilter: colorFilter,
|
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||||||
|
entry: entry,
|
||||||
|
onTap: _onTap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (background == EntryBackground.checkered) {
|
|
||||||
child = VectorViewCheckeredBackground(
|
|
||||||
displaySize: entry.displaySize,
|
|
||||||
viewStateNotifier: _viewStateNotifier,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ class RasterImageView extends StatefulWidget {
|
||||||
|
|
||||||
class _RasterImageViewState extends State<RasterImageView> {
|
class _RasterImageViewState extends State<RasterImageView> {
|
||||||
late Size _displaySize;
|
late Size _displaySize;
|
||||||
|
late bool _useTiles;
|
||||||
bool _isTilingInitialized = false;
|
bool _isTilingInitialized = false;
|
||||||
late int _maxSampleSize;
|
late int _maxSampleSize;
|
||||||
late double _tileSide;
|
late double _tileSide;
|
||||||
|
@ -44,16 +45,19 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
|
|
||||||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||||||
|
|
||||||
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
|
|
||||||
|
|
||||||
ViewState get viewState => viewStateNotifier.value;
|
ViewState get viewState => viewStateNotifier.value;
|
||||||
|
|
||||||
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
|
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
|
||||||
|
|
||||||
|
Rectangle<int> get fullImageRegion => Rectangle<int>(0, 0, entry.width, entry.height);
|
||||||
|
|
||||||
ImageProvider get fullImageProvider {
|
ImageProvider get fullImageProvider {
|
||||||
if (entry.useTiles) {
|
if (_useTiles) {
|
||||||
assert(_isTilingInitialized);
|
assert(_isTilingInitialized);
|
||||||
return entry.getRegion(sampleSize: _maxSampleSize);
|
return entry.getRegion(
|
||||||
|
sampleSize: _maxSampleSize,
|
||||||
|
region: fullImageRegion,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return entry.uriImage;
|
return entry.uriImage;
|
||||||
}
|
}
|
||||||
|
@ -66,8 +70,9 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_displaySize = entry.displaySize;
|
_displaySize = entry.displaySize;
|
||||||
|
_useTiles = entry.useTiles;
|
||||||
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||||
if (!entry.useTiles) _registerFullImage();
|
if (!_useTiles) _registerFullImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -106,23 +111,23 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final useTiles = entry.useTiles;
|
|
||||||
return ValueListenableBuilder<ViewState>(
|
return ValueListenableBuilder<ViewState>(
|
||||||
valueListenable: viewStateNotifier,
|
valueListenable: viewStateNotifier,
|
||||||
builder: (context, viewState, child) {
|
builder: (context, viewState, child) {
|
||||||
final viewportSize = viewState.viewportSize;
|
final viewportSize = viewState.viewportSize;
|
||||||
final viewportSized = viewportSize?.isEmpty == false;
|
final viewportSized = viewportSize?.isEmpty == false;
|
||||||
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
|
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
|
||||||
|
|
||||||
return SizedBox.fromSize(
|
return SizedBox.fromSize(
|
||||||
size: _displaySize * viewState.scale!,
|
size: _displaySize * viewState.scale!,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (useBackground && viewportSized) _buildBackground(),
|
if (entry.canHaveAlpha && viewportSized) _buildBackground(),
|
||||||
_buildLoading(),
|
_buildLoading(),
|
||||||
if (useTiles) ..._getTiles(),
|
if (_useTiles)
|
||||||
if (!useTiles)
|
..._getTiles()
|
||||||
|
else
|
||||||
Image(
|
Image(
|
||||||
image: fullImageProvider,
|
image: fullImageProvider,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
|
@ -230,9 +235,10 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
|
|
||||||
// for the largest sample size (matching the initial scale), the whole image is in view
|
// for the largest sample size (matching the initial scale), the whole image is in view
|
||||||
// so we subsample the whole image without tiling
|
// so we subsample the whole image without tiling
|
||||||
final fullImageRegionTile = RegionTile(
|
final fullImageRegionTile = _RegionTile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale),
|
tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale),
|
||||||
|
regionRect: fullImageRegion,
|
||||||
sampleSize: _maxSampleSize,
|
sampleSize: _maxSampleSize,
|
||||||
);
|
);
|
||||||
final tiles = [fullImageRegionTile];
|
final tiles = [fullImageRegionTile];
|
||||||
|
@ -253,7 +259,7 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
viewRect: viewRect,
|
viewRect: viewRect,
|
||||||
);
|
);
|
||||||
if (rects != null) {
|
if (rects != null) {
|
||||||
tiles.add(RegionTile(
|
tiles.add(_RegionTile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
tileRect: rects.item1,
|
tileRect: rects.item1,
|
||||||
regionRect: rects.item2,
|
regionRect: rects.item2,
|
||||||
|
@ -320,20 +326,20 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RegionTile extends StatefulWidget {
|
class _RegionTile extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
// `tileRect` uses Flutter view coordinates
|
// `tileRect` uses Flutter view coordinates
|
||||||
// `regionRect` uses the raw image pixel coordinates
|
// `regionRect` uses the raw image pixel coordinates
|
||||||
final Rect tileRect;
|
final Rect tileRect;
|
||||||
final Rectangle<int>? regionRect;
|
final Rectangle<int> regionRect;
|
||||||
final int sampleSize;
|
final int sampleSize;
|
||||||
|
|
||||||
const RegionTile({
|
const _RegionTile({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.tileRect,
|
required this.tileRect,
|
||||||
this.regionRect,
|
required this.regionRect,
|
||||||
required this.sampleSize,
|
required this.sampleSize,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -350,7 +356,7 @@ class RegionTile extends StatefulWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RegionTileState extends State<RegionTile> {
|
class _RegionTileState extends State<_RegionTile> {
|
||||||
late RegionProvider _provider;
|
late RegionProvider _provider;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
@ -362,7 +368,7 @@ class _RegionTileState extends State<RegionTile> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant RegionTile oldWidget) {
|
void didUpdateWidget(covariant _RegionTile oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
|
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
|
||||||
_unregisterWidget(oldWidget);
|
_unregisterWidget(oldWidget);
|
||||||
|
@ -376,11 +382,11 @@ class _RegionTileState extends State<RegionTile> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(RegionTile widget) {
|
void _registerWidget(_RegionTile widget) {
|
||||||
_initProvider();
|
_initProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(RegionTile widget) {
|
void _unregisterWidget(_RegionTile widget) {
|
||||||
_pauseProvider();
|
_pauseProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,54 +1,426 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:aves/image_providers/region_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/utils/math_utils.dart';
|
||||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/state.dart';
|
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class VectorViewCheckeredBackground extends StatelessWidget {
|
class VectorImageView extends StatefulWidget {
|
||||||
final Size displaySize;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<ViewState> viewStateNotifier;
|
final ValueNotifier<ViewState> viewStateNotifier;
|
||||||
final Widget child;
|
final ImageErrorWidgetBuilder errorBuilder;
|
||||||
|
|
||||||
const VectorViewCheckeredBackground({
|
const VectorImageView({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.displaySize,
|
required this.entry,
|
||||||
required this.viewStateNotifier,
|
required this.viewStateNotifier,
|
||||||
required this.child,
|
required this.errorBuilder,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_VectorImageViewState createState() => _VectorImageViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VectorImageViewState extends State<VectorImageView> {
|
||||||
|
late Size _displaySize;
|
||||||
|
bool _isTilingInitialized = false;
|
||||||
|
late double _minScale;
|
||||||
|
late double _tileSide;
|
||||||
|
ImageStream? _fullImageStream;
|
||||||
|
late ImageStreamListener _fullImageListener;
|
||||||
|
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
|
||||||
|
|
||||||
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||||||
|
|
||||||
|
ViewState get viewState => viewStateNotifier.value;
|
||||||
|
|
||||||
|
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
|
||||||
|
|
||||||
|
Rectangle<double> get fullImageRegion => Rectangle<double>(.0, .0, entry.width.toDouble(), entry.height.toDouble());
|
||||||
|
|
||||||
|
ImageProvider get fullImageProvider {
|
||||||
|
assert(_isTilingInitialized);
|
||||||
|
return entry.getRegion(
|
||||||
|
scale: _minScale,
|
||||||
|
region: fullImageRegion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_displaySize = entry.displaySize;
|
||||||
|
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant VectorImageView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
final oldViewState = oldWidget.viewStateNotifier.value;
|
||||||
|
final viewState = widget.viewStateNotifier.value;
|
||||||
|
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
|
||||||
|
_isTilingInitialized = false;
|
||||||
|
_fullImageLoaded.value = false;
|
||||||
|
_unregisterFullImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unregisterFullImage();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerFullImage() {
|
||||||
|
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
|
||||||
|
_fullImageStream!.addListener(_fullImageListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unregisterFullImage() {
|
||||||
|
_fullImageStream?.removeListener(_fullImageListener);
|
||||||
|
_fullImageStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
|
||||||
|
_unregisterFullImage();
|
||||||
|
_fullImageLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ViewState>(
|
return ValueListenableBuilder<ViewState>(
|
||||||
valueListenable: viewStateNotifier,
|
valueListenable: viewStateNotifier,
|
||||||
builder: (context, viewState, child) {
|
builder: (context, viewState, child) {
|
||||||
final viewportSize = viewState.viewportSize;
|
final viewportSize = viewState.viewportSize;
|
||||||
if (viewportSize == null) return child!;
|
final viewportSized = viewportSize?.isEmpty == false;
|
||||||
|
if (viewportSized && !_isTilingInitialized) _initTiling(viewportSize!);
|
||||||
|
|
||||||
final side = viewportSize.shortestSide;
|
return SizedBox.fromSize(
|
||||||
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
|
size: _displaySize * viewState.scale!,
|
||||||
|
child: Stack(
|
||||||
final viewSize = displaySize * viewState.scale!;
|
alignment: Alignment.center,
|
||||||
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
|
children: [
|
||||||
final offset = ((decorationSize - viewportSize) as Offset) / 2;
|
_buildLoading(),
|
||||||
|
..._getTiles(),
|
||||||
return Stack(
|
],
|
||||||
alignment: Alignment.center,
|
),
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
width: decorationSize.width,
|
|
||||||
height: decorationSize.height,
|
|
||||||
child: CustomPaint(
|
|
||||||
painter: CheckeredPainter(
|
|
||||||
checkSize: checkSize,
|
|
||||||
offset: offset,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child!,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initTiling(Size viewportSize) {
|
||||||
|
_tileSide = _displaySize.longestSide;
|
||||||
|
// scale for initial state `contained`
|
||||||
|
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
|
||||||
|
_minScale = _imageScaleForViewScale(containedScale);
|
||||||
|
|
||||||
|
_isTilingInitialized = true;
|
||||||
|
_registerFullImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoading() {
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _fullImageLoaded,
|
||||||
|
builder: (context, fullImageLoaded, child) {
|
||||||
|
if (fullImageLoaded) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
|
||||||
|
aspectRatio: entry.displayAspectRatio,
|
||||||
|
child: Image(
|
||||||
|
image: thumbnailProvider,
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _getTiles() {
|
||||||
|
if (!_isTilingInitialized) return [];
|
||||||
|
|
||||||
|
final displayWidth = _displaySize.width;
|
||||||
|
final displayHeight = _displaySize.height;
|
||||||
|
final viewRect = _getViewRect(displayWidth, displayHeight);
|
||||||
|
final viewScale = viewState.scale!;
|
||||||
|
final background = settings.vectorBackground;
|
||||||
|
|
||||||
|
Color? backgroundColor;
|
||||||
|
_BackgroundFrameBuilder? backgroundFrameBuilder;
|
||||||
|
if (background.isColor) {
|
||||||
|
backgroundColor = background.color;
|
||||||
|
} else if (background == EntryBackground.checkered) {
|
||||||
|
final viewportSize = viewState.viewportSize!;
|
||||||
|
final viewSize = _displaySize * viewState.scale!;
|
||||||
|
|
||||||
|
final backgroundSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
|
||||||
|
var backgroundOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
|
||||||
|
backgroundOffset = Offset(max(0, backgroundOffset.dx), max(0, backgroundOffset.dy));
|
||||||
|
backgroundOffset += ((backgroundSize - viewportSize) as Offset) / 2;
|
||||||
|
|
||||||
|
final side = viewportSize.shortestSide;
|
||||||
|
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
|
||||||
|
|
||||||
|
backgroundFrameBuilder = (child, frame, tileRect) {
|
||||||
|
return frame == null
|
||||||
|
? const SizedBox()
|
||||||
|
: DecoratedBox(
|
||||||
|
decoration: _CheckeredBackgroundDecoration(
|
||||||
|
viewportSize: viewportSize,
|
||||||
|
checkSize: checkSize,
|
||||||
|
offset: backgroundOffset - tileRect.topLeft,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// for the largest sample size (matching the initial scale), the whole image is in view
|
||||||
|
// so we subsample the whole image without tiling
|
||||||
|
final fullImageRegionTile = _RegionTile(
|
||||||
|
entry: entry,
|
||||||
|
tileRect: Rect.fromLTWH(0, 0, displayWidth * viewScale, displayHeight * viewScale),
|
||||||
|
regionRect: fullImageRegion,
|
||||||
|
scale: _minScale,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
backgroundFrameBuilder: backgroundFrameBuilder,
|
||||||
|
);
|
||||||
|
final tiles = <Widget>[fullImageRegionTile];
|
||||||
|
|
||||||
|
final maxSvgScale = max(_imageScaleForViewScale(viewScale), _minScale);
|
||||||
|
double nextScale(double scale) => scale * 2;
|
||||||
|
// add `alpha` to the region side so that tiles do not align across layers,
|
||||||
|
// which helps the checkered background deflation workaround
|
||||||
|
// for the tile background bleeding issue
|
||||||
|
var alpha = 0;
|
||||||
|
for (var svgScale = nextScale(_minScale); svgScale <= maxSvgScale; svgScale = nextScale(svgScale)) {
|
||||||
|
final regionSide = (_tileSide + alpha++) / (svgScale / _minScale);
|
||||||
|
for (var x = .0; x < displayWidth; x += regionSide) {
|
||||||
|
for (var y = .0; y < displayHeight; y += regionSide) {
|
||||||
|
final rects = _getTileRects(
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
regionSide: regionSide,
|
||||||
|
displayWidth: displayWidth,
|
||||||
|
displayHeight: displayHeight,
|
||||||
|
scale: viewScale,
|
||||||
|
viewRect: viewRect,
|
||||||
|
);
|
||||||
|
if (rects != null) {
|
||||||
|
tiles.add(_RegionTile(
|
||||||
|
entry: entry,
|
||||||
|
tileRect: rects.item1,
|
||||||
|
regionRect: rects.item2,
|
||||||
|
scale: svgScale,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
backgroundFrameBuilder: backgroundFrameBuilder,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect _getViewRect(double displayWidth, double displayHeight) {
|
||||||
|
final scale = viewState.scale!;
|
||||||
|
final centerOffset = viewState.position;
|
||||||
|
final viewportSize = viewState.viewportSize!;
|
||||||
|
final viewOrigin = Offset(
|
||||||
|
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
|
||||||
|
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
|
||||||
|
);
|
||||||
|
return viewOrigin & viewportSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<Rect, Rectangle<double>>? _getTileRects({
|
||||||
|
required double x,
|
||||||
|
required double y,
|
||||||
|
required double regionSide,
|
||||||
|
required double displayWidth,
|
||||||
|
required double displayHeight,
|
||||||
|
required double scale,
|
||||||
|
required Rect viewRect,
|
||||||
|
}) {
|
||||||
|
final nextX = x + regionSide;
|
||||||
|
final nextY = y + regionSide;
|
||||||
|
final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0);
|
||||||
|
final thisRegionHeight = regionSide - (nextY >= displayHeight ? nextY - displayHeight : 0);
|
||||||
|
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
|
||||||
|
|
||||||
|
// only build visible tiles
|
||||||
|
if (!viewRect.overlaps(tileRect)) return null;
|
||||||
|
|
||||||
|
final regionRect = Rectangle<double>(x, y, thisRegionWidth, thisRegionHeight);
|
||||||
|
return Tuple2<Rect, Rectangle<double>>(tileRect, regionRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _imageScaleForViewScale(double scale) => smallestPowerOf2(scale * window.devicePixelRatio).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef _BackgroundFrameBuilder = Widget Function(Widget child, int? frame, Rect tileRect);
|
||||||
|
|
||||||
|
class _RegionTile extends StatefulWidget {
|
||||||
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
// `tileRect` uses Flutter view coordinates
|
||||||
|
// `regionRect` uses the raw image pixel coordinates
|
||||||
|
final Rect tileRect;
|
||||||
|
final Rectangle<double> regionRect;
|
||||||
|
final double scale;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final _BackgroundFrameBuilder? backgroundFrameBuilder;
|
||||||
|
|
||||||
|
const _RegionTile({
|
||||||
|
Key? key,
|
||||||
|
required this.entry,
|
||||||
|
required this.tileRect,
|
||||||
|
required this.regionRect,
|
||||||
|
required this.scale,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.backgroundFrameBuilder,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_RegionTileState createState() => _RegionTileState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
|
super.debugFillProperties(properties);
|
||||||
|
properties.add(IntProperty('contentId', entry.contentId));
|
||||||
|
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
|
||||||
|
properties.add(DiagnosticsProperty<Rectangle<double>>('regionRect', regionRect));
|
||||||
|
properties.add(DoubleProperty('scale', scale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegionTileState extends State<_RegionTile> {
|
||||||
|
late RegionProvider _provider;
|
||||||
|
|
||||||
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_registerWidget(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _RegionTile oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.scale != widget.scale || oldWidget.scale != widget.scale) {
|
||||||
|
_unregisterWidget(oldWidget);
|
||||||
|
_registerWidget(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unregisterWidget(widget);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerWidget(_RegionTile widget) {
|
||||||
|
_initProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unregisterWidget(_RegionTile widget) {
|
||||||
|
_pauseProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initProvider() {
|
||||||
|
_provider = entry.getRegion(
|
||||||
|
scale: widget.scale,
|
||||||
|
region: widget.regionRect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pauseProvider() => _provider.pause();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tileRect = widget.tileRect;
|
||||||
|
|
||||||
|
Widget child = Image(
|
||||||
|
image: _provider,
|
||||||
|
frameBuilder: (_, child, frame, __) => widget.backgroundFrameBuilder?.call(child, frame, tileRect) ?? child,
|
||||||
|
width: tileRect.width,
|
||||||
|
height: tileRect.height,
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
colorBlendMode: BlendMode.dstOver,
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Positioned.fromRect(
|
||||||
|
rect: tileRect,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CheckeredBackgroundDecoration extends Decoration {
|
||||||
|
final Size viewportSize;
|
||||||
|
final double checkSize;
|
||||||
|
final Offset offset;
|
||||||
|
|
||||||
|
const _CheckeredBackgroundDecoration({
|
||||||
|
required this.viewportSize,
|
||||||
|
required this.checkSize,
|
||||||
|
required this.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_CheckeredBackgroundDecorationPainter createBoxPainter([VoidCallback? onChanged]) {
|
||||||
|
return _CheckeredBackgroundDecorationPainter(this, onChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckeredBackgroundDecorationPainter extends BoxPainter {
|
||||||
|
final _CheckeredBackgroundDecoration decoration;
|
||||||
|
|
||||||
|
const _CheckeredBackgroundDecorationPainter(this.decoration, VoidCallback? onChanged) : super(onChanged);
|
||||||
|
|
||||||
|
static const deflation = Offset(.5, .5);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||||
|
final size = configuration.size;
|
||||||
|
if (size == null) return;
|
||||||
|
|
||||||
|
var decorated = offset & size;
|
||||||
|
// deflate background as a workaround for background bleeding beyond tile image
|
||||||
|
decorated = Rect.fromLTRB(
|
||||||
|
decorated.left + deflation.dx,
|
||||||
|
decorated.top + deflation.dy,
|
||||||
|
decorated.right - deflation.dx,
|
||||||
|
decorated.bottom - deflation.dy,
|
||||||
|
);
|
||||||
|
|
||||||
|
final visible = decorated.intersect(Offset.zero & decoration.viewportSize);
|
||||||
|
final checkOffset = decoration.offset + decorated.topLeft - visible.topLeft - deflation;
|
||||||
|
|
||||||
|
final translation = Offset(max(0, offset.dx + deflation.dx), max(0, offset.dy + deflation.dy));
|
||||||
|
canvas.translate(translation.dx, translation.dy);
|
||||||
|
CheckeredPainter(
|
||||||
|
checkSize: decoration.checkSize,
|
||||||
|
offset: checkOffset,
|
||||||
|
).paint(canvas, visible.size);
|
||||||
|
canvas.translate(-translation.dx, -translation.dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,17 @@ void main() {
|
||||||
expect(highestPowerOf2(42), 32);
|
expect(highestPowerOf2(42), 32);
|
||||||
expect(highestPowerOf2(0), 0);
|
expect(highestPowerOf2(0), 0);
|
||||||
expect(highestPowerOf2(-42), 0);
|
expect(highestPowerOf2(-42), 0);
|
||||||
|
expect(highestPowerOf2(.5), 0);
|
||||||
|
expect(highestPowerOf2(1.5), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smallest power of 2 that is larger than or equal to the number', () {
|
||||||
|
expect(smallestPowerOf2(1024), 1024);
|
||||||
|
expect(smallestPowerOf2(42), 64);
|
||||||
|
expect(smallestPowerOf2(0), 1);
|
||||||
|
expect(smallestPowerOf2(-42), 1);
|
||||||
|
expect(smallestPowerOf2(.5), 1);
|
||||||
|
expect(smallestPowerOf2(1.5), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rounding to a given precision after the decimal', () {
|
test('rounding to a given precision after the decimal', () {
|
||||||
|
|
Loading…
Reference in a new issue