Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions
9242afb4b0 chore: version v1.137.2 2025-08-01 02:45:16 +00:00
Alex
c5f14adff0 feat: drag to select beta timeline (#20456) 2025-07-31 21:29:01 -05:00
Alex
1378f22368 fix: add to album render empty app bar (#20480)
* fix: add to album render empty app bar

* set current album
2025-07-31 21:28:33 -05:00
Alex
4bd465e752 feat: change grid size with gesture (#20455) 2025-07-31 21:02:28 -05:00
github-actions
a07531be3b chore: version v1.137.1 2025-07-31 23:05:34 +00:00
Daniel Dietzler
3cdc6844a1 fix: automatic media location migration without internal assets (#20489) 2025-07-31 22:58:35 +00:00
27 changed files with 470 additions and 141 deletions

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.74",
"version": "2.2.76",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.74",
"version": "2.2.76",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.74",
"version": "2.2.76",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -1,4 +1,12 @@
[
{
"label": "v1.137.2",
"url": "https://v1.137.2.archive.immich.app"
},
{
"label": "v1.137.1",
"url": "https://v1.137.1.archive.immich.app"
},
{
"label": "v1.137.0",
"url": "https://v1.137.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.137.0",
"version": "1.137.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -46,7 +46,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.74",
"version": "2.2.76",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -95,7 +95,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.137.0",
"version": "1.137.2",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -36,7 +36,7 @@ platform :android do
build_type: 'Release',
properties: {
"android.injected.version.code" => 205,
"android.injected.version.name" => "1.137.0",
"android.injected.version.name" => "1.137.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.137.0"
version_number: "1.137.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
@@ -14,7 +13,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
@@ -67,14 +65,14 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
// handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// }
// }
// }
return PopScope(
onPopInvokedWithResult: (didPop, result) async {
@@ -167,16 +165,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
),
),
SettingsSwitchListTile(
valueNotifier: _enableSyncUploadAlbum,
title: "sync_albums".t(context: context),
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
onChanged: handleSyncAlbumToggle,
),
// SettingsSwitchListTile(
// valueNotifier: _enableSyncUploadAlbum,
// title: "sync_albums".t(context: context),
// subtitle: "sync_upload_album_setting_subtitle".t(context: context),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16),
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
// onChanged: handleSyncAlbumToggle,
// ),
ListTile(
title: Text(
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),

View File

@@ -28,13 +28,15 @@ class RemoteAlbumPage extends ConsumerStatefulWidget {
}
class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
late RemoteAlbum _album;
@override
void initState() {
super.initState();
_album = widget.album;
}
Future<void> addAssets(BuildContext context) async {
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id);
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -47,7 +49,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final added = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(
widget.album.id,
_album.id,
newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
@@ -64,14 +66,14 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
Future<void> addUsers(BuildContext context) async {
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: widget.album));
final newUsers = await context.pushRoute<List<String>>(DriftUserSelectionRoute(album: _album));
if (newUsers == null || newUsers.isEmpty) {
return;
}
try {
await ref.read(remoteAlbumProvider.notifier).addUsers(widget.album.id, newUsers);
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers);
if (newUsers.isNotEmpty) {
ImmichToast.show(
@@ -81,7 +83,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
);
}
ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id));
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id));
} catch (e) {
ImmichToast.show(
context: context,
@@ -92,7 +94,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
Future<void> toggleAlbumOrder() async {
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(widget.album.id);
await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder(_album.id);
ref.invalidate(timelineServiceProvider);
}
@@ -106,7 +108,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('album_delete_confirmation'.t(context: context, args: {'album': widget.album.name})),
Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})),
const SizedBox(height: 8),
Text('album_delete_confirmation_description'.t(context: context)),
],
@@ -128,7 +130,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
if (confirmed == true) {
try {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(widget.album.id);
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id);
ImmichToast.show(
context: context,
@@ -151,17 +153,20 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final result = await showDialog<_EditAlbumData?>(
context: context,
barrierDismissible: true,
builder: (context) => _EditAlbumDialog(album: widget.album),
builder: (context) => _EditAlbumDialog(album: _album),
);
if (result != null && context.mounted) {
setState(() {
_album = _album.copyWith(name: result.name, description: result.description ?? '');
});
HapticFeedback.mediumImpact();
}
}
void showOptionSheet(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == widget.album.ownerId : false;
final isOwner = user != null ? user.id == _album.ownerId : false;
showModalBottomSheet(
context: context,
@@ -205,7 +210,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: widget.album.id);
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
@@ -217,7 +222,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: widget.album),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),
);
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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';
@@ -578,6 +579,7 @@ class AddToAlbumHeader extends ConsumerWidget {
return;
}
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum);
context.pushRoute(RemoteAlbumRoute(album: newAlbum));
}

View File

@@ -1,7 +1,7 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
@@ -11,6 +11,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -125,10 +126,14 @@ class _FixedSegmentRow extends ConsumerWidget {
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
_AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
);

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -15,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
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';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -88,10 +92,29 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController();
StreamSubscription? _eventSubscription;
// Drag selection state
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
setState(() {
_perRow = currentTilesPerRow;
_scaleFactor = 7.0 - _perRow;
_baseScaleFactor = _scaleFactor;
});
});
}
void _onEvent(Event event) {
@@ -150,6 +173,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
}
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
});
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) return;
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
@override
Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider);
@@ -177,43 +265,83 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController(
controller: _scrollController,
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
});
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
},
child: TimelineDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode)
const SelectionSliverAppBar()
else if (widget.appBar != null)
widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
),
);
},
@@ -443,3 +571,11 @@ class _MultiSelectStatusButton extends ConsumerWidget {
);
}
}
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}

View File

@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TimelineDragRegion extends StatefulWidget {
final Widget child;
final void Function(TimelineAssetIndex valueKey)? onStart;
final void Function(TimelineAssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const TimelineDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _TimelineDragRegionState();
}
class _TimelineDragRegionState extends State<TimelineDragRegion> {
late TimelineAssetIndex? assetUnderPointer;
late TimelineAssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
TimelineAssetIndex? _getValueKeyAtPosition(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _TimelineAssetIndexProxy)?.target
as _TimelineAssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPosition(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPosition(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class TimelineAssetIndexWrapper extends SingleChildRenderObjectWidget {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndexWrapper({
required Widget super.child,
required this.assetIndex,
required this.segmentIndex,
super.key,
});
@override
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy createRenderObject(BuildContext context) {
return _TimelineAssetIndexProxy(
index: TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy renderObject,
) {
renderObject.index = TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex);
}
}
class _TimelineAssetIndexProxy extends RenderProxyBox {
TimelineAssetIndex index;
_TimelineAssetIndexProxy({required this.index});
}
class TimelineAssetIndex {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex});
@override
bool operator ==(covariant TimelineAssetIndex other) {
if (identical(this, other)) return true;
return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex;
}
@override
int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode;
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.137.0
- API version: 1.137.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.137.0+3001
version: 1.137.2+3002
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -9469,7 +9469,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.137.0",
"version": "1.137.2",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.137.0
* 1.137.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.137.0",
"version": "1.137.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.137.0",
"version": "1.137.2",
"description": "",
"author": "",
"private": true,

View File

@@ -170,27 +170,10 @@ where
-- AssetRepository.getFileSamples
select
"asset"."id",
"asset"."originalPath",
"asset"."sidecarPath",
"asset"."encodedVideoPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"path"
from
"asset_file"
where
"asset"."id" = "asset_file"."assetId"
) as agg
) as "files"
"assetId",
"path"
from
"asset"
where
"asset"."libraryId" is null
"asset_file"
limit
3

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -338,20 +337,7 @@ export class AssetRepository {
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('asset')
.select((eb) => [
'asset.id',
'asset.originalPath',
'asset.sidecarPath',
'asset.encodedVideoPath',
jsonArrayFrom(eb.selectFrom('asset_file').select('path').whereRef('asset.id', '=', 'asset_file.assetId')).as(
'files',
),
])
.where('asset.libraryId', 'is', null)
.limit(sql.lit(3))
.execute();
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@@ -86,12 +86,7 @@ export class CliService extends BaseService {
}
for (const asset of assets) {
paths.push(
asset.originalPath,
asset.sidecarPath,
asset.encodedVideoPath,
...asset.files.map((file) => file.path),
);
paths.push(asset.path);
}
return paths.filter(Boolean) as string[];

View File

@@ -97,18 +97,18 @@ export class StorageService extends BaseService {
const current = StorageCore.getMediaLocation();
const samples = await this.assetRepository.getFileSamples();
if (samples.length > 0) {
const originalPath = samples[0].originalPath;
const path = samples[0].path;
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation);
let previous = savedValue?.location || '';
if (!previous) {
previous = originalPath.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
previous = path.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
}
if (previous !== current) {
this.logger.log(`Media location changed (from=${previous}, to=${current})`);
if (!originalPath.startsWith(previous)) {
if (!path.startsWith(previous)) {
throw new Error(
'Detected an inconsistent media location. For more information, see https://immich.app/errors#inconsistent-media-location',
);

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.137.0",
"version": "1.137.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -94,7 +94,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.137.0",
"version": "1.137.2",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {