mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	* add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
		
			
				
	
	
		
			464 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			464 lines
		
	
	
		
			16 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:easy_localization/easy_localization.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/gallery_stacked_children.dart';
 | 
						|
import 'package:immich_mobile/pages/common/native_video_viewer.page.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/cast.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);
 | 
						|
    final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
 | 
						|
 | 
						|
    final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
 | 
						|
 | 
						|
    GlobalKey getVideoPlayerKey(int id) {
 | 
						|
      videoPlayerKeys.value.putIfAbsent(id, () => GlobalKey());
 | 
						|
      return videoPlayerKeys.value[id]!;
 | 
						|
    }
 | 
						|
 | 
						|
    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 [],
 | 
						|
    );
 | 
						|
 | 
						|
    useEffect(() {
 | 
						|
      final asset = loadAsset(currentIndex.value);
 | 
						|
 | 
						|
      if (asset.isRemote) {
 | 
						|
        ref.read(castProvider.notifier).loadMedia(asset, false);
 | 
						|
      } else {
 | 
						|
        if (isCasting) {
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
						|
            if (context.mounted) {
 | 
						|
              ref.read(castProvider.notifier).stop();
 | 
						|
              context.scaffoldMessenger.showSnackBar(
 | 
						|
                SnackBar(
 | 
						|
                  duration: const Duration(seconds: 1),
 | 
						|
                  content: Text(
 | 
						|
                    "local_asset_cast_failed".tr(),
 | 
						|
                    style: context.textTheme.bodyLarge?.copyWith(
 | 
						|
                      color: context.primaryColor,
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              );
 | 
						|
            }
 | 
						|
          });
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return null;
 | 
						|
    }, [
 | 
						|
      ref.watch(castProvider).isCasting,
 | 
						|
    ]);
 | 
						|
 | 
						|
    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 DraggableScrollableSheet(
 | 
						|
            minChildSize: 0.5,
 | 
						|
            maxChildSize: 1,
 | 
						|
            initialChildSize: 0.75,
 | 
						|
            expand: false,
 | 
						|
            builder: (context, scrollController) {
 | 
						|
              return Padding(
 | 
						|
                padding: EdgeInsets.only(
 | 
						|
                  bottom: context.viewInsets.bottom,
 | 
						|
                ),
 | 
						|
                child: ref.watch(appSettingsServiceProvider).getSetting<bool>(
 | 
						|
                          AppSettingsEnum.advancedTroubleshooting,
 | 
						|
                        )
 | 
						|
                    ? AdvancedBottomSheet(
 | 
						|
                        assetDetail: asset,
 | 
						|
                        scrollController: scrollController,
 | 
						|
                      )
 | 
						|
                    : DetailPanel(
 | 
						|
                        asset: asset,
 | 
						|
                        scrollController: scrollController,
 | 
						|
                      ),
 | 
						|
              );
 | 
						|
            },
 | 
						|
          );
 | 
						|
        },
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    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(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,
 | 
						|
        initialScale: PhotoViewComputedScale.contained * 0.99,
 | 
						|
        minScale: PhotoViewComputedScale.contained * 0.99,
 | 
						|
        errorBuilder: (context, error, stackTrace) => ImmichImage(
 | 
						|
          asset,
 | 
						|
          fit: BoxFit.contain,
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
 | 
						|
      return PhotoViewGalleryPageOptions.customChild(
 | 
						|
        onDragStart: (_, details, __, ___) =>
 | 
						|
            localPosition.value = details.localPosition,
 | 
						|
        onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
 | 
						|
        heroAttributes: _getHeroAttributes(asset),
 | 
						|
        filterQuality: FilterQuality.high,
 | 
						|
        initialScale: PhotoViewComputedScale.contained * 0.99,
 | 
						|
        maxScale: 1.0,
 | 
						|
        minScale: PhotoViewComputedScale.contained * 0.99,
 | 
						|
        basePosition: Alignment.center,
 | 
						|
        child: SizedBox(
 | 
						|
          width: context.width,
 | 
						|
          height: context.height,
 | 
						|
          child: NativeVideoViewerPage(
 | 
						|
            key: getVideoPlayerKey(asset.id),
 | 
						|
            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(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: const ValueKey('gallery'),
 | 
						|
              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);
 | 
						|
                });
 | 
						|
 | 
						|
                context.scaffoldMessenger.hideCurrentSnackBar();
 | 
						|
 | 
						|
                // send image to casting if the server has it
 | 
						|
                if (newAsset.isRemote) {
 | 
						|
                  ref.read(castProvider.notifier).loadMedia(newAsset, false);
 | 
						|
                } else {
 | 
						|
                  context.scaffoldMessenger.clearSnackBars();
 | 
						|
 | 
						|
                  if (isCasting) {
 | 
						|
                    ref.read(castProvider.notifier).stop();
 | 
						|
                    context.scaffoldMessenger.showSnackBar(
 | 
						|
                      SnackBar(
 | 
						|
                        duration: const Duration(seconds: 2),
 | 
						|
                        content: Text(
 | 
						|
                          "local_asset_cast_failed".tr(),
 | 
						|
                          style: context.textTheme.bodyLarge?.copyWith(
 | 
						|
                            color: context.primaryColor,
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                    );
 | 
						|
                  }
 | 
						|
                }
 | 
						|
              },
 | 
						|
              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,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |