Compare commits

..

7 Commits

Author SHA1 Message Date
Alex a564d46017 feat: new feature board 2026-06-22 16:59:23 -05:00
Daniel Dietzler c484bd99b6 fix: ignore external libraries for integrity report checksum check (#29248) 2026-06-22 13:56:24 +00:00
Anthony Clerici c0bf5a4c56 fix(server): use VBR for QSV so the max bitrate is respected (#29240)
* fix(server): use VBR for QSV so the max bitrate is respected

* update test
2026-06-22 09:56:20 -04:00
MuySup d9d50d2848 fix: turkish readme translation (#29234)
* Translation completed

3-2-1 rule translated

* Fix formatting of warning message in Turkish README
2026-06-22 09:55:58 -04:00
Daniel Dietzler c7453a67fd fix: detail panel people reactivity and iterator consumption (#29250) 2026-06-22 15:47:09 +02:00
Daniel Dietzler e918e3a313 feat: keyboard seeking for new video player (#29208) 2026-06-22 09:42:59 -04:00
Matthew Momjian dc7d57ff9a fix(docsc): v3 bump (#29246)
v3 bump in docs
2026-06-21 20:44:58 -05:00
37 changed files with 538 additions and 96 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+17
View File
@@ -1251,6 +1251,22 @@
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
"favorites": "Favorites",
"favorites_page_no_favorites": "No favorite assets found",
"feature_message_non_destructive_editing_body": "Edit your photos freely — the original is always kept untouched.",
"feature_message_non_destructive_editing_title": "Non-destructive editing",
"feature_message_ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
"feature_message_ocr_title": "Search text in your photos",
"feature_message_open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
"feature_message_open_in_immich_title": "Open photos in Immich",
"feature_message_recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
"feature_message_recently_added_title": "Recently added",
"feature_message_settings_subtitle": "See what's new in version {version}",
"feature_message_share_quality_body": "Press and hold the share button to choose the image quality before you share.",
"feature_message_share_quality_title": "Choose your share quality",
"feature_message_slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
"feature_message_slideshow_title": "Slideshow",
"feature_message_upload_to_album_body": "Add photos directly into an album as you upload them.",
"feature_message_upload_to_album_title": "Upload straight to an album",
"feature_message_version": "Version {version}",
"feature_photo_updated": "Feature photo updated",
"features": "Features",
"features_in_development": "Features in Development",
@@ -2530,6 +2546,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"whats_new": "What's new",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/feature_message_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
@@ -32,6 +33,7 @@ class AppConfig {
final BackupConfig backup;
final NetworkConfig network;
final ShareConfig share;
final FeatureMessageConfig featureMessage;
const AppConfig({
this.logLevel = .info,
@@ -46,6 +48,7 @@ class AppConfig {
this.backup = const .new(),
this.network = const .new(),
this.share = const .new(),
this.featureMessage = const .new(),
});
AppConfig copyWith({
@@ -61,6 +64,7 @@ class AppConfig {
BackupConfig? backup,
NetworkConfig? network,
ShareConfig? share,
FeatureMessageConfig? featureMessage,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
@@ -74,6 +78,7 @@ class AppConfig {
backup: backup ?? this.backup,
network: network ?? this.network,
share: share ?? this.share,
featureMessage: featureMessage ?? this.featureMessage,
);
@override
@@ -91,15 +96,29 @@ class AppConfig {
other.album == album &&
other.backup == backup &&
other.network == network &&
other.share == share);
other.share == share &&
other.featureMessage == featureMessage);
@override
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share);
int get hashCode => Object.hash(
logLevel,
theme,
cleanup,
map,
timeline,
image,
viewer,
slideshow,
album,
backup,
network,
share,
featureMessage,
);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
T read<T>(SettingsKey<T> key) =>
(switch (key) {
@@ -146,6 +165,7 @@ class AppConfig {
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
.featureMessageSeenVersion => featureMessage.seenVersion,
})
as T;
@@ -199,6 +219,7 @@ class AppConfig {
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
.featureMessageSeenVersion => copyWith(featureMessage: featureMessage.copyWith(seenVersion: value as int)),
};
}
}
@@ -0,0 +1,18 @@
class FeatureMessageConfig {
final int seenVersion;
const FeatureMessageConfig({this.seenVersion = 0});
FeatureMessageConfig copyWith({int? seenVersion}) =>
FeatureMessageConfig(seenVersion: seenVersion ?? this.seenVersion);
@override
bool operator ==(Object other) =>
identical(this, other) || (other is FeatureMessageConfig && other.seenVersion == seenVersion);
@override
int get hashCode => seenVersion.hashCode;
@override
String toString() => 'FeatureMessageConfig(seenVersion: $seenVersion)';
}
@@ -0,0 +1,49 @@
class FeatureHighlight {
final String image;
final String titleKey;
final String bodyKey;
const FeatureHighlight({required this.image, required this.titleKey, required this.bodyKey});
}
const int featureMessageHighlightVersion = 1;
const String featureMessageReleaseLabel = '3.0.0';
const List<FeatureHighlight> featureMessageHighlights = [
FeatureHighlight(
image: 'assets/feature_message/share_quality.webp',
titleKey: 'feature_message_share_quality_title',
bodyKey: 'feature_message_share_quality_body',
),
FeatureHighlight(
image: 'assets/feature_message/slideshow.webp',
titleKey: 'feature_message_slideshow_title',
bodyKey: 'feature_message_slideshow_body',
),
FeatureHighlight(
image: 'assets/feature_message/recently_added.webp',
titleKey: 'feature_message_recently_added_title',
bodyKey: 'feature_message_recently_added_body',
),
FeatureHighlight(
image: 'assets/feature_message/non_destructive_editing.webp',
titleKey: 'feature_message_non_destructive_editing_title',
bodyKey: 'feature_message_non_destructive_editing_body',
),
FeatureHighlight(
image: 'assets/feature_message/ocr.webp',
titleKey: 'feature_message_ocr_title',
bodyKey: 'feature_message_ocr_body',
),
FeatureHighlight(
image: 'assets/feature_message/open_in_immich.webp',
titleKey: 'feature_message_open_in_immich_title',
bodyKey: 'feature_message_open_in_immich_body',
),
FeatureHighlight(
image: 'assets/feature_message/upload_to_album.webp',
titleKey: 'feature_message_upload_to_album_title',
bodyKey: 'feature_message_upload_to_album_body',
),
];
+4 -1
View File
@@ -73,7 +73,10 @@ enum SettingsKey<T> {
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenVersion<int>();
final _SettingsCodec<T>? _codecOverride;
@@ -0,0 +1,17 @@
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
class FeatureMessageService {
final SettingsRepository _settingsRepository;
const FeatureMessageService(this._settingsRepository);
bool shouldShow() {
final seen = _settingsRepository.appConfig.read(SettingsKey.featureMessageSeenVersion);
return featureMessageHighlights.isNotEmpty && featureMessageHighlightVersion > seen;
}
Future<void> markSeen() =>
_settingsRepository.write(SettingsKey.featureMessageSeenVersion, featureMessageHighlightVersion);
}
@@ -2,7 +2,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
@@ -87,6 +89,14 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
settings.add(
SettingsCard(
icon: Icons.auto_awesome_outlined,
title: 'whats_new'.tr(),
subtitle: 'feature_message_settings_subtitle'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
onTap: () => showFeatureMessageDialog(context),
),
);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
}
}
@@ -116,6 +126,13 @@ class _TabletLayout extends HookWidget {
),
),
),
SliverToBoxAdapter(
child: ListTile(
title: Text('whats_new'.tr()),
leading: const Icon(Icons.auto_awesome_outlined),
onTap: () => showFeatureMessageDialog(context),
),
),
],
),
),
@@ -3,14 +3,37 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerWidget {
class MainTimelinePage extends ConsumerStatefulWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || ref.read(featureMessageCheckedProvider)) {
return;
}
ref.read(featureMessageCheckedProvider.notifier).state = true;
final service = ref.read(featureMessageServiceProvider);
// if (service.shouldShow()) {
showFeatureMessageDialog(context).then((_) => service.markSeen());
// }
});
}
@override
Widget build(BuildContext context) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
@@ -0,0 +1,201 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
Future<void> showFeatureMessageDialog(BuildContext context) {
return showGeneralDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: 'whats_new'.tr(),
barrierColor: Colors.black.withValues(alpha: 0.55),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, __, ___) => const _FeatureMessageDialog(),
transitionBuilder: (_, animation, __, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved), child: child),
);
},
);
}
class _FeatureMessageDialog extends StatefulWidget {
const _FeatureMessageDialog();
@override
State<_FeatureMessageDialog> createState() => _FeatureMessageDialogState();
}
class _FeatureMessageDialogState extends State<_FeatureMessageDialog> {
final PageController _controller = PageController();
int _index = 0;
bool get _isLast => _index >= featureMessageHighlights.length - 1;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _advance() {
if (_isLast) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
clipBehavior: Clip.antiAlias,
backgroundColor: context.colorScheme.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.9, maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('whats_new'.tr(), style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(
'feature_message_version'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: featureMessageHighlights.length,
onPageChanged: (i) => setState(() => _index = i),
itemBuilder: (_, index) => _FeaturePage(highlight: featureMessageHighlights[index]),
),
),
const SizedBox(height: 8),
_PageDots(controller: _controller, index: _index, count: featureMessageHighlights.length),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _advance,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(_isLast ? 'ok'.tr() : 'next'.tr(), key: ValueKey(_isLast)),
),
),
),
),
],
),
),
);
}
}
class _FeaturePage extends StatelessWidget {
final FeatureHighlight highlight;
const _FeaturePage({required this.highlight});
@override
Widget build(BuildContext context) {
final scheme = context.colorScheme;
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: ColoredBox(
color: scheme.surfaceContainerHighest,
child: SizedBox(
width: double.infinity,
height: 300,
child: Image.asset(
highlight.image,
fit: BoxFit.contain,
errorBuilder: (context, _, __) =>
Center(child: Icon(Icons.auto_awesome_outlined, color: scheme.onSurfaceVariant, size: 56)),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
highlight.titleKey.tr(),
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
],
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
final PageController controller;
final int index;
final int count;
const _PageDots({required this.controller, required this.index, required this.count});
@override
Widget build(BuildContext context) {
final primary = context.primaryColor;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final page = controller.hasClients ? (controller.page ?? index.toDouble()) : index.toDouble();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final activeness = (1 - (page - i).abs()).clamp(0.0, 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
height: 7,
width: 7 + 16 * activeness,
decoration: BoxDecoration(
color: Color.lerp(context.colorScheme.surfaceContainerHighest, primary, activeness),
borderRadius: BorderRadius.circular(8),
),
);
}),
);
},
);
}
}
@@ -0,0 +1,101 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
Future<void> showFeatureMessageSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useRootNavigator: true,
builder: (_) => const _FeatureMessageSheet(),
);
}
class _FeatureMessageSheet extends StatelessWidget {
const _FeatureMessageSheet();
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
actions: const [],
resizeOnScroll: false,
expand: false,
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
child: Text(
'whats_new'.tr(),
style: context.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
),
SliverList.separated(
itemCount: featureMessageHighlights.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, index) => _HighlightCard(highlight: featureMessageHighlights[index]),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
footer: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
child: Text('feature_message_got_it'.tr()),
),
),
),
),
);
}
}
class _HighlightCard extends StatelessWidget {
final FeatureHighlight highlight;
const _HighlightCard({required this.highlight});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
highlight.image,
fit: BoxFit.cover,
errorBuilder: (context, _, __) => ColoredBox(
color: context.colorScheme.surfaceContainerHighest,
child: Icon(Icons.image_outlined, color: context.colorScheme.onSurfaceVariant, size: 48),
),
),
),
),
const SizedBox(height: 12),
Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final featureMessageServiceProvider = Provider<FeatureMessageService>(
(ref) => FeatureMessageService(ref.read(settingsProvider)),
);
final featureMessageCheckedProvider = StateProvider<bool>((ref) => false);
@@ -18,6 +18,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -262,6 +263,7 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -349,6 +351,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -8,13 +8,15 @@ class SettingsCard extends StatelessWidget {
required this.icon,
required this.title,
required this.subtitle,
required this.settingRoute,
this.settingRoute,
this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final PageRouteInfo settingRoute;
final PageRouteInfo? settingRoute;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
@@ -38,7 +40,7 @@ class SettingsCard extends StatelessWidget {
),
title: Text(title, style: context.textTheme.titleMedium!.copyWith(color: context.primaryColor)),
subtitle: Text(subtitle, style: context.textTheme.bodyMedium),
onTap: () => context.pushRoute(settingRoute),
onTap: onTap ?? (settingRoute != null ? () => context.pushRoute(settingRoute!) : null),
),
),
);
+1
View File
@@ -119,6 +119,7 @@ flutter:
uses-material-design: true
assets:
- assets/
- assets/feature_message/
fonts:
- family: GoogleSans
fonts:
-28
View File
@@ -289,34 +289,6 @@
"required": ["albumIds"]
}
},
{
"name": "assetDataWebhook",
"title": "Trigger Webhook",
"description": "POST asset data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Asset data will be POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
-2
View File
@@ -5,7 +5,6 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -25,5 +24,4 @@ declare module 'main' {
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function assetDataWebhook(): I32;
}
-22
View File
@@ -181,25 +181,3 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const assetDataWebhook = () => {
return wrapper<WorkflowType.AssetV1, { url: string; headerName?: string; headerValue?: string }>(
({ config, data, functions }) => {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
functions.httpRequest(config.url, {
method: 'POST',
body: JSON.stringify(data.asset),
headers,
});
return {};
},
);
};
-12
View File
@@ -13,7 +13,6 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -34,11 +33,6 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
type HttpRequestOptions = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
@@ -81,11 +75,5 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
httpRequest: (url: string, options?: HttpRequestOptions) =>
call<[string, HttpRequestOptions | undefined], string>(
'httpRequest',
authToken,
[url, options],
),
};
};
+2 -2
View File
@@ -38,8 +38,8 @@
</p>
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
> ⚠️ Değerli fotoğraflarınız ve videolarınız için daima [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) yedekleme planını uygulayın!
>
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
@@ -129,6 +129,7 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
@@ -177,6 +177,7 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
@@ -2939,6 +2939,8 @@ describe(MediaService.name, () => {
'7',
'-global_quality:v',
'23',
'-b:v',
'6897k',
'-maxrate',
'10000k',
'-bufsize',
@@ -74,26 +74,12 @@ export class WorkflowExecutionService extends BaseService {
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const httpRequest = this.wrap<
[
url: string,
options?: {
method?: string;
headers?: Record<string, string>;
body?: string;
},
]
>(async (_, args) => {
const res = await fetch(...args);
return res.text();
});
const functions = {
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
httpRequest,
};
const stubs: typeof functions = {
@@ -101,7 +87,6 @@ export class WorkflowExecutionService extends BaseService {
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
httpRequest: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
+6
View File
@@ -788,6 +788,12 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
// Workaround for https://github.com/immich-app/immich/issues/29220, to be revisited
// QSV seems to ignore -maxrate without -b:v
// -b:v alongside global_quality uses QVBR
if (!this.useCQP()) {
options.push('-b:v', `${bitrates.target}${bitrates.unit}`);
}
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
@@ -686,6 +686,22 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should skip external library files', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, isExternal: true });
await sut.handleChecksumFiles({ refreshOnly: false });
await expect(
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
});
describe('handleChecksumRefresh', () => {
@@ -324,6 +324,18 @@
shortcut: { key: ' ' },
onShortcut: () => (videoPlayer?.paused ? videoPlayer?.play() : videoPlayer?.pause()),
},
{
shortcut: { shift: true, key: 'ArrowLeft' },
onShortcut: () =>
videoPlayer ? (videoPlayer.currentTime = Math.max(videoPlayer.currentTime - 0.4, 0)) : undefined,
},
{
shortcut: { shift: true, key: 'ArrowRight' },
onShortcut: () =>
videoPlayer
? (videoPlayer.currentTime = Math.min(videoPlayer.currentTime + 0.4, videoPlayer.duration))
: undefined,
},
]}
/>
+3 -2
View File
@@ -24,7 +24,8 @@ class FaceManager {
});
readonly people = $derived.by(() => {
const people = new SvelteMap<string, PersonResponseDto>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const people = new Map<string, PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
@@ -32,7 +33,7 @@ class FaceManager {
}
}
return people.values();
return Array.from(people.values());
});
readonly facesByPersonId = $derived.by(() => {