Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9242afb4b0 | ||
|
|
c5f14adff0 | ||
|
|
1378f22368 | ||
|
|
4bd465e752 | ||
|
|
a07531be3b | ||
|
|
3cdc6844a1 |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -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
8
e2e/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.137.0",
|
||||
"version": "1.137.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -9469,7 +9469,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.137.0",
|
||||
"version": "1.137.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.137.0",
|
||||
"version": "1.137.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
6
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user