You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	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 234e6741de.
* 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>
			
			
This commit is contained in:
		| @@ -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, | ||||
|     ); | ||||
|   } | ||||
|   */ | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
							
								
								
									
										158
									
								
								mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'asset_stack.provider.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$assetStackIndexHash() => r'0f2df55e929767c8c698bd432b5e6e351d000a16'; | ||||
| 
 | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   _SystemHash._(); | ||||
| 
 | ||||
|   static int combine(int hash, int value) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + value); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||
|     return hash ^ (hash >> 6); | ||||
|   } | ||||
| 
 | ||||
|   static int finish(int hash) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = hash ^ (hash >> 11); | ||||
|     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// See also [assetStackIndex]. | ||||
| @ProviderFor(assetStackIndex) | ||||
| const assetStackIndexProvider = AssetStackIndexFamily(); | ||||
| 
 | ||||
| /// See also [assetStackIndex]. | ||||
| class AssetStackIndexFamily extends Family<int> { | ||||
|   /// See also [assetStackIndex]. | ||||
|   const AssetStackIndexFamily(); | ||||
| 
 | ||||
|   /// See also [assetStackIndex]. | ||||
|   AssetStackIndexProvider call( | ||||
|     Asset asset, | ||||
|   ) { | ||||
|     return AssetStackIndexProvider( | ||||
|       asset, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   AssetStackIndexProvider getProviderOverride( | ||||
|     covariant AssetStackIndexProvider provider, | ||||
|   ) { | ||||
|     return call( | ||||
|       provider.asset, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||
| 
 | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||
| 
 | ||||
|   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||
| 
 | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||
|       _allTransitiveDependencies; | ||||
| 
 | ||||
|   @override | ||||
|   String? get name => r'assetStackIndexProvider'; | ||||
| } | ||||
| 
 | ||||
| /// See also [assetStackIndex]. | ||||
| class AssetStackIndexProvider extends AutoDisposeProvider<int> { | ||||
|   /// See also [assetStackIndex]. | ||||
|   AssetStackIndexProvider( | ||||
|     Asset asset, | ||||
|   ) : this._internal( | ||||
|           (ref) => assetStackIndex( | ||||
|             ref as AssetStackIndexRef, | ||||
|             asset, | ||||
|           ), | ||||
|           from: assetStackIndexProvider, | ||||
|           name: r'assetStackIndexProvider', | ||||
|           debugGetCreateSourceHash: | ||||
|               const bool.fromEnvironment('dart.vm.product') | ||||
|                   ? null | ||||
|                   : _$assetStackIndexHash, | ||||
|           dependencies: AssetStackIndexFamily._dependencies, | ||||
|           allTransitiveDependencies: | ||||
|               AssetStackIndexFamily._allTransitiveDependencies, | ||||
|           asset: asset, | ||||
|         ); | ||||
| 
 | ||||
|   AssetStackIndexProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.asset, | ||||
|   }) : super.internal(); | ||||
| 
 | ||||
|   final Asset asset; | ||||
| 
 | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     int Function(AssetStackIndexRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: AssetStackIndexProvider._internal( | ||||
|         (ref) => create(ref as AssetStackIndexRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         asset: asset, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   AutoDisposeProviderElement<int> createElement() { | ||||
|     return _AssetStackIndexProviderElement(this); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is AssetStackIndexProvider && other.asset == asset; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, asset.hashCode); | ||||
| 
 | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| mixin AssetStackIndexRef on AutoDisposeProviderRef<int> { | ||||
|   /// The parameter `asset` of this provider. | ||||
|   Asset get asset; | ||||
| } | ||||
| 
 | ||||
| class _AssetStackIndexProviderElement extends AutoDisposeProviderElement<int> | ||||
|     with AssetStackIndexRef { | ||||
|   _AssetStackIndexProviderElement(super.provider); | ||||
| 
 | ||||
|   @override | ||||
|   Asset get asset => (origin as AssetStackIndexProvider).asset; | ||||
| } | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member | ||||
| @@ -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; | ||||
| } | ||||
							
								
								
									
										164
									
								
								mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'video_player_controller_provider.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$videoPlayerControllerHash() => | ||||
|     r'72b45de66542021717807655e25ec92d78d80eec'; | ||||
| 
 | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   _SystemHash._(); | ||||
| 
 | ||||
|   static int combine(int hash, int value) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + value); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||
|     return hash ^ (hash >> 6); | ||||
|   } | ||||
| 
 | ||||
|   static int finish(int hash) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = hash ^ (hash >> 11); | ||||
|     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// See also [videoPlayerController]. | ||||
| @ProviderFor(videoPlayerController) | ||||
| const videoPlayerControllerProvider = VideoPlayerControllerFamily(); | ||||
| 
 | ||||
| /// See also [videoPlayerController]. | ||||
| class VideoPlayerControllerFamily | ||||
|     extends Family<AsyncValue<VideoPlayerController>> { | ||||
|   /// See also [videoPlayerController]. | ||||
|   const VideoPlayerControllerFamily(); | ||||
| 
 | ||||
|   /// See also [videoPlayerController]. | ||||
|   VideoPlayerControllerProvider call({ | ||||
|     required Asset asset, | ||||
|   }) { | ||||
|     return VideoPlayerControllerProvider( | ||||
|       asset: asset, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   VideoPlayerControllerProvider getProviderOverride( | ||||
|     covariant VideoPlayerControllerProvider provider, | ||||
|   ) { | ||||
|     return call( | ||||
|       asset: provider.asset, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||
| 
 | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||
| 
 | ||||
|   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||
| 
 | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||
|       _allTransitiveDependencies; | ||||
| 
 | ||||
|   @override | ||||
|   String? get name => r'videoPlayerControllerProvider'; | ||||
| } | ||||
| 
 | ||||
| /// See also [videoPlayerController]. | ||||
| class VideoPlayerControllerProvider | ||||
|     extends AutoDisposeFutureProvider<VideoPlayerController> { | ||||
|   /// See also [videoPlayerController]. | ||||
|   VideoPlayerControllerProvider({ | ||||
|     required Asset asset, | ||||
|   }) : this._internal( | ||||
|           (ref) => videoPlayerController( | ||||
|             ref as VideoPlayerControllerRef, | ||||
|             asset: asset, | ||||
|           ), | ||||
|           from: videoPlayerControllerProvider, | ||||
|           name: r'videoPlayerControllerProvider', | ||||
|           debugGetCreateSourceHash: | ||||
|               const bool.fromEnvironment('dart.vm.product') | ||||
|                   ? null | ||||
|                   : _$videoPlayerControllerHash, | ||||
|           dependencies: VideoPlayerControllerFamily._dependencies, | ||||
|           allTransitiveDependencies: | ||||
|               VideoPlayerControllerFamily._allTransitiveDependencies, | ||||
|           asset: asset, | ||||
|         ); | ||||
| 
 | ||||
|   VideoPlayerControllerProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.asset, | ||||
|   }) : super.internal(); | ||||
| 
 | ||||
|   final Asset asset; | ||||
| 
 | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider) | ||||
|         create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: VideoPlayerControllerProvider._internal( | ||||
|         (ref) => create(ref as VideoPlayerControllerRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         asset: asset, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<VideoPlayerController> createElement() { | ||||
|     return _VideoPlayerControllerProviderElement(this); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is VideoPlayerControllerProvider && other.asset == asset; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, asset.hashCode); | ||||
| 
 | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| mixin VideoPlayerControllerRef | ||||
|     on AutoDisposeFutureProviderRef<VideoPlayerController> { | ||||
|   /// The parameter `asset` of this provider. | ||||
|   Asset get asset; | ||||
| } | ||||
| 
 | ||||
| class _VideoPlayerControllerProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<VideoPlayerController> | ||||
|     with VideoPlayerControllerRef { | ||||
|   _VideoPlayerControllerProviderElement(super.provider); | ||||
| 
 | ||||
|   @override | ||||
|   Asset get asset => (origin as VideoPlayerControllerProvider).asset; | ||||
| } | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member | ||||
| @@ -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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										345
									
								
								mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
|                 } | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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, | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										110
									
								
								mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										125
									
								
								mobile/lib/modules/asset_viewer/ui/video_controls.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								mobile/lib/modules/asset_viewer/ui/video_controls.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										45
									
								
								mobile/lib/modules/asset_viewer/ui/video_player.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								mobile/lib/modules/asset_viewer/ui/video_player.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52'; | ||||
| String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754'; | ||||
| 
 | ||||
| /// See also [MapStateNotifier]. | ||||
| @ProviderFor(MapStateNotifier) | ||||
|   | ||||
| @@ -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, | ||||
|                   ), | ||||
|                 ); | ||||
|   | ||||
| @@ -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}'; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										48
									
								
								mobile/lib/shared/ui/hooks/timer_hook.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								mobile/lib/shared/ui/hooks/timer_hook.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
|   } | ||||
| } | ||||
| @@ -50,7 +50,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "2.4.2" | ||||
|   async: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: async | ||||
|       sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user