feat: show stacks in asset viewer (#19935)

* feat: show stacks in asset viewer

* fix: global key issue and flash on stack asset change

* feat(mobile): stack and unstack action (#19941)

* feat(mobile): stack and unstack action

* add custom model

* use stackId from ActionSource

* Update mobile/lib/providers/infrastructure/action.provider.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: lint

* fix: bad merge

* fix: test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Daimolean <92239625+wuzihao051119@users.noreply.github.com>
Co-authored-by: wuzihao051119 <wuzihao051119@outlook.com>
This commit is contained in:
shenlong 2025-07-18 10:01:04 +05:30 committed by GitHub
parent 546f841b2c
commit f32cd74232
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1568 additions and 802 deletions

View file

@ -1,16 +1,56 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class StackActionButton extends ConsumerWidget {
const StackActionButton({super.key});
final ActionSource source;
const StackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access stack action');
}
final result =
await ref.read(actionProvider.notifier).stack(user.id, source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'stack_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget {
final ActionSource source;
const UnStackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unStack(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unstack_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "unstack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -0,0 +1,24 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class StackChildrenNotifier
extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null ||
asset is! RemoteAsset ||
asset.stackId == null ||
// The stackCount check is to ensure we only fetch stacks for timelines that have stacks
asset.stackCount == 0) {
return const [];
}
return ref.watch(assetServiceProvider).getStack(asset);
}
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(
StackChildrenNotifier.new,
);

View file

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
int opacity = ref.watch(
assetViewerProvider.select((state) => state.backgroundOpacity),
);
final showControls =
ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
}
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: ref.watch(stackChildrenNotifier(asset)).when(
data: (state) => SizedBox.square(
dimension: 80,
child: _StackList(stack: state),
),
error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack;
const _StackList({required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(
left: 5,
right: 5,
bottom: 30,
),
itemCount: stack.length,
itemBuilder: (ctx, index) {
final asset = stack[index];
return Padding(
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
ref.read(assetViewerProvider.notifier).setStackIndex(index);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
},
child: Container(
height: 60,
width: 60,
decoration: index ==
ref.watch(assetViewerProvider.select((s) => s.stackIndex))
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(
BorderSide(color: Colors.white, width: 2),
),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Stack(
fit: StackFit.expand,
children: [
Image(
fit: BoxFit.cover,
image: getThumbnailImageProvider(
remoteId: asset.id,
size: const Size.square(60),
),
),
if (asset.isVideo)
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
shadows: [
Shadow(
blurRadius: 5.0,
color: Color.fromRGBO(0, 0, 0, 0.6),
offset: Offset(0.0, 0.0),
),
],
),
],
),
),
),
),
);
},
);
}
}

View file

@ -5,10 +5,13 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
@ -85,6 +88,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
int stackIndex = 0;
BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {};
@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
// which could be stack children as well
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
@ -488,7 +496,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageChunkEvent? progress,
int index,
) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren
.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Container(
width: double.infinity,
height: double.infinity,
@ -516,9 +529,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
final asset = ref.read(timelineServiceProvider).getAsset(index);
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren
.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset);
}
@ -604,6 +622,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider
@ -645,7 +664,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
bottomNavigationBar: const ViewerBottomBar(),
bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink()
: const Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AssetStackRow(),
ViewerBottomBar(),
],
),
),
);
}

View file

@ -1,26 +1,40 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class AssetViewerState {
final int backgroundOpacity;
final bool showingBottomSheet;
final bool showingControls;
final BaseAsset? currentAsset;
final int stackIndex;
const AssetViewerState({
this.backgroundOpacity = 255,
this.showingBottomSheet = false,
this.showingControls = true,
this.currentAsset,
this.stackIndex = 0,
});
AssetViewerState copyWith({
int? backgroundOpacity,
bool? showingBottomSheet,
bool? showingControls,
BaseAsset? currentAsset,
int? stackIndex,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
showingControls: showingControls ?? this.showingControls,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
}
@ -36,14 +50,18 @@ class AssetViewerState {
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet &&
other.showingControls == showingControls;
other.showingControls == showingControls &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^
showingControls.hashCode;
showingControls.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
return const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(int opacity) {
state = state.copyWith(
backgroundOpacity: opacity,
@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
void setStackIndex(int index) {
state = state.copyWith(stackIndex: index);
}
}
final assetViewerProvider =

View file

@ -49,7 +49,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton(
source: ActionSource.timeline,
),
const StackActionButton(),
const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View file

@ -49,7 +49,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton(
source: ActionSource.timeline,
),
const StackActionButton(),
const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View file

@ -49,7 +49,7 @@ class GeneralBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton(
source: ActionSource.timeline,
),
const StackActionButton(),
const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View file

@ -52,7 +52,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton(
source: ActionSource.timeline,
),
const StackActionButton(),
const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View file

@ -53,6 +53,9 @@ class ThumbnailTile extends ConsumerWidget {
)
: const BoxDecoration();
final hasStack =
asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0;
return Stack(
children: [
AnimatedContainer(
@ -75,6 +78,19 @@ class ThumbnailTile extends ConsumerWidget {
),
),
),
if (hasStack)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(
right: 10.0,
top: asset.isVideo ? 24.0 : 6.0,
),
child: _StackIndicator(
stackCount: (asset as RemoteAsset).stackCount,
),
),
),
if (asset.isVideo)
Align(
alignment: Alignment.topRight,
@ -182,6 +198,40 @@ class _SelectionIndicator extends StatelessWidget {
}
}
class _StackIndicator extends StatelessWidget {
final int stackCount;
const _StackIndicator({required this.stackCount});
@override
Widget build(BuildContext context) {
return Row(
spacing: 3,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
// CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stackCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
blurRadius: 5.0,
color: Color.fromRGBO(0, 0, 0, 0.6),
),
],
),
),
const _TileOverlayIcon(Icons.burst_mode_rounded),
],
);
}
}
class _VideoIndicator extends StatelessWidget {
final Duration duration;
const _VideoIndicator(this.duration);
@ -192,8 +242,8 @@ class _VideoIndicator extends StatelessWidget {
spacing: 3,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.end,
// CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
duration.format(),

View file

@ -15,6 +15,7 @@ class TimelineArgs {
final double spacing;
final int columnCount;
final bool showStorageIndicator;
final bool withStack;
final GroupAssetsBy? groupBy;
const TimelineArgs({
@ -23,6 +24,7 @@ class TimelineArgs {
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
this.showStorageIndicator = false,
this.withStack = false,
this.groupBy,
});
@ -33,6 +35,7 @@ class TimelineArgs {
maxHeight == other.maxHeight &&
columnCount == other.columnCount &&
showStorageIndicator == other.showStorageIndicator &&
withStack == other.withStack &&
groupBy == other.groupBy;
}
@ -43,6 +46,7 @@ class TimelineArgs {
spacing.hashCode ^
columnCount.hashCode ^
showStorageIndicator.hashCode ^
withStack.hashCode ^
groupBy.hashCode;
}

View file

@ -28,6 +28,7 @@ class Timeline extends StatelessWidget {
this.topSliverWidget,
this.topSliverWidgetHeight,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(
floating: true,
pinned: false,
@ -42,6 +43,7 @@ class Timeline extends StatelessWidget {
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
final bool withStack;
final GroupAssetsBy? groupBy;
@override
@ -58,6 +60,7 @@ class Timeline extends StatelessWidget {
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
),