diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index d8e6b704fbe25..80034ae464630 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -21,40 +21,45 @@ class VideoViewerPage extends HookConsumerWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; - final VoidCallback onVideoEnded; + final VoidCallback? onVideoEnded; final VoidCallback? onPlaying; final VoidCallback? onPaused; + final Duration hideControlsTimer; + final bool showControls; + final bool showDownloadingIndicator; const VideoViewerPage({ super.key, required this.asset, - required this.isMotionVideo, - required this.onVideoEnded, + this.isMotionVideo = false, + this.onVideoEnded, this.onPlaying, this.onPaused, this.placeholder, + this.showControls = true, + this.hideControlsTimer = const Duration(seconds: 5), + this.showDownloadingIndicator = true, }); @override Widget build(BuildContext context, WidgetRef ref) { if (asset.isLocal && asset.livePhotoVideoId == null) { final AsyncValue videoFile = ref.watch(_fileFamily(asset.local!)); - return videoFile.when( - data: (data) => VideoPlayer( - file: data, - isMotionVideo: false, - onVideoEnded: () {}, - ), - error: (error, stackTrace) => Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ), - loading: () => const Center( - child: SizedBox( - width: 75, - height: 75, - child: CircularProgressIndicator.adaptive(), + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: videoFile.when( + data: (data) => VideoPlayer( + file: data, + isMotionVideo: false, + onVideoEnded: () {}, ), + error: (error, stackTrace) => Icon( + Icons.image_not_supported_outlined, + color: context.primaryColor, + ), + loading: () => showDownloadingIndicator + ? const Center(child: ImmichLoadingIndicator()) + : Container(), ), ); } @@ -74,15 +79,24 @@ class VideoViewerPage extends HookConsumerWidget { onPaused: onPaused, onPlaying: onPlaying, placeholder: placeholder, + hideControlsTimer: hideControlsTimer, + showControls: showControls, + showDownloadingIndicator: showDownloadingIndicator, ), - if (downloadAssetStatus == DownloadAssetStatus.loading) - SizedBox( + AnimatedOpacity( + duration: const Duration(milliseconds: 400), + opacity: (downloadAssetStatus == DownloadAssetStatus.loading && + showDownloadingIndicator) + ? 1.0 + : 0.0, + child: SizedBox( height: context.height, width: context.width, child: const Center( child: ImmichLoadingIndicator(), ), ), + ), ], ); } @@ -102,7 +116,9 @@ class VideoPlayer extends StatefulWidget { final String? jwtToken; final File? file; final bool isMotionVideo; - final VoidCallback onVideoEnded; + final VoidCallback? onVideoEnded; + final Duration hideControlsTimer; + final bool showControls; final Function()? onPlaying; final Function()? onPaused; @@ -111,16 +127,23 @@ class VideoPlayer extends StatefulWidget { /// usually, a thumbnail of the video final Widget? placeholder; + final bool showDownloadingIndicator; + const VideoPlayer({ super.key, this.url, this.jwtToken, this.file, - required this.onVideoEnded, + this.onVideoEnded, required this.isMotionVideo, this.onPlaying, this.onPaused, this.placeholder, + this.hideControlsTimer = const Duration( + seconds: 5, + ), + this.showControls = true, + this.showDownloadingIndicator = true, }); @override @@ -149,7 +172,7 @@ class _VideoPlayerState extends State { if (videoPlayerController.value.position == videoPlayerController.value.duration) { WakelockPlus.disable(); - widget.onVideoEnded(); + widget.onVideoEnded?.call(); } } }); @@ -184,9 +207,9 @@ class _VideoPlayerState extends State { autoInitialize: true, allowFullScreen: false, allowedScreenSleep: false, - showControls: !widget.isMotionVideo, + showControls: widget.showControls && !widget.isMotionVideo, customControls: const VideoPlayerControls(), - hideControlsTimer: const Duration(seconds: 5), + hideControlsTimer: widget.hideControlsTimer, ); } @@ -214,9 +237,10 @@ class _VideoPlayerState extends State { child: Stack( children: [ if (widget.placeholder != null) widget.placeholder!, - const Center( - child: ImmichLoadingIndicator(), - ), + if (widget.showDownloadingIndicator) + const Center( + child: ImmichLoadingIndicator(), + ), ], ), ), diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index d9ccaed39f952..dde98e605ec26 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; @@ -11,19 +12,15 @@ import 'package:openapi/api.dart'; class MemoryCard extends StatelessWidget { final Asset asset; - final void Function() onTap; - final void Function() onClose; final String title; - final String? rightCornerText; final bool showTitle; + final Function()? onVideoEnded; const MemoryCard({ required this.asset, - required this.onTap, - required this.onClose, required this.title, required this.showTitle, - this.rightCornerText, + this.onVideoEnded, super.key, }); @@ -63,37 +60,53 @@ class MemoryCard extends StatelessWidget { child: Container(color: Colors.black.withOpacity(0.2)), ), ), - GestureDetector( - onTap: onTap, - child: ImmichImage( - asset, - fit: BoxFit.fitWidth, - height: double.infinity, - width: double.infinity, - type: ThumbnailFormat.JPEG, - preferredLocalAssetSize: 2048, - ), - ), - Positioned( - top: 2.0, - left: 2.0, - child: IconButton( - onPressed: onClose, - icon: const Icon(Icons.close_rounded), - color: Colors.grey[400], - ), - ), - Positioned( - right: 18.0, - top: 18.0, - child: Text( - rightCornerText ?? "", - style: TextStyle( - color: Colors.grey[200], - fontSize: 12.0, - fontWeight: FontWeight.bold, - ), - ), + LayoutBuilder( + builder: (context, constraints) { + // Determine the fit using the aspect ratio + BoxFit fit = BoxFit.fitWidth; + if (asset.width != null && asset.height != null) { + final aspectRatio = asset.height! / asset.width!; + final phoneAspectRatio = + constraints.maxWidth / constraints.maxHeight; + // Look for a 25% difference in either direction + if (phoneAspectRatio * .75 < aspectRatio && + phoneAspectRatio * 1.25 > aspectRatio) { + // Cover to look nice if we have nearly the same aspect ratio + fit = BoxFit.cover; + } + } + + if (asset.isImage) { + return Hero( + tag: 'memory-${asset.id}', + child: ImmichImage( + asset, + fit: fit, + height: double.infinity, + width: double.infinity, + type: ThumbnailFormat.JPEG, + preferredLocalAssetSize: 2048, + ), + ); + } else { + return Hero( + tag: 'memory-${asset.id}', + child: VideoViewerPage( + asset: asset, + showDownloadingIndicator: false, + placeholder: ImmichImage( + asset, + fit: fit, + type: ThumbnailFormat.JPEG, + preferredLocalAssetSize: 2048, + ), + hideControlsTimer: const Duration(seconds: 2), + onVideoEnded: onVideoEnded, + showControls: false, + ), + ); + } + }, ), if (showTitle) Positioned( diff --git a/mobile/lib/modules/memories/ui/memory_epilogue.dart b/mobile/lib/modules/memories/ui/memory_epilogue.dart index 4e32ae6ac5b2e..8dd28637df3a7 100644 --- a/mobile/lib/modules/memories/ui/memory_epilogue.dart +++ b/mobile/lib/modules/memories/ui/memory_epilogue.dart @@ -16,7 +16,7 @@ class _MemoryEpilogueState extends State late final _animationController = AnimationController( vsync: this, duration: const Duration( - seconds: 3, + seconds: 2, ), )..repeat( reverse: true, @@ -29,7 +29,7 @@ class _MemoryEpilogueState extends State super.initState(); _animation = CurvedAnimation( parent: _animationController, - curve: Curves.easeInOut, + curve: Curves.easeIn, ); } @@ -41,74 +41,82 @@ class _MemoryEpilogueState extends State @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.check_circle_outline_sharp, - color: immichDarkThemePrimaryColor, - size: 64.0, - ), - const SizedBox(height: 16.0), - Text( - 'All caught up', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - ), - ), - const SizedBox(height: 16.0), - Text( - 'Check back tomorrow for more memories', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle_outline_sharp, + color: immichDarkThemePrimaryColor, + size: 64.0, + ), + const SizedBox(height: 16.0), + Text( + 'All caught up', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: 16.0), + Text( + 'Check back tomorrow for more memories', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: 16.0), + TextButton( + onPressed: widget.onStartOver, + child: Text( + 'Start Over', + style: context.textTheme.displayMedium?.copyWith( + color: immichDarkThemePrimaryColor, ), - ), - const SizedBox(height: 16.0), - TextButton( - onPressed: widget.onStartOver, - child: Text( - 'Start Over', - style: context.textTheme.displayMedium?.copyWith( - color: immichDarkThemePrimaryColor, ), ), - ), - ], - ), - ), - Column( - children: [ - SizedBox( - height: 48, - child: AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Transform.translate( - offset: Offset(0, 5 * _animationController.value), - child: child, - ); - }, - child: const Icon( - size: 32, - Icons.expand_less_sharp, - color: Colors.white, - ), - ), + ], ), - Text( - 'Swipe up to close', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + children: [ + SizedBox( + height: 48, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, 8 * _animationController.value), + child: child, + ); + }, + child: const Icon( + size: 32, + Icons.expand_less_sharp, + color: Colors.white, + ), + ), + ), + Text( + 'Swipe up to close', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), ), + ], + ), ), - ], - ), - ], + ), + ], + ), ); } } diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index 4e6d4f81a6dfe..1a47d9b661a52 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -16,41 +16,46 @@ class MemoryLane extends HookConsumerWidget { final memoryLane = memoryLaneFutureProvider .whenData( (memories) => memories != null - ? Container( - margin: const EdgeInsets.only(top: 10, left: 10), + ? SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, shrinkWrap: true, itemCount: memories.length, + padding: const EdgeInsets.only( + right: 8.0, + bottom: 8, + top: 10, + left: 10, + ), itemBuilder: (context, index) { final memory = memories[index]; - return Padding( - padding: const EdgeInsets.only(right: 8.0, bottom: 8), - child: GestureDetector( - onTap: () { - HapticFeedback.heavyImpact(); - context.pushRoute( - MemoryRoute( - memories: memories, - memoryIndex: index, + return GestureDetector( + onTap: () { + HapticFeedback.heavyImpact(); + context.pushRoute( + MemoryRoute( + memories: memories, + memoryIndex: index, + ), + ); + }, + child: Stack( + children: [ + Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13.0), ), - ); - }, - child: Stack( - children: [ - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(13.0), + clipBehavior: Clip.hardEdge, + child: ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.2), + BlendMode.darken, ), - clipBehavior: Clip.hardEdge, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), - BlendMode.darken, - ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', child: ImmichImage( memory.assets[0], fit: BoxFit.cover, @@ -61,25 +66,25 @@ class MemoryLane extends HookConsumerWidget { ), ), ), - Positioned( - bottom: 16, - left: 16, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 114, - ), - child: Text( - memory.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + memory.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, ), ), ), - ], - ), + ), + ], ), ); }, diff --git a/mobile/lib/modules/memories/ui/memory_progress_indicator.dart b/mobile/lib/modules/memories/ui/memory_progress_indicator.dart new file mode 100644 index 0000000000000..514f197acd1e2 --- /dev/null +++ b/mobile/lib/modules/memories/ui/memory_progress_indicator.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class MemoryProgressIndicator extends StatelessWidget { + /// The number of ticks in the progress indicator + final int ticks; + + /// The current value of the indicator + final double value; + + const MemoryProgressIndicator({ + super.key, + required this.ticks, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final tickWidth = constraints.maxWidth / ticks; + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(2.0)), + child: Stack( + children: [ + LinearProgressIndicator( + value: value, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + ticks, + (i) => Container( + width: tickWidth, + height: 4, + decoration: BoxDecoration( + border: i == 0 + ? null + : Border( + left: BorderSide( + color: + Theme.of(context).scaffoldBackgroundColor, + width: 1, + ), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index fbc04feae5986..d6a4d2ae6fdf6 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/memories/models/memory.dart'; import 'package:immich_mobile/modules/memories/ui/memory_bottom_info.dart'; import 'package:immich_mobile/modules/memories/ui/memory_card.dart'; import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart'; +import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:openapi/api.dart' as api; @@ -24,15 +25,28 @@ class MemoryPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryPageController = usePageController(initialPage: memoryIndex); - final memoryAssetPageController = usePageController(); final currentMemory = useState(memories[memoryIndex]); final currentAssetPage = useState(0); + final currentMemoryIndex = useState(memoryIndex); final assetProgress = useState( "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}", ); const bgColor = Colors.black; + /// The list of all of the asset page controllers + final memoryAssetPageControllers = + List.generate(memories.length, (i) => usePageController()); + + /// The main vertically scrolling page controller with each list of memories + final memoryPageController = usePageController(initialPage: memoryIndex); + + // The Page Controller that scrolls horizontally with all of the assets + useEffect(() { + // Memories is an immersive activity + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return null; + }); + toNextMemory() { memoryPageController.nextPage( duration: const Duration(milliseconds: 500), @@ -43,7 +57,10 @@ class MemoryPage extends HookConsumerWidget { toNextAsset(int currentAssetIndex) { if (currentAssetIndex + 1 < currentMemory.value.assets.length) { // Go to the next asset - memoryAssetPageController.nextPage( + PageController controller = + memoryAssetPageControllers[currentMemoryIndex.value]; + + controller.nextPage( curve: Curves.easeInOut, duration: const Duration(milliseconds: 500), ); @@ -154,67 +171,134 @@ class MemoryPage extends HookConsumerWidget { }, child: Scaffold( backgroundColor: bgColor, - body: SafeArea( - child: PageView.builder( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - scrollDirection: Axis.vertical, - controller: memoryPageController, - onPageChanged: (pageNumber) { - HapticFeedback.mediumImpact(); - if (pageNumber < memories.length) { - currentMemory.value = memories[pageNumber]; - } - - currentAssetPage.value = 0; - - updateProgressText(); - }, - itemCount: memories.length + 1, - itemBuilder: (context, mIndex) { - // Build last page - if (mIndex == memories.length) { - return MemoryEpilogue( - onStartOver: () => memoryPageController.animateToPage( - 0, - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - ), - ); - } - // Build horizontal page - return Column( - children: [ - Expanded( - child: PageView.builder( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), + body: PopScope( + onPopInvoked: (didPop) { + // Remove immersive mode and go back to normal mode + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + }, + child: SafeArea( + child: PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + scrollDirection: Axis.vertical, + controller: memoryPageController, + onPageChanged: (pageNumber) { + HapticFeedback.mediumImpact(); + if (pageNumber < memories.length) { + currentMemoryIndex.value = pageNumber; + currentMemory.value = memories[pageNumber]; + } + + currentAssetPage.value = 0; + + updateProgressText(); + }, + itemCount: memories.length + 1, + itemBuilder: (context, mIndex) { + // Build last page + if (mIndex == memories.length) { + return MemoryEpilogue( + onStartOver: () => memoryPageController.animateToPage( + 0, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + ), + ); + } + // Build horizontal page + final assetController = memoryAssetPageControllers[mIndex]; + return Column( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 8.0, + bottom: 2.0, ), - controller: memoryAssetPageController, - onPageChanged: onAssetChanged, - scrollDirection: Axis.horizontal, - itemCount: memories[mIndex].assets.length, - itemBuilder: (context, index) { - final asset = memories[mIndex].assets[index]; - return Container( - color: Colors.black, - child: MemoryCard( - asset: asset, - onTap: () => toNextAsset(index), - onClose: () => context.popRoute(), - rightCornerText: assetProgress.value, - title: memories[mIndex].title, - showTitle: index == 0, + child: AnimatedBuilder( + animation: assetController, + builder: (context, child) { + double value = 0.0; + if (assetController.hasClients) { + // We can only access [page] if this has clients + value = assetController.page ?? 0; + } + return MemoryProgressIndicator( + ticks: memories[mIndex].assets.length, + value: (value + 1) / memories[mIndex].assets.length, + ); + }, + ), + ), + Expanded( + child: Stack( + children: [ + PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + controller: assetController, + onPageChanged: onAssetChanged, + scrollDirection: Axis.horizontal, + itemCount: memories[mIndex].assets.length, + itemBuilder: (context, index) { + final asset = memories[mIndex].assets[index]; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + toNextAsset(index); + }, + child: Container( + color: Colors.black, + child: MemoryCard( + asset: asset, + title: memories[mIndex].title, + showTitle: index == 0, + //onVideoEnded: () { + // TODO: At the end of the video, advance to the next asset automatically. If this is a live photo, don't go to + // next asset + //if (asset.livePhotoVideoId == null) { + //toNextAsset(index); + //} + //}, + ), + ), + ); + }, ), - ); - }, + Positioned( + top: 8, + left: 8, + child: MaterialButton( + minWidth: 0, + onPressed: () { + // auto_route doesn't invoke pop scope, so + // turn off full screen mode here + // https://github.com/Milad-Akarie/auto_route_library/issues/1799 + context.popRoute(); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + }, + shape: const CircleBorder(), + color: Colors.white.withOpacity(0.2), + elevation: 0, + child: const Icon( + Icons.close_rounded, + color: Colors.white, + ), + ), + ), + ], + ), ), - ), - MemoryBottomInfo(memory: memories[mIndex]), - ], - ); - }, + MemoryBottomInfo(memory: memories[mIndex]), + ], + ); + }, + ), ), ), ),