Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions mobile/lib/domain/services/asset.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@ class AssetService {

Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return [];
return const [];
}

return _remoteAssetRepository.getStackChildren(asset).then((assets) {
// Include the primary asset in the stack as the first item
return [asset, ...assets];
});
final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}

Future<ExifInfo?> getExif(BaseAsset asset) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}

Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);
final stackId = asset.stackId;
if (stackId == null) {
return Future.value(const []);
}

final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not())
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);

return query.map((row) => row.toDto()).get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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?> {
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
return const [];
Future<List<RemoteAsset>> build(BaseAsset asset) {
if (asset is! RemoteAsset || asset.stackId == null) {
return Future.value(const []);
}

return ref.watch(assetServiceProvider).getStack(asset);
}
}

final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new);
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);
145 changes: 81 additions & 64 deletions mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,33 @@ 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/presentation/widgets/images/thumbnail.widget.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));
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}

if (!showControls) {
opacity = 0;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}

final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;

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(),
),
child: _StackList(stack: stackChildren),
),
);
}
Expand All @@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {

@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)),
],
),
],
),
),
),
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
),
);
},
),
),
);
}
}

class _StackItem extends ConsumerStatefulWidget {
final RemoteAsset asset;
final int index;

const _StackItem({super.key, required this.asset, required this.index});

@override
ConsumerState<_StackItem> createState() => _StackItemState();
}

class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
}

@override
Widget build(BuildContext context) {
const playIcon = Center(
child: 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))],
),
);
const selectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
const unselectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 0.5)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);

Widget thumbnail = Thumbnail.fromAsset(asset: widget.asset, size: const Size(60, 40));
if (widget.asset.isVideo) {
thumbnail = Stack(children: [thumbnail, playIcon]);
}
thumbnail = ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(10)), child: thumbnail);
final isSelected = ref.watch(assetViewerProvider.select((s) => s.stackIndex == widget.index));
return SizedBox(
width: 60,
height: 40,
child: GestureDetector(
onTap: _onTap,
child: DecoratedBox(
decoration: isSelected ? selectedDecoration : unselectedDecoration,
position: DecorationPosition.foreground,
child: thumbnail,
),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,15 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState();

static void setAsset(WidgetRef ref, BaseAsset asset) {
// Always dim the background
ref.read(assetViewerProvider.notifier).setOpacity(255);
ref.read(assetViewerProvider.notifier).reset();
_setAsset(ref, asset);
}

void changeAsset(WidgetRef ref, BaseAsset asset) {
_setAsset(ref, asset);
}

static void _setAsset(WidgetRef ref, BaseAsset asset) {
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
Expand Down Expand Up @@ -109,6 +116,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;

KeepAliveLink? _stackChildrenKeepAlive;

@override
void initState() {
super.initState();
Expand All @@ -119,6 +128,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final asset = ref.read(currentAssetNotifier);
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
}

@override
Expand All @@ -130,6 +143,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close();
super.dispose();
}

Expand Down Expand Up @@ -190,9 +204,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}

AssetViewer.setAsset(ref, asset);
widget.changeAsset(ref, asset);
_precacheAssets(index);
_handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}

void _handleCasting() {
Expand Down Expand Up @@ -520,7 +536,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
}

final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ class AssetViewerState {
stackIndex.hashCode;
}

class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
return const AssetViewerState();
}

void reset() {
state = const AssetViewerState();
}

void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) {
return;
Expand Down Expand Up @@ -117,6 +121,4 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
}

final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
AssetViewerStateNotifier.new,
);
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);