1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-04 10:56:47 +01:00
immich/mobile/lib/pages/common/gallery_viewer.page.dart
2024-12-11 11:23:20 -06:00

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,
);
}
}