import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:auto_route/annotations.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/providers/settings/photo_view_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/photo_viewer/photo_viewer_controls.dart'; import 'package:fladder/screens/photo_viewer/simple_video_player.dart'; import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/custom_cache_manager.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/themes_data.dart'; import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart'; import 'package:fladder/widgets/shared/animated_icon.dart'; import 'package:fladder/widgets/shared/elevated_icon.dart'; import 'package:fladder/widgets/shared/hover_widget.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; @RoutePage() class PhotoViewerScreen extends ConsumerStatefulWidget { final List? items; final String? selected; final Future>? loadingItems; const PhotoViewerScreen({ this.items, @QueryParam("selectedId") this.selected, this.loadingItems, super.key, }); @override ConsumerState createState() => _PhotoViewerScreenState(); } class _PhotoViewerScreenState extends ConsumerState with WidgetsBindingObserver { late List photos = widget.items ?? []; late int currentPage = photos.indexWhere((value) => value.id == widget.selected).clamp(0, photos.length - 1); late final ExtendedPageController controller = ExtendedPageController(initialPage: currentPage); double currentScale = 1.0; bool showInterface = true; bool toolbarHover = false; late final double topPadding = MediaQuery.of(context).viewPadding.top; late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom; bool loadingItems = false; @override void didChangeAppLifecycleState(AppLifecycleState state) async { switch (state) { case AppLifecycleState.resumed: SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge, overlays: []); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Colors.transparent, systemNavigationBarColor: Colors.transparent, systemNavigationBarDividerColor: Colors.transparent, )); default: break; } } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback( (timeStamp) async { cacheNeighbors(currentPage, 2); if (widget.loadingItems != null) { setState(() { loadingItems = true; }); final newItems = await Future.value(widget.loadingItems); if (context.mounted) { setState(() { if (photos.length == 1 && newItems.contains(photos.first)) { photos = newItems; currentPage = photos.indexWhere((value) => value.id == widget.selected).clamp(0, photos.length - 1); controller.jumpToPage(currentPage); } else { photos = {...photos, ...newItems}.toList(); } loadingItems = false; }); } } }, ); } @override void dispose() { controller.dispose(); super.dispose(); } Future removePhoto(ItemBaseModel photo) async { if (photos.length == 1) { Navigator.of(context).pop(); } else { setState(() { photos.remove(photo); }); } } void _showOverlay({bool? show}) { setState(() { showInterface = show ?? !showInterface; }); SystemChrome.setEnabledSystemUIMode( !showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge, overlays: [], ); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Colors.transparent, systemNavigationBarColor: Colors.transparent, systemNavigationBarDividerColor: Colors.transparent, )); } final gestureConfig = GestureConfig( inertialSpeed: 300, initialAlignment: InitialAlignment.center, inPageView: true, initialScale: 1.0, maxScale: 6, minScale: 1, animationMinScale: 0.1, animationMaxScale: 7, cacheGesture: false, reverseMousePointerScrollDirection: true, hitTestBehavior: HitTestBehavior.translucent, ); @override Widget build(BuildContext context) { return Theme( data: ThemesData.of(context).dark, child: PopScope( onPopInvokedWithResult: (didPop, result) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []), child: MouseRegion( opaque: AdaptiveLayout.of(context).isDesktop, onEnter: (event) => setState(() => _showOverlay(show: true)), onExit: (event) => setState(() => _showOverlay(show: false)), child: Scaffold( appBar: photos.isEmpty ? const FladderAppBar( automaticallyImplyLeading: true, ) : null, body: photos.isEmpty ? Center( child: Text(context.localized.noItemsToShow), ) : buildViewer(context), ), ), ), ); } Widget buildViewer(BuildContext context) { final currentPhoto = photos[currentPage]; final imageHash = currentPhoto.images?.primary?.hash; return Stack( children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode)) && imageHash != null ? Opacity( key: Key(currentPhoto.id), opacity: 0.7, child: SizedBox.expand( child: Image( fit: BoxFit.cover, image: BlurHashImage(imageHash), ), ), ) : Container( color: Colors.black, ), ), GestureDetector( onTapUp: (details) => _showOverlay(), onDoubleTapDown: AdaptiveLayout.of(context).isDesktop ? null : (details) async { await openOptions( context, currentPhoto, removePhoto, ); }, onLongPress: () { if (currentPhoto.userData.isFavourite == true) { HapticFeedback.lightImpact(); } else { markAsFavourite(currentPhoto, value: true); HapticFeedback.heavyImpact(); } }, child: ExtendedImageGesturePageView.builder( itemCount: photos.length, controller: controller, onPageChanged: (index) => setState(() { currentPage = index; cacheNeighbors(index, 3); SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge, overlays: []); }), itemBuilder: (context, index) { final photo = photos[index]; return ExtendedImage( key: Key(photo.id), fit: BoxFit.contain, mode: ExtendedImageMode.gesture, initGestureConfigHandler: (state) => gestureConfig, handleLoadingProgress: true, onDoubleTap: (state) { return; }, gaplessPlayback: true, loadStateChanged: (state) { return Stack( alignment: Alignment.center, fit: StackFit.expand, children: [ if (state.extendedImageLoadState != LoadState.completed) Positioned.fill( child: CachedNetworkImage( fit: BoxFit.contain, cacheManager: CustomCacheManager.instance, imageUrl: photo.thumbnail?.primary?.path ?? "", ), ), switch (state.extendedImageLoadState) { LoadState.loading => const Center( child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round), ), LoadState.completed => switch (photo.internalType) { FladderItemType.video => SimpleVideoPlayer( onTapped: _showOverlay, showOverlay: showInterface, video: photos[index], ), _ => state.completedWidget, }, LoadState.failed || _ => Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(24).copyWith(top: topPadding + 85), child: Card( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( context.localized.failedToLoadImage, style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 6), FilledButton.tonal( onPressed: () => state.reLoadImage(), child: Text(context.localized.retry), ) ], ), ), ), ), ), } ], ); }, image: CachedNetworkImageProvider( photo.images?.primary?.path ?? "", cacheManager: CustomCacheManager.instance, ), ); }, ), ), IgnorePointer( ignoring: !showInterface, child: AnimatedOpacity( duration: const Duration(milliseconds: 500), opacity: showInterface ? 1 : 0, child: PhotoViewerControls( padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), currentIndex: currentPage, itemCount: photos.length, loadingMoreItems: loadingItems, pageController: controller, photo: currentPhoto, toggleOverlay: (value) => setState(() => showInterface = value ?? !showInterface), openOptions: () => openOptions(context, currentPhoto, removePhoto), onPhotoChanged: (photo) { setState(() { int index = photos.indexOf(currentPhoto); photos.remove(currentPhoto); photos.insert(index, photo); }); }, ), ), ), if (AdaptiveLayout.of(context).isDesktop) ...{ Align( alignment: Alignment.centerRight, child: HoverWidget( child: (visible) => AnimatedOpacity( duration: const Duration(milliseconds: 500), opacity: visible ? 1 : 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( alignment: Alignment.centerRight, width: 50, height: MediaQuery.sizeOf(context).height * 0.5, child: IconButton.filledTonal( style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => controller.nextPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut), icon: const Icon(IconsaxPlusBold.arrow_right_1), ), ), ), ), ), ), Align( alignment: Alignment.centerLeft, child: HoverWidget( child: (visible) => AnimatedOpacity( duration: const Duration(milliseconds: 500), opacity: visible ? 1 : 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( alignment: Alignment.centerLeft, width: 50, height: MediaQuery.sizeOf(context).height * 0.5, child: IconButton.filledTonal( style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => controller.previousPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut), icon: const Icon(IconsaxPlusBold.arrow_left), ), ), ), ), ), ), }, if (AdaptiveLayout.of(context).isDesktop) AnimatedOpacity( duration: const Duration(milliseconds: 500), opacity: showInterface ? 1 : toolbarHover ? 1 : 0, child: Align( alignment: Alignment.topCenter, widthFactor: 1, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.5), Colors.black.withValues(alpha: 0), ], ), ), height: 45, child: MouseRegion( onEnter: (event) => setState(() => toolbarHover = true), onExit: (event) => setState(() => toolbarHover = false), child: const Column( children: [ DefaultTitleBar( brightness: Brightness.dark, ), ], ), ), ), ), ), Align( child: AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: showInterface ? 0 : 1, child: AnimatedVisibilityIcon( key: Key(currentPhoto.id), isFilled: currentPhoto.userData.isFavourite, filledIcon: IconsaxPlusBold.heart, outlinedIcon: IconsaxPlusLinear.heart, ), ), ) ], ); } Future openOptions(BuildContext context, PhotoModel currentPhoto, Function(ItemBaseModel item) onRemove) => showBottomSheetPill( context: context, content: (context, scrollController) { return ListView( shrinkWrap: true, controller: scrollController, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Consumer(builder: (context, ref, child) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ElevatedIconButtonLabel( label: context.localized.loop, onPressed: () => ref .read(photoViewSettingsProvider.notifier) .update((state) => state.copyWith(repeat: !state.repeat)), icon: ref.watch(photoViewSettingsProvider.select((value) => value.repeat)) ? IconsaxPlusLinear.repeat : IconsaxPlusLinear.repeate_one, ), ElevatedIconButtonLabel( label: context.localized.audio, onPressed: () => ref .read(photoViewSettingsProvider.notifier) .update((state) => state.copyWith(mute: !state.mute)), icon: ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? IconsaxPlusLinear.volume_slash : IconsaxPlusLinear.volume_high, ), ElevatedIconButtonLabel( label: context.localized.autoPlay, onPressed: () => ref .read(photoViewSettingsProvider.notifier) .update((state) => state.copyWith(autoPlay: !state.autoPlay)), icon: ref.watch(photoViewSettingsProvider.select((value) => value.autoPlay)) ? IconsaxPlusLinear.play_remove : IconsaxPlusLinear.play, ), ElevatedIconButtonLabel( label: context.localized.backgroundBlur, onPressed: () => ref .read(photoViewSettingsProvider.notifier) .update((state) => state.copyWith(theaterMode: !state.theaterMode)), icon: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode)) ? IconsaxPlusLinear.filter_remove : IconsaxPlusLinear.filter, ), ].addInBetween(const SizedBox(width: 16)), ); }), ), ), const Divider(), ...currentPhoto .generateActions( context, ref, exclude: { ItemActions.details, ItemActions.markPlayed, ItemActions.markUnplayed, }, onDeleteSuccesFully: onRemove, ) .listTileItems(context, useIcons: true), ], ); }, ); Future markAsFavourite(PhotoModel photo, {bool? value}) async { await ref.read(userProvider.notifier).setAsFavorite(value ?? !photo.userData.isFavourite, photo.id); setState(() { int index = photos.indexOf(photo); photos.remove(photo); photos.insert( index, photo.copyWith(userData: photo.userData.copyWith(isFavourite: value ?? !photo.userData.isFavourite))); }); } void cacheNeighbors(int index, int range) { photos .getRange((index - range).clamp(0, photos.length - 1), (index + range).clamp(0, photos.length - 1)) .forEach((element) { precacheImage( CachedNetworkImageProvider( element.thumbnail?.primary?.path ?? "", cacheManager: CustomCacheManager.instance, ), context); if (AdaptiveLayout.of(context).isDesktop) { precacheImage( CachedNetworkImageProvider( element.images?.primary?.path ?? "", cacheManager: CustomCacheManager.instance, ), context); } }); } }