From 4ef4cc8016b9751c3aee32e9672d3d4acfec25a3 Mon Sep 17 00:00:00 2001 From: martyfuhry <martyfuhry@gmail.com> Date: Tue, 5 Mar 2024 22:42:22 -0500 Subject: [PATCH] refactor(mobile): Refactor video player page and gallery bottom app bar (#7625) * Fixes double video auto initialize issue and placeholder for video controller * WIP unravel stack index * Refactors video player controller format fixing video format Working format * Fixes hide on pause * Got hiding when tapped working * Hides controls when video starts and fixes placeholder for memory card Remove prints * Fixes show controls with microtask * fix LivePhotos not playing * removes unused function callbacks and moves wakelock * Update motion video * Fixing motion photo playing * Renames to isPlayingVideo * Fixes playing video on change * pause on dispose * fixing issues with sync between controls * Adds gallery app bar * Switches to memoized * Fixes pause * Revert "Switches to memoized" This reverts commit 234e6741dea05aa0b967dde746f1d625f15bed94. * uses stateful widget * Fixes double video play by using provider and new chewie video player wip format Fixes motion photos format --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> --- .../hooks/chewiew_controller_hook.dart | 97 +-- .../providers/asset_stack.provider.dart | 8 + .../providers/asset_stack.provider.g.dart | Bin 0 -> 4361 bytes .../video_player_controller_provider.dart | 44 ++ .../video_player_controller_provider.g.dart | Bin 0 -> 4820 bytes .../video_player_controls_provider.dart | 58 +- .../video_player_value_provider.dart | 71 +- .../asset_viewer/ui/bottom_gallery_bar.dart | 345 +++++++++ .../ui/custom_video_player_controls.dart | 107 +++ .../asset_viewer/ui/gallery_app_bar.dart | 110 +++ .../asset_viewer/ui/video_controls.dart | 125 ++++ .../modules/asset_viewer/ui/video_player.dart | 45 ++ .../ui/video_player_controls.dart | 209 ------ .../asset_viewer/views/gallery_viewer.dart | 675 +++--------------- .../asset_viewer/views/video_viewer_page.dart | 158 +++- .../map/providers/map_state.provider.g.dart | Bin 933 -> 933 bytes .../lib/modules/memories/ui/memory_card.dart | 10 +- mobile/lib/routing/router.gr.dart | 24 +- mobile/lib/shared/ui/hooks/timer_hook.dart | 48 ++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 21 files changed, 1205 insertions(+), 932 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_controls.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_player.dart delete mode 100644 mobile/lib/modules/asset_viewer/ui/video_player_controls.dart create mode 100644 mobile/lib/shared/ui/hooks/timer_hook.dart diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart index 224eb838e7..5daeb389ec 100644 --- a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -1,26 +1,19 @@ -import 'dart:async'; - import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:video_player/video_player.dart'; -import 'package:immich_mobile/shared/models/store.dart' as store; -import 'package:wakelock_plus/wakelock_plus.dart'; /// Provides the initialized video player controller /// If the asset is local, use the local file /// Otherwise, use a video player with a URL -ChewieController? useChewieController( - Asset asset, { +ChewieController useChewieController({ + required VideoPlayerController controller, EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( bottom: 100, ), bool showOptions = true, bool showControlsOnInitialize = false, bool autoPlay = true, - bool autoInitialize = true, bool allowFullScreen = false, bool allowedScreenSleep = false, bool showControls = true, @@ -33,7 +26,7 @@ ChewieController? useChewieController( }) { return use( _ChewieControllerHook( - asset: asset, + controller: controller, placeholder: placeholder, showOptions: showOptions, controlsSafeAreaMinimum: controlsSafeAreaMinimum, @@ -43,7 +36,6 @@ ChewieController? useChewieController( hideControlsTimer: hideControlsTimer, showControlsOnInitialize: showControlsOnInitialize, showControls: showControls, - autoInitialize: autoInitialize, allowedScreenSleep: allowedScreenSleep, onPlaying: onPlaying, onPaused: onPaused, @@ -52,13 +44,12 @@ ChewieController? useChewieController( ); } -class _ChewieControllerHook extends Hook<ChewieController?> { - final Asset asset; +class _ChewieControllerHook extends Hook<ChewieController> { + final VideoPlayerController controller; final EdgeInsets controlsSafeAreaMinimum; final bool showOptions; final bool showControlsOnInitialize; final bool autoPlay; - final bool autoInitialize; final bool allowFullScreen; final bool allowedScreenSleep; final bool showControls; @@ -70,14 +61,13 @@ class _ChewieControllerHook extends Hook<ChewieController?> { final VoidCallback? onVideoEnded; const _ChewieControllerHook({ - required this.asset, + required this.controller, this.controlsSafeAreaMinimum = const EdgeInsets.only( bottom: 100, ), this.showOptions = true, this.showControlsOnInitialize = false, this.autoPlay = true, - this.autoInitialize = true, this.allowFullScreen = false, this.allowedScreenSleep = false, this.showControls = true, @@ -94,28 +84,33 @@ class _ChewieControllerHook extends Hook<ChewieController?> { } class _ChewieControllerHookState - extends HookState<ChewieController?, _ChewieControllerHook> { - ChewieController? chewieController; - VideoPlayerController? videoPlayerController; - - @override - void initHook() async { - super.initHook(); - unawaited(_initialize()); - } + extends HookState<ChewieController, _ChewieControllerHook> { + late ChewieController chewieController = ChewieController( + videoPlayerController: hook.controller, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); @override void dispose() { - chewieController?.dispose(); - videoPlayerController?.dispose(); + chewieController.dispose(); super.dispose(); } @override - ChewieController? build(BuildContext context) { + ChewieController build(BuildContext context) { return chewieController; } + /* /// Initializes the chewie controller and video player controller Future<void> _initialize() async { if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { @@ -141,39 +136,21 @@ class _ChewieControllerHookState ); } - videoPlayerController!.addListener(() { - final value = videoPlayerController!.value; - if (value.isPlaying) { - WakelockPlus.enable(); - hook.onPlaying?.call(); - } else if (!value.isPlaying) { - WakelockPlus.disable(); - hook.onPaused?.call(); - } - - if (value.position == value.duration) { - WakelockPlus.disable(); - hook.onVideoEnded?.call(); - } - }); - await videoPlayerController!.initialize(); - setState(() { - chewieController = ChewieController( - videoPlayerController: videoPlayerController!, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - autoInitialize: hook.autoInitialize, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - }); + chewieController = ChewieController( + videoPlayerController: videoPlayerController!, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); } + */ } diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart index 5c20e1479f..b6928c6ba8 100644 --- a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -2,6 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier<List<Asset>> { final Asset _asset; @@ -49,3 +52,8 @@ final assetStackProvider = .sortByFileCreatedAtDesc() .findAll(); }); + +@riverpod +int assetStackIndex(AssetStackIndexRef ref, Asset asset) { + return -1; +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..142e46d32292f8f30a135d930c2ca43c263dba4e GIT binary patch literal 4361 zcmb_fZBOGy5dNNDF&`>$T519mIC79XN_bOMr~s)tRjtplC$O#KP1oxbRPo<Cv+H%7 zjRSWo`2^mX*?D=Mnc2-vI6u8Qy*a)+9m2`=@D#p>;Wb=c-@(t<!^^Wz@Z%@AIKCQo zI@4HbkYiYnivo32=s5W+oGLjJDXK7~)AjeA&gLe3`({73b|Yq}rZPRp9968PdgCiP zw_1t(9-?ndT(=z7WuD^WMO-`tT{s$mTJMdw)A8;u9&8`%@9ih=lD&g>qx6TJ?a?mo z;m+<>+Uxb=tv#E8O*oNLfhmlYoWPLtc02s3lVo%QM7K|c#tB=8e>>3pKa7Gdr4WE} zt3?7L*O17`NaPss-w&j-VZP4dti-O-2cRU0`&=sQ1Jx*=phgwZRrGO!xh}{c7+pAm z-s9HT{$LFPHVEH=wf5e~{cmGI(0in0?DH*j2Vc>J!$a8WbtyP@MWHTLZaw;=a$+!m zJ#+l*#XlCgpgOql-z;Y1=1!f8+RYv3<<}&_<gpRT*4AREty!-U)ng|X618hxc<xXx zHpz`!M2NFO!fzfze-Aq!f2jy~CRN}acRPv93k}|9r$J~qW+u-Oe-=+f_GB>l+PxOb zt@?nE8uPROYwnOrH!w(j%_b1o+22;H6*FG9;c3^=CUKSp27gAvW@UpuI7n5nT%m7O zVXj7s3}=JakCJ+nqCQ9;R_Mb$YUj&!C2egcauzEQ6TerfPOBQXcsq{@z=?%1mz9N^ zK2|-&rudRtL_Esyuy#q=Y7IU>l;V`?hj}8fAduu`mYJ^9jPfM|6e%p)7%UoX_P8(b zhYEM6;=B-=M#1n6oO{9Gz+{cO@=BSdRM`3PfeQ>dbzeI%ObzDLa(Si!@dd1IWtTHi z^Xs_Oawv+aEYO{O4cS(pZ3RdApdUmLM#N@Rd0bJ6AqEgA9GgMu1?@OvrAUa_<AeML z#}=tpXn|n%Q@44}6ScppqcsJ&5`CK7o=YxK>rL4dN9FxF>Jx>r##>pc1TScA^(!zq z*DO{?Qf49hahlJBl4iyR>%9Mjvk5OmX_@HtuBXd3J{T^w9Q50_k3}Enr0n}iwA>9B znA=od31&Y+4-?MrO8tdPxa(B4nSZAyNdbFyT@`#d73Gvx$tW>(xRP3o1*(l|%~AMw zDHLs0R*DO3ukj9=t*2Gsm1vgCwS%M8G{eF>Ftjl!<b&rf^s4}BCfO9xq7!E}Q5N4z zjmC`)aMR{jp&u;Acs)8R^F#}o2VSy?VfAR}g3T?v;>|r*6L*UvuT<h*<d*(6G^ia| z0-`hG7nadqcM0nP|J&XV8icoWpJ$crSZ4E7a_+#wxak{~&Cgbr+ZSBf27H4pK(BmW zu@Wpd>eCE)H@E%TCZzgAPY!+-)N69&mD8ozi8H?1ujNJsk{HFWCOA3@u19}jLT@<z zfXchYd;_u6p51M*wr2J0azWM{NLyAQMk7x{UW-p;id8_g=D24_;}I0P7fx>jRGDir z!Mmp^hECbUR=;1`6j^e^y3^EJNaFm_6;>|_)~fPG6L}&YMeesLW{#w<%jKh!#Zi0r zfb+)}d^UXl%&%oGPffp&d@|)U=`liPAE;i2FIW1dx!;77iTfXF4Q=5tv|dA$?XS~n z>7*QyPILG3BH(=|uw9~s&kjIc3mgu&epy9hsiLvSXi#e^`B6r|X+15<5er0e92FR6 rm_{aJ{1&FSt4O1=Fo&+CMiZQjcyf5>Gf@cIdd<BW3r+9-O0@GIWkhZK literal 0 HcmV?d00001 diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart new file mode 100644 index 0000000000..714c38e2ab --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart @@ -0,0 +1,44 @@ +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:video_player/video_player.dart'; + +part 'video_player_controller_provider.g.dart'; + +@riverpod +Future<VideoPlayerController> videoPlayerController( + VideoPlayerControllerRef ref, { + required Asset asset, +}) async { + late VideoPlayerController controller; + 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'); + } + controller = VideoPlayerController.file(file); + } else { + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}' + : '$serverEndpoint/asset/file/${asset.remoteId}'; + + final url = Uri.parse(videoUrl); + final accessToken = Store.get(StoreKey.accessToken); + + controller = VideoPlayerController.networkUrl( + url, + httpHeaders: {"x-immich-user-token": accessToken}, + ); + } + + await controller.initialize(); + + ref.onDispose(() { + controller.dispose(); + }); + + return controller; +} diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..a9b287e953e17749855607cbf48fce353ac5a151 GIT binary patch literal 4820 zcmb_gZByGu5dQ98u^&1?ZmBUOK<WSq#gJq=#hJjQlj*dIEUn=>vd%r7T$oP&dw1`2 zvLsH%nc`32+uPf>=h<EF?7-Xe%kz(?*XLt6n~cxlM;K4wa&is7PR8#qKEuzS;oa%w zxYt`mN`sui)>0%W!$lh1qY7i0X(iK?{#+=@M{1Ce<kqWRZ)XR-f3gp2`zV&E7BYE@ z8LCK2^~6u~uCx-_4TRqrA3qou&nge!Mfq*ehvSnT&_`_@?oD6pCwOqM|6*^rw|lsI z_;Pr7u)mLc`xyVUmmI!KUJems8;cz{lM8_f%#@tNn4@_${-qbEQJzD1b)RdT^9%4V zsjvM%2!lQa6Cl@-7BPrSLoDZ0kzv4p-_o6J^X)Q93+$Ww02D59lSzdmpv*;c)RZE6 zioTp<rgP&1--Tls-tErp2cAJ-zWf2KwpT{(e=7@u;oWdJJmgpC4Su2zM@O(b?2~gQ zoTv+xnG^W4G~(n04$Se_mj5%63Ce|Y|H*tdW<IY%QJMMNS@|u2FmbH8vb)=K)f@FP zP(wDN5vYA@!q*<fVu#GQLWC&ICH(HK)jtNkHy=tsUPu*qh59{$Z?3_6=x$Sed}746 z(QpyXMS5>)b2EEvrLWOt>HzOFW=RfK{n2TDpT(cJu#dbqsYDnrn^^z^9QN0%spik- z`n;N|$i-2b25cU&1^!(KMd&}xbJRdUM7>QXUtR4gTmNen21XUw+oP2dfE7yKNa9FE z2_xCH%HRgIvvgA4u@A&@87UDFx2#vNDC>*;Xt`lLZ(8f52bJ%2vrBTb`FT%V6-`q- zs_auXJ14IpOmIO3f>|sur`pVlG&S^CcYK`!avPdDPMS*VWV?Yql;dj^Wx3GQoyJdK z+!}?p^H6)}lz9*e+tOZhwjrZ7K<$fK*V2b*7j5cS8<Wf$P&;*|3>>Ua3oXYYU&tIU z3SxY8QXTOACUmbY*T;c40(~p;K}c-{RTiZVZj9eR;f!0qpDXK<jP)5|(+{4HIAgf1 zJ_Zd9EZehwU5rPDk^gBIzq&Z*dGu(O>o*dd7B_EEpDBzqUdckm_>P+Ns9~$)	E@ zfNeU@mO@E0Py|~%_6?Tv!9vNTi1k*#b(j5mZSd+j7_A$+nwGBMKk~=ea>by*6m=Dy z;Awm7ttY3wQ%mD1=J^NNCAOvzc(uE9wyQ8N7BpFhvB}S+)M6%3ZI`oN)m*F;r{5aU zI;hj0SKtw!9;ovdjaN07OY6X(t?CBU2}fWZr~qgKMbloC)*I&ol5_nFT=V-)=vxa) z%Q6$y#V;2{7Hc81z?%+&D^NWzd9PX3v&{l~(`tJzd(Ixm3sQ+2ky(DRL4eBCjv{ud z+J(R<s>sM1!vC%#oEnu_B3b2vwOO{{KI%+^MuQv~%&yKli=-{sNh)oH9*~qa`{$a@ zCUTx4FE?Gew&5y;Xp?iJnb|x-ZvnV8xJz8KexsNaK@v)NF@@vfU^4v+V_KQ@E!9r* zE%P0SjvnFOglEs}Z96iNPCN4P!eln0`pCn(5chcAI?iN*<%2ZI*`4aRu~WJQa<~nu z$h4T__5A_|?y~U^)?H-DZEH^bmSOgCl&$Zu<-*3d%IJ{;b8#m!KMfM@j4GKyXR=AA zE75KxxW2{J_6uJO*L>k0F*uDCum|+isTs>TfJVqnK;>$_NnIFYADwa5m#5M34-r>q zNK8&Qt5Zv)3rAfK7_;RsPd0&NDGGq!09TfMTaFx}Y8?(|QiU^-(%7M?kd9L-NAGD~ zOj#h5voOaf#UwOQ<{vQV2M3XaMQ#pV3kc^ppK_;boi9Z$XhSgXYRonLY*UK%{sUgt BBo_bx literal 0 HcmV?d00001 diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart index b73824f864..d935358936 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart @@ -1,10 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class VideoPlaybackControls { - VideoPlaybackControls({required this.position, required this.mute}); + VideoPlaybackControls({ + required this.position, + required this.mute, + required this.pause, + }); final double position; final bool mute; + final bool pause; } final videoPlayerControlsProvider = @@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { : super( VideoPlaybackControls( position: 0, + pause: false, mute: false, ), ); @@ -29,18 +35,62 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { state = value; } + void reset() { + state = VideoPlaybackControls( + position: 0, + pause: false, + mute: false, + ); + } + double get position => state.position; bool get mute => state.mute; set position(double value) { - state = VideoPlaybackControls(position: value, mute: state.mute); + state = VideoPlaybackControls( + position: value, + mute: state.mute, + pause: state.pause, + ); } set mute(bool value) { - state = VideoPlaybackControls(position: state.position, mute: value); + state = VideoPlaybackControls( + position: state.position, + mute: value, + pause: state.pause, + ); } void toggleMute() { - state = VideoPlaybackControls(position: state.position, mute: !state.mute); + state = VideoPlaybackControls( + position: state.position, + mute: !state.mute, + pause: state.pause, + ); + } + + void pause() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: true, + ); + } + + void play() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: false, + ); + } + + void togglePlay() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: !state.pause, + ); } } diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart index 66f9389a09..ebdf739ef0 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart @@ -1,10 +1,65 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:video_player/video_player.dart'; + +enum VideoPlaybackState { + initializing, + paused, + playing, + buffering, + completed, +} class VideoPlaybackValue { - VideoPlaybackValue({required this.position, required this.duration}); - + /// The current position of the video final Duration position; + + /// The total duration of the video final Duration duration; + + /// The current state of the video playback + final VideoPlaybackState state; + + /// The volume of the video + final double volume; + + VideoPlaybackValue({ + required this.position, + required this.duration, + required this.state, + required this.volume, + }); + + factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { + final video = controller?.value; + late VideoPlaybackState s; + if (video == null) { + s = VideoPlaybackState.initializing; + } else if (video.isCompleted) { + s = VideoPlaybackState.completed; + } else if (video.isPlaying) { + s = VideoPlaybackState.playing; + } else if (video.isBuffering) { + s = VideoPlaybackState.buffering; + } else { + s = VideoPlaybackState.paused; + } + + return VideoPlaybackValue( + position: video?.position ?? Duration.zero, + duration: video?.duration ?? Duration.zero, + state: s, + volume: video?.volume ?? 0.0, + ); + } + + factory VideoPlaybackValue.uninitialized() { + return VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, + ); + } } final videoPlaybackValueProvider = @@ -15,10 +70,7 @@ final videoPlaybackValueProvider = class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> { VideoPlaybackValueState(this.ref) : super( - VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - ), + VideoPlaybackValue.uninitialized(), ); final Ref ref; @@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> { } set position(Duration value) { - state = VideoPlaybackValue(position: value, duration: state.duration); + state = VideoPlaybackValue( + position: value, + duration: state.duration, + state: state.state, + volume: state.volume, + ); } } diff --git a/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart new file mode 100644 index 0000000000..a7d5e4e71c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart @@ -0,0 +1,345 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart'; +import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; + +class BottomGalleryBar extends ConsumerWidget { + final Asset asset; + final bool showStack; + final int stackIndex; + final int totalAssets; + final bool showVideoPlayerControls; + final PageController controller; + + const BottomGalleryBar({ + super.key, + required this.showStack, + required this.stackIndex, + required this.asset, + required this.controller, + required this.totalAssets, + required this.showVideoPlayerControls, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + + final stack = showStack && asset.stackChildrenCount > 0 + ? ref.watch(assetStackStateProvider(asset)) + : <Asset>[]; + final stackElements = showStack ? [asset, ...stack] : <Asset>[]; + bool isParent = stackIndex == -1 || stackIndex == 0; + final navStack = AutoRouter.of(context).stackData; + final isTrashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final isFromTrash = isTrashEnabled && + navStack.length > 2 && + navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + // !!!! itemsList and actionlist should always be in sync + final itemsList = [ + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + if (isOwner) + asset.isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + if (isOwner && stack.isNotEmpty) + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ), + if (isOwner) + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + if (!isOwner) + BottomNavigationBarItem( + icon: const Icon(Icons.download_outlined), + label: 'download'.tr(), + tooltip: 'download'.tr(), + ), + ]; + + void removeAssetFromStack() { + if (stackIndex > 0 && showStack) { + ref + .read(assetStackStateProvider(asset).notifier) + .removeChild(stackIndex - 1); + } + } + + void handleDelete() async { + // Cannot delete readOnly / external assets. They are handled through library offline jobs + if (asset.isReadOnly) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_delete_err_read_only'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + Future<bool> onDelete(bool force) async { + final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( + {asset}, + force: force, + ); + if (isDeleted && isParent) { + if (totalAssets == 1) { + // Handle only one asset + context.popRoute(); + } else { + // Go to next page otherwise + controller.nextPage( + duration: const Duration(milliseconds: 100), + curve: Curves.fastLinearToSlowEaseIn, + ); + } + } + return isDeleted; + } + + // Asset is trashed + if (isTrashEnabled && !isFromTrash) { + final isDeleted = await onDelete(false); + if (isDeleted) { + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && asset.isRemote && isParent) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + removeAssetFromStack(); + } + return; + } + + // Asset is permanently removed + showDialog( + context: context, + builder: (BuildContext _) { + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); + }, + ); + } + + void showStackActionItems() { + showModalBottomSheet<void>( + context: context, + enableDrag: false, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isParent) + ListTile( + leading: const Icon( + Icons.bookmark_border_outlined, + size: 24, + ), + onTap: () async { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + asset, + stackElements.elementAt(stackIndex), + ); + ctx.pop(); + context.popRoute(); + }, + title: const Text( + "viewer_stack_use_as_main_asset", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.copy_all_outlined, + size: 24, + ), + onTap: () async { + if (isParent) { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + asset, + stackElements + .elementAt(1), // Next asset as parent + ); + // Remove itself from stack + await ref.read(assetStackServiceProvider).updateStack( + stackElements.elementAt(1), + childrenToRemove: [asset], + ); + ctx.pop(); + context.popRoute(); + } else { + await ref.read(assetStackServiceProvider).updateStack( + asset, + childrenToRemove: [ + stackElements.elementAt(stackIndex), + ], + ); + removeAssetFromStack(); + ctx.pop(); + } + }, + title: const Text( + "viewer_remove_from_stack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.filter_none_outlined, + size: 18, + ), + onTap: () async { + await ref.read(assetStackServiceProvider).updateStack( + asset, + childrenToRemove: stack, + ); + ctx.pop(); + context.popRoute(); + }, + title: const Text( + "viewer_unstack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + ); + }, + ); + } + + shareAsset() { + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + } + + handleArchive() { + ref.read(assetProvider.notifier).toggleArchive([asset]); + if (isParent) { + context.popRoute(); + return; + } + removeAssetFromStack(); + } + + handleDownload() { + if (asset.isLocal) { + return; + } + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset, + context, + ); + } + + List<Function(int)> actionslist = [ + (_) => shareAsset(), + if (isOwner) (_) => handleArchive(), + if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), + if (isOwner) (_) => handleDelete(), + if (!isOwner) (_) => handleDownload(), + ]; + + return IgnorePointer( + ignoring: !ref.watch(showControlsProvider), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + child: Column( + children: [ + Visibility( + visible: showVideoPlayerControls, + child: const VideoControls(), + ), + BottomNavigationBar( + backgroundColor: Colors.black.withOpacity(0.4), + unselectedIconTheme: const IconThemeData(color: Colors.white), + selectedIconTheme: const IconThemeData(color: Colors.white), + unselectedLabelStyle: const TextStyle(color: Colors.black), + selectedLabelStyle: const TextStyle(color: Colors.black), + showSelectedLabels: false, + showUnselectedLabels: false, + items: itemsList, + onTap: (index) { + if (index < actionslist.length) { + actionslist[index].call(index); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart new file mode 100644 index 0000000000..0e8f14301a --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart'; + +class CustomVideoPlayerControls extends HookConsumerWidget { + final Duration hideTimerDuration; + + const CustomVideoPlayerControls({ + super.key, + this.hideTimerDuration = const Duration(seconds: 3), + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // A timer to hide the controls + final hideTimer = useTimer( + hideTimerDuration, + () { + final state = ref.read(videoPlaybackValueProvider).state; + // Do not hide on paused + if (state != VideoPlaybackState.paused) { + ref.read(showControlsProvider.notifier).show = false; + } + }, + ); + + final showBuffering = useState(false); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider).state; + + /// Shows the controls and starts the timer to hide them + void showControlsAndStartHideTimer() { + hideTimer.reset(); + ref.read(showControlsProvider.notifier).show = true; + } + + // When we mute, show the controls + ref.listen(videoPlayerControlsProvider.select((v) => v.mute), + (previous, next) { + showControlsAndStartHideTimer(); + }); + + // When we change position, show or hide timer + ref.listen(videoPlayerControlsProvider.select((v) => v.position), + (previous, next) { + 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 { + ref.read(videoPlayerControlsProvider.notifier).play(); + } + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: showControlsAndStartHideTimer, + child: AbsorbPointer( + absorbing: !ref.watch(showControlsProvider), + child: Stack( + children: [ + if (showBuffering.value) + const Center( + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 400), + ), + ) + else + GestureDetector( + onTap: () { + if (state != VideoPlaybackState.playing) { + togglePlay(); + } + ref.read(showControlsProvider.notifier).show = false; + }, + child: CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: state == VideoPlaybackState.playing, + show: ref.watch(showControlsProvider), + onPressed: togglePlay, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart new file mode 100644 index 0000000000..a16f1f04d6 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart @@ -0,0 +1,110 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; +import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; + +class GalleryAppBar extends ConsumerWidget { + final Asset asset; + final void Function() showInfo; + final void Function() onToggleMotionVideo; + final bool isPlayingVideo; + + const GalleryAppBar({ + super.key, + required this.asset, + required this.showInfo, + required this.onToggleMotionVideo, + required this.isPlayingVideo, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentAlbumProvider); + final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + + final isPartner = ref + .watch(partnerSharedWithProvider) + .map((e) => e.isarId) + .contains(asset.ownerId); + + toggleFavorite(Asset asset) => + ref.read(assetProvider.notifier).toggleFavorite([asset]); + + handleActivities() { + if (album != null && album.shared && album.remoteId != null) { + context.pushRoute(const ActivitiesRoute()); + } + } + + handleUpload(Asset asset) { + showDialog( + context: context, + builder: (BuildContext _) { + return UploadDialog( + onUpload: () { + ref + .read(manualUploadProvider.notifier) + .uploadAssets(context, [asset]); + }, + ); + }, + ); + } + + addToAlbum(Asset addToAlbumAsset) { + showModalBottomSheet( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + context: context, + builder: (BuildContext _) { + return AddToAlbumBottomSheet( + assets: [addToAlbumAsset], + ); + }, + ); + } + + return IgnorePointer( + ignoring: !ref.watch(showControlsProvider), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + child: Container( + color: Colors.black.withOpacity(0.4), + child: TopControlAppBar( + isOwner: isOwner, + isPartner: isPartner, + isPlayingMotionVideo: isPlayingVideo, + asset: asset, + onMoreInfoPressed: showInfo, + onFavorite: toggleFavorite, + onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, + onDownloadPressed: asset.isLocal + ? null + : () => + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset, + context, + ), + onToggleMotionVideo: onToggleMotionVideo, + onAddToAlbumPressed: () => addToAlbum(asset), + onActivitiesPressed: handleActivities, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_controls.dart new file mode 100644 index 0000000000..45a9372099 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_controls.dart @@ -0,0 +1,125 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; + +/// The video controls for the [videPlayerControlsProvider] +class VideoControls extends ConsumerWidget { + const VideoControls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final duration = + ref.watch(videoPlaybackValueProvider.select((v) => v.duration)); + final position = + ref.watch(videoPlaybackValueProvider.select((v) => v.position)); + + return AnimatedOpacity( + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: OrientationBuilder( + builder: (context, orientation) => Container( + padding: EdgeInsets.symmetric( + horizontal: orientation == Orientation.portrait ? 12.0 : 64.0, + ), + color: Colors.black.withOpacity(0.4), + child: Padding( + padding: MediaQuery.of(context).orientation == Orientation.portrait + ? const EdgeInsets.symmetric(horizontal: 12.0) + : const EdgeInsets.symmetric(horizontal: 64.0), + child: Row( + children: [ + Text( + _formatDuration(position), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + Expanded( + child: Slider( + value: duration == Duration.zero + ? 0.0 + : min( + position.inMicroseconds / + duration.inMicroseconds * + 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: Colors.white.withOpacity(0.75), + onChanged: (position) { + ref.read(videoPlayerControlsProvider.notifier).position = + position; + }, + ), + ), + Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + IconButton( + icon: Icon( + ref.watch( + videoPlayerControlsProvider.select((value) => value.mute), + ) + ? Icons.volume_off + : Icons.volume_up, + ), + onPressed: () => ref + .read(videoPlayerControlsProvider.notifier) + .toggleMute(), + color: Colors.white, + ), + ], + ), + ), + ), + ), + ); + } + + String _formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + + return formattedTime; + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player.dart b/mobile/lib/modules/asset_viewer/ui/video_player.dart new file mode 100644 index 0000000000..1f856e7d0f --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_player.dart @@ -0,0 +1,45 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/custom_video_player_controls.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPlayerViewer extends HookConsumerWidget { + final VideoPlayerController controller; + final bool isMotionVideo; + final Widget? placeholder; + final Duration hideControlsTimer; + final bool showControls; + final bool showDownloadingIndicator; + + const VideoPlayerViewer({ + super.key, + required this.controller, + required this.isMotionVideo, + this.placeholder, + required this.hideControlsTimer, + required this.showControls, + required this.showDownloadingIndicator, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chewie = useChewieController( + controller: controller, + controlsSafeAreaMinimum: const EdgeInsets.only( + bottom: 100, + ), + placeholder: SizedBox.expand(child: placeholder), + customControls: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), + showControls: showControls && !isMotionVideo, + hideControlsTimer: hideControlsTimer, + ); + + return Chewie( + controller: chewie, + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart deleted file mode 100644 index bfc45b8a35..0000000000 --- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'dart:async'; - -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; -import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerControls extends ConsumerStatefulWidget { - const VideoPlayerControls({ - super.key, - }); - - @override - VideoPlayerControlsState createState() => VideoPlayerControlsState(); -} - -class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls> - with SingleTickerProviderStateMixin { - late VideoPlayerController controller; - late VideoPlayerValue _latestValue; - bool _displayBufferingIndicator = false; - double? _latestVolume; - Timer? _hideTimer; - - ChewieController? _chewieController; - ChewieController get chewieController => _chewieController!; - - @override - Widget build(BuildContext context) { - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, value) { - _mute(value); - _cancelAndRestartTimer(); - }); - - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - _seekTo(position); - _cancelAndRestartTimer(); - }); - - if (_latestValue.hasError) { - return chewieController.errorBuilder?.call( - context, - chewieController.videoPlayerController.value.errorDescription!, - ) ?? - const Center( - child: Icon( - Icons.error, - color: Colors.white, - size: 42, - ), - ); - } - - return GestureDetector( - onTap: () => _cancelAndRestartTimer(), - child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), - child: Stack( - children: [ - if (_displayBufferingIndicator) - const Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 400), - ), - ) - else - _buildHitArea(), - ], - ), - ), - ); - } - - @override - void dispose() { - _dispose(); - - super.dispose(); - } - - void _dispose() { - controller.removeListener(_updateState); - _hideTimer?.cancel(); - } - - @override - void didChangeDependencies() { - final oldController = _chewieController; - _chewieController = ChewieController.of(context); - controller = chewieController.videoPlayerController; - _latestValue = controller.value; - - if (oldController != chewieController) { - _dispose(); - _initialize(); - } - - super.didChangeDependencies(); - } - - Widget _buildHitArea() { - final bool isFinished = _latestValue.position >= _latestValue.duration; - - return GestureDetector( - onTap: () { - if (!_latestValue.isPlaying) { - _playPause(); - } - ref.read(showControlsProvider.notifier).show = false; - }, - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: isFinished, - isPlaying: controller.value.isPlaying, - show: ref.watch(showControlsProvider), - onPressed: _playPause, - ), - ); - } - - void _cancelAndRestartTimer() { - _hideTimer?.cancel(); - _startHideTimer(); - ref.read(showControlsProvider.notifier).show = true; - } - - Future<void> _initialize() async { - ref.read(showControlsProvider.notifier).show = false; - _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); - - _latestValue = controller.value; - controller.addListener(_updateState); - - if (controller.value.isPlaying || chewieController.autoPlay) { - _startHideTimer(); - } - } - - void _playPause() { - final isFinished = _latestValue.position >= _latestValue.duration; - - setState(() { - if (controller.value.isPlaying) { - ref.read(showControlsProvider.notifier).show = true; - _hideTimer?.cancel(); - controller.pause(); - } else { - _cancelAndRestartTimer(); - - if (!controller.value.isInitialized) { - controller.initialize().then((_) { - controller.play(); - }); - } else { - if (isFinished) { - controller.seekTo(Duration.zero); - } - controller.play(); - } - } - }); - } - - void _startHideTimer() { - final hideControlsTimer = chewieController.hideControlsTimer; - _hideTimer?.cancel(); - _hideTimer = Timer(hideControlsTimer, () { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - void _updateState() { - if (!mounted) return; - - _displayBufferingIndicator = controller.value.isBuffering; - - setState(() { - _latestValue = controller.value; - ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue( - position: _latestValue.position, - duration: _latestValue.duration, - ); - }); - } - - void _mute(bool mute) { - if (mute) { - _latestVolume = controller.value.volume; - controller.setVolume(0); - } else { - controller.setVolume(_latestVolume ?? 0.5); - } - } - - void _seekTo(double position) { - final Duration pos = controller.value.duration * (position / 100.0); - if (pos != controller.value.position) { - controller.seekTo(pos); - } - } -} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index dfdfb32844..2af7679a91 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -2,46 +2,31 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui' as ui; -import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; -import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; -import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; -import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; -import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; -import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; @@ -73,18 +58,16 @@ class GalleryViewerPage extends HookConsumerWidget { final settings = ref.watch(appSettingsServiceProvider); final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); - final isZoomed = useState<bool>(false); - final isPlayingMotionVideo = useState(false); + final isZoomed = useState(false); final isPlayingVideo = useState(false); - Offset? localPosition; + final localPosition = useState<Offset?>(null); final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); - final isTrashEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final navStack = AutoRouter.of(context).stackData; - final isFromTrash = isTrashEnabled && - navStack.length > 2 && - navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + // Update is playing motion video + ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { + isPlayingVideo.value = state == VideoPlaybackState.playing; + }); + final stackIndex = useState(-1); final stack = showStack && currentAsset.stackChildrenCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) @@ -92,30 +75,23 @@ class GalleryViewerPage extends HookConsumerWidget { final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[]; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = currentAsset.id == Isar.autoIncrement; - final album = ref.watch(currentAlbumProvider); - Asset asset() => stackIndex.value == -1 + Asset asset = stackIndex.value == -1 ? currentAsset : stackElements.elementAt(stackIndex.value); - final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId; - final isPartner = ref - .watch(partnerSharedWithProvider) - .map((e) => e.isarId) - .contains(asset().ownerId); - - bool isParent = stackIndex.value == -1 || stackIndex.value == 0; + 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()), + () => ref.read(currentAssetProvider.notifier).set(asset), ); return null; }, - [asset()], + [asset], ); useEffect( @@ -124,15 +100,11 @@ class GalleryViewerPage extends HookConsumerWidget { settings.getSetting<bool>(AppSettingsEnum.loadPreview); isLoadOriginal.value = settings.getSetting<bool>(AppSettingsEnum.loadOriginal); - isPlayingMotionVideo.value = false; return null; }, [], ); - void toggleFavorite(Asset asset) => - ref.read(assetProvider.notifier).toggleFavorite([asset]); - Future<void> precacheNextImage(int index) async { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently @@ -168,97 +140,8 @@ class GalleryViewerPage extends HookConsumerWidget { child: ref .watch(appSettingsServiceProvider) .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset()) - : ExifBottomSheet(asset: asset()), - ); - }, - ); - } - - void removeAssetFromStack() { - if (stackIndex.value > 0 && showStack) { - ref - .read(assetStackStateProvider(currentAsset).notifier) - .removeChild(stackIndex.value - 1); - stackIndex.value = stackIndex.value - 1; - } - } - - void handleDelete(Asset deleteAsset) async { - // Cannot delete readOnly / external assets. They are handled through library offline jobs - if (asset().isReadOnly) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_delete_err_read_only'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - Future<bool> onDelete(bool force) async { - final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( - {deleteAsset}, - force: force, - ); - if (isDeleted && isParent) { - if (totalAssets == 1) { - // Handle only one asset - context.popRoute(); - } else { - // Go to next page otherwise - controller.nextPage( - duration: const Duration(milliseconds: 100), - curve: Curves.fastLinearToSlowEaseIn, - ); - } - } - return isDeleted; - } - - // Asset is trashed - if (isTrashEnabled && !isFromTrash) { - final isDeleted = await onDelete(false); - if (isDeleted) { - // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && deleteAsset.isRemote && isParent) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'Asset trashed', - gravity: ToastGravity.BOTTOM, - ); - } - removeAssetFromStack(); - } - return; - } - - // Asset is permanently removed - showDialog( - context: context, - builder: (BuildContext _) { - return DeleteDialog( - onDelete: () async { - final isDeleted = await onDelete(true); - if (isDeleted) { - removeAssetFromStack(); - } - }, - ); - }, - ); - } - - void addToAlbum(Asset addToAlbumAsset) { - showModalBottomSheet( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - context: context, - builder: (BuildContext _) { - return AddToAlbumBottomSheet( - assets: [addToAlbumAsset], + ? AdvancedBottomSheet(assetDetail: asset) + : ExifBottomSheet(asset: asset), ); }, ); @@ -274,12 +157,12 @@ class GalleryViewerPage extends HookConsumerWidget { } // Guard [localPosition] null - if (localPosition == null) { + if (localPosition.value == null) { return; } // Check for delta from initial down point - final d = details.localPosition - localPosition!; + final d = details.localPosition - localPosition.value!; // If the magnitude of the dx swipe is large, we probably didn't mean to go down if (d.dx.abs() > dxThreshold) { return; @@ -293,175 +176,52 @@ class GalleryViewerPage extends HookConsumerWidget { } } - shareAsset() { - if (asset().isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context); - } + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + isPlayingVideo.value = false; + return null; + }, + [], + ); - handleArchive(Asset asset) { - ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { - context.popRoute(); - return; - } - removeAssetFromStack(); - } - - handleUpload(Asset asset) { - showDialog( - context: context, - builder: (BuildContext _) { - return UploadDialog( - onUpload: () { - ref - .read(manualUploadProvider.notifier) - .uploadAssets(context, [asset]); - }, - ); - }, - ); - } - - handleDownload() { - if (asset().isLocal) { - return; - } - if (asset().isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset(), - context, - ); - } - - handleActivities() { - if (album != null && album.shared && album.remoteId != null) { - context.pushRoute(const ActivitiesRoute()); - } - } - - buildAppBar() { - return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - child: Container( - color: Colors.black.withOpacity(0.4), - child: TopControlAppBar( - isOwner: isOwner, - isPartner: isPartner, - isPlayingMotionVideo: isPlayingMotionVideo.value, - asset: asset(), - onMoreInfoPressed: showInfo, - onFavorite: toggleFavorite, - onUploadPressed: - asset().isLocal ? () => handleUpload(asset()) : null, - onDownloadPressed: asset().isLocal - ? null - : () => - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset(), - context, - ), - onToggleMotionVideo: (() { - isPlayingMotionVideo.value = !isPlayingMotionVideo.value; - }), - onAddToAlbumPressed: () => addToAlbum(asset()), - onActivitiesPressed: handleActivities, - ), + 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; + }, + [], + ); - Widget buildProgressBar() { - final playerValue = ref.watch(videoPlaybackValueProvider); - - return Expanded( - child: Slider( - value: playerValue.duration == Duration.zero - ? 0.0 - : min( - playerValue.position.inMicroseconds / - playerValue.duration.inMicroseconds * - 100, - 100, - ), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: Colors.white.withOpacity(0.75), - onChanged: (position) { - ref.read(videoPlayerControlsProvider.notifier).position = position; - }, - ), - ); - } - - Text buildPosition() { - final position = ref - .watch(videoPlaybackValueProvider.select((value) => value.position)); - - return Text( - _formatDuration(position), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ); - } - - Text buildDuration() { - final duration = ref - .watch(videoPlaybackValueProvider.select((value) => value.duration)); - - return Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ); - } - - Widget buildMuteButton() { - return IconButton( - icon: Icon( - ref.watch(videoPlayerControlsProvider.select((value) => value.mute)) - ? Icons.volume_off - : Icons.volume_up, - ), - onPressed: () => - ref.read(videoPlayerControlsProvider.notifier).toggleMute(), - color: Colors.white, - ); - } + ref.listen(showControlsProvider, (_, show) { + if (show) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + }); Widget buildStackedChildren() { return ListView.builder( shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 10, + right: 10, + bottom: 30, + ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; return Padding( @@ -495,246 +255,6 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - void showStackActionItems() { - showModalBottomSheet<void>( - context: context, - enableDrag: false, - builder: (BuildContext ctx) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - currentAsset, - stackElements.elementAt(stackIndex.value), - ); - ctx.pop(); - context.popRoute(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - currentAsset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [currentAsset], - ); - ctx.pop(); - context.popRoute(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - currentAsset, - childrenToRemove: [ - stackElements.elementAt(stackIndex.value), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.filter_none_outlined, - size: 18, - ), - onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - currentAsset, - childrenToRemove: stack, - ); - ctx.pop(); - context.popRoute(); - }, - title: const Text( - "viewer_unstack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ], - ), - ), - ); - }, - ); - } - - // TODO: Migrate to a custom bottom bar and handle long press to delete - Widget buildBottomBar() { - // !!!! itemsList and actionlist should always be in sync - final itemsList = [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - if (isOwner) - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - if (isOwner && stack.isNotEmpty) - BottomNavigationBarItem( - icon: const Icon(Icons.burst_mode_outlined), - label: 'control_bottom_app_bar_stack'.tr(), - tooltip: 'control_bottom_app_bar_stack'.tr(), - ), - if (isOwner) - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - if (!isOwner) - BottomNavigationBarItem( - icon: const Icon(Icons.download_outlined), - label: 'download'.tr(), - tooltip: 'download'.tr(), - ), - ]; - - List<Function(int)> actionslist = [ - (_) => shareAsset(), - if (isOwner) (_) => handleArchive(asset()), - if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), - if (isOwner) (_) => handleDelete(asset()), - if (!isOwner) (_) => handleDownload(), - ]; - - return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - child: Column( - children: [ - if (stack.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: 10, - bottom: 30, - ), - child: SizedBox( - height: 40, - child: buildStackedChildren(), - ), - ), - Visibility( - visible: !asset().isImage && !isPlayingMotionVideo.value, - child: Container( - color: Colors.black.withOpacity(0.4), - child: Padding( - padding: MediaQuery.of(context).orientation == - Orientation.portrait - ? const EdgeInsets.symmetric(horizontal: 12.0) - : const EdgeInsets.symmetric(horizontal: 64.0), - child: Row( - children: [ - buildPosition(), - buildProgressBar(), - buildDuration(), - buildMuteButton(), - ], - ), - ), - ), - ), - BottomNavigationBar( - backgroundColor: Colors.black.withOpacity(0.4), - unselectedIconTheme: const IconThemeData(color: Colors.white), - selectedIconTheme: const IconThemeData(color: Colors.white), - unselectedLabelStyle: const TextStyle(color: Colors.black), - selectedLabelStyle: const TextStyle(color: Colors.black), - showSelectedLabels: false, - showUnselectedLabels: false, - items: itemsList, - onTap: (index) { - if (index < actionslist.length) { - actionslist[index].call(index); - } - }, - ), - ], - ), - ), - ); - } - - useEffect( - () { - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - 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); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - }); - return PopScope( canPop: false, onPopInvoked: (_) { @@ -762,7 +282,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), ImmichThumbnail( - asset: asset(), + asset: asset, fit: BoxFit.contain, ), ], @@ -782,6 +302,7 @@ class GalleryViewerPage extends HookConsumerWidget { HapticFeedback.selectionClick(); currentIndex.value = value; stackIndex.value = -1; + isPlayingVideo.value = false; // Wait for page change animation to finish await Future.delayed(const Duration(milliseconds: 400)); @@ -790,14 +311,14 @@ class GalleryViewerPage extends HookConsumerWidget { }, builder: (context, index) { final a = - index == currentIndex.value ? asset() : loadAsset(index); + index == currentIndex.value ? asset : loadAsset(index); final ImageProvider provider = ImmichImage.imageProvider(asset: a); - if (a.isImage && !isPlayingMotionVideo.value) { + if (a.isImage && !isPlayingVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => - localPosition = details.localPosition, + localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), onTapDown: (_, __, ___) { @@ -821,7 +342,7 @@ class GalleryViewerPage extends HookConsumerWidget { } else { return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => - localPosition = details.localPosition, + localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( @@ -834,15 +355,9 @@ class GalleryViewerPage extends HookConsumerWidget { minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( - onPlaying: () { - isPlayingVideo.value = true; - }, - onPaused: () => - WidgetsBinding.instance.addPostFrameCallback( - (_) => isPlayingVideo.value = false, - ), + key: ValueKey(a), asset: a, - isMotionVideo: isPlayingMotionVideo.value, + isMotionVideo: a.livePhotoVideoId != null, placeholder: Image( image: provider, fit: BoxFit.contain, @@ -850,11 +365,6 @@ class GalleryViewerPage extends HookConsumerWidget { width: context.width, alignment: Alignment.center, ), - onVideoEnded: () { - if (isPlayingMotionVideo.value) { - isPlayingMotionVideo.value = false; - } - }, ), ); } @@ -864,50 +374,41 @@ class GalleryViewerPage extends HookConsumerWidget { top: 0, left: 0, right: 0, - child: buildAppBar(), + child: GalleryAppBar( + asset: asset, + showInfo: showInfo, + isPlayingVideo: isPlayingVideo.value, + onToggleMotionVideo: () => + isPlayingVideo.value = !isPlayingVideo.value, + ), ), Positioned( bottom: 0, left: 0, right: 0, - child: buildBottomBar(), + child: Column( + children: [ + Visibility( + visible: stack.isNotEmpty, + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), + BottomGalleryBar( + totalAssets: totalAssets, + controller: controller, + showStack: showStack, + stackIndex: stackIndex.value, + asset: asset, + showVideoPlayerControls: !asset.isImage && !isMotionPhoto, + ), + ], + ), ), ], ), ), ); } - - String _formatDuration(Duration position) { - final ms = position.inMilliseconds; - - int seconds = ms ~/ 1000; - final int hours = seconds ~/ 3600; - seconds = seconds % 3600; - final minutes = seconds ~/ 60; - seconds = seconds % 60; - - final hoursString = hours >= 10 - ? '$hours' - : hours == 0 - ? '00' - : '0$hours'; - - final minutesString = minutes >= 10 - ? '$minutes' - : minutes == 0 - ? '00' - : '0$minutes'; - - final secondsString = seconds >= 10 - ? '$seconds' - : seconds == 0 - ? '00' - : '0$seconds'; - - final formattedTime = - '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; - - return formattedTime; - } } 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 0da2bc52db..22f00c001d 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,21 +1,22 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:chewie/chewie.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controller_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/video_player.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() // ignore: must_be_immutable -class VideoViewerPage extends HookWidget { +class VideoViewerPage extends HookConsumerWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; - final VoidCallback? onVideoEnded; - final VoidCallback? onPlaying; - final VoidCallback? onPaused; final Duration hideControlsTimer; final bool showControls; final bool showDownloadingIndicator; @@ -24,9 +25,6 @@ class VideoViewerPage extends HookWidget { super.key, required this.asset, this.isMotionVideo = false, - this.onVideoEnded, - this.onPlaying, - this.onPaused, this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), @@ -34,29 +32,107 @@ class VideoViewerPage extends HookWidget { }); @override - Widget build(BuildContext context) { - final controller = useChewieController( - asset, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: placeholder, - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - customControls: const VideoPlayerControls(), - onPlaying: onPlaying, - onPaused: onPaused, - onVideoEnded: onVideoEnded, + build(BuildContext context, WidgetRef ref) { + final controller = + ref.watch(videoPlayerControllerProvider(asset: asset)).value; + // The last volume of the video used when mute is toggled + final lastVolume = useState(0.5); + + // When the volume changes, set the volume + ref.listen(videoPlayerControlsProvider.select((value) => value.mute), + (_, mute) { + if (mute) { + controller?.setVolume(0.0); + } else { + controller?.setVolume(lastVolume.value); + } + }); + + // When the position changes, seek to the position + ref.listen(videoPlayerControlsProvider.select((value) => value.position), + (_, position) { + if (controller == null) { + // No seeeking if there is no video + return; + } + + // Find the position to seek to + final Duration seek = controller.value.duration * (position / 100.0); + controller.seekTo(seek); + }); + + // When the custom video controls paus or plays + ref.listen(videoPlayerControlsProvider.select((value) => value.pause), + (lastPause, pause) { + if (pause) { + controller?.pause(); + } else { + controller?.play(); + } + }); + + // Updates the [videoPlaybackValueProvider] with the current + // position and duration of the video from the Chewie [controller] + // Also sets the error if there is an error in the playback + void updateVideoPlayback() { + final videoPlayback = VideoPlaybackValue.fromController(controller); + 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(); + } + } + + // Adds and removes the listener to the video player + useEffect( + () { + Future.microtask( + () => ref.read(videoPlayerControlsProvider.notifier).reset(), + ); + // Guard no controller + if (controller == null) { + return null; + } + + // Hide the controls + // Done in a microtask to avoid setting the state while the is building + if (!isMotionVideo) { + Future.microtask(() { + ref.read(showControlsProvider.notifier).show = false; + }); + } + + // Subscribes to listener + controller.addListener(updateVideoPlayback); + return () { + // Removes listener when we dispose + controller.removeListener(updateVideoPlayback); + controller.pause(); + }; + }, + [controller], ); - // Loading + final size = MediaQuery.sizeOf(context); + return PopScope( + onPopInvoked: (pop) { + ref.read(videoPlaybackValueProvider.notifier).value = + VideoPlaybackValue.uninitialized(); + }, child: AnimatedSwitcher( duration: const Duration(milliseconds: 400), - child: Builder( - builder: (context) { - if (controller == null) { - return Stack( + child: Stack( + children: [ + Visibility( + visible: controller == null, + child: Stack( children: [ if (placeholder != null) placeholder!, const Positioned.fill( @@ -67,18 +143,22 @@ class VideoViewerPage extends HookWidget { ), ), ], - ); - } - - final size = MediaQuery.of(context).size; - return SizedBox( - height: size.height, - width: size.width, - child: Chewie( - controller: controller, ), - ); - }, + ), + if (controller != null) + SizedBox( + height: size.height, + width: size.width, + child: VideoPlayerViewer( + controller: controller, + isMotionVideo: isMotionVideo, + placeholder: placeholder, + hideControlsTimer: hideControlsTimer, + showControls: showControls, + showDownloadingIndicator: showDownloadingIndicator, + ), + ), + ], ), ), ); diff --git a/mobile/lib/modules/map/providers/map_state.provider.g.dart b/mobile/lib/modules/map/providers/map_state.provider.g.dart index ca75292e7892745a1c35f420ca38d7bc2fce6e39..d1b3e54b7106960d68af50432c0a05b216dd1374 100644 GIT binary patch delta 53 zcmZ3=zLb4~8l#4piGf9mnW0%~vSnJbL5g8onvq$mk*S%bk%^&svWc;&fk|4Lxv9x! IKgMt-0Ey!cApigX delta 53 zcmZ3=zLb4~8l#4BlBt1ZlBs!;xru>6l7Xd#p?Ruha$;(#WlCyNnuVpMp}A3VimB0N IKgMt-0FH|ev;Y7A diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index af57c272ae..5a316db279 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -69,14 +69,16 @@ class MemoryCard extends StatelessWidget { return Hero( tag: 'memory-${asset.id}', child: VideoViewerPage( + key: ValueKey(asset), asset: asset, showDownloadingIndicator: false, - placeholder: ImmichImage( - asset, - fit: fit, + placeholder: SizedBox.expand( + child: ImmichImage( + asset, + fit: fit, + ), ), hideControlsTimer: const Duration(seconds: 2), - onVideoEnded: onVideoEnded, showControls: false, ), ); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 16ac5efb0e..64bd492a77 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -350,9 +350,6 @@ abstract class _$AppRouter extends RootStackRouter { key: args.key, asset: args.asset, isMotionVideo: args.isMotionVideo, - onVideoEnded: args.onVideoEnded, - onPlaying: args.onPlaying, - onPaused: args.onPaused, placeholder: args.placeholder, showControls: args.showControls, hideControlsTimer: args.hideControlsTimer, @@ -1388,12 +1385,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { Key? key, required Asset asset, bool isMotionVideo = false, - void Function()? onVideoEnded, - void Function()? onPlaying, - void Function()? onPaused, Widget? placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(milliseconds: 1500), + Duration hideControlsTimer = const Duration(seconds: 5), bool showDownloadingIndicator = true, List<PageRouteInfo>? children, }) : super( @@ -1402,9 +1396,6 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { key: key, asset: asset, isMotionVideo: isMotionVideo, - onVideoEnded: onVideoEnded, - onPlaying: onPlaying, - onPaused: onPaused, placeholder: placeholder, showControls: showControls, hideControlsTimer: hideControlsTimer, @@ -1424,12 +1415,9 @@ class VideoViewerRouteArgs { this.key, required this.asset, this.isMotionVideo = false, - this.onVideoEnded, - this.onPlaying, - this.onPaused, this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(milliseconds: 1500), + this.hideControlsTimer = const Duration(seconds: 5), this.showDownloadingIndicator = true, }); @@ -1439,12 +1427,6 @@ class VideoViewerRouteArgs { final bool isMotionVideo; - final void Function()? onVideoEnded; - - final void Function()? onPlaying; - - final void Function()? onPaused; - final Widget? placeholder; final bool showControls; @@ -1455,6 +1437,6 @@ class VideoViewerRouteArgs { @override String toString() { - return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}'; + return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}'; } } diff --git a/mobile/lib/shared/ui/hooks/timer_hook.dart b/mobile/lib/shared/ui/hooks/timer_hook.dart new file mode 100644 index 0000000000..a78fed42c3 --- /dev/null +++ b/mobile/lib/shared/ui/hooks/timer_hook.dart @@ -0,0 +1,48 @@ +import 'package:async/async.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +RestartableTimer useTimer( + Duration duration, + void Function() callback, +) { + return use( + _TimerHook( + duration: duration, + callback: callback, + ), + ); +} + +class _TimerHook extends Hook<RestartableTimer> { + final Duration duration; + final void Function() callback; + + const _TimerHook({ + required this.duration, + required this.callback, + }); + @override + HookState<RestartableTimer, Hook<RestartableTimer>> createState() => + _TimerHookState(); +} + +class _TimerHookState extends HookState<RestartableTimer, _TimerHook> { + late RestartableTimer timer; + @override + void initHook() { + super.initHook(); + timer = RestartableTimer(hook.duration, hook.callback); + } + + @override + RestartableTimer build(BuildContext context) { + return timer; + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f27351898d..f7a57bb2b3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.4.2" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 04056977a4..cf29809caa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 + async: ^2.11.0 openapi: path: openapi