Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-03-16 13:00:41 +01:00
539 changed files with 24437 additions and 10200 deletions
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return [];
final user = ref.read(currentUserProvider);
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
final latest = ref.read(assetViewerProvider).currentAsset;
if (latest == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -25,6 +26,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
return;
}
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirm =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: count),
) ??
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => TrashDeleteDialog(count: selectCount),
builder: (context) => PermanentDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
onPress() {
if (currentAsset == null) {
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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/user.provider.dart';
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
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));
@@ -8,7 +8,7 @@ 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';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget {
@@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel();
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
import 'package:immich_mobile/providers/app_settings.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/infrastructure/asset_viewer/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';
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
return;
}
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -1,5 +1,6 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
@@ -11,34 +12,36 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetDetails extends ConsumerWidget {
final BaseAsset asset;
final double minHeight;
const AssetDetails({required this.minHeight, super.key});
const AssetDetails({super.key, required this.asset, required this.minHeight});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
return Container(
constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DragHandle(),
const DateTimeDetails(),
const PeopleDetails(),
const LocationDetails(),
const TechnicalDetails(),
const RatingDetails(),
const AppearsInDetails(),
SizedBox(height: context.padding.bottom + 48),
],
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DragHandle(),
DateTimeDetails(asset: asset, exifInfo: exifInfo),
PeopleDetails(asset: asset),
LocationDetails(asset: asset, exifInfo: exifInfo),
TechnicalDetails(asset: asset, exifInfo: exifInfo),
RatingDetails(exifInfo: exifInfo),
AppearsInDetails(asset: asset),
SizedBox(height: context.padding.bottom + 48),
],
),
),
);
}
@@ -8,27 +8,25 @@ 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/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppearsInDetails extends ConsumerWidget {
const AppearsInDetails({super.key});
final BaseAsset asset;
const AppearsInDetails({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
if (!asset.hasRemote) return const SizedBox.shrink();
String? remoteAssetId;
if (asset is RemoteAsset) {
remoteAssetId = asset.id;
} else if (asset is LocalAsset) {
remoteAssetId = asset.remoteAssetId;
}
final remoteAssetId = switch (asset) {
RemoteAsset(:final id) => id,
LocalAsset(:final remoteAssetId) => remoteAssetId,
};
if (remoteAssetId == null) return const SizedBox.shrink();
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
class DateTimeDetails extends ConsumerWidget {
const DateTimeDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final asset = this.asset;
final exifInfo = this.exifInfo;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return Column(
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
@override
Widget build(BuildContext context) {
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final currentDescription = currentExifInfo?.description ?? '';
final currentDescription = widget.exif.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
onTapOutside: (_) => saveDescription(widget.exif.description),
),
),
);
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class LocationDetails extends ConsumerStatefulWidget {
const LocationDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const LocationDetails({super.key, required this.asset, this.exifInfo});
@override
ConsumerState createState() => _LocationDetailsState();
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
_mapController = controller;
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override
void initState() {
super.initState();
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
void didUpdateWidget(LocationDetails oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.exifInfo != oldWidget.exifInfo) {
final exif = widget.exifInfo;
if (exif != null && exif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!)));
}
}
}
void editLocation() async {
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final asset = widget.asset;
final exifInfo = widget.exifInfo;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets
@@ -7,7 +7,6 @@ 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/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.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';
@@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
class PeopleDetails extends ConsumerStatefulWidget {
const PeopleDetails({super.key});
class PeopleDetails extends ConsumerWidget {
final BaseAsset asset;
const PeopleDetails({super.key, required this.asset});
@override
ConsumerState createState() => _PeopleDetailsState();
}
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
@@ -1,16 +1,18 @@
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/exif.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/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
class RatingDetails extends ConsumerWidget {
const RatingDetails({super.key});
final ExifInfo? exifInfo;
const RatingDetails({super.key, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
if (!isRatingEnabled) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
@@ -6,21 +6,20 @@ 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/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = '';
class TechnicalDetails extends ConsumerWidget {
const TechnicalDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final exifInfo = this.exifInfo;
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
@@ -12,17 +12,15 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.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/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.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/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -52,10 +50,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
final _scrollController = SnapScrollController();
double _snapOffset = 0.0;
DragStartDetails? _dragStart;
@@ -65,23 +60,21 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
void initState() {
super.initState();
_proxyScrollController.addListener(_onScroll);
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) {
_proxyScrollController.jumpTo(_snapOffset);
_scrollController.jumpTo(_snapOffset);
}
});
}
@override
void dispose() {
_proxyScrollController.dispose();
_scrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose();
}
@@ -94,20 +87,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
if (!_scrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
bool _willClose(double scrollVelocity) {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
bool _willClose(double scrollVelocity) =>
_scrollController.hasClients &&
_snapOffset > 0 &&
_scrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
SnapScrollPhysics.minSnapDistance;
final position = _proxyScrollController.position;
return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _onScroll() {
final offset = _proxyScrollController.offset;
void _syncShowingDetails() {
final offset = _scrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
@@ -129,8 +122,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _startProxyDrag() {
if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
if (_scrollController.hasClients && _dragStart != null) {
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
}
}
@@ -150,6 +143,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.scroll:
if (_drag == null) _startProxyDrag();
_drag?.update(details);
_syncShowingDetails();
case _DragIntent.dismiss:
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
}
@@ -168,9 +163,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.none:
case _DragIntent.scroll:
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
if (_willClose(scrollVelocity)) {
_viewer.setShowingDetails(false);
}
_viewer.setShowingDetails(!_willClose(scrollVelocity));
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
@@ -249,17 +243,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed =
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.covering ||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
@@ -294,30 +282,26 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_listenForScaleBoundaries(controller);
}
Widget _buildPhotoView(
BaseAsset displayAsset,
BaseAsset asset, {
required bool isCurrentPage,
required bool showingDetails,
Widget _buildPhotoView({
required BaseAsset asset,
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) {
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
final size = context.sizeData;
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
key: ValueKey(displayAsset.heroTag),
key: Key(asset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
tightMode: true,
enablePanAlways: true,
disableScaleGestures: showingDetails,
disableScaleGestures: _showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild,
onDragStart: _onDragStart,
@@ -325,41 +309,41 @@ class _AssetPageState extends ConsumerState<AssetPage> {
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => SizedBox(
width: size.width,
height: size.height,
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
),
);
}
return PhotoView.customChild(
key: ValueKey(displayAsset),
key: Key(asset.heroTag),
childSize: asset.width != null && asset.height != null
? Size(asset.width!.toDouble(), asset.height!.toDouble())
: null,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
scaleStateChangedCallback: _onScaleStateChanged,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
basePosition: Alignment.center,
disableScaleGestures: true,
disableScaleGestures: _showingDetails,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: ValueKey(displayAsset),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
isCurrent: isCurrent,
image: Image(
key: ValueKey(displayAsset.heroTag),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
@@ -386,6 +370,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
displayAsset = stackChildren.elementAt(stackIndex);
}
final isCurrent = currentHeroTag == displayAsset.heroTag;
final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context);
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
@@ -395,43 +381,29 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_snapOffset = detailsOffset - snapTarget;
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_scrollController.hasClients) {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
return ProviderScope(
overrides: [
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
currentAssetExifProvider.overrideWith((ref) {
final a = ref.watch(currentAssetNotifier);
if (a == null) return Future.value(null);
return ref.watch(assetServiceProvider).getExif(a);
}),
],
child: Stack(
children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
return Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
physics: const SnapScrollPhysics(),
child: ColoredBox(
color: _showingDetails ? Colors.black : Colors.transparent,
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
displayAsset,
asset,
isCurrentPage: currentHeroTag == asset.heroTag,
showingDetails: _showingDetails,
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
),
),
if (showingOcr && !_isZoomed && displayAsset.width != null && displayAsset.height != null)
@@ -455,7 +427,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(minHeight: viewportHeight - snapTarget),
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
),
),
],
@@ -464,8 +436,37 @@ class _AssetPageState extends ConsumerState<AssetPage> {
],
),
),
],
),
),
if (stackChildren != null && stackChildren.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: context.padding.bottom,
child: AssetStackRow(stack: stackChildren),
),
],
);
}
}
// A global key is used for video viewers to prevent them from being
// unnecessarily recreated. They're quite expensive, and maintain internal
// state. This can cause videos to restart multiple times during normal usage,
// like a hero animation.
//
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
// rather than equal. Hero tags are created with string interpolation, which
// prevents Dart from interning them. As such, hero tags are not identical, even
// if they are equal.
class _NativeVideoViewerKey extends GlobalKey {
final String value;
const _NativeVideoViewerKey(this.value) : super.constructor();
@override
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
@override
int get hashCode => value.hashCode;
}
@@ -1,53 +1,42 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showingDetails) {
return const SizedBox.shrink();
}
return _StackList(stack: stackChildren);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack;
const _StackList({required this.stack});
const AssetStackRow({super.key, required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
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);
}),
if (stack.isEmpty) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: 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);
}),
),
),
),
),
),
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
final notifier = ref.read(assetViewerProvider.notifier);
notifier.setAsset(widget.asset);
notifier.setStackIndex(widget.index);
}
@override
@@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget {
}
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
// which could be stack children as well
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
// Hide controls by default for videos
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
}
@@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
}
@@ -110,13 +103,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void initState() {
super.initState();
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
_reloadSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
final assetViewer = ref.read(assetViewerProvider);
_setSystemUIMode(assetViewer.showingControls, assetViewer.showingDetails);
}
@override
@@ -131,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose();
}
// The normal onPageChange callback listens to OnScrollUpdate events, and will
// round the current page and update whenever that value changes. In practise,
// this means that the page will change when swiped half way, and may flip
// whilst dragging.
//
// Changing the page at the end of a scroll should be more robust, and allow
// the page to be dragged more than half way whilst keeping the current video
// playing, and preventing the video on the next page from becoming ready
// unnecessarily.
bool _onScrollEnd(ScrollEndNotification notification) {
if (notification.depth != 0) return false;
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
}
void _onAssetInit(Duration timeStamp) {
_preloader.preload(widget.initialIndex, context.sizeData);
_handleCasting();
@@ -150,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleCasting() {
if (!ref.read(castProvider).isCasting) return;
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return;
if (asset is RemoteAsset) {
@@ -192,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index);
}
@@ -218,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
@@ -226,6 +244,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
@override
Widget build(BuildContext context) {
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -245,31 +270,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
final (controls, details) = state;
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
_setSystemUIMode(controls, details);
});
return PopScope(
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
child: Scaffold(
backgroundColor: backgroundColor,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
PhotoViewGestureDetectorScope(
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
@@ -279,21 +302,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
onPageChanged: (index) => _onAssetChanged(index),
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
],
),
),
],
),
);
}
@@ -1,138 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState {
final double backgroundOpacity;
final bool showingDetails;
final bool showingControls;
final bool isZoomed;
final bool showingOcr;
final BaseAsset? currentAsset;
final int stackIndex;
const AssetViewerState({
this.backgroundOpacity = 1.0,
this.showingDetails = false,
this.showingControls = true,
this.isZoomed = false,
this.showingOcr = false,
this.currentAsset,
this.stackIndex = 0,
});
AssetViewerState copyWith({
double? backgroundOpacity,
bool? showingDetails,
bool? showingControls,
bool? isZoomed,
bool? showingOcr,
BaseAsset? currentAsset,
int? stackIndex,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingDetails: showingDetails ?? this.showingDetails,
showingControls: showingControls ?? this.showingControls,
isZoomed: isZoomed ?? this.isZoomed,
showingOcr: showingOcr ?? this.showingOcr,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
}
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingDetails == showingDetails &&
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.showingOcr == showingOcr &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingDetails.hashCode ^
showingControls.hashCode ^
isZoomed.hashCode ^
showingOcr.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
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;
}
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(double opacity) {
if (opacity == state.backgroundOpacity) {
return;
}
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
}
void setShowingDetails(bool showing) {
if (showing == state.showingDetails) {
return;
}
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
void setControls(bool isShowing) {
if (isShowing == state.showingControls) {
return;
}
state = state.copyWith(showingControls: isShowing);
}
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
void setZoomed(bool isZoomed) {
if (isZoomed == state.isZoomed) {
return;
}
state = state.copyWith(isZoomed: isZoomed);
}
void setStackIndex(int index) {
if (index == state.stackIndex) {
return;
}
state = state.copyWith(stackIndex: index);
}
void toggleOcr() {
state = state.copyWith(showingOcr: !state.showingOcr);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
@@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.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/user.provider.dart';
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -62,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
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),
],
),
),
),
),
),
@@ -1,471 +1,235 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.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/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
if (asset is RemoteAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
LocalAsset localAsset => localAsset.remoteId == asset.id,
_ => false,
};
} else if (asset is LocalAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
LocalAsset localAsset => localAsset.id == asset.id,
_ => false,
};
}
return false;
}
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final bool isCurrent;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.isCurrent = false,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
}
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true);
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
static final _log = Logger('NativeVideoViewer');
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetNotifier));
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
NativeVideoPlayerController? _controller;
late final Future<VideoSource?> _videoSource;
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_videoSource = _createSource();
}
@override
void didUpdateWidget(NativeVideoViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
return;
}
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_loadTimer?.cancel();
_removeListeners();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
switch (state) {
case AppLifecycleState.resumed:
if (_shouldPlayOnForeground) await _notifier.play();
case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground) await _notifier.pause();
default:
}
}
Future<VideoSource?> _createSource() async {
if (!mounted) return null;
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
if (!mounted) return null;
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!mounted) return null;
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) return;
_notifier.onNativePlaybackReady();
// onPlaybackReady may be called multiple times, usually when more data
// loads. If this is not the first time that the player has become ready, we
// should not autoplay.
if (_isVideoReady) return;
setState(() => _isVideoReady = true);
if (ref.read(assetViewerProvider).showingDetails) return;
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) await _notifier.play();
}
void _onPlaybackEnded() {
if (!mounted) return;
_notifier.onNativePlaybackEnded();
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void _onPlaybackPositionChanged() {
if (!mounted) return;
_notifier.onNativePositionChanged();
}
void _onPlaybackStatusChanged() {
if (!mounted) return;
_notifier.onNativeStatusChanged();
}
void _removeListeners() {
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
}
void _loadVideo() async {
final nc = _controller;
if (nc == null || nc.videoSource != null || !mounted) return;
final source = await _videoSource;
if (source == null || !mounted) return;
await _notifier.load(source);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
void _initController(NativeVideoPlayerController nc) {
if (_controller != null || !mounted) return;
_notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(_onPlaybackReady);
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
_controller = nc;
if (widget.isCurrent) _loadVideo();
}
@override
Widget build(BuildContext context) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
if (!context.mounted) {
return null;
}
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!context.mounted) {
return null;
}
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
return source;
}
final remoteId = (videoAsset as RemoteAsset).id;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
}
}, [asset.heroTag]);
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
return;
}
try {
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) {
await videoController.play();
}
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null || !context.mounted) {
return;
}
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
unawaited(
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
Size? videoContextSize;
if (videoAspectRatio == null || context == null) {
return null;
}
final contextAspectRatio = context.width / context.height;
if (videoAspectRatio > contextAspectRatio) {
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
} else {
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
}
return videoContextSize;
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
if (value != null) {
isVisible.value = _isCurrentAsset(value, asset);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await controller.value?.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return SizedBox(
width: context.width,
height: context.height,
return IgnorePointer(
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
],
),
);
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
}
@@ -1,119 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showingDetails) {
showControls = false;
}
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
// Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
showControlsAndStartHideTimer();
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
if (cast.castState == CastState.playing) {
ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) {
ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) {
// resend the play command since its finished
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
return;
}
// ref.read(castProvider.notifier).loadMedia(asset, true);
}
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
}
}
void toggleControlsVisibility() {
if (showBuffering) {
return;
}
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),
),
);
}
}
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
class ViewerBottomAppBar extends ConsumerWidget {
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0.0;
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
);
}
}
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -9,10 +9,9 @@ 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/presentation/widgets/asset_viewer/asset_viewer.state.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/infrastructure/asset_viewer/asset.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';
@@ -23,7 +22,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -37,16 +36,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final hasOcr = asset is RemoteAsset && ref.watch(driftOcrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id));
}
if (!showControls) {
opacity = 0.0;
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
@@ -88,17 +84,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: AppBar(
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: showingDetails
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: AppBar(
backgroundColor: Colors.transparent,
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
),
),
);
@@ -114,17 +122,14 @@ class _AppBarBackButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
shape: const CircleBorder(),
iconSize: 22,
iconColor: foregroundColor,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),
@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -119,10 +119,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged)
const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [
@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.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;
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
required double scale,
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
final codecCompleter = Completer<ui.Codec>();
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
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);
}
}
},
onError: (Object error, StackTrace stack) {
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(error, stack);
}
},
onDone: () {
// also complete if we are done but no error occurred, and we didn't call complete yet
// could happen on cancellation
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
}
},
);
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();
}
}
}
}
@@ -1,3 +1,5 @@
import 'dart:ui' as ui;
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -75,6 +77,29 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return null;
}
try {
final codec = await request.loadCodec();
if (codec == null || isCancelled) {
codec?.dispose();
PaintingBinding.instance.imageCache.evict(this);
return null;
}
return codec;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
} finally {
this.request = null;
}
}
Stream<ImageInfo> initialImageStream() async* {
final cachedOperation = this.cachedOperation;
if (cachedOperation == null) {
@@ -115,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else {
final String assetId;
final String thumbhash;
@@ -128,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
provider = RemoteFullImageProvider(
assetId: assetId,
thumbhash: thumbhash,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
);
}
return provider;
@@ -1,11 +1,10 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final String id;
final Size size;
final AssetType assetType;
final bool isAnimated;
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
@@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
@@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* loadRequest(request, decode);
}
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode);
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);
if (codec == null) {
throw StateError('Failed to load animated codec for local asset ${key.id}');
}
yield codec;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size;
return id == other.id && size == other.size && isAnimated == other.isAnimated;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode;
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
}
@@ -4,9 +4,9 @@ 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/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -37,7 +37,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
}
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders());
final request = this.request = RemoteImageRequest(uri: key.url);
return loadRequest(request, decode);
}
@@ -59,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String assetId;
final String thumbhash;
final AssetType assetType;
final bool isAnimated;
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
required this.isAnimated,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -69,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
@@ -88,10 +109,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
);
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
@@ -105,20 +124,47 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode);
}
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),
);
yield* loadRequest(previewRequest, decode, evictOnError: 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);
if (codec == null) {
throw StateError('Failed to load animated codec for asset ${key.assetId}');
}
yield codec;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
}
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget {
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
),
if (asset.isAnimatedImage)
const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)),
],
);
}
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
final RemoteAsset asset;
final String title;
final bool showTitle;
final bool isCurrent;
final Function()? onVideoEnded;
const DriftMemoryCard({
required this.asset,
required this.title,
required this.showTitle,
this.isCurrent = false,
this.onVideoEnded,
super.key,
});
@@ -37,33 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
LayoutBuilder(
builder: (context, constraints) {
final r = asset.width != null && asset.height != null
? asset.width! / asset.height!
: constraints.maxWidth / constraints.maxHeight;
// Determine the fit using the aspect ratio
BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) {
final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
// Cover to look nice if we have nearly the same aspect ratio
fit = BoxFit.cover;
}
}
if (asset.isImage) {
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
} else {
return SizedBox(
width: context.width,
height: context.height,
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
return Center(
child: AspectRatio(
aspectRatio: r,
child: NativeVideoViewer(
key: ValueKey(asset.id),
asset: asset,
isCurrent: isCurrent,
showControls: false,
playbackDelayFactor: 2,
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
),
);
}
),
);
},
),
if (showTitle)
@@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
@@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget {
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(48.0),
topLeft: Radius.circular(kScrubberThumbHeight),
bottomLeft: Radius.circular(kScrubberThumbHeight),
topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0),
),
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
child: Container(
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
),
),
);
}
@@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -34,6 +35,7 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -41,13 +43,14 @@ class Timeline extends StatelessWidget {
this.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
@@ -55,9 +58,9 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy;
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
final Widget? loadingWidget;
@override
Widget build(BuildContext context) {
@@ -82,13 +85,14 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
),
@@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth;
final Widget? loadingWidget;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreAssetPosition,
);
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
@@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
},
child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
@@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final grid = CustomScrollView(
primary: true,
@@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
@@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,