mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 10:56:47 +01:00
3053d84e49
fix live photo zoom
388 lines
14 KiB
Dart
388 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
|
import 'package:immich_mobile/pages/common/download_panel.dart';
|
|
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
|
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
|
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
|
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
|
|
import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart';
|
|
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart';
|
|
import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
|
|
|
@RoutePage()
|
|
// ignore: must_be_immutable
|
|
/// Expects [currentAssetProvider] to be set before navigating to this page
|
|
class GalleryViewerPage extends HookConsumerWidget {
|
|
final int initialIndex;
|
|
final int heroOffset;
|
|
final bool showStack;
|
|
final RenderList renderList;
|
|
|
|
GalleryViewerPage({
|
|
super.key,
|
|
required this.renderList,
|
|
this.initialIndex = 0,
|
|
this.heroOffset = 0,
|
|
this.showStack = false,
|
|
}) : controller = PageController(initialPage: initialIndex);
|
|
|
|
final PageController controller;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final totalAssets = useState(renderList.totalAssets);
|
|
final isZoomed = useState(false);
|
|
final stackIndex = useState(0);
|
|
final localPosition = useRef<Offset?>(null);
|
|
final currentIndex = useValueNotifier(initialIndex);
|
|
final loadAsset = renderList.loadAsset;
|
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
|
|
|
Future<void> precacheNextImage(int index) async {
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
|
|
void onError(Object exception, StackTrace? stackTrace) {
|
|
// swallow error silently
|
|
log.severe('Error precaching next image: $exception, $stackTrace');
|
|
}
|
|
|
|
try {
|
|
if (index < totalAssets.value && index >= 0) {
|
|
final asset = loadAsset(index);
|
|
await precacheImage(
|
|
ImmichImage.imageProvider(
|
|
asset: asset,
|
|
width: context.width,
|
|
height: context.height,
|
|
),
|
|
context,
|
|
onError: onError,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// swallow error silently
|
|
log.severe('Error precaching next image: $e');
|
|
context.maybePop();
|
|
}
|
|
}
|
|
|
|
useEffect(
|
|
() {
|
|
if (ref.read(showControlsProvider)) {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
} else {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
}
|
|
|
|
// Delay this a bit so we can finish loading the page
|
|
Timer(const Duration(milliseconds: 400), () {
|
|
precacheNextImage(currentIndex.value + 1);
|
|
});
|
|
|
|
return null;
|
|
},
|
|
const [],
|
|
);
|
|
|
|
void showInfo() {
|
|
final asset = ref.read(currentAssetProvider);
|
|
if (asset == null) {
|
|
return;
|
|
}
|
|
showModalBottomSheet(
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
|
),
|
|
barrierColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
enableDrag: true,
|
|
context: context,
|
|
useSafeArea: true,
|
|
builder: (context) {
|
|
return FractionallySizedBox(
|
|
heightFactor: 0.75,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: context.viewInsets.bottom,
|
|
),
|
|
child: ref
|
|
.watch(appSettingsServiceProvider)
|
|
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
|
|
? AdvancedBottomSheet(assetDetail: asset)
|
|
: DetailPanel(asset: asset),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void handleSwipeUpDown(DragUpdateDetails details) {
|
|
const int sensitivity = 15;
|
|
const int dxThreshold = 50;
|
|
const double ratioThreshold = 3.0;
|
|
|
|
if (isZoomed.value) {
|
|
return;
|
|
}
|
|
|
|
// Guard [localPosition] null
|
|
if (localPosition.value == null) {
|
|
return;
|
|
}
|
|
|
|
// Check for delta from initial down point
|
|
final d = details.localPosition - localPosition.value!;
|
|
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
|
|
if (d.dx.abs() > dxThreshold) {
|
|
return;
|
|
}
|
|
|
|
final ratio = d.dy / max(d.dx.abs(), 1);
|
|
if (d.dy > sensitivity && ratio > ratioThreshold) {
|
|
context.maybePop();
|
|
} else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
|
|
showInfo();
|
|
}
|
|
}
|
|
|
|
ref.listen(showControlsProvider, (_, show) {
|
|
if (show || Platform.isIOS) {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
return;
|
|
}
|
|
|
|
// This prevents the bottom bar from "dropping" while the controls are being hidden
|
|
Timer(const Duration(milliseconds: 100), () {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
});
|
|
});
|
|
|
|
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
|
|
return PhotoViewGalleryPageOptions(
|
|
onDragStart: (_, details, __) {
|
|
localPosition.value = details.localPosition;
|
|
},
|
|
onDragUpdate: (_, details, __) {
|
|
handleSwipeUpDown(details);
|
|
},
|
|
onTapDown: (_, __, ___) {
|
|
ref.read(showControlsProvider.notifier).toggle();
|
|
},
|
|
onLongPressStart: asset.isMotionPhoto
|
|
? (_, __, ___) {
|
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
|
}
|
|
: null,
|
|
imageProvider: ImmichImage.imageProvider(asset: asset),
|
|
heroAttributes: _getHeroAttributes(asset),
|
|
filterQuality: FilterQuality.high,
|
|
tightMode: true,
|
|
minScale: PhotoViewComputedScale.contained,
|
|
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
|
asset,
|
|
fit: BoxFit.contain,
|
|
),
|
|
);
|
|
}
|
|
|
|
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
|
// This key is to prevent the video player from being re-initialized during the hero animation
|
|
final key = GlobalKey();
|
|
return PhotoViewGalleryPageOptions.customChild(
|
|
onDragStart: (_, details, __) =>
|
|
localPosition.value = details.localPosition,
|
|
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
|
heroAttributes: _getHeroAttributes(asset),
|
|
filterQuality: FilterQuality.high,
|
|
initialScale: 1.0,
|
|
maxScale: 1.0,
|
|
minScale: 1.0,
|
|
basePosition: Alignment.center,
|
|
child: SizedBox(
|
|
width: context.width,
|
|
height: context.height,
|
|
child: NativeVideoViewerPage(
|
|
key: key,
|
|
asset: asset,
|
|
image: Image(
|
|
key: ValueKey(asset),
|
|
image: ImmichImage.imageProvider(
|
|
asset: asset,
|
|
width: context.width,
|
|
height: context.height,
|
|
),
|
|
fit: BoxFit.contain,
|
|
height: context.height,
|
|
width: context.width,
|
|
alignment: Alignment.center,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
|
|
var newAsset = loadAsset(index);
|
|
final stackId = newAsset.stackId;
|
|
if (stackId != null && currentIndex.value == index) {
|
|
final stackElements =
|
|
ref.read(assetStackStateProvider(newAsset.stackId!));
|
|
if (stackIndex.value < stackElements.length) {
|
|
newAsset = stackElements.elementAt(stackIndex.value);
|
|
}
|
|
}
|
|
|
|
if (newAsset.isImage && !isPlayingMotionVideo) {
|
|
return buildImage(context, newAsset);
|
|
}
|
|
return buildVideo(context, newAsset);
|
|
}
|
|
|
|
return PopScope(
|
|
// Change immersive mode back to normal "edgeToEdge" mode
|
|
onPopInvokedWithResult: (didPop, _) =>
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
|
|
child: Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Stack(
|
|
children: [
|
|
PhotoViewGallery.builder(
|
|
key: ValueKey(isPlayingMotionVideo),
|
|
scaleStateChangedCallback: (state) {
|
|
final asset = ref.read(currentAssetProvider);
|
|
if (asset == null) {
|
|
return;
|
|
}
|
|
|
|
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
|
|
isZoomed.value = state != PhotoViewScaleState.initial;
|
|
ref.read(showControlsProvider.notifier).show =
|
|
!isZoomed.value;
|
|
}
|
|
},
|
|
gaplessPlayback: true,
|
|
loadingBuilder: (context, event, index) {
|
|
final asset = loadAsset(index);
|
|
return ClipRect(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
BackdropFilter(
|
|
filter: ui.ImageFilter.blur(
|
|
sigmaX: 10,
|
|
sigmaY: 10,
|
|
),
|
|
),
|
|
ImmichThumbnail(
|
|
key: ValueKey(asset),
|
|
asset: asset,
|
|
fit: BoxFit.contain,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
pageController: controller,
|
|
scrollPhysics: isZoomed.value
|
|
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
|
: (Platform.isIOS
|
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
|
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
|
),
|
|
itemCount: totalAssets.value,
|
|
scrollDirection: Axis.horizontal,
|
|
onPageChanged: (value) {
|
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
|
|
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
|
|
|
final newAsset = loadAsset(value);
|
|
|
|
currentIndex.value = value;
|
|
stackIndex.value = 0;
|
|
|
|
ref.read(currentAssetProvider.notifier).set(newAsset);
|
|
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
}
|
|
|
|
// Wait for page change animation to finish, then precache the next image
|
|
Timer(const Duration(milliseconds: 400), () {
|
|
precacheNextImage(next);
|
|
});
|
|
},
|
|
builder: buildAsset,
|
|
),
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: GalleryAppBar(
|
|
key: const ValueKey('app-bar'),
|
|
showInfo: showInfo,
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Column(
|
|
children: [
|
|
GalleryStackedChildren(stackIndex),
|
|
BottomGalleryBar(
|
|
key: const ValueKey('bottom-bar'),
|
|
renderList: renderList,
|
|
totalAssets: totalAssets,
|
|
controller: controller,
|
|
showStack: showStack,
|
|
stackIndex: stackIndex,
|
|
assetIndex: currentIndex,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const DownloadPanel(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@pragma('vm:prefer-inline')
|
|
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
|
|
return PhotoViewHeroAttributes(
|
|
tag: asset.isInDb
|
|
? asset.id + heroOffset
|
|
: '${asset.remoteId}-$heroOffset',
|
|
transitionOnUserGestures: true,
|
|
);
|
|
}
|
|
}
|