Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'open-api/**'
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'open-api/**'
- 'i18n/en.json'
force-filters: |
- '.github/workflows/static_analysis.yml'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
- 'mise.toml'
mobile:
- 'mobile/**'
- 'open-api/**'
- 'mise.toml'
machine-learning:
- 'machine-learning/**'
Expand Down
4 changes: 0 additions & 4 deletions docker/hwaccel.ml.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,9 @@ services:
- 'c 189:* rmw'
devices:
- /dev/dri:/dev/dri
volumes:
- /dev/bus/usb:/dev/bus/usb

openvino-wsl:
devices:
- /dev/dri:/dev/dri
- /dev/dxg:/dev/dxg
volumes:
- /dev/bus/usb:/dev/bus/usb
- /usr/lib/wsl:/usr/lib/wsl
18 changes: 18 additions & 0 deletions docs/docs/FAQ.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -543,4 +543,22 @@ The damaged database dump can be used to manually recover any changes made since

The causes of possible corruption are many, but can include unexpected poweroffs or unmounts, use of a network share for Postgres data, or a poor storage medium such an SD card or failing HDD/SSD.

### Should I upgrade the database to a newer major version?

We recommend using the same version of PostgreSQL as in our default docker-compose.yml, however newer versions are also supported. Do note that the major upgrade process is not as simple as just changing the version of the container image. See [the PostgreSQL documentation](https://www.postgresql.org/docs/current/upgrading.html) for more detail.

## Other

### Will Immich save me money?

Probably not. At first glance, it seems like it might: compared to cloud-hosted commercial services, Immich has a one time purchase for the [product key](https://buy.immich.app/), but there are no monthly fees to run Immich on your own server. However, there are many other costs like hardware, storage for backups, and your own time. There are many good reasons for self-hosting, but saving money is rarely one of them.

### I really want a particular feature. Can I sponsor development of it?

No, we have no feature sponsorship scheme. The Immich team [works on Immich full-time](https://immich.app/blog/immich-joins-futo), which is supported by users paying for [a product key](https://buy.immich.app/). We'd rather not let money influence the roadmap. If features could be bought, we'd end up building Immich for the highest bidder instead of for all of our users.

A common alternative suggestion is to offer bounties that can be collected by anyone who submits a pull request, but we don't want those either; the Zig project has [explained the reason well](https://ziglang.org/news/bounties-damage-open-source-projects/).

If you really want to pay someone to build a feature you like, you're welcome to hire somebody directly and pay them for their time, but please note that we provide no guarantee that we'll merge the resulting work so we recommend that you check in with us first.

[huggingface]: https://huggingface.co/immich-app
13 changes: 4 additions & 9 deletions mobile/lib/infrastructure/repositories/memory.repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,17 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
return const [];
}

final Map<String, Memory> memoriesMap = {};
final memories = <String, ({MemoryEntityData memory, List<RemoteAsset> assets})>{};

for (final row in rows) {
final memory = row.readTable(_db.memoryEntity);
final asset = row.readTable(_db.remoteAssetEntity);

final existingMemory = memoriesMap[memory.id];
if (existingMemory != null) {
existingMemory.assets.add(asset.toDto());
} else {
final assets = [asset.toDto()];
memoriesMap[memory.id] = memory.toDto().copyWith(assets: assets);
}
final entry = memories.putIfAbsent(memory.id, () => (memory: memory, assets: []));
entry.assets.add(asset.toDto());
}

return memoriesMap.values.toList(growable: false);
return memories.values.map((e) => e.memory.toDto().copyWith(assets: e.assets)).toList(growable: false);
}

Future<Memory?> get(String memoryId) async {
Expand Down
8 changes: 4 additions & 4 deletions mobile/lib/infrastructure/utils/exif.converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ abstract final class ExifDtoConverter {
timeZone: dto.timeZone.orElse(null),
dateTimeOriginal: dto.dateTimeOriginal.orElse(null),
isFlipped: isOrientationFlipped(dto.orientation.orElse(null)),
latitude: dto.latitude.orElse(null)?.toDouble(),
longitude: dto.longitude.orElse(null)?.toDouble(),
latitude: dto.latitude.orElse(null),
longitude: dto.longitude.orElse(null),
city: dto.city.orElse(null),
state: dto.state.orElse(null),
country: dto.country.orElse(null),
make: dto.make.orElse(null),
model: dto.model.orElse(null),
lens: dto.lensModel.orElse(null),
f: dto.fNumber.orElse(null)?.toDouble(),
mm: dto.focalLength.orElse(null)?.toDouble(),
f: dto.fNumber.orElse(null),
mm: dto.focalLength.orElse(null),
iso: dto.iso.orElse(null),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)),
);
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/presentation/pages/edit/editor.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class EditorProvider extends Notifier<EditorState> {
flipVertical: transform.mirrorVertical,
);

_animateRotation(transform.rotation.toInt(), duration: Duration.zero);
_animateRotation(transform.rotation, duration: Duration.zero);
}

void _animateRotation(int angle, {Duration duration = const Duration(milliseconds: 300)}) {
Expand Down
7 changes: 5 additions & 2 deletions mobile/lib/presentation/pages/search/search.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class SearchPage extends HookConsumerWidget {
expanded: true,
onSearch: handleApply,
onClear: handleClear,
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
child: PeoplePicker(onSelect: handleOnSelect, initialSelection: filter.value.people),
),
),
),
Expand Down Expand Up @@ -192,7 +192,10 @@ class SearchPage extends HookConsumerWidget {
expanded: true,
onSearch: handleApply,
onClear: handleClear,
child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
child: TagPicker(
onSelectExistingTag: handleOnSelect,
initialSelection: (filter.value.tagIds ?? []).toSet(),
),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class _FixedSegmentRow extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final recommendDeferredLoading = ref.watch(timelineStateProvider.select((s) => s.recommendDeferredLoading));
final timelineService = ref.watch(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);

Expand All @@ -119,7 +119,7 @@ class _FixedSegmentRow extends ConsumerWidget {
);
}

if (isScrubbing) {
if (recommendDeferredLoading) {
return _buildPlaceholder(context);
}

Expand Down
34 changes: 0 additions & 34 deletions mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.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/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection;

part 'scrubber.widget.freezed.dart';
Expand Down Expand Up @@ -91,8 +90,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
bool _isDragging = false;
List<_Segment> _segments = [];
int _monthCount = 0;
DateTime? _currentScrubberDate;
Debouncer? _scrubberDebouncer;

late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer;
Expand Down Expand Up @@ -147,7 +144,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
_scrubberDebouncer?.dispose();
super.dispose();
}

Expand Down Expand Up @@ -191,24 +187,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
return false;
}

void _onScrubberDateChanged(DateTime date) {
if (_currentScrubberDate != date) {
// Date changed, immediately set scrubbing to true
_currentScrubberDate = date;
ref.read(timelineStateProvider.notifier).setScrubbing(true);

// Initialize debouncer if needed
_scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50));

// Debounce setting scrubbing to false
_scrubberDebouncer!.run(() {
if (_currentScrubberDate == date) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
}
});
}
}

void _onDragStart(DragStartDetails _) {
setState(() {
_isDragging = true;
Expand Down Expand Up @@ -239,11 +217,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label;

// Notify timeline state of the new scrubber date position
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
_onScrubberDateChanged(nearestMonthSegment.date);
}
}
}

Expand Down Expand Up @@ -351,13 +324,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = false;
});

ref.read(timelineStateProvider.notifier).setScrubbing(false);

// Reset scrubber tracking when drag ends
_currentScrubberDate = null;
_scrubberDebouncer?.dispose();
_scrubberDebouncer = null;

_resetThumbTimer();
}

Expand Down
38 changes: 28 additions & 10 deletions mobile/lib/presentation/widgets/timeline/timeline.state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,44 @@ abstract class TimelineArgs with _$TimelineArgs {
}) = _TimelineArgs;
}

@freezed
abstract class TimelineState with _$TimelineState {
const TimelineState._();
class TimelineState {
final bool isScrolling;

const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState;
/// Indicates whether the timeline is scrolling beyond some configured "high" speed,
/// such as when programmatically scrolling to the top or a really fast user fling
final bool recommendDeferredLoading;

bool get isInteracting => isScrubbing || isScrolling;
}
const TimelineState({this.isScrolling = false, this.recommendDeferredLoading = false});

class TimelineStateNotifier extends Notifier<TimelineState> {
void setScrubbing(bool isScrubbing) {
state = state.copyWith(isScrubbing: isScrubbing);
bool get isInteracting => isScrolling || recommendDeferredLoading;

@override
bool operator ==(covariant TimelineState other) {
return isScrolling == other.isScrolling && recommendDeferredLoading == other.recommendDeferredLoading;
}

@override
int get hashCode => isScrolling.hashCode ^ recommendDeferredLoading.hashCode;

TimelineState copyWith({bool? isScrolling, bool? recommendDeferredLoading}) {
return TimelineState(
isScrolling: isScrolling ?? this.isScrolling,
recommendDeferredLoading: recommendDeferredLoading ?? this.recommendDeferredLoading,
);
}
}

class TimelineStateNotifier extends Notifier<TimelineState> {
void setScrolling(bool isScrolling) {
state = state.copyWith(isScrolling: isScrolling);
}

void setRecommendDeferredLoading(bool recommendDeferredLoading) {
state = state.copyWith(recommendDeferredLoading: recommendDeferredLoading);
}

@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
TimelineState build() => const TimelineState(isScrolling: false, recommendDeferredLoading: false);
}

// This provider watches the buckets from the timeline service & args and serves the segments.
Expand Down
51 changes: 31 additions & 20 deletions mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/utils/debounce.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';
Expand Down Expand Up @@ -157,6 +158,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;

final Debouncer _fastScrollDebouncer = Debouncer(interval: const Duration(milliseconds: 100));

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -265,27 +268,39 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_fastScrollDebouncer.dispose();
_scrollController.dispose();
unawaited(_eventSubscription?.cancel());
super.dispose();
}

/// Track whether the timeline is moving fast enough to defer per-row asset loading
bool _onScrollVelocityNotification(ScrollNotification notification) {
// Only consider the primary timeline ScrollView (no nested views) and update events
if (notification.depth != 0 || notification is! ScrollUpdateNotification) {
return false;
}

// Use Flutter's built in fast velocity tracking
if (_scrollController.position.recommendDeferredLoading(context)) {
ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(true);

// We cannot rely on scroll end events, as the timeline scrubber jumps from position
// to position, resulting in large spikes in velocity followed by low velocity
_fastScrollDebouncer.run(() => ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(false));
}
return false;
}

void _scrollToTop() {
if (!_scrollController.hasClients) {
return;
}

final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
unawaited(
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false)),
);
_scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
}

void _scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
// Find the segment that contains assets from the target date
Expand All @@ -312,18 +327,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
if (fallbackSegment != null) {
// Scroll to the segment with a small offset to show the header
final targetOffset = fallbackSegment.startOffset - 50;
timelineState.setScrubbing(true);
unawaited(
_scrollController
.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => timelineState.setScrubbing(false)),
_scrollController.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
} else {
timelineState.setScrubbing(false);
}
});
}
Expand Down Expand Up @@ -526,7 +534,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
NotificationListener<ScrollNotification>(
onNotification: _onScrollVelocityNotification,
child: timeline,
),
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
Expand Down
Loading
Loading