import 'dart:async'; import 'package:flutter/material.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/entities/store.entity.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.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/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:native_video_player/native_video_player.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; const NativeVideoViewerPage({ super.key, required this.asset, this.isMotionVideo = false, this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), this.loopVideo = false, }); @override Widget build(BuildContext context, WidgetRef ref) { 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); void checkIfBuffering([Timer? timer]) { if (!context.mounted) { timer?.cancel(); return; } final videoPlayback = ref.read(videoPlaybackValueProvider); if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && videoPlayback.state != VideoPlaybackState.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 { // 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(), ); } } // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), (_, mute) { if (mute) { controller.value?.setVolume(0.0); } else { controller.value?.setVolume(0.7); } }); // 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 return; } // Find the position to seek to final Duration seek = asset.duration * (position / 100.0); controller.value?.seekTo(seek.inSeconds); }); // When the custom video controls paus or plays ref.listen(videoPlayerControlsProvider.select((value) => value.pause), (_, pause) { if (pause) { controller.value?.pause(); } else { controller.value?.play(); } }); void updateVideoPlayback() { if (controller.value == 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; } final state = videoPlayback.state; // Enable the WakeLock while the video is playing if (state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); } else { // Sync with the controls pause WakelockPlus.disable(); } } void onPlaybackReady() { controller.value?.play(); controller.value?.setVolume(0.9); } void onPlaybackPositionChanged() { updateVideoPlayback(); } void onPlaybackEnded() { if (loopVideo) { controller.value?.play(); } } Future initController(NativeVideoPlayerController nc) async { if (controller.value != null) { return; } nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); final videoSource = await createSource(asset); nc.loadVideoSource(videoSource); 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(); controller.value?.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); controller.value?.onPlaybackStatusChanged .removeListener(onPlaybackPositionChanged); controller.value?.onPlaybackReady.removeListener(onPlaybackReady); controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); }; }, [], ); 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), ), ), ), ], ), ), ], ), ), ), ), ); } }