mirror of
https://github.com/immich-app/immich.git
synced 2026-03-16 22:49:21 -04:00
The background of the photo view does not extend below the height of the viewport, and so the asset details fade in over black with the photo view, and the standard surface colour scheme of the scaffold for the rest. This leads to a janky animation. We can't change the background of the scaffold to black, as it in turn makes the iOS bouncing scroll physics cut off incorrectly. The best fix is to remove background decoration from the photo view, and defer to the parent to colour the background. Co-authored-by: Alex <alex.tran1502@gmail.com>
469 lines
16 KiB
Dart
469 lines
16 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/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/providers/asset_viewer/asset_viewer.provider.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/app_settings.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/timeline.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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_scrollController.hasClients) return;
|
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
|
if (_showingDetails && _snapOffset > 0) {
|
|
_scrollController.jumpTo(_snapOffset);
|
|
}
|
|
});
|
|
}
|
|
|
|
@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(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.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);
|
|
|
|
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
|
if (heroTag != null) {
|
|
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
|
}
|
|
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,
|
|
}) {
|
|
final size = context.sizeData;
|
|
|
|
if (asset.isImage && !isPlayingMotionVideo) {
|
|
return PhotoView(
|
|
key: Key(asset.heroTag),
|
|
index: widget.index,
|
|
imageProvider: getFullImageProvider(asset, size: size),
|
|
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,
|
|
isCurrent: isCurrent,
|
|
image: Image(
|
|
image: getFullImageProvider(asset, size: size),
|
|
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 asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
|
if (asset == null) {
|
|
return const Center(child: ImmichLoadingIndicator());
|
|
}
|
|
|
|
BaseAsset displayAsset = asset;
|
|
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
|
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;
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
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;
|
|
}
|