mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/share-downloading
This commit is contained in:
+31
-4
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
@@ -14,13 +21,33 @@ class EditImageActionButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
onPress() {
|
||||
if (currentAsset == null) {
|
||||
Future<void> editImage(List<AssetEdit> edits) async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final image = Image(image: getFullImageProvider(currentAsset));
|
||||
context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false));
|
||||
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
|
||||
}
|
||||
|
||||
Future<void> onPress() async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageProvider = getFullImageProvider(currentAsset, edited: false);
|
||||
|
||||
final image = Image(image: imageProvider);
|
||||
final (edits, exifInfo) = await (
|
||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
|
||||
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
|
||||
).wait;
|
||||
|
||||
if (exifInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
||||
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
|
||||
}
|
||||
|
||||
return BaseActionButton(
|
||||
|
||||
@@ -2,8 +2,10 @@ 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/domain/models/asset/base_asset.model.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/asset_viewer/asset_viewer.provider.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';
|
||||
@@ -23,6 +25,12 @@ class FavoriteActionButton extends ConsumerWidget {
|
||||
final result = await ref.read(actionProvider.notifier).favorite(source);
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
if (result.success) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
final activities = ref.watch(albumActivityProvider((album?.id ?? "", asset?.id)));
|
||||
|
||||
onTap(Activity? liked) async {
|
||||
if (user == null) {
|
||||
@@ -31,12 +31,12 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (liked != null) {
|
||||
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
|
||||
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).removeActivity(liked.id);
|
||||
} else {
|
||||
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
|
||||
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).addLike();
|
||||
}
|
||||
|
||||
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
ref.invalidate(albumActivityProvider((album?.id ?? "", asset?.id)));
|
||||
}
|
||||
|
||||
return activities.when(
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
|
||||
@@ -2,8 +2,10 @@ 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/domain/models/asset/base_asset.model.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/asset_viewer/asset_viewer.provider.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';
|
||||
@@ -23,6 +25,12 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
||||
final result = await ref.read(actionProvider.notifier).unFavorite(source);
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
if (result.success) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.wi
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -833,7 +833,7 @@ class CreateAlbumButton extends ConsumerWidget {
|
||||
// Invalidate using the asset's remote ID to refresh the "Appears in" list
|
||||
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
|
||||
|
||||
context.pop();
|
||||
ContextHelper(context).pop();
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@ import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
@@ -73,7 +73,7 @@ class PeopleDetails extends ConsumerWidget {
|
||||
context.back();
|
||||
return;
|
||||
}
|
||||
context.pop();
|
||||
ContextHelper(context).pop();
|
||||
context.pushRoute(DriftPersonRoute(person: person));
|
||||
},
|
||||
onNameTap: () => showNameEditModal(person),
|
||||
|
||||
@@ -65,13 +65,15 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
|
||||
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).reset();
|
||||
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
|
||||
class ViewerBottomBar extends ConsumerWidget {
|
||||
@@ -30,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
@@ -38,7 +41,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
|
||||
if (isOwner) ...[
|
||||
@@ -71,16 +76,13 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,17 +4,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const ViewerTopAppBar({super.key});
|
||||
@@ -36,7 +36,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||
ref.watch(albumActivityProvider(album.id, asset.id));
|
||||
ref.watch(albumActivityProvider((album.id, asset.id)));
|
||||
}
|
||||
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
@@ -10,20 +10,19 @@ class TrashBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
),
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: const SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,24 +3,21 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show InformationCollector;
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
|
||||
|
||||
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
|
||||
/// which makes resource cleanup possible when no longer needed.
|
||||
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
|
||||
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
// True once any image or the codec has been provided.
|
||||
// Until then the image cache holds one listener, so "last real listener gone"
|
||||
// is _listenerCount == 1, not 0.
|
||||
bool didProvideImage = false;
|
||||
|
||||
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter with CacheAwareListenerTrackerMixin {
|
||||
AnimatedImageStreamCompleter._({
|
||||
required super.codec,
|
||||
required super.scale,
|
||||
required bool hadInitialImage,
|
||||
super.informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) : _onLastListenerRemoved = onLastListenerRemoved;
|
||||
}) {
|
||||
setupListenerTracking(hadInitialImage: hadInitialImage, onLastListenerRemoved: onLastListenerRemoved);
|
||||
}
|
||||
|
||||
factory AnimatedImageStreamCompleter({
|
||||
required Stream<Object> stream,
|
||||
@@ -33,23 +30,21 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
final self = AnimatedImageStreamCompleter._(
|
||||
codec: codecCompleter.future,
|
||||
scale: scale,
|
||||
hadInitialImage: initialImage != null,
|
||||
informationCollector: informationCollector,
|
||||
onLastListenerRemoved: onLastListenerRemoved,
|
||||
);
|
||||
|
||||
if (initialImage != null) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(initialImage);
|
||||
}
|
||||
|
||||
stream.listen(
|
||||
(item) {
|
||||
if (item is ImageInfo) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(item);
|
||||
} else if (item is ui.Codec) {
|
||||
if (!codecCompleter.isCompleted) {
|
||||
self.didProvideImage = true;
|
||||
codecCompleter.complete(item);
|
||||
}
|
||||
}
|
||||
@@ -70,27 +65,4 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
super.addListener(listener);
|
||||
_listenerCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount--;
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
|
||||
|
||||
if (onlyCacheListenerLeft || noListenersAfterCodec) {
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
if (onLastListenerRemoved != null) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// Tracks listeners on an [ImageStreamCompleter] to safely cancel in-flight
|
||||
/// network requests without interfering with [ImageCache] internals.
|
||||
///
|
||||
/// ### Problem
|
||||
/// Cancelling fetches when the listener count drops to 1 (cache only) or 0
|
||||
/// is unsafe due to three framework behaviours:
|
||||
///
|
||||
/// 1. **Memory-pressure eviction** — `ImageCache.clear()` removes the cache
|
||||
/// listener while UI widgets still need the image. A count-based check
|
||||
/// would cancel the active fetch, leaving the UI with no image.
|
||||
/// 2. **Synchronous detach during `putIfAbsent`** — When an `initialImage`
|
||||
/// is provided, the cache attaches, receives the frame, and detaches
|
||||
/// synchronously *before* the UI widget can attach. Count reaches 0 and
|
||||
/// would trigger a false cancel.
|
||||
/// 3. **Listener misidentification** — After the cache detaches (via 1 or 2),
|
||||
/// the next UI listener could be mistaken for the cache listener, causing
|
||||
/// incorrect cancellations when that widget is disposed.
|
||||
///
|
||||
/// ### Solution: First-Listener Heuristic
|
||||
/// The cache is always the first listener attached (via `putIfAbsent`). This
|
||||
/// mixin records that identity once and uses it for all subsequent decisions:
|
||||
///
|
||||
/// * **Identity locking** — The first listener is assumed to be the cache.
|
||||
/// Once identified, `_hasIdentifiedCacheListener` prevents reassignment.
|
||||
/// * **Targeted cancellation** — Cancel only when the identified cache
|
||||
/// listener is the sole remaining listener and no image has been delivered.
|
||||
/// * **Sync-removal bypass** — When `hadInitialImage` is set, the first
|
||||
/// synchronous removal of the cache listener is ignored so the fetch
|
||||
/// survives until the UI attaches.
|
||||
mixin CacheAwareListenerTrackerMixin on ImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
bool _hadInitialImage = false;
|
||||
bool _hasIgnoredFirstSyncRemoval = false;
|
||||
ImageStreamListener? _cacheListener;
|
||||
bool _hasIdentifiedCacheListener = false;
|
||||
|
||||
/// Initializes the tracking state. Must be called in the subclass constructor.
|
||||
void setupListenerTracking({required bool hadInitialImage, void Function()? onLastListenerRemoved}) {
|
||||
_hadInitialImage = hadInitialImage;
|
||||
_onLastListenerRemoved = onLastListenerRemoved;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
if (!_hasIdentifiedCacheListener) {
|
||||
_hasIdentifiedCacheListener = true;
|
||||
_cacheListener = listener;
|
||||
}
|
||||
|
||||
_listenerCount++;
|
||||
super.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount--;
|
||||
|
||||
final bool isCacheListener = listener == _cacheListener;
|
||||
if (isCacheListener) {
|
||||
_cacheListener = null;
|
||||
}
|
||||
|
||||
if (_hadInitialImage && !_hasIgnoredFirstSyncRemoval && isCacheListener) {
|
||||
_hasIgnoredFirstSyncRemoval = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && _cacheListener != null;
|
||||
|
||||
final bool completelyAbandoned = _listenerCount == 0;
|
||||
|
||||
if (onlyCacheListenerLeft || completelyAbandoned) {
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
if (onLastListenerRemoved != null) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
static final _log = Logger('CancellableImageProviderMixin');
|
||||
|
||||
bool isCancelled = false;
|
||||
bool isFinished = false;
|
||||
ImageRequest? request;
|
||||
CancelableOperation<ImageInfo?>? cachedOperation;
|
||||
|
||||
@@ -50,24 +51,26 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
return null;
|
||||
}
|
||||
|
||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* {
|
||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {required bool isFinal}) async* {
|
||||
if (isCancelled) {
|
||||
this.request = null;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final image = await request.load(decode);
|
||||
if ((image == null && evictOnError) || isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
} else if (image == null) {
|
||||
if (isCancelled || image == null) {
|
||||
image?.dispose();
|
||||
return;
|
||||
}
|
||||
isFinished = isFinal;
|
||||
yield image;
|
||||
} catch (e, stack) {
|
||||
if (evictOnError) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (isFinal) {
|
||||
isFinished = true;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
}
|
||||
@@ -77,24 +80,27 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
|
||||
Future<ui.Codec?> loadCodecRequest(ImageRequest request, {required bool isFinal}) async {
|
||||
if (isCancelled) {
|
||||
this.request = null;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final codec = await request.loadCodec();
|
||||
if (codec == null || isCancelled) {
|
||||
if (isCancelled || codec == null) {
|
||||
codec?.dispose();
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return null;
|
||||
}
|
||||
isFinished = isFinal;
|
||||
return codec;
|
||||
} catch (e) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
if (isFinal) {
|
||||
isFinished = true;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
@@ -121,6 +127,8 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
@override
|
||||
void cancel() {
|
||||
isCancelled = true;
|
||||
final hasActiveWork = !isFinished;
|
||||
|
||||
final request = this.request;
|
||||
if (request != null) {
|
||||
this.request = null;
|
||||
@@ -132,10 +140,14 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
cachedOperation = null;
|
||||
operation.cancel();
|
||||
}
|
||||
|
||||
if (hasActiveWork) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
|
||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
|
||||
// Create new provider and cache it
|
||||
final ImageProvider provider;
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
@@ -158,13 +170,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
||||
thumbhash: thumbhash,
|
||||
assetType: asset.type,
|
||||
isAnimated: asset.isAnimatedImage,
|
||||
edited: edited,
|
||||
);
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||
@@ -172,7 +185,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
|
||||
@@ -36,7 +36,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
|
||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
|
||||
return loadRequest(request, decode);
|
||||
return loadRequest(request, decode, isFinal: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -100,37 +100,35 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(request, decode);
|
||||
yield* loadRequest(request, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (!Store.get(StoreKey.loadOriginal, false)) {
|
||||
if (!loadOriginal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
|
||||
|
||||
yield* loadRequest(request, decode);
|
||||
yield* loadRequest(request, decode, isFinal: true);
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,17 +138,17 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
|
||||
final codec = await loadCodecRequest(originalRequest);
|
||||
final codec = await loadCodecRequest(originalRequest, isFinal: true);
|
||||
if (codec == null) {
|
||||
if (isCancelled) return;
|
||||
throw StateError('Failed to load animated codec for local asset ${key.id}');
|
||||
}
|
||||
yield codec;
|
||||
|
||||
@@ -6,14 +6,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
|
||||
|
||||
/// An ImageStreamCompleter with support for loading multiple images.
|
||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
// True once setImage() has been called at least once.
|
||||
bool didProvideImage = false;
|
||||
|
||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
|
||||
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
|
||||
/// should be the primary images to display (typically asynchronously as they load).
|
||||
/// The [initialImage] is an optional image that will be emitted synchronously
|
||||
@@ -24,14 +20,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
InformationCollector? informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) {
|
||||
setupListenerTracking(hadInitialImage: initialImage != null, onLastListenerRemoved: onLastListenerRemoved);
|
||||
|
||||
if (initialImage != null) {
|
||||
didProvideImage = true;
|
||||
setImage(initialImage);
|
||||
}
|
||||
_onLastListenerRemoved = onLastListenerRemoved;
|
||||
|
||||
images.listen(
|
||||
(image) {
|
||||
didProvideImage = true;
|
||||
setImage(image);
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
@@ -45,26 +41,4 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
super.addListener(listener);
|
||||
_listenerCount = _listenerCount + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount = _listenerCount - 1;
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
|
||||
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
|
||||
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ import 'package:openapi/api.dart';
|
||||
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteImageProvider> {
|
||||
final String url;
|
||||
final bool edited;
|
||||
|
||||
RemoteImageProvider({required this.url});
|
||||
RemoteImageProvider({required this.url, this.edited = true});
|
||||
|
||||
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
|
||||
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
|
||||
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true})
|
||||
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
|
||||
|
||||
@override
|
||||
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -38,20 +39,20 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = RemoteImageRequest(uri: key.url);
|
||||
return loadRequest(request, decode);
|
||||
return loadRequest(request, decode, isFinal: true);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteImageProvider) {
|
||||
return url == other.url;
|
||||
return url == other.url && edited == other.edited;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => url.hashCode;
|
||||
int get hashCode => url.hashCode ^ edited.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
@@ -60,12 +61,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
final String thumbhash;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
final bool edited;
|
||||
|
||||
RemoteFullImageProvider({
|
||||
required this.assetId,
|
||||
required this.thumbhash,
|
||||
required this.assetType,
|
||||
required this.isAnimated,
|
||||
this.edited = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -91,7 +94,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
initialImage: getInitialImage(
|
||||
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
|
||||
),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
@@ -105,51 +110,64 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (!loadOriginal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
|
||||
yield* loadRequest(originalRequest, decode);
|
||||
final originalRequest = request = RemoteImageRequest(
|
||||
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
|
||||
);
|
||||
yield* loadRequest(originalRequest, decode, isFinal: true);
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, evictOnError: false);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
|
||||
final codec = await loadCodecRequest(originalRequest);
|
||||
final originalRequest = request = RemoteImageRequest(
|
||||
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
|
||||
);
|
||||
final codec = await loadCodecRequest(originalRequest, isFinal: true);
|
||||
if (codec == null) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
throw StateError('Failed to load animated codec for asset ${key.assetId}');
|
||||
}
|
||||
yield codec;
|
||||
@@ -159,12 +177,15 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteFullImageProvider) {
|
||||
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
|
||||
return assetId == other.assetId &&
|
||||
thumbhash == other.thumbhash &&
|
||||
isAnimated == other.isAnimated &&
|
||||
edited == other.edited;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
|
||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode ^ edited.hashCode;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
|
||||
|
||||
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
|
||||
return loadRequest(request, decode);
|
||||
return loadRequest(request, decode, isFinal: true);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -132,7 +132,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
|
||||
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
|
||||
final currentRoute = ref.read(currentRouteNameProvider);
|
||||
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
|
||||
if (currentRoute == AssetViewerRoute.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user