278 lines
9 KiB
Dart
278 lines
9 KiB
Dart
import 'dart:math';
|
|
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 ThumbnailImage extends StatefulWidget {
|
|
final AvesEntry entry;
|
|
final double extent;
|
|
final BoxFit? fit;
|
|
final bool showLoadingBackground;
|
|
final ValueNotifier<bool>? cancellableNotifier;
|
|
final Object? heroTag;
|
|
|
|
const ThumbnailImage({
|
|
Key? key,
|
|
required this.entry,
|
|
required this.extent,
|
|
this.fit,
|
|
this.showLoadingBackground = true,
|
|
this.cancellableNotifier,
|
|
this.heroTag,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
_ThumbnailImageState createState() => _ThumbnailImageState();
|
|
}
|
|
|
|
class _ThumbnailImageState extends State<ThumbnailImage> {
|
|
final _providers = <_ConditionalImageProvider>[];
|
|
_ProviderStream? _currentProviderStream;
|
|
ImageInfo? _lastImageInfo;
|
|
Object? _lastException;
|
|
late final ImageStreamListener _streamListener;
|
|
late DisposableBuildContext<State<ThumbnailImage>> _scrollAwareContext;
|
|
|
|
AvesEntry get entry => widget.entry;
|
|
|
|
double get extent => widget.extent;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_streamListener = ImageStreamListener(_onImageLoad, onError: _onError);
|
|
_scrollAwareContext = DisposableBuildContext<State<ThumbnailImage>>(this);
|
|
_registerWidget(widget);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ThumbnailImage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.entry != entry) {
|
|
_unregisterWidget(oldWidget);
|
|
_registerWidget(widget);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_unregisterWidget(widget);
|
|
_scrollAwareContext.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _registerWidget(ThumbnailImage widget) {
|
|
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
|
_initProvider();
|
|
}
|
|
|
|
void _unregisterWidget(ThumbnailImage widget) {
|
|
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
|
_pauseProvider();
|
|
_currentProviderStream?.stopListening();
|
|
_currentProviderStream = null;
|
|
_replaceImage(null);
|
|
}
|
|
|
|
void _initProvider() {
|
|
if (!entry.canDecode) return;
|
|
|
|
_lastException = null;
|
|
_providers.clear();
|
|
_providers.addAll([
|
|
if (!entry.isSvg)
|
|
_ConditionalImageProvider(
|
|
ScrollAwareImageProvider(
|
|
context: _scrollAwareContext,
|
|
imageProvider: entry.getThumbnail(),
|
|
),
|
|
),
|
|
_ConditionalImageProvider(
|
|
ScrollAwareImageProvider(
|
|
context: _scrollAwareContext,
|
|
imageProvider: entry.getThumbnail(extent: extent),
|
|
),
|
|
_needSizedProvider,
|
|
),
|
|
]);
|
|
_loadNextProvider();
|
|
}
|
|
|
|
void _loadNextProvider([ImageInfo? imageInfo]) {
|
|
final nextIndex = _currentProviderStream == null ? 0 : (_providers.indexOf(_currentProviderStream!.provider) + 1);
|
|
if (nextIndex < _providers.length) {
|
|
final provider = _providers[nextIndex];
|
|
if (provider.predicate?.call(imageInfo) ?? true) {
|
|
_currentProviderStream?.stopListening();
|
|
_currentProviderStream = _ProviderStream(provider, _streamListener);
|
|
_currentProviderStream!.startListening();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onImageLoad(ImageInfo imageInfo, bool synchronousCall) {
|
|
_replaceImage(imageInfo);
|
|
_loadNextProvider(imageInfo);
|
|
}
|
|
|
|
void _replaceImage(ImageInfo? imageInfo) {
|
|
_lastImageInfo?.dispose();
|
|
_lastImageInfo = imageInfo;
|
|
if (imageInfo != null) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
void _onError(Object exception, StackTrace? stackTrace) {
|
|
if (mounted) {
|
|
setState(() => _lastException = exception);
|
|
}
|
|
}
|
|
|
|
bool _needSizedProvider(ImageInfo? currentImageInfo) {
|
|
if (currentImageInfo == null) return true;
|
|
final currentImage = currentImageInfo.image;
|
|
// directly uses `devicePixelRatio` as it never changes, to avoid visiting ancestors via `MediaQuery`
|
|
final sizedThreshold = extent * window.devicePixelRatio;
|
|
return sizedThreshold > min(currentImage.width, currentImage.height);
|
|
}
|
|
|
|
void _pauseProvider() async {
|
|
if (widget.cancellableNotifier?.value ?? false) {
|
|
final key = await _currentProviderStream?.provider.provider.obtainKey(ImageConfiguration.empty);
|
|
if (key is ThumbnailProviderKey) {
|
|
imageFileService.cancelThumbnail(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
Color? _loadingBackgroundColor;
|
|
|
|
Color get loadingBackgroundColor {
|
|
if (_loadingBackgroundColor == null) {
|
|
final rgb = 0x30 + entry.uri.hashCode % 0x20;
|
|
_loadingBackgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb);
|
|
}
|
|
return _loadingBackgroundColor!;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!entry.canDecode) {
|
|
return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null);
|
|
} else if (_lastException != null) {
|
|
return _buildError(context, _lastException.toString(), null);
|
|
}
|
|
|
|
// 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 ? loadingBackgroundColor : Colors.transparent,
|
|
width: extent,
|
|
height: extent,
|
|
)
|
|
: 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,
|
|
color: backgroundColor,
|
|
colorBlendMode: BlendMode.dstOver,
|
|
fit: fit,
|
|
);
|
|
});
|
|
|
|
return widget.heroTag != null
|
|
? Hero(
|
|
tag: widget.heroTag!,
|
|
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
|
return TransitionImage(
|
|
image: entry.bestCachedThumbnail,
|
|
animation: animation,
|
|
);
|
|
},
|
|
transitionOnUserGestures: true,
|
|
child: image,
|
|
)
|
|
: image;
|
|
}
|
|
|
|
Widget _buildError(BuildContext context, Object error, StackTrace? stackTrace) => ErrorThumbnail(
|
|
entry: entry,
|
|
extent: extent,
|
|
tooltip: error.toString(),
|
|
);
|
|
|
|
// when the entry image itself changed (e.g. after rotation)
|
|
void _onImageChanged() async {
|
|
// rebuild to refresh the thumbnails
|
|
_pauseProvider();
|
|
_initProvider();
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
class _ConditionalImageProvider {
|
|
final ImageProvider provider;
|
|
final bool Function(ImageInfo?)? predicate;
|
|
|
|
const _ConditionalImageProvider(this.provider, [this.predicate]);
|
|
}
|
|
|
|
class _ProviderStream {
|
|
final _ConditionalImageProvider provider;
|
|
final ImageStream _stream;
|
|
final ImageStreamListener listener;
|
|
|
|
_ProviderStream(this.provider, this.listener) : _stream = provider.provider.resolve(ImageConfiguration.empty);
|
|
|
|
void startListening() => _stream.addListener(listener);
|
|
|
|
void stopListening() => _stream.removeListener(listener);
|
|
}
|