diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 8e6837e0a6..c70733dba8 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -13,7 +13,6 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_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/pages/common/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'; @@ -353,10 +352,6 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } else { - final useNativePlayer = - a.isLocal && a.livePhotoVideoId == null; - debugPrint("asset.isLocal ${asset.isLocal}"); - debugPrint("build video player $useNativePlayer"); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -371,24 +366,19 @@ class GalleryViewerPage extends HookConsumerWidget { maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: useNativePlayer - ? NativeVideoViewerPage( - key: ValueKey(a), - asset: a, - ) - : VideoViewerPage( - 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, - ), - ), + 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, + ), + ), ); } }, diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 5e7b8c71f2..3a8d22d9a3 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,158 +1,226 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.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/providers/asset_viewer/native_video_player_controller_provider.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_controller_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/widgets/asset_viewer/video_player.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 ConsumerStatefulWidget { +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 - NativeVideoViewerPageState createState() => NativeVideoViewerPageState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); -class NativeVideoViewerPageState extends ConsumerState { - NativeVideoPlayerController? _controller; + Future createSource(Asset asset) async { + if (asset.isLocal && asset.livePhotoVideoId == null) { + final file = await asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + 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'; - bool isAutoplayEnabled = false; - bool isPlaybackLoopEnabled = false; - - double videoWidth = 0; - double videoHeight = 0; - - Future _initController(NativeVideoPlayerController controller) async { - _controller = controller; - - _controller?. // - onPlaybackStatusChanged - .addListener(_onPlaybackStatusChanged); - _controller?. // - onPlaybackPositionChanged - .addListener(_onPlaybackPositionChanged); - _controller?. // - onPlaybackSpeedChanged - .addListener(_onPlaybackSpeedChanged); - _controller?. // - onVolumeChanged - .addListener(_onPlaybackVolumeChanged); - _controller?. // - onPlaybackReady - .addListener(_onPlaybackReady); - _controller?. // - onPlaybackEnded - .addListener(_onPlaybackEnded); - - await _loadVideoSource(); - } - - Future _loadVideoSource() async { - final videoSource = await _createVideoSource(); - await _controller?.loadVideoSource(videoSource); - } - - Future _createVideoSource() async { - final file = await widget.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); + return await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + } } - return await VideoSource.init( - path: file.path, - type: VideoSourceType.file, + // 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; + 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(); + } + + void onPlaybackPositionChanged() { + updateVideoPlayback(); + } + + void onPlaybackEnded() { + if (loopVideo) { + controller.value?.play(); + } + } + + Future initController(NativeVideoPlayerController nc) async { + if (controller.value != null) { + return; + } + + controller.value = nc; + + controller.value?.onPlaybackPositionChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.addListener(onPlaybackReady); + controller.value?.onPlaybackEnded.addListener(onPlaybackEnded); + + final videoSource = await createSource(asset); + controller.value?.loadVideoSource(videoSource); + } + + useEffect( + () { + Future.microtask( + () => ref.read(videoPlayerControlsProvider.notifier).reset(), + ); + + if (isMotionVideo) { + // ignore: prefer-extracting-callbacks + Future.microtask(() { + ref.read(showControlsProvider.notifier).show = false; + }); + } + + return () { + controller.value?.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.removeListener(onPlaybackReady); + controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); + }; + }, + [], ); - } - @override - void dispose() { - _controller?. // - onPlaybackStatusChanged - .removeListener(_onPlaybackStatusChanged); - _controller?. // - onPlaybackPositionChanged - .removeListener(_onPlaybackPositionChanged); - _controller?. // - onPlaybackSpeedChanged - .removeListener(_onPlaybackSpeedChanged); - _controller?. // - onVolumeChanged - .removeListener(_onPlaybackVolumeChanged); - _controller?. // - onPlaybackReady - .removeListener(_onPlaybackReady); - _controller?. // - onPlaybackEnded - .removeListener(_onPlaybackEnded); - _controller = null; - super.dispose(); - } + void updatePlayback(VideoPlaybackValue value) => + ref.read(videoPlaybackValueProvider.notifier).value = value; - void _onPlaybackReady() { - final videoInfo = _controller?.videoInfo; - if (videoInfo != null) { - videoWidth = videoInfo.width.toDouble(); - videoHeight = videoInfo.height.toDouble(); - } - setState(() {}); - _controller?.play(); - } + final size = MediaQuery.sizeOf(context); - void _onPlaybackStatusChanged() { - setState(() {}); - } - - void _onPlaybackPositionChanged() { - setState(() {}); - } - - void _onPlaybackSpeedChanged() { - setState(() {}); - } - - void _onPlaybackVolumeChanged() { - setState(() {}); - } - - void _onPlaybackEnded() { - if (isPlaybackLoopEnabled) { - _controller?.play(); - } - } - - @override - Widget build(BuildContext context) { - return PopScope( - onPopInvoked: (pop) {}, - child: SizedBox( - height: videoHeight, - width: videoWidth, - child: AspectRatio( - aspectRatio: 16 / 9, - child: NativeVideoPlayerView( - onViewReady: _initController, + return SizedBox( + height: size.height, + width: size.width, + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + child: PopScope( + onPopInvokedWithResult: (didPop, _) => + updatePlayback(VideoPlaybackValue.uninitialized()), + child: SizedBox( + height: size.height, + width: size.width, + child: Stack( + children: [ + Center( + child: AspectRatio( + aspectRatio: (asset.width ?? 1) / (asset.height ?? 1), + 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), + ), + ), + ), + ], + ), + ), + ], + ), ), ), ), ); } - // final Asset asset; - // final Widget? placeholder; - // final Duration hideControlsTimer; - // final bool showControls; - // final bool showDownloadingIndicator; - // final bool loopVideo; } diff --git a/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart deleted file mode 100644 index f8e712a8b6..0000000000 --- a/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:native_video_player/native_video_player.dart'; - -final nativePlayerControllerProvider = - StateProvider((ref) => NativeVideoPlayerController(0)); 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 ebdf739ef0..82b971ee0c 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:native_video_player/native_video_player.dart'; import 'package:video_player/video_player.dart'; enum VideoPlaybackState { @@ -29,6 +30,32 @@ class VideoPlaybackValue { required this.volume, }); + factory VideoPlaybackValue.fromNativeController( + NativeVideoPlayerController controller, + ) { + final playbackInfo = controller.playbackInfo; + final videoInfo = controller.videoInfo; + 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; + } + + return VideoPlaybackValue( + position: Duration(seconds: playbackInfo?.position ?? 0), + duration: Duration(seconds: videoInfo?.duration ?? 0), + state: s, + volume: playbackInfo?.volume ?? 0.0, + ); + } + factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { final video = controller?.value; late VideoPlaybackState s; 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 a34fcb9baf..d53f268ae5 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -4,9 +4,9 @@ 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'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { final Duration hideTimerDuration; @@ -86,12 +86,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ) else GestureDetector( - onTap: () { - if (state != VideoPlaybackState.playing) { - togglePlay(); - } - ref.read(showControlsProvider.notifier).show = false; - }, + onTap: () => + ref.read(showControlsProvider.notifier).show = false, child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, diff --git a/mobile/lib/widgets/asset_viewer/native_video_player.dart b/mobile/lib/widgets/asset_viewer/native_video_player.dart deleted file mode 100644 index 26213da2cf..0000000000 --- a/mobile/lib/widgets/asset_viewer/native_video_player.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:native_video_player/native_video_player.dart'; -import 'package:video_player/video_player.dart'; - -class NativeVideoPlayer extends HookConsumerWidget { - final VideoPlayerController controller; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - final Asset asset; - - const NativeVideoPlayer({ - super.key, - required this.controller, - required this.isMotionVideo, - this.placeholder, - required this.hideControlsTimer, - required this.showControls, - required this.showDownloadingIndicator, - required this.loopVideo, - required this.asset, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return NativeVideoPlayerView( - onViewReady: (controller) async { - try { - String path = ''; - VideoSourceType type = VideoSourceType.file; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - path = file.path; - type = VideoSourceType.file; - - final videoSource = await VideoSource.init( - path: path, - type: type, - ); - - await controller.loadVideoSource(videoSource); - await controller.play(); - - Future.delayed(const Duration(milliseconds: 100), () async { - await controller.setVolume(0.5); - }); - } - } catch (e) { - print('Error loading video: $e'); - } - }, - ); - } -} diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index fb7cc882a0..138ee6debb 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -2,9 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; @@ -68,10 +68,9 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: VideoViewerPage( + child: NativeVideoViewerPage( key: ValueKey(asset), asset: asset, - showDownloadingIndicator: false, placeholder: SizedBox.expand( child: ImmichImage( asset, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index af4561db05..cf4878dd75 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1027,10 +1027,11 @@ packages: native_video_player: dependency: "direct main" description: - name: native_video_player - sha256: "8df92df138c13ebf9df6b30525f9c4198534705fd450a98da14856d3a0e48cd4" - url: "https://pub.dev" - source: hosted + path: "." + ref: "feat/headers" + resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed" + url: "https://github.com/immich-app/native_video_player" + source: git version: "1.3.1" nested: dependency: transitive diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e8d8d35529..77a4aece03 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,7 +64,10 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 - native_video_player: ^1.3.1 + native_video_player: + git: + url: https://github.com/immich-app/native_video_player + ref: feat/headers #image editing packages crop_image: ^1.0.13