Compare commits

..

4 Commits

Author SHA1 Message Date
Santo Shakil b4a4ddfd56 fix(mobile): keep drag select going while the grid auto scrolls 2026-06-28 00:46:24 +06:00
github-actions ac74bca18b chore: version v3.0.0-rc.4 2026-06-27 13:14:47 +00:00
shenlong e9d1951858 fix: dispatch menu onPressed manually (#29353)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-27 08:10:47 -05:00
Santo Shakil 6e1143e799 fix(mobile): hide video thumbnail when video is ready (#29012) 2026-06-26 22:20:40 -05:00
31 changed files with 655 additions and 247 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v3.0.0-rc.3",
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
"label": "v3.0.0-rc.4",
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
},
{
"label": "v2.7.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+4 -4
View File
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
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', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
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')
@@ -138,9 +138,7 @@ class LocalSyncService {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums();
final getAlbumsTime = stopwatch.elapsedMilliseconds;
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
final getAllTime = stopwatch.elapsedMilliseconds;
await diffSortedLists(
dbAlbums,
@@ -150,15 +148,10 @@ class LocalSyncService {
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
final diffTime = stopwatch.elapsedMilliseconds;
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info(
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
);
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
+2 -4
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:isolate';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
@@ -67,16 +66,15 @@ class LogService {
}
void _handleLogRecord(LogRecord r) {
final int isolateHash = Isolate.current.hashCode;
dPrint(
() =>
'[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
'${r.error == null ? '' : '\nError: ${r.error}'}'
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
);
final record = LogMessage(
message: '[$isolateHash] ${r.message}',
message: r.message,
level: r.level.toLogLevel(),
createdAt: r.time,
logger: r.loggerName,
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -91,7 +90,6 @@ class TimelineFactory {
}
class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
@@ -107,49 +105,34 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen(
(buckets) {
_mutex.run(() async {
try {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_log.info(
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
}
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
} catch (error, stack) {
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
rethrow;
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
});
},
onError: (Object error, StackTrace stack) {
_log.severe('[$origin] bucket stream errored', error, stack);
},
);
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
}
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
});
});
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
@@ -181,13 +164,6 @@ class TimelineService {
_buffer = await _assetSource(start, len);
_bufferOffset = start;
if (!hasRange(index, count)) {
_log.warning(
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
);
}
return getAssets(index, count);
}
@@ -206,6 +182,18 @@ class TimelineService {
return _buffer.slice(start, start + count);
}
/// Reads a range without disturbing the buffer; queries the source if it isn't resident.
Future<List<BaseAsset>> getAssetsRange(int index, int count) async {
if (index < 0 || count <= 0 || index >= _totalAssets) {
return const [];
}
final clamped = math.min(count, _totalAssets - index);
if (hasRange(index, clamped)) {
return getAssets(index, clamped);
}
return _assetSource(index, clamped);
}
// Preload assets around the given index for asset viewer
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
@@ -49,14 +49,21 @@ class BaseActionButton extends ConsumerWidget {
if (menuItem) {
final iconColor = this.iconColor;
final onPressed = this.onPressed;
return MenuItemButton(
closeOnActivate: false,
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed,
onPressed: onPressed == null
? null
: () {
onPressed();
MenuController.maybeOf(context)?.close();
},
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
);
}
@@ -260,7 +260,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return IgnorePointer(
child: Stack(
children: [
Center(child: widget.image),
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
@@ -0,0 +1,175 @@
import 'dart:collection';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
// Tracks the [anchor..current] range selected by a drag. The in-buffer part of
// each tick is selected synchronously so it follows the finger without racing;
// the rare beyond-buffer part is read async (applied only if still in range) and
// [end] reads whatever is still missing so the final range always lands.
class DragSelectionController {
DragSelectionController({required this.getAssetSafe, required this.getAssetsRange, required this.onChange});
final BaseAsset? Function(int index) getAssetSafe;
final Future<List<BaseAsset>> Function(int index, int count) getAssetsRange;
final void Function(Set<BaseAsset> select, Set<BaseAsset> deselect) onChange;
final HashMap<int, BaseAsset> _selected = HashMap();
// Indices the buffer didn't hold yet (the edge outran the async buffer-load on a
// fast scroll); read in by _extendPending and guaranteed by end().
final Set<int> _pending = {};
int? _anchor;
int? _lo;
int? _hi;
bool _disposed = false;
// Call before starting a new drag so a previous drag's in-flight read can't
// leak into the new selection.
void dispose() => _disposed = true;
void _emit(Set<BaseAsset> select, Set<BaseAsset> deselect) {
if (_disposed) {
return;
}
onChange(select, deselect);
}
void start(int anchor) {
_selected.clear();
_pending.clear();
_anchor = anchor;
_lo = anchor;
_hi = anchor;
_select(anchor);
_extendPending();
}
void enter(int current) {
final anchor = _anchor;
if (anchor == null || _lo == null || _hi == null) {
return;
}
final ns = current < anchor ? current : anchor;
final ne = current < anchor ? anchor : current;
final ps = _lo!;
final pe = _hi!;
if (ns == ps && ne == pe) {
return;
}
final toSelect = <BaseAsset>{};
final toDeselect = <BaseAsset>{};
_forEach(ps, ns - 1, (k) => _removeIndex(k, toDeselect));
_forEach(ne + 1, pe, (k) => _removeIndex(k, toDeselect));
_forEach(ns, ps - 1, (k) => _addIndex(k, toSelect));
_forEach(pe + 1, ne, (k) => _addIndex(k, toSelect));
_lo = ns;
_hi = ne;
if (toSelect.isNotEmpty || toDeselect.isNotEmpty) {
_emit(toSelect, toDeselect);
}
_extendPending();
}
Future<void> end() async {
final lo = _lo;
final hi = _hi;
if (lo == null || hi == null) {
return;
}
final missing = <int>[];
for (var k = lo; k <= hi; k++) {
if (!_selected.containsKey(k)) {
missing.add(k);
}
}
if (missing.isEmpty) {
return;
}
final from = missing.first;
final assets = await getAssetsRange(from, missing.last - from + 1);
final missingSet = missing.toSet();
final toSelect = <BaseAsset>{};
for (var i = 0; i < assets.length; i++) {
final idx = from + i;
if (missingSet.contains(idx) && !_selected.containsKey(idx)) {
_selected[idx] = assets[i];
_pending.remove(idx);
toSelect.add(assets[i]);
}
}
if (toSelect.isNotEmpty) {
_emit(toSelect, const {});
}
}
void _select(int index) {
final asset = getAssetSafe(index);
if (asset != null) {
_selected[index] = asset;
_pending.remove(index);
_emit({asset}, const {});
} else {
_pending.add(index);
}
}
void _addIndex(int index, Set<BaseAsset> toSelect) {
if (_selected.containsKey(index)) {
return;
}
final asset = getAssetSafe(index);
if (asset != null) {
_selected[index] = asset;
_pending.remove(index);
toSelect.add(asset);
} else {
_pending.add(index);
}
}
void _removeIndex(int index, Set<BaseAsset> toDeselect) {
_pending.remove(index);
final asset = _selected.remove(index);
if (asset != null) {
toDeselect.add(asset);
}
}
Future<void> _extendPending() async {
if (_pending.isEmpty) {
return;
}
var from = _pending.first;
var to = _pending.first;
for (final k in _pending) {
if (k < from) {
from = k;
}
if (k > to) {
to = k;
}
}
final assets = await getAssetsRange(from, to - from + 1);
final toSelect = <BaseAsset>{};
for (var i = 0; i < assets.length; i++) {
final idx = from + i;
if (_pending.contains(idx) && _lo != null && idx >= _lo! && idx <= _hi!) {
_selected[idx] = assets[i];
toSelect.add(assets[i]);
}
}
_pending.removeWhere((idx) => _selected.containsKey(idx));
if (toSelect.isNotEmpty) {
_emit(toSelect, const {});
}
}
void _forEach(int lo, int hi, void Function(int) fn) {
for (var k = lo; k <= hi; k++) {
fn(k);
}
}
}
@@ -23,7 +23,6 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class FixedSegment extends Segment {
final double tileHeight;
@@ -91,7 +90,6 @@ class FixedSegment extends Segment {
}
class _FixedSegmentRow extends ConsumerWidget {
static final Logger _log = Logger('TimelineRow');
final int assetIndex;
final int assetCount;
final double tileHeight;
@@ -111,20 +109,8 @@ class _FixedSegmentRow extends ConsumerWidget {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
final inRange = timelineService.hasRange(assetIndex, assetCount);
if (assetIndex == 0) {
_log.info(
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
'branch=${inRange
? "assets"
: isScrubbing
? "placeholder(scrubbing)"
: "future(load)"}',
);
}
if (inRange) {
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
@@ -143,13 +129,6 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
if (snapshot.hasError) {
_log.warning(
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
snapshot.error,
snapshot.stackTrace,
);
}
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:logging/logging.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
@@ -85,7 +84,6 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
}
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
static final Logger _log = Logger('Scrubber');
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
@@ -116,7 +114,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void initState() {
super.initState();
_log.info('Scrubber initState');
_isDragging = false;
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
@@ -137,10 +134,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
if (oldEnd != newEnd) {
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
}
@@ -148,15 +142,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void dispose() {
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
_log.warning(
'Scrubber dispose mid-scrub '
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
);
} else {
_log.info('Scrubber dispose');
}
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
@@ -223,7 +208,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragStart(DragStartDetails _) {
_log.info('scrub dragStart');
setState(() {
_isDragging = true;
_labelAnimationController.forward();
@@ -238,15 +222,9 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
if (_scrubberHeight <= 0) {
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
return;
}
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
if (maxScrollExtent <= 0) {
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
@@ -366,7 +344,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragEnd(DragEndDetails _) {
_log.info('scrub dragEnd -> setScrubbing(false)');
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
@@ -7,7 +7,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs {
final double maxWidth;
@@ -72,27 +71,14 @@ class TimelineState {
}
class TimelineStateNotifier extends Notifier<TimelineState> {
static final Logger _log = Logger('TimelineState');
void setScrubbing(bool isScrubbing) {
if (state.isScrubbing != isScrubbing) {
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
}
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
if (state.isScrolling != isScrolling) {
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
}
state = state.copyWith(isScrolling: isScrolling);
}
static String _callSite() {
final frames = StackTrace.current.toString().split('\n');
return frames.length > 2 ? frames[2].trim() : 'unknown';
}
@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
}
@@ -110,11 +96,6 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger('TimelineService').info(
'[${timelineService.origin}] segment layout: '
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
);
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
@@ -8,7 +7,6 @@ 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/events.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -20,6 +18,7 @@ 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';
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
@@ -28,7 +27,27 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
import 'package:logging/logging.dart';
// First asset index of the row shown at [offset]. Pure for testing.
@visibleForTesting
int? assetIndexAtOffset(
List<Segment> segments,
double offset, {
required int columnCount,
required double maxScrollExtent,
}) {
final clamped = offset.clamp(0.0, maxScrollExtent);
final segment = segments.findByOffset(clamped) ?? segments.lastOrNull;
if (segment == null) {
return null;
}
final rowIndex = segment.getMinChildIndexForScrollOffset(clamped);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
return segment.firstAssetIndex + rowIndexInSegment * columnCount;
}
return segment.firstAssetIndex;
}
class Timeline extends StatelessWidget {
const Timeline({
@@ -137,14 +156,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
// Drag selection state
static const _autoScrollStep = 175.0;
static const _autoScrollDuration = Duration(milliseconds: 125);
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
DragSelectionController? _dragController;
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
@@ -155,7 +174,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -182,7 +200,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) {
case ScrollToTopEvent():
{
@@ -190,10 +207,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
.whenComplete(() => timelineState.setScrubbing(false));
}
case ScrollToDateEvent scrollToDateEvent:
@@ -233,27 +247,18 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
int? _getCurrentAssetIndex(List<Segment> segments) {
final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent);
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
int? targetAssetIndex;
if (segment != null) {
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
} else {
targetAssetIndex = segment.firstAssetIndex;
}
}
return targetAssetIndex;
}
int? _getCurrentAssetIndex(List<Segment> segments) => _assetIndexAtOffset(segments, _scrollController.offset);
int? _assetIndexAtOffset(List<Segment> segments, double offset) => assetIndexAtOffset(
segments,
offset,
columnCount: ref.read(timelineArgsProvider).columnCount,
maxScrollExtent: _scrollController.position.maxScrollExtent,
);
@override
void dispose() {
_log.info('SliverTimeline dispose');
_dragController?.dispose();
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -294,12 +299,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() {
_log.info('ScrollToDate animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
.whenComplete(() => timelineState.setScrubbing(false));
} else {
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
timelineState.setScrubbing(false);
}
});
@@ -307,9 +308,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
// Stop the old drag's controller so its in-flight read can't leak into this one.
_dragController?.dispose();
final timelineService = ref.read(timelineServiceProvider);
_dragController = DragSelectionController(
getAssetSafe: timelineService.getAssetSafe,
getAssetsRange: timelineService.getAssetsRange,
onChange: (select, deselect) {
if (!mounted) {
return;
}
ref.read(multiSelectProvider.notifier).selectRange(select, deselect);
},
)..start(index.assetIndex);
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
@@ -325,8 +338,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Apply the full final range even if a read is still in flight on lift.
final finishing = _dragController?.end();
if (finishing != null) {
unawaited(finishing);
}
final timelineState = ref.read(timelineStateProvider.notifier);
Future.delayed(const Duration(milliseconds: 300), () {
timelineState.setScrolling(false);
@@ -334,42 +351,33 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
final position = _scrollController.position;
final step = direction == ScrollDirection.forward ? _autoScrollStep : -_autoScrollStep;
final target = (_scrollController.offset + step).clamp(0.0, position.maxScrollExtent);
_scrollController.animateTo(target, duration: _autoScrollDuration, curve: Curves.easeOut);
// A held finger emits no move events, so extend the selection to the asset
// at the leading edge of the scroll instead.
final controller = _dragController;
if (controller == null) {
return;
}
final segments = ref.read(timelineSegmentProvider).valueOrNull;
if (segments == null) {
return;
}
final edgeOffset = direction == ScrollDirection.forward ? target + position.viewportDimension : target;
final edgeIndex = _assetIndexAtOffset(segments, edgeOffset);
if (edgeIndex != null) {
controller.enter(edgeIndex);
}
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) {
if (!_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);
}
}
_dragController?.enter(index.assetIndex);
}
@override
@@ -101,20 +101,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
}
Future<void> _cancelSync() async {
final backgroundManager = _ref.read(backgroundSyncProvider);
final nativeSync = _ref.read(nativeSyncApiProvider);
await Future.wait([
nativeSync.cancelSync(),
nativeSync.cancelHashing(),
backgroundManager.cancel(),
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
}
Future<void> _handleBetaTimelineResume() async {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
_log.info("Handling beta timeline resume");
await _cancelSync();
// Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500));
@@ -208,7 +196,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
unawaited(_cancelSync());
_ref.read(websocketProvider.notifier).disconnect();
}
@@ -5,9 +5,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
final _log = Logger('TimelineProvider');
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -21,11 +18,7 @@ final timelineServiceProvider = Provider<TimelineService>(
(ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
_log.info('main TimelineService built users=$timelineUsers');
ref.onDispose(() {
_log.info('main TimelineService disposed');
timelineService.dispose();
});
ref.onDispose(timelineService.dispose);
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -43,12 +36,8 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) {
_log.info('timelineUsers: currentUserId=null -> []');
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
_log.info('timelineUsers emission: $users');
return users;
});
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
});
@@ -97,6 +97,15 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
}
}
// Drops the previous drag range and adds the new one in a single update. The
// full-set copy per drag tick is the accepted cost of immutable state.
void selectRange(Set<BaseAsset> toSelect, Set<BaseAsset> toDeselect) {
final selectedAssets = state.selectedAssets.toSet()
..removeAll(toDeselect)
..addAll(toSelect);
state = state.copyWith(selectedAssets: selectedAssets);
}
void reset() {
state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false);
}
+1 -1
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: 3.0.0-rc.3
- API version: 3.0.0-rc.4
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+4 -4
View File
@@ -366,18 +366,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_sqlite_async:
dependency: "direct main"
description:
+3 -3
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0-rc.3+3051
version: 3.0.0-rc.4+3052
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.34.0
drift: ^2.32.1
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
@@ -96,7 +96,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0
build_runner: ^2.13.1
# Drift generator
drift_dev: ^2.34.0
drift_dev: ^2.32.1
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
void main() {
// Two day-segments, 4 columns, 8 assets each (2 rows). header 50, tile 100, no spacing.
// A: header@[0,50) rows@50,150 assets 0..7 offset [0,250]
// B: header@[250,300) rows@300,400 assets 8..15 offset [250,500]
const columnCount = 4;
const maxScrollExtent = 500.0;
final segments = <Segment>[
const FixedSegment(
firstIndex: 0,
lastIndex: 2,
startOffset: 0,
endOffset: 250,
firstAssetIndex: 0,
bucket: Bucket(assetCount: 8),
tileHeight: 100,
columnCount: columnCount,
headerExtent: 50,
spacing: 0,
header: HeaderType.day,
),
const FixedSegment(
firstIndex: 3,
lastIndex: 5,
startOffset: 250,
endOffset: 500,
firstAssetIndex: 8,
bucket: Bucket(assetCount: 8),
tileHeight: 100,
columnCount: columnCount,
headerExtent: 50,
spacing: 0,
header: HeaderType.day,
),
];
int? at(double offset) =>
assetIndexAtOffset(segments, offset, columnCount: columnCount, maxScrollExtent: maxScrollExtent);
test('maps an offset to the first asset of the row shown there', () {
expect(at(0), 0); // top of segment A
expect(at(150), 4); // second row of A
expect(at(350), 8); // first row of B
expect(at(450), 12); // second row of B
});
test('clamps offsets outside the scroll range', () {
expect(at(-100), 0); // below the top -> first asset
expect(at(9999), at(maxScrollExtent)); // past the end -> same as the max offset
});
test('returns null for empty segments', () {
expect(assetIndexAtOffset(const [], 100, columnCount: columnCount, maxScrollExtent: maxScrollExtent), isNull);
});
}
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
import '../../../factories/remote_asset_factory.dart';
void main() {
const total = 50;
late List<BaseAsset> all;
late Set<int> inBuffer; // indices getAssetSafe resolves synchronously
late List<Completer<List<BaseAsset>>> reads; // pending async reads, completed manually
late List<({int from, int count})> readArgs;
late Set<BaseAsset> selected;
late DragSelectionController sut;
Set<BaseAsset> range(int lo, int hi) => {for (var i = lo; i <= hi; i++) all[i]};
void completeRead(int i) {
final a = readArgs[i];
reads[i].complete([for (var k = a.from; k < a.from + a.count && k < total; k++) all[k]]);
}
Future<void> settle() => Future(() {});
setUp(() {
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(3, '0')}'));
inBuffer = {for (var i = 0; i < total; i++) i}; // default: everything buffered (sync)
reads = [];
readArgs = [];
selected = {};
sut = DragSelectionController(
getAssetSafe: (i) => inBuffer.contains(i) ? all[i] : null,
getAssetsRange: (from, count) {
final c = Completer<List<BaseAsset>>();
reads.add(c);
readArgs.add((from: from, count: count));
return c.future;
},
onChange: (select, deselect) {
selected
..addAll(select)
..removeAll(deselect);
},
);
});
group('live selection (all in buffer, synchronous)', () {
test('dragging down selects the whole range as it grows', () {
sut.start(2);
sut.enter(6);
sut.enter(12);
expect(selected, range(2, 12));
expect(reads, isEmpty, reason: 'everything buffered -> no async read');
});
test('dragging back up toward the anchor deselects the shrunk tail', () {
sut.start(2);
sut.enter(12);
expect(selected, range(2, 12));
sut.enter(6); // reverse
expect(selected, range(2, 6));
sut.enter(3); // reverse more
expect(selected, range(2, 3));
});
test('dragging past the anchor flips the range', () {
sut.start(10);
sut.enter(14);
expect(selected, range(10, 14));
sut.enter(7); // crosses the anchor
expect(selected, range(7, 10));
});
});
group('beyond-buffer (async)', () {
setUp(() => inBuffer = {}); // nothing buffered -> every tile needs an async read
test('drag-end fills the full range even if every live read is still in flight', () async {
sut.start(0);
sut.enter(20);
// simulate the real-rate race: none of the in-drag reads have completed
expect(selected, isEmpty);
final ending = sut.end();
// end() issues its own read for the missing range; complete it
completeRead(reads.length - 1);
await ending;
expect(selected, range(0, 20), reason: 'final range must always apply on drag-end');
});
test('out-of-order live read completions never corrupt the selection', () async {
sut.start(0); // issues read for [0,1]
sut.enter(10); // issues read for [0,11]
sut.enter(20); // issues read for [0,21]
expect(reads.length, 3);
// complete newest first, then older ones (out of order)
completeRead(2);
await settle();
completeRead(1);
await settle();
completeRead(0);
await settle();
expect(selected, range(0, 20));
final ending = sut.end();
await ending; // nothing missing -> no extra read
expect(selected, range(0, 20));
});
test('a disposed controller never emits when its in-flight read resolves', () async {
sut.start(0); // read [0,1] (pending, in flight)
sut.enter(20); // read [0,21] (pending, in flight)
expect(selected, isEmpty);
// a new drag starts -> the old controller is disposed
final ending = sut.end(); // issues end()'s fill read
sut.dispose();
// every in-flight read for the old controller now resolves
for (var i = 0; i < reads.length; i++) {
if (!reads[i].isCompleted) {
completeRead(i);
}
}
await ending;
await settle();
expect(selected, isEmpty, reason: 'a disposed controller must not leak into the new selection');
});
test('a late read for tiles dragged back out of range is ignored', () async {
sut.start(0); // read [0,1]
sut.enter(20); // read [0,21]
sut.enter(5); // shrink back; read [0,6]
// complete the stale wide read AFTER the shrink
completeRead(1); // [0,21]
await settle();
// indices 6..20 left the range -> must not be selected
expect(selected.intersection(range(6, 20)), isEmpty);
final ending = sut.end();
// complete any read end() issued for the (now smaller) missing range
for (var i = 0; i < reads.length; i++) {
if (!reads[i].isCompleted) {
completeRead(i);
}
}
await ending;
expect(selected, range(0, 5));
});
});
}
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import '../factories/remote_asset_factory.dart';
void main() {
// total must exceed the sliding buffer so it cannot hold the whole library at once
const total = 2000;
late List<BaseAsset> all;
late TimelineService sut;
TimelineService buildService() {
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(5, '0')}'));
return TimelineService((
assetSource: (index, count) async {
final end = (index + count) > total ? total : index + count;
return all.sublist(index, end);
},
bucketSource: () => Stream.value([const Bucket(assetCount: total)]),
origin: TimelineOrigin.main,
));
}
Future<void> settle() => Future.delayed(const Duration(milliseconds: 10));
setUp(() async {
sut = buildService();
await settle(); // let the bucket subscription load the first batch and set totalAssets
});
tearDown(() async {
await sut.dispose();
});
test('buffer holds the first batch but not the whole library', () {
expect(sut.totalAssets, total);
expect(sut.hasRange(0, kTimelineAssetLoadBatchSize), isTrue);
expect(sut.hasRange(0, total), isFalse);
});
// #27118 / #20855 mechanism: drag-selecting from a low anchor while the grid
// auto-scrolls down slides the buffer forward to follow the finger. once the
// buffer offset passes the anchor, hasRange(anchor, ...) is false and
// _handleDragAssetEnter silently stops extending the selection.
test('anchor drops out of the buffer after the grid scrolls down', () async {
const anchor = 5;
expect(sut.hasRange(anchor, 100), isTrue);
// the grid loads a far-down range as it auto-scrolls during the drag
await sut.loadAssets(1500, 1);
const current = 1500;
const count = current - anchor + 1;
expect(sut.hasRange(anchor, 1), isFalse, reason: 'anchor is now below the buffer offset');
expect(sut.hasRange(anchor, count), isFalse, reason: 'the full drag range is no longer resident');
expect(() => sut.getAssets(anchor, count), throwsRangeError);
});
// the fix: getAssetsRange returns the whole drag range regardless of the
// buffer position, so the selection keeps extending while scrolling.
group('getAssetsRange', () {
test('returns a buffered range', () async {
final assets = await sut.getAssetsRange(0, 50);
expect(assets.length, 50);
expect(assets.first, all[0]);
expect(assets.last, all[49]);
});
test('returns a range wider than the buffer', () async {
final assets = await sut.getAssetsRange(0, total);
expect(assets.length, total);
expect(assets.first, all[0]);
expect(assets.last, all[total - 1]);
});
test('returns the anchor range after the buffer scrolled past the anchor', () async {
const anchor = 5;
await sut.loadAssets(1500, 1); // buffer slides forward, dropping the anchor
expect(sut.hasRange(anchor, 1), isFalse);
const current = 1500;
const count = current - anchor + 1;
final assets = await sut.getAssetsRange(anchor, count);
expect(assets.length, count);
expect(assets.first, all[anchor]);
expect(assets.last, all[current]);
});
test('clamps a range that runs past the end and ignores invalid input', () async {
final tail = await sut.getAssetsRange(total - 10, 100);
expect(tail.length, 10);
expect(await sut.getAssetsRange(-1, 10), isEmpty);
expect(await sut.getAssetsRange(0, 0), isEmpty);
expect(await sut.getAssetsRange(total, 10), isEmpty);
});
});
}
+1 -1
View File
@@ -16206,7 +16206,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.0-rc.3
* 3.0.0-rc.4
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {