mirror of https://github.com/immich-app/immich.git
feat(mobile): sqlite asset viewer (#19552)
* add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/19644/head^2
parent
ec603a008c
commit
7855974a29
File diff suppressed because one or more lines are too long
@ -0,0 +1,19 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
|
||||
const AssetService({
|
||||
required RemoteAssetRepository remoteAssetRepository,
|
||||
}) : _remoteAssetRepository = remoteAssetRepository;
|
||||
|
||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||
if (asset is LocalAsset || asset is! RemoteAsset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _remoteAssetRepository.getExif(asset.id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,473 @@
|
||||
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/domain/services/timeline.service.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/bottom_sheet.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';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AssetViewerPage extends StatelessWidget {
|
||||
final int initialIndex;
|
||||
final TimelineService timelineService;
|
||||
|
||||
const AssetViewerPage({
|
||||
super.key,
|
||||
required this.initialIndex,
|
||||
required this.timelineService,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This is necessary to ensure that the timeline service is available
|
||||
// since the Timeline and AssetViewer are on different routes / Widget subtrees.
|
||||
return ProviderScope(
|
||||
overrides: [timelineServiceProvider.overrideWithValue(timelineService)],
|
||||
child: AssetViewer(initialIndex: initialIndex),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetViewer extends ConsumerStatefulWidget {
|
||||
final int initialIndex;
|
||||
final Platform? platform;
|
||||
|
||||
const AssetViewer({
|
||||
super.key,
|
||||
required this.initialIndex,
|
||||
this.platform,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _AssetViewerState();
|
||||
}
|
||||
|
||||
const double _kBottomSheetMinimumExtent = 0.4;
|
||||
const double _kBottomSheetSnapExtent = 0.7;
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late PageController pageController;
|
||||
late DraggableScrollableController bottomSheetController;
|
||||
PersistentBottomSheetController? sheetCloseNotifier;
|
||||
// PhotoViewGallery takes care of disposing it's controllers
|
||||
PhotoViewControllerBase? viewController;
|
||||
|
||||
late Platform platform;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
bool? hasDraggedDown;
|
||||
bool isSnapping = false;
|
||||
bool blockGestures = false;
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool showingBottomSheet = false;
|
||||
double? initialScale;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
int backgroundOpacity = 255;
|
||||
|
||||
// Delayed operations that should be cancelled on disposal
|
||||
final List<Timer> _delayedOperations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
pageController = PageController(initialPage: widget.initialIndex);
|
||||
platform = widget.platform ?? const LocalPlatform();
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onAssetChanged(widget.initialIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
bottomSheetController.dispose();
|
||||
_cancelTimers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color get backgroundColor {
|
||||
if (showingBottomSheet && !context.isDarkTheme) {
|
||||
return Colors.white;
|
||||
}
|
||||
return Colors.black.withAlpha(backgroundOpacity);
|
||||
}
|
||||
|
||||
void _cancelTimers() {
|
||||
for (final timer in _delayedOperations) {
|
||||
timer.cancel();
|
||||
}
|
||||
_delayedOperations.clear();
|
||||
}
|
||||
|
||||
// This is used to calculate the scale of the asset when the bottom sheet is showing.
|
||||
// It is a small increment to ensure that the asset is slightly zoomed in when the
|
||||
// bottom sheet is showing, which emulates the zoom effect.
|
||||
double get _getScaleForBottomSheet =>
|
||||
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
|
||||
0.01;
|
||||
|
||||
Future<void> _precacheImage(int index) async {
|
||||
if (!mounted || index < 0 || index >= totalAssets) {
|
||||
return;
|
||||
}
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final screenSize = Size(context.width, context.height);
|
||||
|
||||
// Precache both thumbnail and full image for smooth transitions
|
||||
unawaited(
|
||||
Future.wait([
|
||||
precacheImage(
|
||||
getThumbnailImageProvider(asset: asset, size: screenSize),
|
||||
context,
|
||||
onError: (_, __) {},
|
||||
),
|
||||
precacheImage(
|
||||
getFullImageProvider(asset, size: screenSize),
|
||||
context,
|
||||
onError: (_, __) {},
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) {
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
|
||||
_cancelTimers();
|
||||
// This will trigger the pre-caching of adjacent assets ensuring
|
||||
// that they are ready when the user navigates to them.
|
||||
final timer = Timer(Durations.medium4, () {
|
||||
// Check if widget is still mounted before proceeding
|
||||
if (!mounted) return;
|
||||
|
||||
for (final offset in [-1, 1]) {
|
||||
unawaited(_precacheImage(index + offset));
|
||||
}
|
||||
});
|
||||
_delayedOperations.add(timer);
|
||||
}
|
||||
|
||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||
viewController ??= controller;
|
||||
if (showingBottomSheet) {
|
||||
final verticalOffset = (context.height * bottomSheetController.size) -
|
||||
(context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||
_onAssetChanged(index);
|
||||
viewController = controller;
|
||||
|
||||
// If the bottom sheet is showing, we need to adjust scale the asset to
|
||||
// emulate the zoom effect
|
||||
if (showingBottomSheet) {
|
||||
initialScale = controller?.scale;
|
||||
controller?.scale = _getScaleForBottomSheet;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
_,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerValue value,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
dragDownPosition = details.localPosition;
|
||||
initialPhotoViewState = value;
|
||||
final isZoomed =
|
||||
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleStateController.scaleState == PhotoViewScaleState.covering;
|
||||
if (!showingBottomSheet && isZoomed) {
|
||||
blockGestures = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragEnd(BuildContext ctx, _, __) {
|
||||
dragInProgress = false;
|
||||
|
||||
if (shouldPopOnDrag) {
|
||||
// Dismiss immediately without state updates to avoid rebuilds
|
||||
ctx.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not reset the state if the bottom sheet is showing
|
||||
if (showingBottomSheet) {
|
||||
_snapBottomSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the gestures are blocked, do not reset the state
|
||||
if (blockGestures) {
|
||||
blockGestures = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
backgroundOpacity = 255;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||
if (blockGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragInProgress = true;
|
||||
final delta = details.localPosition - dragDownPosition;
|
||||
hasDraggedDown ??= delta.dy > 0;
|
||||
if (!hasDraggedDown! || showingBottomSheet) {
|
||||
_handleDragUp(ctx, delta);
|
||||
return;
|
||||
}
|
||||
|
||||
_handleDragDown(ctx, delta);
|
||||
}
|
||||
|
||||
void _handleDragUp(BuildContext ctx, Offset delta) {
|
||||
const double openThreshold = 50;
|
||||
const double closeThreshold = 25;
|
||||
|
||||
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
||||
final distanceToOrigin = position.distance;
|
||||
|
||||
if (showingBottomSheet && distanceToOrigin < closeThreshold) {
|
||||
// Prevents the user from dragging the bottom sheet further down
|
||||
blockGestures = true;
|
||||
sheetCloseNotifier?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
viewController?.updateMultiple(position: position);
|
||||
// Moves the bottom sheet when the asset is being dragged up
|
||||
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||
final centre = (ctx.height * _kBottomSheetMinimumExtent);
|
||||
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
|
||||
}
|
||||
|
||||
if (distanceToOrigin > openThreshold && !showingBottomSheet) {
|
||||
_openBottomSheet(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void _openBottomSheet(BuildContext ctx) {
|
||||
setState(() {
|
||||
initialScale = viewController?.scale;
|
||||
viewController?.animateMultiple(scale: _getScaleForBottomSheet);
|
||||
showingBottomSheet = true;
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseNotifier = showBottomSheet(
|
||||
context: ctx,
|
||||
sheetAnimationStyle: AnimationStyle(
|
||||
duration: Duration.zero,
|
||||
reverseDuration: Duration.zero,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
|
||||
),
|
||||
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||
builder: (_) {
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: _onNotification,
|
||||
child: AssetDetailBottomSheet(
|
||||
controller: bottomSheetController,
|
||||
initialChildSize: _kBottomSheetMinimumExtent,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
sheetCloseNotifier?.closed.then((_) => _handleSheetClose());
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSheetClose() {
|
||||
setState(() {
|
||||
showingBottomSheet = false;
|
||||
sheetCloseNotifier = null;
|
||||
viewController?.animateMultiple(
|
||||
position: Offset.zero,
|
||||
scale: initialScale,
|
||||
);
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _snapBottomSheet() {
|
||||
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||
bottomSheetController.size < 0.4) {
|
||||
return;
|
||||
}
|
||||
isSnapping = true;
|
||||
bottomSheetController.animateTo(
|
||||
_kBottomSheetSnapExtent,
|
||||
duration: Durations.short3,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
bool _onNotification(Notification delta) {
|
||||
// Ignore notifications when user dragging the asset
|
||||
if (dragInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delta is DraggableScrollableNotification) {
|
||||
_handleDraggableNotification(delta);
|
||||
}
|
||||
|
||||
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
|
||||
// the isSnapping guard is to prevent the notification from recursively handling the
|
||||
// notification, eventually resulting in a heap overflow
|
||||
if (!isSnapping && delta is ScrollEndNotification) {
|
||||
_snapBottomSheet();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
||||
final verticalOffset = (context.height * delta.extent) -
|
||||
(context.height * _kBottomSheetMinimumExtent);
|
||||
// Moves the asset when the bottom sheet is being dragged
|
||||
if (verticalOffset > 0) {
|
||||
viewController?.position = Offset(0, -verticalOffset);
|
||||
}
|
||||
|
||||
final currentExtent = delta.extent;
|
||||
final isDraggingDown = currentExtent < previousExtent;
|
||||
previousExtent = currentExtent;
|
||||
// Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent
|
||||
if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) {
|
||||
sheetCloseNotifier?.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||
const double dragRatio = 0.2;
|
||||
const double popThreshold = 75;
|
||||
|
||||
final distance = delta.distance;
|
||||
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
if (initialPhotoViewState.scale != null) {
|
||||
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final newBackgroundOpacity =
|
||||
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
|
||||
viewController?.updateMultiple(
|
||||
position: initialPhotoViewState.position + delta,
|
||||
scale: updatedScale,
|
||||
);
|
||||
if (shouldPopOnDrag != newShouldPopOnDrag ||
|
||||
backgroundOpacity != newBackgroundOpacity) {
|
||||
setState(() {
|
||||
shouldPopOnDrag = newShouldPopOnDrag;
|
||||
backgroundOpacity = newBackgroundOpacity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _placeholderBuilder(
|
||||
BuildContext ctx,
|
||||
ImageChunkEvent? progress,
|
||||
int index,
|
||||
) {
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
size: Size(
|
||||
ctx.width,
|
||||
ctx.height,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final size = Size(ctx.width, ctx.height);
|
||||
final imageProvider = getFullImageProvider(asset, size: size);
|
||||
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: imageProvider,
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
initialScale: PhotoViewComputedScale.contained * 0.999,
|
||||
minScale: PhotoViewComputedScale.contained * 0.999,
|
||||
disableScaleGestures: showingBottomSheet,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black.withAlpha(backgroundOpacity),
|
||||
body: PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: platform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||
,
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
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/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class AssetDetailBottomSheet extends BaseBottomSheet {
|
||||
const AssetDetailBottomSheet({
|
||||
super.controller,
|
||||
super.initialChildSize,
|
||||
super.key,
|
||||
}) : super(
|
||||
actions: const [],
|
||||
slivers: const [_AssetDetailBottomSheet()],
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 1.0,
|
||||
expand: false,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
);
|
||||
}
|
||||
|
||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const _AssetDetailBottomSheet();
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
||||
final dateTime = asset.createdAt.toLocal();
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
return '$date$_kSeparator$time';
|
||||
}
|
||||
|
||||
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height ?? exifInfo?.height;
|
||||
final width = asset.width ?? exifInfo?.width;
|
||||
final resolution =
|
||||
(width != null && height != null) ? "$width x $height" : null;
|
||||
final fileSize =
|
||||
exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
||||
|
||||
return switch ((fileSize, resolution)) {
|
||||
(null, null) => '',
|
||||
(String fileSize, null) => fileSize,
|
||||
(null, String resolution) => resolution,
|
||||
(String fileSize, String resolution) =>
|
||||
'$fileSize$_kSeparator$resolution',
|
||||
};
|
||||
}
|
||||
|
||||
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch ((exifInfo.make, exifInfo.model)) {
|
||||
(null, null) => null,
|
||||
(String make, null) => make,
|
||||
(null, String model) => model,
|
||||
(String make, String model) => '$make $model',
|
||||
};
|
||||
}
|
||||
|
||||
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fNumber =
|
||||
exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final exposureTime =
|
||||
exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final focalLength =
|
||||
exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
|
||||
return [fNumber, exposureTime, focalLength, iso]
|
||||
.where((spec) => spec != null && spec.isNotEmpty)
|
||||
.join(_kSeparator);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
_SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
// Details header
|
||||
_SheetTile(
|
||||
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
// File info
|
||||
_SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
leading: Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 30,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.textTheme.labelLarge?.color?.withAlpha(200),
|
||||
),
|
||||
),
|
||||
// Camera info
|
||||
if (cameraTitle != null)
|
||||
_SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
leading: Icon(
|
||||
Icons.camera_outlined,
|
||||
size: 30,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.textTheme.labelLarge?.color?.withAlpha(200),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetTile extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final String? subtitle;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
|
||||
const _SheetTile({
|
||||
required this.title,
|
||||
this.titleStyle,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget titleWidget;
|
||||
if (leading == null) {
|
||||
titleWidget = LimitedBox(
|
||||
maxWidth: double.infinity,
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
} else {
|
||||
titleWidget = Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
final Widget? subtitleWidget;
|
||||
if (leading == null && subtitle != null) {
|
||||
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
||||
} else if (leading != null && subtitle != null) {
|
||||
subtitleWidget = Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(subtitle!, style: subtitleStyle),
|
||||
);
|
||||
} else {
|
||||
subtitleWidget = null;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: titleWidget,
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class FullImage extends StatelessWidget {
|
||||
const FullImage(
|
||||
this.asset, {
|
||||
required this.size,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder = const ThumbnailPlaceholder(),
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final Widget? placeholder;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = getFullImageProvider(asset, size: size);
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
|
||||
image: provider,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
provider.evict();
|
||||
return const Icon(Icons.image_not_supported_outlined, size: 32);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
|
||||
ImageProvider getFullImageProvider(
|
||||
BaseAsset asset, {
|
||||
Size size = const Size(1080, 1920),
|
||||
}) {
|
||||
// Create new provider and cache it
|
||||
final ImageProvider provider;
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size);
|
||||
} else {
|
||||
final String assetId;
|
||||
if (asset is LocalAsset && asset.hasRemote) {
|
||||
assetId = asset.remoteId!;
|
||||
} else if (asset is RemoteAsset) {
|
||||
assetId = asset.id;
|
||||
} else {
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
provider = RemoteFullImageProvider(assetId: assetId);
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
ImageProvider getThumbnailImageProvider({
|
||||
BaseAsset? asset,
|
||||
String? remoteId,
|
||||
Size size = const Size.square(256),
|
||||
}) {
|
||||
assert(
|
||||
asset != null || remoteId != null,
|
||||
'Either asset or remoteId must be provided',
|
||||
);
|
||||
|
||||
if (remoteId != null) {
|
||||
return RemoteThumbProvider(assetId: remoteId);
|
||||
}
|
||||
|
||||
if (_shouldUseLocalAsset(asset!)) {
|
||||
return LocalThumbProvider(asset: asset as LocalAsset, size: size);
|
||||
}
|
||||
|
||||
final String assetId;
|
||||
if (asset is LocalAsset && asset.hasRemote) {
|
||||
assetId = asset.remoteId!;
|
||||
} else if (asset is RemoteAsset) {
|
||||
assetId = asset.id;
|
||||
} else {
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
|
||||
return RemoteThumbProvider(assetId: assetId);
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset is LocalAsset &&
|
||||
(!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
||||
@ -0,0 +1,241 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final AssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
final LocalAsset asset;
|
||||
final Size size;
|
||||
|
||||
const LocalThumbProvider({
|
||||
required this.asset,
|
||||
this.size = const Size.square(kTimelineFixedTileExtent),
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
LocalThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final cacheKey =
|
||||
'${key.asset.id}-${key.asset.updatedAt}-${key.size.width}x${key.size.height}';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer =
|
||||
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes =
|
||||
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${key.asset.name} failed",
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
final AssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final StorageRepository _storageRepository = const StorageRepository();
|
||||
|
||||
final LocalAsset asset;
|
||||
final Size size;
|
||||
|
||||
const LocalFullImageProvider({
|
||||
required this.asset,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalFullImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.name);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<Codec> _codec(
|
||||
LocalFullImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async* {
|
||||
try {
|
||||
switch (key.asset.type) {
|
||||
case AssetType.image:
|
||||
yield* _decodeProgressive(key, decode);
|
||||
break;
|
||||
case AssetType.video:
|
||||
final codec = await _getThumbnailCodec(key, decode);
|
||||
if (codec == null) {
|
||||
throw StateError("Failed to load preview for ${key.asset.name}");
|
||||
}
|
||||
yield codec;
|
||||
break;
|
||||
case AssetType.other:
|
||||
case AssetType.audio:
|
||||
throw StateError('Unsupported asset type ${key.asset.type}');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
Logger('ImmichLocalImageProvider')
|
||||
.severe('Error loading local image ${key.asset.name}', error, stack);
|
||||
throw const ImageLoadingException(
|
||||
'Could not load image from local storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec?> _getThumbnailCodec(
|
||||
LocalFullImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final thumbBytes =
|
||||
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
|
||||
if (thumbBytes == null) {
|
||||
return null;
|
||||
}
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
Stream<Codec> _decodeProgressive(
|
||||
LocalFullImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async* {
|
||||
final file = await _storageRepository.getFileForAsset(key.asset);
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${key.asset.name} failed");
|
||||
}
|
||||
|
||||
final fileSize = await file.length();
|
||||
final devicePixelRatio =
|
||||
PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
|
||||
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
|
||||
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
|
||||
|
||||
if (isProgressive) {
|
||||
try {
|
||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
|
||||
final size = Size(
|
||||
(key.size.width * progressiveMultiplier).clamp(256, 1024),
|
||||
(key.size.height * progressiveMultiplier).clamp(256, 1024),
|
||||
);
|
||||
final mediumThumb =
|
||||
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
|
||||
if (mediumThumb != null) {
|
||||
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
|
||||
yield await decode(mediumBuffer);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Load original only when the file is smaller or if the user wants to load original images
|
||||
// Or load a slightly larger image for progressive loading
|
||||
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
|
||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
|
||||
final size = Size(
|
||||
(key.size.width * progressiveMultiplier).clamp(512, 2048),
|
||||
(key.size.height * progressiveMultiplier).clamp(512, 2048),
|
||||
);
|
||||
final highThumb =
|
||||
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
|
||||
if (highThumb != null) {
|
||||
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
|
||||
yield await decode(highBuffer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.path);
|
||||
yield await decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalFullImageProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt &&
|
||||
size == other.size;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode;
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final AssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
final LocalAsset asset;
|
||||
final double height;
|
||||
final double width;
|
||||
|
||||
LocalThumbProvider({
|
||||
required this.asset,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
LocalThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer =
|
||||
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return await decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
|
||||
key.asset.id,
|
||||
size: Size(key.width, key.height),
|
||||
);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${key.asset.name} failed",
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const RemoteThumbProvider({
|
||||
required this.assetId,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
);
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
||||
final String assetId;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const RemoteFullImageProvider({
|
||||
required this.assetId,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteFullImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
);
|
||||
}
|
||||
|
||||
Stream<Codec> _codec(
|
||||
RemoteFullImageProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async* {
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
getPreviewUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
);
|
||||
|
||||
if (AppSetting.get(Setting.loadOriginal)) {
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
getOriginalUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
);
|
||||
}
|
||||
await chunkController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteFullImageProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final double height;
|
||||
final double width;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const RemoteThumbProvider({
|
||||
required this.assetId,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
);
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
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';
|
||||
|
||||
final currentAssetNotifier =
|
||||
NotifierProvider<CurrentAssetNotifier, BaseAsset>(CurrentAssetNotifier.new);
|
||||
|
||||
class CurrentAssetNotifier extends Notifier<BaseAsset> {
|
||||
@override
|
||||
BaseAsset build() {
|
||||
throw UnimplementedError(
|
||||
'An asset must be set before using the currentAssetProvider.',
|
||||
);
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
state = asset;
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssetExifProvider = FutureProvider(
|
||||
(ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue