nc-photos/app/lib/widget/image_viewer.dart
2024-10-07 01:16:21 +08:00

327 lines
8.8 KiB
Dart

import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/flutter_util.dart' as flutter_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
import 'package:nc_photos/np_api_util.dart';
import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod;
import 'package:nc_photos/widget/network_thumbnail.dart';
import 'package:nc_photos/widget/zoomable_viewer.dart';
import 'package:np_codegen/np_codegen.dart';
part 'image_viewer.g.dart';
class LocalImageViewer extends StatefulWidget {
const LocalImageViewer({
super.key,
required this.file,
required this.canZoom,
this.onLoaded,
this.onHeightChanged,
this.onZoomStarted,
this.onZoomEnded,
});
@override
createState() => _LocalImageViewerState();
final LocalFile file;
final bool canZoom;
final VoidCallback? onLoaded;
final ValueChanged<double>? onHeightChanged;
final VoidCallback? onZoomStarted;
final VoidCallback? onZoomEnded;
}
@npLog
class _LocalImageViewerState extends State<LocalImageViewer> {
@override
build(BuildContext context) {
final ImageProvider provider;
if (widget.file is LocalUriFile) {
provider = ContentUriImage((widget.file as LocalUriFile).uri);
} else {
throw ArgumentError("Invalid file");
}
return _ImageViewer(
canZoom: widget.canZoom,
onHeightChanged: widget.onHeightChanged,
onZoomStarted: widget.onZoomStarted,
onZoomEnded: widget.onZoomEnded,
child: Image(
image: provider,
fit: BoxFit.contain,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onItemLoaded();
});
return child;
},
),
);
}
void _onItemLoaded() {
if (!_isLoaded) {
_log.info("[_onItemLoaded] ${widget.file.logTag}");
_isLoaded = true;
widget.onLoaded?.call();
}
}
var _isLoaded = false;
}
class RemoteImageViewer extends StatefulWidget {
const RemoteImageViewer({
super.key,
required this.account,
required this.file,
required this.canZoom,
this.onLoaded,
this.onHeightChanged,
this.onZoomStarted,
this.onZoomEnded,
});
@override
createState() => _RemoteImageViewerState();
static void preloadImage(Account account, FileDescriptor file) {
LargeImageCacheManager.inst.getFileStream(
_getImageUrl(account, file),
headers: {
"Authorization": AuthUtil.fromAccount(account).toHeaderValue(),
},
);
}
final Account account;
final FileDescriptor file;
final bool canZoom;
final VoidCallback? onLoaded;
final ValueChanged<double>? onHeightChanged;
final VoidCallback? onZoomStarted;
final VoidCallback? onZoomEnded;
}
@npLog
class _RemoteImageViewerState extends State<RemoteImageViewer> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
// needed to get rid of the large image blinking during Hero animation
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
return _ImageViewer(
canZoom: widget.canZoom,
onHeightChanged: widget.onHeightChanged,
onZoomStarted: widget.onZoomStarted,
onZoomEnded: widget.onZoomEnded,
child: Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: !_isHeroDone || !_isLoaded ? 1 : 0,
child: Hero(
tag: flutter_util.getImageHeroTag(widget.file),
flightShuttleBuilder: (flightContext, animation, flightDirection,
fromHeroContext, toHeroContext) {
_isHeroDone = false;
animation.addStatusListener(_animationListener);
return flutter_util.defaultHeroFlightShuttleBuilder(
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
);
},
child: _PreviewImage(
account: widget.account,
file: widget.file,
),
),
),
if (_isHeroDone)
_FullSizedImage(
account: widget.account,
file: widget.file,
onItemLoaded: _onItemLoaded,
),
],
),
);
}
void _onItemLoaded() {
if (!_isLoaded) {
_log.info("[_onItemLoaded]");
_isLoaded = true;
widget.onLoaded?.call();
}
}
void _animationListener(AnimationStatus status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
_isHeroDone = true;
if (mounted) {
setState(() {});
}
}
}
var _isLoaded = false;
// initially set to true such that the large image will show when hero didn't
// run (i.e., when swiping in viewer)
var _isHeroDone = true;
}
class _ImageViewer extends StatefulWidget {
const _ImageViewer({
required this.child,
required this.canZoom,
this.onHeightChanged,
this.onZoomStarted,
this.onZoomEnded,
});
@override
createState() => _ImageViewerState();
final Widget child;
final bool canZoom;
final ValueChanged<double>? onHeightChanged;
final VoidCallback? onZoomStarted;
final VoidCallback? onZoomEnded;
}
@npLog
class _ImageViewerState extends State<_ImageViewer>
with TickerProviderStateMixin {
@override
build(BuildContext context) {
final content = Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
alignment: Alignment.center,
child: NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_key.currentContext != null) {
widget.onHeightChanged?.call(_key.currentContext!.size!.height);
}
});
return false;
},
child: SizeChangedLayoutNotifier(
key: _key,
child: IntrinsicHeight(child: widget.child),
),
),
);
if (widget.canZoom) {
return ZoomableViewer(
onZoomStarted: widget.onZoomStarted,
onZoomEnded: widget.onZoomEnded,
child: content,
);
} else {
return content;
}
}
final _key = GlobalKey();
}
String _getImageUrl(Account account, FileDescriptor file) {
if (file.fdMime == "image/gif") {
return api_util.getFileUrl(account, file);
} else {
return api_util.getFilePreviewUrl(
account,
file,
width: k.photoLargeSize,
height: k.photoLargeSize,
isKeepAspectRatio: true,
);
}
}
class _PreviewImage extends StatelessWidget {
const _PreviewImage({
required this.account,
required this.file,
});
@override
Widget build(BuildContext context) {
return mod.CachedNetworkImage(
fit: BoxFit.contain,
cacheManager: ThumbnailCacheManager.inst,
imageUrl: NetworkRectThumbnail.imageUrlForFile(account, file),
httpHeaders: {
"Authorization": AuthUtil.fromAccount(account).toHeaderValue(),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
imageBuilder: (context, child, imageProvider) {
const SizeChangedLayoutNotification().dispatch(context);
return child;
},
);
}
final Account account;
final FileDescriptor file;
}
class _FullSizedImage extends StatelessWidget {
const _FullSizedImage({
required this.account,
required this.file,
this.onItemLoaded,
});
@override
Widget build(BuildContext context) {
return mod.CachedNetworkImage(
fit: BoxFit.contain,
cacheManager: LargeImageCacheManager.inst,
imageUrl: _getImageUrl(account, file),
httpHeaders: {
"Authorization": AuthUtil.fromAccount(account).toHeaderValue(),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
imageBuilder: (context, child, imageProvider) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onItemLoaded?.call();
});
const SizeChangedLayoutNotification().dispatch(context);
return child;
},
);
}
final Account account;
final FileDescriptor file;
final VoidCallback? onItemLoaded;
}