505 lines
17 KiB
Dart
505 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:flutter/gestures.dart' show Drag, kTouchSlop;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
|
|
|
enum _DragIntent { none, scroll, dismiss }
|
|
|
|
class AssetPage extends ConsumerStatefulWidget {
|
|
final int index;
|
|
final int heroOffset;
|
|
final void Function(int direction)? onTapNavigate;
|
|
|
|
const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate});
|
|
|
|
@override
|
|
ConsumerState createState() => _AssetPageState();
|
|
}
|
|
|
|
class _AssetPageState extends ConsumerState<AssetPage> {
|
|
PhotoViewControllerBase? _viewController;
|
|
StreamSubscription? _scaleBoundarySub;
|
|
StreamSubscription? _eventSubscription;
|
|
|
|
AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier);
|
|
|
|
late PhotoViewControllerValue _initialPhotoViewState;
|
|
|
|
bool _showingDetails = false;
|
|
bool _isZoomed = false;
|
|
|
|
final _scrollController = SnapScrollController();
|
|
double _snapOffset = 0.0;
|
|
|
|
DragStartDetails? _dragStart;
|
|
_DragIntent _dragIntent = _DragIntent.none;
|
|
Drag? _drag;
|
|
|
|
BaseAsset? _asset;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
|
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_scrollController.hasClients) {
|
|
return;
|
|
}
|
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
|
if (_showingDetails && _snapOffset > 0) {
|
|
_scrollController.jumpTo(_snapOffset);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(AssetPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.index != widget.index) {
|
|
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
_scaleBoundarySub?.cancel();
|
|
_eventSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onEvent(Event event) {
|
|
switch (event) {
|
|
case ViewerShowDetailsEvent():
|
|
_showDetails();
|
|
default:
|
|
}
|
|
}
|
|
|
|
void _showDetails() {
|
|
if (!_scrollController.hasClients || _snapOffset <= 0) {
|
|
return;
|
|
}
|
|
_viewer.setShowingDetails(true);
|
|
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
|
}
|
|
|
|
bool _willClose(double scrollVelocity) =>
|
|
_scrollController.hasClients &&
|
|
_snapOffset > 0 &&
|
|
_scrollController.position.pixels < _snapOffset &&
|
|
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
|
|
SnapScrollPhysics.minSnapDistance;
|
|
|
|
void _syncShowingDetails() {
|
|
final offset = _scrollController.offset;
|
|
if (offset > SnapScrollPhysics.minSnapDistance) {
|
|
_viewer.setShowingDetails(true);
|
|
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
|
_viewer.setShowingDetails(false);
|
|
}
|
|
}
|
|
|
|
void _beginDrag(DragStartDetails details) {
|
|
_dragStart = details;
|
|
|
|
if (_viewController != null) {
|
|
_initialPhotoViewState = _viewController!.value;
|
|
}
|
|
|
|
if (_showingDetails) {
|
|
_dragIntent = _DragIntent.scroll;
|
|
_startProxyDrag();
|
|
}
|
|
}
|
|
|
|
void _startProxyDrag() {
|
|
if (_scrollController.hasClients && _dragStart != null) {
|
|
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
|
|
}
|
|
}
|
|
|
|
void _updateDrag(DragUpdateDetails details) {
|
|
if (_dragStart == null) {
|
|
return;
|
|
}
|
|
|
|
if (_dragIntent == _DragIntent.none) {
|
|
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
|
|
< 0 => _DragIntent.scroll,
|
|
> 0 => _DragIntent.dismiss,
|
|
_ => _DragIntent.none,
|
|
};
|
|
}
|
|
|
|
switch (_dragIntent) {
|
|
case _DragIntent.none:
|
|
case _DragIntent.scroll:
|
|
if (_drag == null) {
|
|
_startProxyDrag();
|
|
}
|
|
_drag?.update(details);
|
|
|
|
_syncShowingDetails();
|
|
case _DragIntent.dismiss:
|
|
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
|
|
}
|
|
}
|
|
|
|
void _endDrag(DragEndDetails details) {
|
|
if (_dragStart == null) {
|
|
return;
|
|
}
|
|
|
|
final start = _dragStart;
|
|
_dragStart = null;
|
|
|
|
final intent = _dragIntent;
|
|
_dragIntent = _DragIntent.none;
|
|
|
|
switch (intent) {
|
|
case _DragIntent.none:
|
|
case _DragIntent.scroll:
|
|
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
|
|
_viewer.setShowingDetails(!_willClose(scrollVelocity));
|
|
|
|
_drag?.end(details);
|
|
_drag = null;
|
|
case _DragIntent.dismiss:
|
|
const popThreshold = 75.0;
|
|
if (details.localPosition.dy - start!.localPosition.dy > popThreshold) {
|
|
context.maybePop();
|
|
return;
|
|
}
|
|
_viewController?.animateMultiple(
|
|
position: _initialPhotoViewState.position,
|
|
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
|
|
rotation: _initialPhotoViewState.rotation,
|
|
);
|
|
_viewer.setOpacity(1.0);
|
|
}
|
|
}
|
|
|
|
void _onDragStart(
|
|
BuildContext context,
|
|
DragStartDetails details,
|
|
PhotoViewControllerBase controller,
|
|
PhotoViewScaleStateController scaleStateController,
|
|
) {
|
|
if (!_showingDetails && _isZoomed) {
|
|
return;
|
|
}
|
|
_beginDrag(details);
|
|
}
|
|
|
|
void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) =>
|
|
_updateDrag(details);
|
|
|
|
void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details);
|
|
|
|
void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0));
|
|
|
|
void _handleDragDown(BuildContext context, Offset delta) {
|
|
const dragRatio = 0.2;
|
|
|
|
final distance = delta.dy.abs();
|
|
final maxScaleDistance = context.height * 0.5;
|
|
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
|
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;
|
|
final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null;
|
|
|
|
final opacity = 1.0 - (scaleReduction / dragRatio);
|
|
|
|
_viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale);
|
|
_viewer.setOpacity(opacity);
|
|
}
|
|
|
|
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
|
|
if (_showingDetails || _dragStart != null) {
|
|
return;
|
|
}
|
|
|
|
final tapToNavigate = ref.read(appConfigProvider).viewer.tapToNavigate;
|
|
if (!tapToNavigate) {
|
|
_viewer.toggleControls();
|
|
return;
|
|
}
|
|
|
|
final tapX = details.globalPosition.dx;
|
|
final screenWidth = context.width;
|
|
|
|
// Navigate if the user taps in the leftmost or rightmost quarter of the screen
|
|
final tappedLeftSide = tapX < screenWidth / 4;
|
|
final tappedRightSide = tapX > screenWidth * (3 / 4);
|
|
|
|
if (tappedLeftSide) {
|
|
widget.onTapNavigate?.call(-1);
|
|
} else if (tappedRightSide) {
|
|
widget.onTapNavigate?.call(1);
|
|
} else {
|
|
_viewer.toggleControls();
|
|
}
|
|
}
|
|
|
|
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
|
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
|
|
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
|
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
|
|
_viewer.setZoomed(_isZoomed);
|
|
|
|
if (scaleState != PhotoViewScaleState.initial) {
|
|
if (_dragStart == null) {
|
|
_viewer.setControls(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_showingDetails) {
|
|
_viewer.setControls(true);
|
|
}
|
|
}
|
|
|
|
void _listenForScaleBoundaries(PhotoViewControllerBase? controller) {
|
|
_scaleBoundarySub?.cancel();
|
|
_scaleBoundarySub = null;
|
|
if (controller == null || controller.scaleBoundaries != null) {
|
|
return;
|
|
}
|
|
_scaleBoundarySub = controller.outputStateStream.listen((_) {
|
|
if (controller.scaleBoundaries != null) {
|
|
_scaleBoundarySub?.cancel();
|
|
_scaleBoundarySub = null;
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) {
|
|
final sb = _viewController?.scaleBoundaries;
|
|
if (sb != null) {
|
|
return sb.childSize.height * sb.initialScale;
|
|
}
|
|
|
|
if (asset == null || asset.width == null || asset.height == null) {
|
|
return maxHeight;
|
|
}
|
|
|
|
final r = asset.width! / asset.height!;
|
|
return math.min(maxWidth / r, maxHeight);
|
|
}
|
|
|
|
void _onPageBuild(PhotoViewControllerBase controller) {
|
|
_viewController = controller;
|
|
_listenForScaleBoundaries(controller);
|
|
}
|
|
|
|
Widget _buildPhotoView({
|
|
required BaseAsset asset,
|
|
required PhotoViewHeroAttributes? heroAttributes,
|
|
required bool isCurrent,
|
|
required bool isPlayingMotionVideo,
|
|
required String? localFilePath,
|
|
}) {
|
|
final size = context.sizeData;
|
|
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
|
|
|
|
if (asset.isImage && !isPlayingMotionVideo) {
|
|
return PhotoView(
|
|
key: Key(asset.heroTag),
|
|
index: widget.index,
|
|
imageProvider: imageProvider,
|
|
heroAttributes: heroAttributes,
|
|
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.high,
|
|
tightMode: true,
|
|
enablePanAlways: true,
|
|
disableScaleGestures: _showingDetails,
|
|
scaleStateChangedCallback: _onScaleStateChanged,
|
|
onPageBuild: _onPageBuild,
|
|
onDragStart: _onDragStart,
|
|
onDragUpdate: _onDragUpdate,
|
|
onDragEnd: _onDragEnd,
|
|
onDragCancel: _onDragCancel,
|
|
onTapUp: _onTapUp,
|
|
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
|
errorBuilder: (_, __, ___) => SizedBox(
|
|
width: size.width,
|
|
height: size.height,
|
|
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
|
),
|
|
);
|
|
}
|
|
|
|
return PhotoView.customChild(
|
|
key: Key(asset.heroTag),
|
|
childSize: asset.width != null && asset.height != null
|
|
? Size(asset.width!.toDouble(), asset.height!.toDouble())
|
|
: null,
|
|
onDragStart: _onDragStart,
|
|
onDragUpdate: _onDragUpdate,
|
|
onDragEnd: _onDragEnd,
|
|
onDragCancel: _onDragCancel,
|
|
onTapUp: _onTapUp,
|
|
scaleStateChangedCallback: _onScaleStateChanged,
|
|
heroAttributes: heroAttributes,
|
|
filterQuality: FilterQuality.high,
|
|
basePosition: Alignment.center,
|
|
disableScaleGestures: _showingDetails,
|
|
minScale: PhotoViewComputedScale.contained,
|
|
initialScale: PhotoViewComputedScale.contained,
|
|
tightMode: true,
|
|
onPageBuild: _onPageBuild,
|
|
enablePanAlways: true,
|
|
child: NativeVideoViewer(
|
|
key: _NativeVideoViewerKey(asset.heroTag),
|
|
asset: asset,
|
|
localFilePath: localFilePath,
|
|
isCurrent: isCurrent,
|
|
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
|
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
|
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
|
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
|
|
|
final asset = _asset;
|
|
if (asset == null) {
|
|
return const Center(child: ImmichLoadingIndicator());
|
|
}
|
|
|
|
BaseAsset displayAsset = asset;
|
|
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
|
|
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
|
|
if (stackChildren != null && stackChildren.isNotEmpty) {
|
|
displayAsset = stackChildren.elementAt(stackIndex);
|
|
}
|
|
|
|
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
|
|
|
final viewportWidth = MediaQuery.widthOf(context);
|
|
final viewportHeight = MediaQuery.heightOf(context);
|
|
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
|
|
|
final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2;
|
|
final snapTarget = viewportHeight / 3;
|
|
|
|
_snapOffset = detailsOffset - snapTarget;
|
|
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
|
}
|
|
|
|
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
|
|
|
return Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
controller: _scrollController,
|
|
physics: const SnapScrollPhysics(),
|
|
child: ColoredBox(
|
|
color: _showingDetails ? Colors.black : Colors.transparent,
|
|
child: Stack(
|
|
children: [
|
|
SizedBox(
|
|
width: viewportWidth,
|
|
height: viewportHeight,
|
|
child: _buildPhotoView(
|
|
asset: displayAsset,
|
|
heroAttributes: isCurrent
|
|
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
|
: null,
|
|
isCurrent: isCurrent,
|
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
|
localFilePath: viewIntentFilePath,
|
|
),
|
|
),
|
|
IgnorePointer(
|
|
ignoring: !_showingDetails,
|
|
child: Column(
|
|
children: [
|
|
SizedBox(height: detailsOffset),
|
|
GestureDetector(
|
|
onVerticalDragStart: _beginDrag,
|
|
onVerticalDragUpdate: _updateDrag,
|
|
onVerticalDragEnd: _endDrag,
|
|
onVerticalDragCancel: _onDragCancel,
|
|
child: AnimatedOpacity(
|
|
opacity: _showingDetails ? 1.0 : 0.0,
|
|
duration: Durations.short2,
|
|
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (stackChildren != null && stackChildren.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: context.padding.bottom,
|
|
child: AssetStackRow(stack: stackChildren),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// A global key is used for video viewers to prevent them from being
|
|
// unnecessarily recreated. They're quite expensive, and maintain internal
|
|
// state. This can cause videos to restart multiple times during normal usage,
|
|
// like a hero animation.
|
|
//
|
|
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
|
|
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
|
|
// rather than equal. Hero tags are created with string interpolation, which
|
|
// prevents Dart from interning them. As such, hero tags are not identical, even
|
|
// if they are equal.
|
|
class _NativeVideoViewerKey extends GlobalKey {
|
|
final String value;
|
|
|
|
const _NativeVideoViewerKey(this.value) : super.constructor();
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
|
|
|
|
@override
|
|
int get hashCode => value.hashCode;
|
|
}
|