WIP unravel stack index

This commit is contained in:
Marty Fuhry
2024-03-01 14:58:20 -05:00
parent f18872c18c
commit adb541e627
9 changed files with 748 additions and 521 deletions
@@ -97,7 +97,7 @@ class _ChewieControllerHookState
@override @override
void initHook() async { void initHook() async {
super.initHook(); super.initHook();
unawaited(_initialize()); _initialize().whenComplete(() => setState(() {}));
} }
@override @override
@@ -155,7 +155,6 @@ class _ChewieControllerHookState
await videoPlayerController!.initialize(); await videoPlayerController!.initialize();
setState(() {
chewieController = ChewieController( chewieController = ChewieController(
videoPlayerController: videoPlayerController!, videoPlayerController: videoPlayerController!,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
@@ -169,6 +168,5 @@ class _ChewieControllerHookState
placeholder: hook.placeholder, placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer, 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/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.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>> { class AssetStackNotifier extends StateNotifier<List<Asset>> {
final Asset _asset; final Asset _asset;
@@ -49,3 +52,8 @@ final assetStackProvider =
.sortByFileCreatedAtDesc() .sortByFileCreatedAtDesc()
.findAll(); .findAll();
}); });
@riverpod
int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
return -1;
}
@@ -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,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))
: <Asset>[];
final stackElements = showStack ? [asset, ...stack] : <Asset>[];
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<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 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.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<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: [
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);
}
},
),
],
),
),
);
}
}
@@ -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;
}
}
@@ -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/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.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/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/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/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/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/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/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/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.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 isFromDto = currentAsset.id == Isar.autoIncrement;
final album = ref.watch(currentAlbumProvider); final album = ref.watch(currentAlbumProvider);
Asset asset() => stackIndex.value == -1 Asset asset = stackIndex.value == -1
? currentAsset ? currentAsset
: stackElements.elementAt(stackIndex.value); : stackElements.elementAt(stackIndex.value);
final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId; final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
final isPartner = ref final isPartner = ref
.watch(partnerSharedWithProvider) .watch(partnerSharedWithProvider)
.map((e) => e.isarId) .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 // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (_, __) {}); ref.listen(currentAssetProvider, (_, __) {});
@@ -111,11 +109,11 @@ class GalleryViewerPage extends HookConsumerWidget {
() { () {
// Delay state update to after the execution of build method // Delay state update to after the execution of build method
Future.microtask( Future.microtask(
() => ref.read(currentAssetProvider.notifier).set(asset()), () => ref.read(currentAssetProvider.notifier).set(asset),
); );
return null; return null;
}, },
[asset()], [asset],
); );
useEffect( useEffect(
@@ -168,82 +166,8 @@ class GalleryViewerPage extends HookConsumerWidget {
child: ref child: ref
.watch(appSettingsServiceProvider) .watch(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting) .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
? AdvancedBottomSheet(assetDetail: asset()) ? AdvancedBottomSheet(assetDetail: asset)
: ExifBottomSheet(asset: 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();
}
},
); );
}, },
); );
@@ -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() { handleActivities() {
if (album != null && album.shared && album.remoteId != null) { if (album != null && album.shared && album.remoteId != null) {
context.pushRoute(const ActivitiesRoute()); context.pushRoute(const ActivitiesRoute());
@@ -368,22 +235,22 @@ class GalleryViewerPage extends HookConsumerWidget {
isOwner: isOwner, isOwner: isOwner,
isPartner: isPartner, isPartner: isPartner,
isPlayingMotionVideo: isPlayingMotionVideo.value, isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: asset(), asset: asset,
onMoreInfoPressed: showInfo, onMoreInfoPressed: showInfo,
onFavorite: toggleFavorite, onFavorite: toggleFavorite,
onUploadPressed: onUploadPressed:
asset().isLocal ? () => handleUpload(asset()) : null, asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset().isLocal onDownloadPressed: asset.isLocal
? null ? null
: () => : () =>
ref.read(imageViewerStateProvider.notifier).downloadAsset( ref.read(imageViewerStateProvider.notifier).downloadAsset(
asset(), asset,
context, context,
), ),
onToggleMotionVideo: (() { onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onAddToAlbumPressed: () => addToAlbum(asset()), onAddToAlbumPressed: () => addToAlbum(asset),
onActivitiesPressed: handleActivities, onActivitiesPressed: handleActivities,
), ),
), ),
@@ -391,71 +258,44 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} }
Widget buildProgressBar() { // TODO: Migrate to a custom bottom bar and handle long press to delete
final playerValue = ref.watch(videoPlaybackValueProvider); Widget buildBottomBar() {
}
return Expanded( useEffect(
child: Slider( () {
value: playerValue.duration == Duration.zero if (ref.read(showControlsProvider)) {
? 0.0 SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
: min( } else {
playerValue.position.inMicroseconds / SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
playerValue.duration.inMicroseconds * }
100, return null;
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;
}, },
[],
);
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;
},
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() { ref.listen(showControlsProvider, (_, show) {
final duration = ref if (show) {
.watch(videoPlaybackValueProvider.select((value) => value.duration)); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
return Text( SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
_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() { Widget buildStackedChildren() {
return ListView.builder( return ListView.builder(
@@ -495,246 +335,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( return PopScope(
canPop: false, canPop: false,
onPopInvoked: (_) { onPopInvoked: (_) {
@@ -762,7 +362,7 @@ class GalleryViewerPage extends HookConsumerWidget {
), ),
), ),
ImmichThumbnail( ImmichThumbnail(
asset: asset(), asset: asset,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
], ],
@@ -790,7 +390,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}, },
builder: (context, index) { builder: (context, index) {
final a = final a =
index == currentIndex.value ? asset() : loadAsset(index); index == currentIndex.value ? asset : loadAsset(index);
final ImageProvider provider = final ImageProvider provider =
ImmichImage.imageProvider(asset: a); ImmichImage.imageProvider(asset: a);
@@ -870,44 +470,15 @@ class GalleryViewerPage extends HookConsumerWidget {
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 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;
}
} }
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:flutter_hooks/flutter_hooks.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/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/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
@@ -50,13 +51,16 @@ class VideoViewerPage extends HookWidget {
); );
// Loading // Loading
final size = MediaQuery.of(context).size;
print('showControls $showControls and isMotion $isMotionVideo');
return PopScope( return PopScope(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
child: Builder( child: Stack(
builder: (context) { children: [
if (controller == null) { Visibility(
return Stack( visible: controller == null,
child: Stack(
children: [ children: [
if (placeholder != null) placeholder!, if (placeholder != null) placeholder!,
const Positioned.fill( const Positioned.fill(
@@ -67,18 +71,30 @@ class VideoViewerPage extends HookWidget {
), ),
), ),
], ],
); ),
} ),
if (controller != null)
final size = MediaQuery.of(context).size; SizedBox(
return SizedBox(
height: size.height, height: size.height,
width: size.width, width: size.width,
child: Chewie( child: Chewie(
controller: controller, controller: controller,
), ),
); ),
}, if (controller != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: SizedBox(
width: size.width,
child: Visibility(
visible: true,
child: const VideoControls(),
),
),
),
],
), ),
), ),
); );
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'map_state.provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52'; String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754';
/// See also [MapStateNotifier]. /// See also [MapStateNotifier].
@ProviderFor(MapStateNotifier) @ProviderFor(MapStateNotifier)
+2 -2
View File
@@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused, void Function()? onPaused,
Widget? placeholder, Widget? placeholder,
bool showControls = true, bool showControls = true,
Duration hideControlsTimer = const Duration(milliseconds: 1500), Duration hideControlsTimer = const Duration(seconds: 5),
bool showDownloadingIndicator = true, bool showDownloadingIndicator = true,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused, this.onPaused,
this.placeholder, this.placeholder,
this.showControls = true, this.showControls = true,
this.hideControlsTimer = const Duration(milliseconds: 1500), this.hideControlsTimer = const Duration(seconds: 5),
this.showDownloadingIndicator = true, this.showDownloadingIndicator = true,
}); });