Compare commits

...

8 Commits

Author SHA1 Message Date
midzelis
7302d42def feat: generate progressive JPEGs for thumbnails 2026-01-23 00:54:11 +00:00
shenlong
9b2939d778 fix(mobile): bring back map settings (#25448)
* fix(mobile): bring back map settings

* chore: styling

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-22 21:51:48 +00:00
shenlong
bccad2940e fix: incorrect asset viewer scale on image frame update (#25430)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-22 15:46:30 -06:00
Min Idzelis
dd72c32c60 feat: rename parallel tests to ui, split test step into: [e2e, ui] (#25439) 2026-01-22 13:44:00 -05:00
Brandon Wees
4bd01b70ff fix: asset edit sequence (#25457) 2026-01-22 12:41:01 -06:00
renovate[bot]
945f7fb9ea chore(deps): update dependency lodash-es to v4.17.23 [security] (#25453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 13:38:13 -05:00
Mees Frensel
78f400305b fix(web): don't show ocr button on panoramas (#25450) 2026-01-22 10:07:05 -06:00
bo0tzz
55477a8a1a chore: revert mise-action bump (#25451) 2026-01-22 16:53:14 +01:00
42 changed files with 607 additions and 123 deletions

View File

@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Load parameters
id: parameters

View File

@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Destroy Docs Subdomain
env:

View File

@@ -502,7 +502,12 @@ jobs:
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0

View File

@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
{
name: 'ui',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.ui-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'firefox',

View File

@@ -104,6 +104,8 @@
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
"image_preview_title": "Preview Settings",
"image_progressive": "Progressive",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
"image_quality": "Quality",
"image_resolution": "Resolution",
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",

View File

@@ -30,3 +30,8 @@ class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
// Map Events
class MapMarkerReloadEvent extends Event {
const MapMarkerReloadEvent();
}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
@@ -11,7 +12,8 @@ class MapFactory {
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
}
class MapService {

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -82,8 +81,8 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService(_timelineRepository.map(userIds, options, groupBy));
}
class TimelineService {

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class DriftMapRepository extends DriftDatabaseRepository {
@@ -12,9 +13,27 @@ class DriftMapRepository extends DriftDatabaseRepository {
const DriftMapRepository(super._db) : _db = _db;
MapQuery remote(String ownerId) => _mapQueryBuilder(
assetFilter: (row) =>
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
assetFilter: (row) {
Expression<bool> condition =
row.deletedAt.isNull() &
row.ownerId.isIn(ownerIds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]);
if (options.onlyFavorites) {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
return condition;
},
);
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {

View File

@@ -15,6 +15,22 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
class TimelineMapOptions {
final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const TimelineMapOptions({
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
}
class DriftTimelineRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -467,15 +483,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count),
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
origin: TimelineOrigin.map,
);
Stream<List<Bucket>> _watchMapBucket(
String userId,
LatLngBounds bounds, {
List<String> userId,
TimelineMapOptions options, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
@@ -496,14 +512,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
@@ -512,8 +540,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
Future<List<BaseAsset>> _getMapBucketAssets(
String userId,
LatLngBounds bounds, {
List<String> userId,
TimelineMapOptions options, {
required int offset,
required int count,
}) {
@@ -526,13 +554,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
@@ -10,6 +11,16 @@ class DriftMapPage extends StatelessWidget {
const DriftMapPage({super.key, this.initialLocation});
void onSettingsPressed(BuildContext context) {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const DriftMapSettingsSheet(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -18,8 +29,8 @@ class DriftMapPage extends StatelessWidget {
children: [
DriftMap(initialLocation: initialLocation),
Positioned(
left: 16,
top: 60,
left: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => context.pop(),
@@ -32,6 +43,21 @@ class DriftMapPage extends StatelessWidget {
),
),
),
Positioned(
right: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => onSettingsPressed(context),
icon: const Icon(Icons.more_vert_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),
],
),
);

View File

@@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool assetReloadRequested = false;
double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
@@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
@@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
hasDraggedDown = null;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: initialPhotoViewState.scale,
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
ref.read(assetViewerProvider.notifier).setOpacity(255);
@@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
if (initialPhotoViewState.scale != null) {
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
}
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
@@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
@@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: initialScale);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;

View File

@@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget {
Widget build(BuildContext context) {
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
@@ -38,8 +38,13 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive');
}
final bounds = ref.watch(mapStateProvider).bounds;
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds);
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref
.watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions());
ref.onDispose(timelineService.dispose);
return timelineService;
}),

View File

@@ -1,11 +1,30 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const MapState({required this.bounds});
const MapState({
this.themeMode = ThemeMode.system,
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
@override
bool operator ==(covariant MapState other) {
@@ -15,9 +34,31 @@ class MapState {
@override
int get hashCode => bounds.hashCode;
MapState copyWith({LatLngBounds? bounds}) {
return MapState(bounds: bounds ?? this.bounds);
MapState copyWith({
LatLngBounds? bounds,
ThemeMode? themeMode,
bool? onlyFavorites,
bool? includeArchived,
bool? withPartners,
int? relativeDays,
}) {
return MapState(
bounds: bounds ?? this.bounds,
themeMode: themeMode ?? this.themeMode,
onlyFavorites: onlyFavorites ?? this.onlyFavorites,
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
);
}
TimelineMapOptions toOptions() => TimelineMapOptions(
bounds: bounds,
onlyFavorites: onlyFavorites,
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
);
}
class MapStateNotifier extends Notifier<MapState> {
@@ -31,11 +72,50 @@ class MapStateNotifier extends Notifier<MapState> {
return true;
}
void switchTheme(ThemeMode mode) {
// TODO: Remove this line when map theme provider is removed
// Until then, keep both in sync as MapThemeOverride uses map state provider
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
state = state.copyWith(themeMode: mode);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchIncludeArchived(bool isIncludeArchived) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchWithPartners(bool isWithPartners) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners);
state = state.copyWith(withPartners: isWithPartners);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setRelativeTime(int relativeDays) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() => MapState(
// TODO: set default bounds
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
MapState build() {
final appSettingsService = ref.read(appSettingsServiceProvider);
return MapState(
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
}
}
// This provider watches the markers from the map service and serves the markers.

View File

@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -51,11 +53,19 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
StreamSubscription? _eventSubscription;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen<MapMarkerReloadEvent>(_onEvent);
}
@override
void dispose() {
_debouncer.dispose();
bottomSheetOffset.dispose();
_eventSubscription?.cancel();
super.dispose();
}
@@ -63,6 +73,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
mapController = controller;
}
void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true));
Future<void> onMapReady() async {
final controller = mapController;
if (controller == null) {
@@ -98,7 +110,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
);
}
_debouncer.run(setBounds);
_debouncer.run(() => setBounds(forceReload: true));
controller.addListener(onMapMoved);
}
@@ -110,7 +122,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
_debouncer.run(setBounds);
}
Future<void> setBounds() async {
Future<void> setBounds({bool forceReload = false}) async {
final controller = mapController;
if (controller == null || !mounted) {
return;
@@ -127,7 +139,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final bounds = await controller.getVisibleRegion();
unawaited(
_reloadMutex.run(() async {
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
final markers = await ref.read(mapMarkerProvider(bounds).future);
await reloadMarkers(markers);
}
@@ -203,7 +215,7 @@ class _Map extends StatelessWidget {
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
attributionButtonMargins: const Point(8, kToolbarHeight),
),
),
);
@@ -244,7 +256,7 @@ class _DynamicMyLocationButton extends StatelessWidget {
valueListenable: bottomSheetOffset,
builder: (context, offset, child) {
return Positioned(
right: 16,
right: 20,
bottom: context.height * (offset - 0.02) + context.padding.bottom,
child: AnimatedOpacity(
opacity: offset < 0.8 ? 1 : 0,

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
color: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode),
),
const Divider(height: 30, thickness: 1),
MapSettingsListTile(
title: "map_settings_only_show_favorites".t(context: context),
selected: mapState.onlyFavorites,
onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived".t(context: context),
selected: mapState.includeArchived,
onChanged: (includeArchive) =>
ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive),
),
MapSettingsListTile(
title: "map_settings_include_show_partners".t(context: context),
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
@@ -13,7 +15,11 @@ final mapServiceProvider = Provider<MapService>(
throw Exception('User must be logged in to access map');
}
final mapService = ref.watch(mapFactoryProvider).remote(user.id);
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
return mapService;
},
// Empty dependencies to inform the framework that this provider

View File

@@ -1,4 +1,3 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -14,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget {
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
activeThumbColor: context.primaryColor,
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
value: selected,
onChanged: onChanged,
);

View File

@@ -1,5 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class MapTimeDropDown extends StatelessWidget {
final int relativeTime;
@@ -11,41 +13,47 @@ class MapTimeDropDown extends StatelessWidget {
Widget build(BuildContext context) {
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)),
),
LayoutBuilder(
builder: (_, constraints) => DropdownMenu(
width: constraints.maxWidth * 0.9,
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".tr()),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".tr(),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}),
),
],
return Padding(
padding: const EdgeInsets.only(left: 16, right: 28.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"date_range".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
),
],
Flexible(
child: DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".t(context: context),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
),
],
),
),
],
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 20),
child: Center(
child: Text(
"map_settings_theme_settings",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
).tr(),
"map_settings_theme_settings".t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
),
),
Row(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
/// The interface in which controllers will be implemented.
///
@@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The scale factor to transform the child (image or a customChild).
late double? scale;
double? get initialScale;
ScaleBoundaries? scaleBoundaries;
/// Nevermind this method :D, look away
void setScaleInvisibly(double? scale);
@@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
ScaleBoundaries? scaleBoundaries;
late void Function(Offset)? _animatePosition;
late void Function(double)? _animateScale;
late void Function(double)? _animateRotation;
@@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
}
_valueNotifier.value = newValue;
}
@override
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
}

View File

@@ -108,6 +108,17 @@ class _ImageWrapperState extends State<ImageWrapper> {
}
}
// Should be called only when _imageSize is not null
ScaleBoundaries get scaleBoundaries {
return ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
}
// retrieve image from the provider
void _resolveImage() {
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
@@ -133,6 +144,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastStack = null;
_didLoadSynchronously = synchronousCall;
widget.controller.scaleBoundaries = scaleBoundaries;
}
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
@@ -204,14 +216,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
);
}
final scaleBoundaries = ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
return PhotoViewCore(
imageProvider: widget.imageProvider,
backgroundDecoration: widget.backgroundDecoration,

View File

@@ -15,6 +15,7 @@ class SystemConfigGeneratedFullsizeImageDto {
SystemConfigGeneratedFullsizeImageDto({
required this.enabled,
required this.format,
required this.progressive,
required this.quality,
});
@@ -22,6 +23,8 @@ class SystemConfigGeneratedFullsizeImageDto {
ImageFormat format;
bool progressive;
/// Minimum value: 1
/// Maximum value: 100
int quality;
@@ -30,6 +33,7 @@ class SystemConfigGeneratedFullsizeImageDto {
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
other.enabled == enabled &&
other.format == format &&
other.progressive == progressive &&
other.quality == quality;
@override
@@ -37,15 +41,17 @@ class SystemConfigGeneratedFullsizeImageDto {
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(format.hashCode) +
(progressive.hashCode) +
(quality.hashCode);
@override
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]';
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'format'] = this.format;
json[r'progressive'] = this.progressive;
json[r'quality'] = this.quality;
return json;
}
@@ -61,6 +67,7 @@ class SystemConfigGeneratedFullsizeImageDto {
return SystemConfigGeneratedFullsizeImageDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
format: ImageFormat.fromJson(json[r'format'])!,
progressive: mapValueOfType<bool>(json, r'progressive')!,
quality: mapValueOfType<int>(json, r'quality')!,
);
}
@@ -111,6 +118,7 @@ class SystemConfigGeneratedFullsizeImageDto {
static const requiredKeys = <String>{
'enabled',
'format',
'progressive',
'quality',
};
}

View File

@@ -14,12 +14,15 @@ class SystemConfigGeneratedImageDto {
/// Returns a new [SystemConfigGeneratedImageDto] instance.
SystemConfigGeneratedImageDto({
required this.format,
required this.progressive,
required this.quality,
required this.size,
});
ImageFormat format;
bool progressive;
/// Minimum value: 1
/// Maximum value: 100
int quality;
@@ -30,6 +33,7 @@ class SystemConfigGeneratedImageDto {
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
other.format == format &&
other.progressive == progressive &&
other.quality == quality &&
other.size == size;
@@ -37,15 +41,17 @@ class SystemConfigGeneratedImageDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(format.hashCode) +
(progressive.hashCode) +
(quality.hashCode) +
(size.hashCode);
@override
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'format'] = this.format;
json[r'progressive'] = this.progressive;
json[r'quality'] = this.quality;
json[r'size'] = this.size;
return json;
@@ -61,6 +67,7 @@ class SystemConfigGeneratedImageDto {
return SystemConfigGeneratedImageDto(
format: ImageFormat.fromJson(json[r'format'])!,
progressive: mapValueOfType<bool>(json, r'progressive')!,
quality: mapValueOfType<int>(json, r'quality')!,
size: mapValueOfType<int>(json, r'size')!,
);
@@ -111,6 +118,7 @@ class SystemConfigGeneratedImageDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'format',
'progressive',
'quality',
'size',
};

View File

@@ -22618,6 +22618,9 @@
}
]
},
"progressive": {
"type": "boolean"
},
"quality": {
"maximum": 100,
"minimum": 1,
@@ -22627,6 +22630,7 @@
"required": [
"enabled",
"format",
"progressive",
"quality"
],
"type": "object"
@@ -22640,6 +22644,9 @@
}
]
},
"progressive": {
"type": "boolean"
},
"quality": {
"maximum": 100,
"minimum": 1,
@@ -22652,6 +22659,7 @@
},
"required": [
"format",
"progressive",
"quality",
"size"
],

View File

@@ -1538,10 +1538,12 @@ export type SystemConfigFFmpegDto = {
export type SystemConfigGeneratedFullsizeImageDto = {
enabled: boolean;
format: ImageFormat;
progressive: boolean;
quality: number;
};
export type SystemConfigGeneratedImageDto = {
format: ImageFormat;
progressive: boolean;
quality: number;
size: number;
};

18
pnpm-lock.yaml generated
View File

@@ -36,7 +36,7 @@ importers:
version: 1.20.1
lodash-es:
specifier: ^4.17.21
version: 4.17.22
version: 4.17.23
micromatch:
specifier: ^4.0.8
version: 4.0.8
@@ -802,7 +802,7 @@ importers:
version: 4.1.0
lodash-es:
specifier: ^4.17.21
version: 4.17.22
version: 4.17.23
luxon:
specifier: ^3.4.4
version: 3.7.2
@@ -8916,8 +8916,8 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -19332,7 +19332,7 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
lodash-es: 4.17.22
lodash-es: 4.17.23
chevrotain@11.0.3:
dependencies:
@@ -20027,7 +20027,7 @@ snapshots:
dagre-d3-es@7.0.13:
dependencies:
d3: 7.9.0
lodash-es: 4.17.22
lodash-es: 4.17.23
data-urls@3.0.2:
dependencies:
@@ -22376,7 +22376,7 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.17.22: {}
lodash-es@4.17.23: {}
lodash.camelcase@4.3.0: {}
@@ -22810,7 +22810,7 @@ snapshots:
dompurify: 3.3.1
katex: 0.16.27
khroma: 2.1.0
lodash-es: 4.17.22
lodash-es: 4.17.23
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
@@ -25714,7 +25714,7 @@ snapshots:
json-source-map: 0.6.1
jsonpath-plus: 10.3.0
jsonrepair: 3.13.1
lodash-es: 4.17.22
lodash-es: 4.17.23
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1

View File

@@ -319,11 +319,13 @@ export const defaults = Object.freeze<SystemConfig>({
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
},
preview: {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
},
colorspace: Colorspace.P3,
extractEmbedded: false,
@@ -331,6 +333,7 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: false,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
},
},
newVersionCheck: {

View File

@@ -585,6 +585,9 @@ class SystemConfigGeneratedImageDto {
@Type(() => Number)
@ApiProperty({ type: 'integer' })
size!: number;
@ValidateBoolean()
progressive!: boolean;
}
class SystemConfigGeneratedFullsizeImageDto {
@@ -600,6 +603,9 @@ class SystemConfigGeneratedFullsizeImageDto {
@Type(() => Number)
@ApiProperty({ type: 'integer' })
quality!: number;
@ValidateBoolean()
progressive!: boolean;
}
export class SystemConfigImageDto {

View File

@@ -15,3 +15,5 @@ from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

View File

@@ -12,14 +12,14 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return await this.db.transaction().execute(async (trx) => {
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
if (edits.length > 0) {
return trx
.insertInto('asset_edit')
.values(edits.map((edit) => ({ assetId, ...edit })))
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.returning(['action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>;
}
@@ -31,11 +31,12 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
async getAll(assetId: string): Promise<AssetEditActionItem[]> {
getAll(assetId: string): Promise<AssetEditActionItem[]> {
return this.db
.selectFrom('asset_edit')
.select(['action', 'parameters'])
.where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>;
}
}

View File

@@ -176,6 +176,7 @@ export class MediaRepository {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
});
await decoded.toFile(output);

View File

@@ -0,0 +1,14 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM "asset_edit";`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db);
await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db);
}

View File

@@ -9,6 +9,7 @@ import {
Generated,
PrimaryGeneratedColumn,
Table,
Unique,
} from 'src/sql-tools';
@Table('asset_edit')
@@ -19,6 +20,7 @@ import {
referencingOldTableAs: 'deleted_edit',
when: 'pg_trigger_depth() = 0',
})
@Unique({ columns: ['assetId', 'sequence'] })
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -31,4 +33,7 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@Column({ type: 'jsonb' })
parameters!: AssetEditActionParameter[T];
@Column({ type: 'integer' })
sequence!: number;
}

View File

@@ -352,6 +352,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -365,6 +366,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -575,6 +577,7 @@ describe(MediaService.name, () => {
format,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -588,6 +591,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -622,6 +626,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -635,6 +640,7 @@ describe(MediaService.name, () => {
format,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -643,6 +649,58 @@ describe(MediaService.name, () => {
);
});
it('should generate progressive JPEG for preview when enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: false } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
format: ImageFormat.Jpeg,
progressive: true,
}),
expect.stringContaining('preview.jpeg'),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
format: ImageFormat.Webp,
progressive: false,
}),
expect.stringContaining('thumbnail.webp'),
);
});
it('should generate progressive JPEG for thumbnail when enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
format: ImageFormat.Jpeg,
progressive: false,
}),
expect.stringContaining('preview.jpeg'),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
format: ImageFormat.Jpeg,
progressive: true,
}),
expect.stringContaining('thumbnail.jpeg'),
);
});
it('should delete previous thumbnail if different path', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
@@ -776,6 +834,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -807,6 +866,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Webp,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -820,6 +880,7 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -849,6 +910,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -861,6 +923,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
size: 1440,
processInvalidImages: false,
raw: rawInfo,
@@ -892,6 +955,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -948,6 +1012,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.Srgb,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -987,6 +1052,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Webp,
quality: 90,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -994,6 +1060,27 @@ describe(MediaService.name, () => {
expect.any(String),
);
});
it('should generate progressive JPEG for fullsize when enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
format: ImageFormat.Jpeg,
progressive: true,
}),
expect.stringContaining('fullsize.jpeg'),
);
});
});
describe('handleAssetEditThumbnailGeneration', () => {
@@ -1198,6 +1285,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1242,6 +1330,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1284,6 +1373,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1326,6 +1416,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1368,6 +1459,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1410,6 +1502,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1457,6 +1550,7 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',

View File

@@ -351,6 +351,7 @@ export class MediaService extends BaseService {
const fullsizeOptions = {
format: image.fullsize.format,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
...thumbnailOptions,
};
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
@@ -434,6 +435,7 @@ export class MediaService extends BaseService {
format: ImageFormat.Jpeg,
raw: info,
quality: image.thumbnail.quality,
progressive: false,
processInvalidImages: false,
size: FACE_THUMBNAIL_SIZE,
edits: [

View File

@@ -167,13 +167,15 @@ const updatedConfig = Object.freeze<SystemConfig>({
size: 250,
format: ImageFormat.Webp,
quality: 80,
progressive: false,
},
preview: {
size: 1440,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
},
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 },
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80, progressive: false },
colorspace: Colorspace.P3,
extractEmbedded: false,
},

View File

@@ -31,12 +31,14 @@ export interface FullsizeImageOptions {
format: ImageFormat;
quality: number;
enabled: boolean;
progressive: boolean;
}
export interface ImageOptions {
format: ImageFormat;
quality: number;
size: number;
progressive: boolean;
}
export interface RawImageInfo {
@@ -57,7 +59,7 @@ export interface DecodeToBufferOptions extends DecodeImageOptions {
orientation?: ExifOrientation;
}
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };

View File

@@ -37,6 +37,11 @@
name="format"
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.thumbnail.progressive = false;
}
}}
/>
<SettingSelect
@@ -64,6 +69,15 @@
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.thumbnail.progressive}
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -82,6 +96,11 @@
name="format"
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.preview.progressive = false;
}
}}
/>
<SettingSelect
@@ -108,6 +127,15 @@
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.preview.progressive}
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -137,6 +165,11 @@
name="format"
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.fullsize.progressive = false;
}
}}
/>
<SettingInputField
@@ -147,6 +180,17 @@
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.fullsize.progressive}
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
disabled={disabled ||
!configToEdit.image.fullsize.enabled ||
configToEdit.image.fullsize.format === ImageFormat.Webp}
/>
</SettingAccordion>
<div class="mt-4">

View File

@@ -431,6 +431,7 @@
const showOcrButton = $derived(
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor &&
ocrManager.hasOcrData,
);