From adb541e6270949d20000dadad3f1d46f9bc260ee Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Fri, 1 Mar 2024 14:58:20 -0500 Subject: [PATCH] WIP unravel stack index --- .../hooks/chewiew_controller_hook.dart | 30 +- .../providers/asset_stack.provider.dart | 8 + .../providers/asset_stack.provider.g.dart | 158 +++++ .../asset_viewer/ui/bottom_gallery_bar.dart | 357 ++++++++++++ .../asset_viewer/ui/video_controls.dart | 119 ++++ .../asset_viewer/views/gallery_viewer.dart | 545 ++---------------- .../asset_viewer/views/video_viewer_page.dart | 46 +- .../map/providers/map_state.provider.g.dart | 2 +- mobile/lib/routing/router.gr.dart | 4 +- 9 files changed, 748 insertions(+), 521 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_controls.dart diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart index 8b115eec63..a561d52aeb 100644 --- a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -97,7 +97,7 @@ class _ChewieControllerHookState @override void initHook() async { super.initHook(); - unawaited(_initialize()); + _initialize().whenComplete(() => setState(() {})); } @override @@ -155,20 +155,18 @@ class _ChewieControllerHookState await videoPlayerController!.initialize(); - setState(() { - 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, - ); - }); + chewieController = ChewieController( + videoPlayerController: videoPlayerController!, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); } } diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart index 5c20e1479f..b6928c6ba8 100644 --- a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -2,6 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { final Asset _asset; @@ -49,3 +52,8 @@ final assetStackProvider = .sortByFileCreatedAtDesc() .findAll(); }); + +@riverpod +int assetStackIndex(AssetStackIndexRef ref, Asset asset) { + return -1; +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart new file mode 100644 index 0000000000..142e46d322 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart @@ -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 { + /// 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? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'assetStackIndexProvider'; +} + +/// See also [assetStackIndex]. +class AssetStackIndexProvider extends AutoDisposeProvider { + /// 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 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 { + /// The parameter `asset` of this provider. + Asset get asset; +} + +class _AssetStackIndexProviderElement extends AutoDisposeProviderElement + 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 diff --git a/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart new file mode 100644 index 0000000000..e90062b5fe --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart @@ -0,0 +1,357 @@ +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/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/ui/upload_dialog.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'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; + +class BottomGalleryBar extends ConsumerWidget { + final Asset asset; + final bool showStack; + final int stackIndex; + final PageController controller; + + const BottomGalleryBar({ + super.key, + required this.showStack, + required this.stackIndex, + required this.asset, + required this.controller, + }); + + @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)) + : []; + final stackElements = showStack ? [asset, ...stack] : []; + bool isParent = stackIndex == -1 || stackIndex == 0; + + // !!!! 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); + 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 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 showStackActionItems() { + showModalBottomSheet( + 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.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( + 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.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( + 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(); + } + + 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, + ); + } + + List 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: [ + if (stack.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, + bottom: 30, + ), + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), + BottomNavigationBar( + backgroundColor: Colors.black.withOpacity(0.4), + unselectedIconTheme: const IconThemeData(color: Colors.white), + selectedIconTheme: const IconThemeData(color: Colors.white), + unselectedLabelStyle: const TextStyle(color: Colors.black), + selectedLabelStyle: const TextStyle(color: Colors.black), + showSelectedLabels: false, + showUnselectedLabels: false, + items: itemsList, + onTap: (index) { + if (index < actionslist.length) { + actionslist[index].call(index); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_controls.dart new file mode 100644 index 0000000000..a05be240c5 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_controls.dart @@ -0,0 +1,119 @@ +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 player = ref.watch(videoPlaybackValueProvider); + print('player is $player'); + final duration = player.duration; + final position = player.position; + + return AnimatedOpacity( + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + 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: [ + Text( + _formatDuration(position), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + Expanded( + child: Slider( + value: player.duration == Duration.zero + ? 0.0 + : min( + player.position.inMicroseconds / + player.duration.inMicroseconds * + 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: Colors.white.withOpacity(0.75), + onChanged: (position) { + ref.read(videoPlayerControlsProvider.notifier).position = + position; + }, + ), + ), + Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + IconButton( + icon: Icon( + ref.watch( + videoPlayerControlsProvider.select((value) => value.mute), + ) + ? Icons.volume_off + : Icons.volume_up, + ), + onPressed: () => + ref.read(videoPlayerControlsProvider.notifier).toggleMute(), + color: Colors.white, + ), + ], + ), + ), + ), + ); + } + + String _formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + + return formattedTime; + } +} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index dfdfb32844..95b69aa4c7 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -15,12 +15,11 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote 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/views/video_viewer_page.dart'; @@ -94,16 +93,15 @@ class GalleryViewerPage extends HookConsumerWidget { 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 isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; final isPartner = ref .watch(partnerSharedWithProvider) .map((e) => e.isarId) - .contains(asset().ownerId); + .contains(asset.ownerId); - bool isParent = stackIndex.value == -1 || stackIndex.value == 0; // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page ref.listen(currentAssetProvider, (_, __) {}); @@ -111,11 +109,11 @@ class GalleryViewerPage extends HookConsumerWidget { () { // 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( @@ -168,82 +166,8 @@ class GalleryViewerPage extends HookConsumerWidget { child: ref .watch(appSettingsServiceProvider) .getSetting(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 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(); - } - }, + ? AdvancedBottomSheet(assetDetail: asset) + : ExifBottomSheet(asset: asset), ); }, ); @@ -293,63 +217,6 @@ 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); - } - - 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()); @@ -368,22 +235,22 @@ class GalleryViewerPage extends HookConsumerWidget { isOwner: isOwner, isPartner: isPartner, isPlayingMotionVideo: isPlayingMotionVideo.value, - asset: asset(), + asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onUploadPressed: - asset().isLocal ? () => handleUpload(asset()) : null, - onDownloadPressed: asset().isLocal + asset.isLocal ? () => handleUpload(asset) : null, + onDownloadPressed: asset.isLocal ? null : () => ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset(), + asset, context, ), onToggleMotionVideo: (() { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), - onAddToAlbumPressed: () => addToAlbum(asset()), + onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, ), ), @@ -391,313 +258,8 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - 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, - ); - } - - Widget buildStackedChildren() { - return ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: stackElements.length, - itemBuilder: (context, index) { - final assetId = stackElements.elementAt(index).remoteId; - return Padding( - padding: const EdgeInsets.only(right: 10), - child: GestureDetector( - onTap: () => stackIndex.value = index, - child: Container( - width: 40, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: (stackIndex.value == -1 && index == 0) || - index == stackIndex.value - ? Border.all( - color: Colors.white, - width: 2, - ) - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image( - fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId!), - ), - ), - ), - ), - ); - }, - ); - } - - void showStackActionItems() { - showModalBottomSheet( - 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 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( @@ -735,6 +297,44 @@ class GalleryViewerPage extends HookConsumerWidget { } }); + Widget buildStackedChildren() { + return ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + itemBuilder: (context, index) { + final assetId = stackElements.elementAt(index).remoteId; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: GestureDetector( + onTap: () => stackIndex.value = index, + child: Container( + width: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: (stackIndex.value == -1 && index == 0) || + index == stackIndex.value + ? Border.all( + color: Colors.white, + width: 2, + ) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image( + fit: BoxFit.cover, + image: ImmichRemoteImageProvider(assetId: assetId!), + ), + ), + ), + ), + ); + }, + ); + } + return PopScope( canPop: false, onPopInvoked: (_) { @@ -762,7 +362,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), ImmichThumbnail( - asset: asset(), + asset: asset, fit: BoxFit.contain, ), ], @@ -790,7 +390,7 @@ 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); @@ -870,44 +470,15 @@ class GalleryViewerPage extends HookConsumerWidget { bottom: 0, left: 0, right: 0, - child: buildBottomBar(), + child: Column( + children: [ + buildStackedChildren(), + BottomGalleryBar( + ), ), ], ), ), ); } - - String _formatDuration(Duration position) { - final ms = position.inMilliseconds; - - int seconds = ms ~/ 1000; - final int hours = seconds ~/ 3600; - seconds = seconds % 3600; - final minutes = seconds ~/ 60; - seconds = seconds % 60; - - final hoursString = hours >= 10 - ? '$hours' - : hours == 0 - ? '00' - : '0$hours'; - - final minutesString = minutes >= 10 - ? '$minutes' - : minutes == 0 - ? '00' - : '0$minutes'; - - final secondsString = seconds >= 10 - ? '$seconds' - : seconds == 0 - ? '00' - : '0$seconds'; - - final formattedTime = - '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; - - return formattedTime; - } } diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index b340b0fd5e..0708c5e3b6 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -3,6 +3,7 @@ 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_controls.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; @@ -50,13 +51,16 @@ class VideoViewerPage extends HookWidget { ); // Loading + final size = MediaQuery.of(context).size; + print('showControls $showControls and isMotion $isMotionVideo'); return PopScope( 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 +71,30 @@ 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: Chewie( + controller: controller, + ), + ), + if (controller != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: SizedBox( + width: size.width, + child: Visibility( + visible: true, + child: const VideoControls(), + ), + ), + ), + ], ), ), ); diff --git a/mobile/lib/modules/map/providers/map_state.provider.g.dart b/mobile/lib/modules/map/providers/map_state.provider.g.dart index ca75292e78..d1b3e54b71 100644 --- a/mobile/lib/modules/map/providers/map_state.provider.g.dart +++ b/mobile/lib/modules/map/providers/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52'; +String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 16ac5efb0e..f6968dafe5 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo { void Function()? onPaused, Widget? placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(milliseconds: 1500), + Duration hideControlsTimer = const Duration(seconds: 5), bool showDownloadingIndicator = true, List? children, }) : super( @@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs { this.onPaused, this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(milliseconds: 1500), + this.hideControlsTimer = const Duration(seconds: 5), this.showDownloadingIndicator = true, });