diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 182c10307f..2f04f61c5a 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -22,12 +22,8 @@ class Asset { durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, - height = isFlipped(remote) - ? remote.exifInfo?.exifImageWidth?.toInt() - : remote.exifInfo?.exifImageHeight?.toInt(), - width = isFlipped(remote) - ? remote.exifInfo?.exifImageHeight?.toInt() - : remote.exifInfo?.exifImageWidth?.toInt(), + height = remote.exifInfo?.exifImageHeight?.toInt(), + width = remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), exifInfo = @@ -172,6 +168,9 @@ class Asset { @ignore bool get isImage => type == AssetType.image; + @ignore + bool get isMotionPhoto => livePhotoVideoId != null; + @ignore AssetState get storage { if (isRemote && isLocal) { @@ -192,6 +191,14 @@ class Asset { @ignore set byteHash(List hash) => checksum = base64.encode(hash); + @ignore + int? get orientatedWidth => + exifInfo != null && exifInfo!.isFlipped ? height : width; + + @ignore + int? get orientatedHeight => + exifInfo != null && exifInfo!.isFlipped ? width : height; + @override bool operator ==(other) { if (other is! Asset) return false; @@ -511,21 +518,3 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } - -/// Returns `true` if this [int] is flipped 90° clockwise -bool isRotated90CW(int orientation) { - return [7, 8, -90].contains(orientation); -} - -/// Returns `true` if this [int] is flipped 270° clockwise -bool isRotated270CW(int orientation) { - return [5, 6, 90].contains(orientation); -} - -/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise -bool isFlipped(AssetResponseDto response) { - final int orientation = - int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0; - return orientation != 0 && - (isRotated90CW(orientation) || isRotated270CW(orientation)); -} diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 583e627c5d..7a0db3fdeb 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -47,7 +47,10 @@ class ExifInfo { String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; @ignore - bool get isFlipped => _isOrientationFlipped(orientation); + bool? _isFlipped; + + @ignore + bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); @ignore double? get latitude => lat; diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart new file mode 100644 index 0000000000..838c2afd3c --- /dev/null +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -0,0 +1,32 @@ +import 'package:flutter/cupertino.dart'; + +const _spring = SpringDescription( + mass: 40, + stiffness: 100, + damping: 1, +); + +// https://stackoverflow.com/a/74453792 +class FastScrollPhysics extends ScrollPhysics { + const FastScrollPhysics({super.parent}); + + @override + FastScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => _spring; +} + +class FastClampingScrollPhysics extends ClampingScrollPhysics { + const FastClampingScrollPhysics({super.parent}); + + @override + FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastClampingScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => _spring; +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index c70733dba8..2e28551188 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.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/providers/app_settings.provider.dart'; @@ -53,21 +54,15 @@ class GalleryViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsServiceProvider); - final loadAsset = renderList.loadAsset; final totalAssets = useState(renderList.totalAssets); - final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final isZoomed = useState(false); - final isPlayingVideo = useState(false); - final localPosition = useState(null); - final currentIndex = useState(initialIndex); - final currentAsset = loadAsset(currentIndex.value); - // Update is playing motion video - ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { - isPlayingVideo.value = state == VideoPlaybackState.playing; - }); - + final isPlayingMotionVideo = useState(false); final stackIndex = useState(-1); + final localPosition = useRef(null); + final currentIndex = useValueNotifier(initialIndex); + final loadAsset = renderList.loadAsset; + final currentAsset = loadAsset(currentIndex.value); + final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; @@ -79,30 +74,23 @@ class GalleryViewerPage extends HookConsumerWidget { ? currentAsset : stackElements.elementAt(stackIndex.value); - final isMotionPhoto = asset.livePhotoVideoId != null; - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (_, __) {}); - useEffect( - () { - // Delay state update to after the execution of build method - Future.microtask( - () => ref.read(currentAssetProvider.notifier).set(asset), - ); - return null; - }, - [asset], - ); - - useEffect( - () { - shouldLoopVideo.value = - settings.getSetting(AppSettingsEnum.loopVideo); - return null; - }, - [], - ); + // // Update is playing motion video + if (asset.isMotionPhoto) { + ref.listen( + videoPlaybackValueProvider.select( + (playback) => playback.state == VideoPlaybackState.playing, + ), (wasPlaying, isPlaying) { + if (wasPlaying != null && wasPlaying && !isPlaying) { + isPlayingMotionVideo.value = false; + } + }); + } Future precacheNextImage(int index) async { + if (!context.mounted) { + return; + } + void onError(Object exception, StackTrace? stackTrace) { // swallow error silently debugPrint('Error precaching next image: $exception, $stackTrace'); @@ -110,6 +98,7 @@ class GalleryViewerPage extends HookConsumerWidget { try { if (index < totalAssets.value && index >= 0) { + log.info('Precaching next image at index $index'); final asset = loadAsset(index); await precacheImage( ImmichImage.imageProvider(asset: asset), @@ -124,6 +113,27 @@ class GalleryViewerPage extends HookConsumerWidget { } } + // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page + ref.listen(currentAssetProvider, (prev, cur) { + log.info('Current asset changed from ${prev?.id} to ${cur?.id}'); + }); + + useEffect(() { + ref.read(currentAssetProvider.notifier).set(asset); + 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; + }); + void showInfo() { showModalBottomSheet( shape: const RoundedRectangleBorder( @@ -182,34 +192,6 @@ class GalleryViewerPage extends HookConsumerWidget { } } - useEffect( - () { - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - isPlayingVideo.value = false; - return null; - }, - [], - ); - - useEffect( - () { - // No need to await this - unawaited( - // Delay this a bit so we can finish loading the page - Future.delayed(const Duration(milliseconds: 400)).then( - // Precache the next image - (_) => precacheNextImage(currentIndex.value + 1), - ), - ); - return null; - }, - [], - ); - ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -219,7 +201,12 @@ class GalleryViewerPage extends HookConsumerWidget { }); Widget buildStackedChildren() { + if (!showStack) { + return const SizedBox(); + } + return ListView.builder( + key: ValueKey(currentAsset), shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, @@ -230,7 +217,11 @@ class GalleryViewerPage extends HookConsumerWidget { ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; + if (assetId == null) { + return const SizedBox(); + } return Padding( + key: ValueKey(assetId), padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () => stackIndex.value = index, @@ -252,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget { borderRadius: BorderRadius.circular(4), child: Image( fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId!), + image: ImmichRemoteImageProvider(assetId: assetId), ), ), ), @@ -262,6 +253,95 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + Object getHeroTag(Asset asset) { + return isFromDto + ? '${asset.remoteId}-$heroOffset' + : asset.id + heroOffset; + } + + 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: (_, __, ___) { + if (asset.livePhotoVideoId != null) { + isPlayingMotionVideo.value = true; + } + }, + imageProvider: ImmichImage.imageProvider(asset: asset), + heroAttributes: PhotoViewHeroAttributes( + tag: getHeroTag(asset), + transitionOnUserGestures: true, + ), + filterQuality: FilterQuality.high, + tightMode: true, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) => ImmichImage( + asset, + fit: BoxFit.contain, + ), + ); + } + + PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { + final key = GlobalKey(); + final tag = getHeroTag(asset); + return PhotoViewGalleryPageOptions.customChild( + onDragStart: (_, details, __) => + localPosition.value = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + heroAttributes: PhotoViewHeroAttributes( + tag: tag, + transitionOnUserGestures: true, + ), + filterQuality: FilterQuality.high, + initialScale: 1.0, + maxScale: 1.0, + minScale: 1.0, + basePosition: Alignment.center, + child: Hero( + tag: tag, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: key, + asset: asset, + placeholder: Image( + key: ValueKey(asset), + image: ImmichImage.imageProvider(asset: asset), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ), + ); + } + + PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { + final newAsset = index == currentIndex.value ? asset : loadAsset(index); + + if (newAsset.isImage) { + ref.read(showControlsProvider.notifier).show = false; + } + + if (newAsset.isImage && !isPlayingMotionVideo.value) { + return buildImage(context, newAsset); + } + log.info('Loading asset ${newAsset.id} (index $index) as video'); + return buildVideo(context, newAsset); + } + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -271,10 +351,13 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( + key: ValueKey(asset), scaleStateChangedCallback: (state) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, + // wantKeepAlive: true, + gaplessPlayback: true, loadingBuilder: (context, event, index) => ClipRect( child: Stack( fit: StackFit.expand, @@ -286,6 +369,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), ImmichThumbnail( + key: ValueKey(asset), asset: asset, fit: BoxFit.contain, ), @@ -296,92 +380,40 @@ class GalleryViewerPage extends HookConsumerWidget { scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in : (Platform.isIOS - ? const ScrollPhysics() // Use bouncing physics for iOS - : const ClampingScrollPhysics() // Use heavy physics for Android + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, - onPageChanged: (value) async { + onPageChanged: (value) { + log.info('Page changed to $value'); final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); + final newAsset = + value == currentIndex.value ? asset : loadAsset(value); + if (!newAsset.isImage || newAsset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + currentIndex.value = value; stackIndex.value = -1; - isPlayingVideo.value = false; + isPlayingMotionVideo.value = false; - // Wait for page change animation to finish - await Future.delayed(const Duration(milliseconds: 400)); - // Then precache the next image - unawaited(precacheNextImage(next)); - }, - builder: (context, index) { - final a = - index == currentIndex.value ? asset : loadAsset(index); - - final ImageProvider provider = - ImmichImage.imageProvider(asset: a); - - if (a.isImage && !isPlayingVideo.value) { - return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - onTapDown: (_, __, ___) { - ref.read(showControlsProvider.notifier).toggle(); - }, - onLongPressStart: (_, __, ___) { - if (asset.livePhotoVideoId != null) { - isPlayingVideo.value = true; - } - }, - imageProvider: provider, - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - transitionOnUserGestures: true, - ), - filterQuality: FilterQuality.high, - tightMode: true, - minScale: PhotoViewComputedScale.contained, - errorBuilder: (context, error, stackTrace) => ImmichImage( - a, - fit: BoxFit.contain, - ), - ); - } else { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - ), - filterQuality: FilterQuality.high, - maxScale: 1.0, - minScale: 1.0, - basePosition: Alignment.center, - child: NativeVideoViewerPage( - key: ValueKey(a), - asset: a, - isMotionVideo: a.livePhotoVideoId != null, - loopVideo: shouldLoopVideo.value, - placeholder: Image( - image: provider, - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), - ), - ); - } + // Delay setting the new asset to avoid a stutter in the page change animation + // TODO: make the scroll animation finish more quickly, and ideally have a callback for when it's done + ref.read(currentAssetProvider.notifier).set(newAsset); + // Timer(const Duration(milliseconds: 450), () { + // ref.read(currentAssetProvider.notifier).set(newAsset); + // }); + + // Wait for page change animation to finish, then precache the next image + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(next); + }); }, + builder: buildAsset, ), Positioned( top: 0, @@ -390,9 +422,9 @@ class GalleryViewerPage extends HookConsumerWidget { child: GalleryAppBar( asset: asset, showInfo: showInfo, - isPlayingVideo: isPlayingVideo.value, + isPlayingVideo: isPlayingMotionVideo.value, onToggleMotionVideo: () => - isPlayingVideo.value = !isPlayingVideo.value, + isPlayingMotionVideo.value = !isPlayingMotionVideo.value, ), ), Positioned( @@ -416,7 +448,8 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex: stackIndex.value, asset: asset, assetIndex: currentIndex, - showVideoPlayerControls: !asset.isImage && !isMotionPhoto, + showVideoPlayerControls: + !asset.isImage && !asset.isMotionPhoto, ), ], ), diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index f6c66aa608..de2fad12a3 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -5,304 +5,429 @@ 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/entities/store.entity.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; +import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; +import 'package:photo_manager/photo_manager.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; - final bool isMotionVideo; - final Widget? placeholder; final bool showControls; final Duration hideControlsTimer; - final bool loopVideo; + final Widget placeholder; + // final ValueNotifier? doInitialize; const NativeVideoViewerPage({ super.key, required this.asset, - this.isMotionVideo = false, - this.placeholder, + required this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), - this.loopVideo = false, }); @override Widget build(BuildContext context, WidgetRef ref) { + final loopVideo = ref.watch( + appSettingsServiceProvider.select( + (settings) => settings.getSetting(AppSettingsEnum.loopVideo), + ), + ); final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); - final width = useRef(asset.width?.toDouble() ?? 1.0); - final height = useRef(asset.height?.toDouble() ?? 1.0); + final currentAsset = useState(ref.read(currentAssetProvider)); + final isCurrent = currentAsset.value == asset; - void checkIfBuffering([Timer? timer]) { + final log = Logger('NativeVideoViewerPage'); + log.info('Building NativeVideoViewerPage'); + + final localEntity = useMemoized(() { + if (!asset.isLocal) { + return null; + } + + return AssetEntity.fromId(asset.localId!); + }); + + Future calculateAspectRatio() async { + if (!context.mounted) { + log.info('calculateAspectRatio: Context is not mounted'); + return null; + } + + log.info('Calculating aspect ratio'); + late final double? orientatedWidth; + late final double? orientatedHeight; + + if (asset.exifInfo != null) { + orientatedWidth = asset.orientatedWidth?.toDouble(); + orientatedHeight = asset.orientatedHeight?.toDouble(); + } else if (localEntity != null) { + final entity = await localEntity; + orientatedWidth = entity?.orientatedWidth.toDouble(); + orientatedHeight = entity?.orientatedHeight.toDouble(); + } else { + final entity = await ref.read(assetServiceProvider).loadExif(asset); + orientatedWidth = entity.orientatedWidth?.toDouble(); + orientatedHeight = entity.orientatedHeight?.toDouble(); + } + + log.info('Calculated aspect ratio'); + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth / orientatedHeight; + } + + return 1.0; + } + + Future createSource() async { + if (!context.mounted) { + log.info('createSource: Context is not mounted'); + return null; + } + + if (localEntity != null && asset.livePhotoVideoId == null) { + log.info('Loading video from local storage'); + + final file = await (await localEntity)!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + final source = await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + log.info('Loaded video from local storage'); + return source; + } + + log.info('Loading video from server'); + + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; + + final source = await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + log.info('Loaded video from server'); + return source; + } + + final videoSource = useState(null); + final aspectRatio = useState(null); + useMemoized( + () async { + if (!context.mounted) { + log.info('combined: Context is not mounted'); + return null; + } + + final (videoSourceRes, aspectRatioRes) = + await (createSource(), calculateAspectRatio()).wait; + if (videoSourceRes == null || aspectRatioRes == null) { + log.info('combined: Video source or aspect ratio is null'); + return; + } + + // if opening a remote video from a hero animation, delay initialization to avoid a stutter + if (!asset.isLocal && isCurrent) { + await Future.delayed(const Duration(milliseconds: 150)); + } + + videoSource.value = videoSourceRes; + aspectRatio.value = aspectRatioRes; + }, + ); + + void checkIfBuffering() { if (!context.mounted) { - timer?.cancel(); return; } + log.info('Checking if buffering'); final videoPlayback = ref.read(videoPlaybackValueProvider); if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && videoPlayback.state != VideoPlaybackState.buffering) { + log.info('Marking video as buffering'); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(state: VideoPlaybackState.buffering); } } // timer to mark videos as buffering if the position does not change - final bufferingTimer = useRef( - Timer.periodic(const Duration(seconds: 5), checkIfBuffering), - ); - - Future createSource(Asset asset) async { - if (asset.isLocal && asset.livePhotoVideoId == null) { - final entity = await asset.local!.obtainForNewProperties(); - final file = await entity?.file; - if (entity == null || file == null) { - throw Exception('No file found for the video'); - } - - width.value = entity.orientatedWidth.toDouble(); - height.value = entity.orientatedHeight.toDouble(); - - return await VideoSource.init( - path: file.path, - type: VideoSourceType.file, - ); - } else { - final assetWithExif = - await ref.read(assetServiceProvider).loadExif(asset); - final shouldFlip = assetWithExif.exifInfo?.isFlipped ?? false; - width.value = (shouldFlip ? assetWithExif.height : assetWithExif.width) - ?.toDouble() ?? - width.value; - height.value = (shouldFlip ? assetWithExif.width : assetWithExif.height) - ?.toDouble() ?? - height.value; - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; - - return await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); - } - } + useInterval(const Duration(seconds: 5), checkIfBuffering); // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), (_, mute) { + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { + return; + } + try { - if (mute) { - controller.value?.setVolume(0.0); - } else { - controller.value?.setVolume(0.7); + if (mute && playbackInfo.volume != 0.0) { + playerController.setVolume(0.0); + } else if (!mute && playbackInfo.volume != 0.7) { + playerController.setVolume(0.7); } - } catch (_) { - // Consume error from the controller + } catch (error) { + log.severe('Error setting volume: $error'); } }); // When the position changes, seek to the position ref.listen(videoPlayerControlsProvider.select((value) => value.position), (_, position) { - if (controller.value == null) { - // No seeeking if there is no video + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { return; } // Find the position to seek to - final Duration seek = asset.duration * (position / 100.0); - try { - controller.value?.seekTo(seek.inSeconds); - } catch (_) { - // Consume error from the controller + final int seek = (asset.duration * (position / 100.0)).inSeconds; + if (seek != playbackInfo.position) { + try { + playerController.seekTo(seek); + } catch (error) { + log.severe('Error seeking to position $position: $error'); + } } + + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: seek); }); - // When the custom video controls paus or plays + // // When the custom video controls pause or play ref.listen(videoPlayerControlsProvider.select((value) => value.pause), (_, pause) { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + try { if (pause) { - controller.value?.pause(); + log.info('Pausing video'); + videoController.pause(); } else { - controller.value?.play(); + log.info('Playing video'); + videoController.play(); } - } catch (_) { - // Consume error from the controller + } catch (error) { + log.severe('Error pausing or playing video: $error'); } }); - void updateVideoPlayback() { - if (controller.value == null || !context.mounted) { + void onPlaybackReady() { + final videoController = controller.value; + if (videoController == null || !isCurrent || !context.mounted) { + return; + } + + log.info('Playback ready for video ${asset.id}'); + + try { + videoController.play(); + videoController.setVolume(0.9); + } catch (error) { + log.severe('Error playing video: $error'); + } + } + + ref.listen(currentAssetProvider, (_, value) { + log.info( + 'Changing currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', + ); + // Delay the video playback to avoid a stutter in the swipe animation + Timer(const Duration(milliseconds: 350), () { + if (!context.mounted) { + return; + } + + log.info( + 'Changed currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', + ); + currentAsset.value = value; + if (currentAsset.value == asset) { + onPlaybackReady(); + } + }); + }); + + void onPlaybackStatusChanged() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { return; } final videoPlayback = - VideoPlaybackValue.fromNativeController(controller.value!); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - // Check if the video is buffering - if (videoPlayback.state == VideoPlaybackState.playing) { - isBuffering.value = - lastVideoPosition.value == videoPlayback.position.inSeconds; - lastVideoPosition.value = videoPlayback.position.inSeconds; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; + VideoPlaybackValue.fromNativeController(videoController); + if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) { + return; } - final state = videoPlayback.state; + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - // Enable the WakeLock while the video is playing - if (state == VideoPlaybackState.playing) { + if (videoPlayback.state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); + log.info('Video ${asset.id} is playing; enabled wakelock'); } else { // Sync with the controls pause WakelockPlus.disable(); - } - } - - void onPlaybackReady() { - try { - controller.value?.play(); - controller.value?.setVolume(0.9); - } catch (_) { - // Consume error from the controller + log.info('Video ${asset.id} is not playing; disabled wakelock'); } } void onPlaybackPositionChanged() { - updateVideoPlayback(); - } - - void onPlaybackEnded() { - try { - if (loopVideo) { - controller.value?.play(); - } - } catch (_) { - // Consume error from the controller - } - } - - Future initController(NativeVideoPlayerController nc) async { - if (controller.value != null) { + final videoController = controller.value; + if (videoController == null || !context.mounted) { return; } + final playbackInfo = videoController.playbackInfo; + if (playbackInfo == null) { + return; + } + + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: playbackInfo.position); + + // Check if the video is buffering + if (playbackInfo.status == PlaybackStatus.playing) { + isBuffering.value = lastVideoPosition.value == playbackInfo.position; + lastVideoPosition.value = playbackInfo.position; + } else { + isBuffering.value = false; + lastVideoPosition.value = -1; + } + } + + void onPlaybackEnded() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + if (loopVideo) { + try { + videoController.play(); + } catch (error) { + log.severe('Error looping video: $error'); + } + } else { + WakelockPlus.disable(); + } + } + + void initController(NativeVideoPlayerController nc) { + log.info('initController for ${asset.id} started'); + if (controller.value != null) { + log.info( + 'initController for ${asset.id}: Controller already initialized'); + return; + } + ref.read(videoPlayerControlsProvider.notifier).reset(); + ref.read(videoPlaybackValueProvider.notifier).reset(); + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); - nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - final videoSource = await createSource(asset); - nc.loadVideoSource(videoSource); + nc.loadVideoSource(videoSource.value!); + log.info('initController for ${asset.id}: setting controller'); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } useEffect( () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); - - if (isMotionVideo) { - // ignore: prefer-extracting-callbacks - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); - } - return () { - bufferingTimer.value.cancel(); - try { - controller.value?.onPlaybackPositionChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.removeListener(onPlaybackReady); - controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); - controller.value?.stop(); - } catch (_) { - // Consume error from the controller + log.info('Cleaning up video ${asset.id}'); + final playerController = controller.value; + if (playerController == null) { + log.info('Controller is null'); + return; } + + try { + playerController.stop(); + + playerController.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + playerController.onPlaybackStatusChanged + .removeListener(onPlaybackStatusChanged); + playerController.onPlaybackReady.removeListener(onPlaybackReady); + playerController.onPlaybackEnded.removeListener(onPlaybackEnded); + } catch (error) { + log.severe('Error during useEffect cleanup: $error'); + } + + controller.value = null; + WakelockPlus.disable(); }; }, - [], + [videoSource], ); - double calculateAspectRatio() { - if (width.value == 0 || height.value == 0) { - return 1; - } - return width.value / height.value; - } - - final size = MediaQuery.sizeOf(context); - - return SizedBox( - height: size.height, - width: size.width, - child: GestureDetector( - behavior: HitTestBehavior.deferToChild, - child: PopScope( - onPopInvokedWithResult: (didPop, _) => ref - .read(videoPlaybackValueProvider.notifier) - .value = VideoPlaybackValue.uninitialized(), - child: SizedBox( - height: size.height, - width: size.width, - child: Stack( - children: [ - Center( - child: AspectRatio( - aspectRatio: calculateAspectRatio(), - child: NativeVideoPlayerView( - onViewReady: initController, - ), - ), - ), - if (showControls) - Center( - child: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - ), - Visibility( - visible: controller.value == null, - child: Stack( - children: [ - if (placeholder != null) placeholder!, - const Positioned.fill( - child: Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), - ), - ), - ), - ], - ), - ), - ], + return Stack( + children: [ + placeholder, + Center( + key: ValueKey('player-${asset.hashCode}'), + child: aspectRatio.value != null + ? AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ) + : null, + ), + // covers the video with the placeholder + if (showControls) + Center( + key: ValueKey('controls-${asset.hashCode}'), + child: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, ), ), - ), - ), + ], ); } } diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 573f7277f2..4a7617f4d5 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -124,8 +124,7 @@ class VideoViewerPage extends HookConsumerWidget { return PopScope( onPopInvokedWithResult: (didPop, _) { - ref.read(videoPlaybackValueProvider.notifier).value = - VideoPlaybackValue.uninitialized(); + ref.read(videoPlaybackValueProvider.notifier).reset(); }, child: AnimatedSwitcher( duration: const Duration(milliseconds: 400), diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index d15b26ea20..20f8fd7d2e 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -1,7 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class VideoPlaybackControls { - VideoPlaybackControls({ + const VideoPlaybackControls({ required this.position, required this.mute, required this.pause, @@ -17,15 +17,14 @@ final videoPlayerControlsProvider = return VideoPlayerControls(ref); }); +const videoPlayerControlsDefault = VideoPlaybackControls( + position: 0, + pause: false, + mute: false, +); + class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) - : super( - VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ), - ); + VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); final Ref ref; @@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier { } void reset() { - state = VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ); + state = videoPlayerControlsDefault; } double get position => state.position; bool get mute => state.mute; set position(double value) { + if (state.position == value) { + return; + } + state = VideoPlaybackControls( position: value, mute: state.mute, @@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier { } set mute(bool value) { + if (state.mute == value) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: value, @@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier { } void pause() { + if (state.pause) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: state.mute, @@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier { } void play() { + if (!state.pause) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: state.mute, @@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier { } void restart() { - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: true, - ); - state = VideoPlaybackControls( position: 0, mute: state.mute, diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index bffe6c7cf6..b4f690fa22 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -23,7 +23,7 @@ class VideoPlaybackValue { /// The volume of the video final double volume; - VideoPlaybackValue({ + const VideoPlaybackValue({ required this.position, required this.duration, required this.state, @@ -33,32 +33,24 @@ class VideoPlaybackValue { factory VideoPlaybackValue.fromNativeController( NativeVideoPlayerController controller, ) { - PlaybackInfo? playbackInfo; - VideoInfo? videoInfo; - try { - playbackInfo = controller.playbackInfo; - videoInfo = controller.videoInfo; - } catch (_) { - // Consume error from the controller - } - late VideoPlaybackState s; - if (playbackInfo?.status == null) { - s = VideoPlaybackState.initializing; - } else if (playbackInfo?.status == PlaybackStatus.stopped && - (playbackInfo?.positionFraction == 1 || - playbackInfo?.positionFraction == 0)) { - s = VideoPlaybackState.completed; - } else if (playbackInfo?.status == PlaybackStatus.playing) { - s = VideoPlaybackState.playing; - } else { - s = VideoPlaybackState.paused; + final playbackInfo = controller.playbackInfo; + final videoInfo = controller.videoInfo; + + if (playbackInfo == null || videoInfo == null) { + return videoPlaybackValueDefault; } + final VideoPlaybackState status = switch (playbackInfo.status) { + PlaybackStatus.playing => VideoPlaybackState.playing, + PlaybackStatus.paused => VideoPlaybackState.paused, + PlaybackStatus.stopped => VideoPlaybackState.completed, + }; + return VideoPlaybackValue( - position: Duration(seconds: playbackInfo?.position ?? 0), - duration: Duration(seconds: videoInfo?.duration ?? 0), - state: s, - volume: playbackInfo?.volume ?? 0.0, + position: Duration(seconds: playbackInfo.position), + duration: Duration(seconds: videoInfo.duration), + state: status, + volume: playbackInfo.volume, ); } @@ -85,15 +77,6 @@ class VideoPlaybackValue { ); } - factory VideoPlaybackValue.uninitialized() { - return VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, - ); - } - VideoPlaybackValue copyWith({ Duration? position, Duration? duration, @@ -109,16 +92,20 @@ class VideoPlaybackValue { } } +const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, +); + final videoPlaybackValueProvider = StateNotifierProvider((ref) { return VideoPlaybackValueState(ref); }); class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) - : super( - VideoPlaybackValue.uninitialized(), - ); + VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); final Ref ref; @@ -129,6 +116,7 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { + if (state.position == value) return; state = VideoPlaybackValue( position: value, duration: state.duration, @@ -136,4 +124,8 @@ class VideoPlaybackValueState extends StateNotifier { volume: state.volume, ); } + + void reset() { + state = videoPlaybackValueDefault; + } } diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart new file mode 100644 index 0000000000..0c346065f7 --- /dev/null +++ b/mobile/lib/utils/hooks/interval_hook.dart @@ -0,0 +1,18 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_hooks/flutter_hooks.dart'; + +// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 +void useInterval(Duration delay, VoidCallback callback) { + final savedCallback = useRef(callback); + savedCallback.value = callback; + + useEffect( + () { + final timer = Timer.periodic(delay, (_) => savedCallback.value()); + return timer.cancel; + }, + [delay], + ); +} diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index d53f268ae5..2fd0a38edc 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; @@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { } }, ); - - final showBuffering = useState(false); final VideoPlaybackState state = - ref.watch(videoPlaybackValueProvider).state; + ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final showBuffering = state == VideoPlaybackState.buffering; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { showControlsAndStartHideTimer(); }); - ref.listen(videoPlaybackValueProvider.select((value) => value.state), - (_, state) { - // Show buffering - showBuffering.value = state == VideoPlaybackState.buffering; - }); - /// Toggles between playing and pausing depending on the state of the video void togglePlay() { showControlsAndStartHideTimer(); - final state = ref.read(videoPlaybackValueProvider).state; if (state == VideoPlaybackState.playing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } else if (state == VideoPlaybackState.completed) { @@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { absorbing: !ref.watch(showControlsProvider), child: Stack( children: [ - if (showBuffering.value) + if (showBuffering) const Center( child: DelayedLoadingIndicator( fadeInDuration: Duration(milliseconds: 400), diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 3c650bdc6a..b2a0107546 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget { Widget build(BuildContext context) { final textColor = context.isDarkTheme ? Colors.white : Colors.black; - String resolution = asset.width != null && asset.height != null - ? "${asset.height} x ${asset.width} " - : ""; + String resolution = + asset.orientatedHeight != null && asset.orientatedWidth != null + ? "${asset.orientatedHeight} x ${asset.orientatedWidth} " + : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 138ee6debb..de331670ab 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -69,16 +69,16 @@ class MemoryCard extends StatelessWidget { return Hero( tag: 'memory-${asset.id}', child: NativeVideoViewerPage( - key: ValueKey(asset), + key: ValueKey(asset.id), asset: asset, + hideControlsTimer: const Duration(seconds: 2), + showControls: false, placeholder: SizedBox.expand( child: ImmichImage( asset, fit: fit, ), ), - hideControlsTimer: const Duration(seconds: 2), - showControls: false, ), ); }