1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-03 01:22:44 +01:00

Compare commits

...

15 commits

Author SHA1 Message Date
Arno
57e0bc57d1
Merge fd94741698 into 060300de8a 2025-01-28 19:53:03 +01:00
André Ventura
060300de8a
fix(web): cancel people merge selection: do not show "Change name successfully" notification ()
fix(web): cancel people merge selection: do not show "Change name successfully" notification.

Co-authored-by: André Ventura <afv@users.noreply.github.com>
2025-01-28 11:43:52 -06:00
Miguel Angel Nubla
c2ba1cc202
docs: add immich-upload-optimizer to Community Projects list () 2025-01-28 09:40:00 -06:00
PastLeo
08db77db23
feat: resolution selection and default preview playback for 360° panorama videos ()
* original/preview switching in photo-sphere-viewer

1. default to preview in photo-sphere-viewer video mode
2. install and integrate @photo-sphere-viewer/settings-plugin & @photo-sphere-viewer/resolution-plugin

* fix lint errors
2025-01-28 09:09:40 -06:00
Arno Wiest
fd94741698 rudimentary switch sorting order 2025-01-25 16:08:27 +01:00
Arno Wiest
9b495e55ff basic asset viewing in folders 2025-01-25 15:37:52 +01:00
Arno Wiest
f78532e345 add simple alphanumeric sorting for folders 2025-01-25 14:44:04 +01:00
Arno
22b32e596b
Merge branch 'main' into feat/mobile-folder-view 2025-01-25 14:27:26 +01:00
shenlong-tanwen
4e050307be fix: openapi generator shadowing query param in /view/folder 2025-01-22 06:17:34 +05:30
Arno
62fa2298bc
Merge branch 'main' into feat/mobile-folder-view 2025-01-10 21:53:42 +01:00
Arno
766949bfe2
Merge branch 'main' into feat/mobile-folder-view 2025-01-06 18:58:56 +01:00
Arno
538dd32f17
Merge branch 'main' into feat/mobile-folder-view 2025-01-04 19:04:12 +01:00
Arno Wiest
c1c37fbd43 fix: refactored data model and tried to implement asset loading 2025-01-04 14:05:34 +01:00
Alex
424e3b611b
Merge branch 'main' of github.com:immich-app/immich into feat/mobile-folder-view 2025-01-03 12:38:24 -06:00
Arno Wiest
e3e63e5d8b very rough prototype for folder navigation without assets 2025-01-02 23:46:41 +01:00
57 changed files with 868 additions and 39 deletions

View file

@ -99,6 +99,11 @@ const projects: CommunityProjectProps[] = [
description: 'Downloads a configurable number of random photos based on people or album ID.',
url: 'https://github.com/jon6fingrs/immich-dl',
},
{
title: 'Immich Upload Optimizer',
description: 'Automatically optimize files uploaded to Immich in order to save storage space',
url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer',
},
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View file

@ -274,6 +274,7 @@
"favorites_page_title": "Favorites",
"filename_search": "File name or extension",
"filter": "Filter",
"folders": "Folders",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"grant_permission": "Grant permission",
"haptic_feedback_switch": "Enable haptic feedback",

View file

@ -0,0 +1,6 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IFolderApiRepository {
Future<List<String>> getAllUniquePaths();
Future<List<Asset>> getAssetsForPath(String? path);
}

View file

@ -0,0 +1,12 @@
import 'package:immich_mobile/models/folder/root_folder.model.dart';
class RecursiveFolder extends RootFolder {
final String name;
final String path;
RecursiveFolder({
required this.path,
required this.name,
required super.subfolders,
});
}

View file

@ -0,0 +1,9 @@
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
class RootFolder {
final List<RecursiveFolder> subfolders;
RootFolder({
required this.subfolders,
});
}

View file

@ -0,0 +1,165 @@
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';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
import 'package:immich_mobile/models/folder/root_folder.model.dart';
import 'package:immich_mobile/providers/folder.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class FolderPage extends HookConsumerWidget {
final RecursiveFolder? folder;
const FolderPage({super.key, this.folder});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folderState = ref.watch(folderStructureProvider);
useEffect(
() {
if (folder == null) {
ref.read(folderStructureProvider.notifier).fetchFolders();
}
return null;
},
[],
);
void onToggleSortOrder() {
if (folder != null) {
ref.read(folderRenderListProvider(folder!).notifier).toggleSortOrder();
}
ref.read(folderStructureProvider.notifier).toggleSortOrder();
}
return Scaffold(
appBar: AppBar(
title: Text(folder?.name ?? 'Root'),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: onToggleSortOrder,
),
],
),
body: folderState.when(
data: (rootFolder) {
if (folder == null) {
return FolderContent(folder: rootFolder);
} else {
return FolderContent(folder: folder!);
}
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
ImmichToast.show(
context: context,
msg: "Failed to load folder".tr(),
toastType: ToastType.error,
);
return Center(child: const Text("Failed to load folder").tr());
},
),
);
}
}
class FolderContent extends HookConsumerWidget {
final RootFolder? folder;
const FolderContent({super.key, this.folder});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (folder == null) {
return Center(child: const Text("Folder not found").tr());
}
final folderRenderlist = ref.watch(folderRenderListProvider(folder!));
useEffect(
() {
ref.read(folderRenderListProvider(folder!).notifier).fetchAssets();
return null;
},
[folder],
);
return folderRenderlist.when(
data: (list) {
return ListView(
children: [
if (folder!.subfolders.isNotEmpty)
...folder!.subfolders.map(
(subfolder) => ListTile(
leading: Icon(Icons.folder, color: context.primaryColor),
title: Text(subfolder.name),
onTap: () =>
context.pushRoute(FolderRoute(folder: subfolder)),
),
),
if (!list.isEmpty &&
list.allAssets != null &&
list.allAssets!.isNotEmpty)
...list.allAssets!.map(
(asset) => ListTile(
onTap: () => context.pushRoute(
GalleryViewerRoute(
renderList: list,
initialIndex: list.allAssets!.indexOf(asset),
),
),
leading: SizedBox(
// height: 100,
width: 80,
child: ThumbnailImage(
asset: asset,
showStorageIndicator: false,
),
),
title: Row(
children: [
Flexible(
child: Text(
// Remove the file extension from the file name
// Sometimes the file name has multiple dots (.TS.mp4)
asset.fileName.split('.').first,
softWrap: false,
overflow: TextOverflow.ellipsis,
),
),
Text(
// Display the file extension(s)
".${asset.fileName.substring(asset.fileName.indexOf('.') + 1)}",
),
],
),
subtitle: Text(
"${asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo?.fileSize ?? 0) : ""} · ${DateFormat.yMMMd().format(asset.fileCreatedAt)}",
),
),
),
if (folder!.subfolders.isEmpty && list.isEmpty)
Center(child: const Text("No subfolders or assets").tr()),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
ImmichToast.show(
context: context,
msg: "Failed to load assets".tr(),
toastType: ToastType.error,
);
return Center(child: const Text("Failed to load assets").tr());
},
);
}
}

View file

@ -78,6 +78,7 @@ class LibraryPage extends ConsumerWidget {
PeopleCollectionCard(),
PlacesCollectionCard(),
LocalAlbumsCollectionCard(),
FoldersCollectionCard(),
],
),
const SizedBox(height: 12),
@ -380,6 +381,54 @@ class PlacesCollectionCard extends StatelessWidget {
}
}
class FoldersCollectionCard extends StatelessWidget {
const FoldersCollectionCard({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(FolderRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: Icon(
Icons.folder_outlined,
color: context.primaryColor,
size: 48,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'folders'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;

View file

@ -0,0 +1,76 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/models/folder/root_folder.model.dart';
import 'package:immich_mobile/services/folder.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:logging/logging.dart';
class FolderStructureNotifier extends StateNotifier<AsyncValue<RootFolder>> {
final FolderService _folderService;
final Logger _log = Logger("FolderStructureNotifier");
var sortOrder = SortOrder.asc;
FolderStructureNotifier(this._folderService) : super(const AsyncLoading());
Future<void> fetchFolders() async {
try {
final folders = await _folderService.getFolderStructure(sortOrder);
state = AsyncData(folders);
} catch (e, stack) {
_log.severe("Failed to build folder structure", e, stack);
state = AsyncError(e, stack);
}
}
Future<void> toggleSortOrder() {
sortOrder = sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
return fetchFolders();
}
}
final folderStructureProvider =
StateNotifierProvider<FolderStructureNotifier, AsyncValue<RootFolder>>(
(ref) {
return FolderStructureNotifier(
ref.watch(folderServiceProvider),
);
});
class FolderRenderListNotifier extends StateNotifier<AsyncValue<RenderList>> {
final FolderService _folderService;
final RootFolder _folder;
final Logger _log = Logger("FolderAssetsNotifier");
var sortOrder = SortOrder.asc;
FolderRenderListNotifier(this._folderService, this._folder)
: super(const AsyncLoading());
Future<void> fetchAssets() async {
try {
final assets = await _folderService.getFolderAssets(_folder, sortOrder);
state =
AsyncData(await RenderList.fromAssets(assets, GroupAssetsBy.none));
} catch (e, stack) {
_log.severe("Failed to fetch folder assets", e, stack);
state = AsyncError(e, stack);
}
}
Future<void> toggleSortOrder() {
sortOrder = sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
return fetchAssets();
}
}
final folderRenderListProvider = StateNotifierProvider.family<
FolderRenderListNotifier,
AsyncValue<RenderList>,
RootFolder>((ref, folder) {
return FolderRenderListNotifier(
ref.watch(folderServiceProvider),
folder,
);
});

View file

@ -0,0 +1,44 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/folder_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final folderApiRepositoryProvider = Provider(
(ref) => FolderApiRepository(
ref.watch(apiServiceProvider).viewApi,
),
);
class FolderApiRepository extends ApiRepository
implements IFolderApiRepository {
final ViewApi _api;
final Logger _log = Logger("FolderApiRepository");
FolderApiRepository(this._api);
@override
Future<List<String>> getAllUniquePaths() async {
try {
final list = await _api.getUniqueOriginalPaths();
return list ?? [];
} catch (e, stack) {
_log.severe("Failed to fetch unique original links", e, stack);
return [];
}
}
@override
Future<List<Asset>> getAssetsForPath(String? path) async {
try {
final list = await _api.getAssetsByOriginalPath(path ?? '/');
print("Assets for path: $path -> $list");
return list != null ? list.map(Asset.remote).toList() : [];
} catch (e, stack) {
_log.severe("Failed to fetch Assets by original path", e, stack);
return [];
}
}
}

View file

@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
@ -16,6 +17,7 @@ import 'package:immich_mobile/pages/backup/backup_options.page.dart';
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
import 'package:immich_mobile/pages/albums/albums.page.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
import 'package:immich_mobile/pages/library/local_albums.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
@ -208,6 +210,11 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: FolderRoute.page,
guards: [_authGuard],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
AutoRoute(
page: PartnerDetailRoute.page,
guards: [_authGuard, _duplicateGuard],

View file

@ -1175,6 +1175,40 @@ class PartnerRoute extends PageRouteInfo<void> {
);
}
/// manually written (with love) route for
/// [FolderPage]
class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
FolderRoute({
RecursiveFolder? folder,
List<PageRouteInfo>? children,
}) : super(
FolderRoute.name,
args: FolderRouteArgs(folder: folder),
initialChildren: children,
);
static const String name = 'FolderRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<FolderRouteArgs>();
return FolderPage(folder: args.folder);
},
);
}
class FolderRouteArgs {
const FolderRouteArgs({this.folder});
final RecursiveFolder? folder;
@override
String toString() {
return 'FolderRouteArgs{folder: $folder}';
}
}
/// generated route for
/// [PeopleCollectionPage]
class PeopleCollectionRoute extends PageRouteInfo<void> {

View file

@ -31,6 +31,7 @@ class ApiService implements Authentication {
late DownloadApi downloadApi;
late TrashApi trashApi;
late StacksApi stacksApi;
late ViewApi viewApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -64,6 +65,7 @@ class ApiService implements Authentication {
downloadApi = DownloadApi(_apiClient);
trashApi = TrashApi(_apiClient);
stacksApi = StacksApi(_apiClient);
viewApi = ViewApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View file

@ -0,0 +1,129 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
import 'package:immich_mobile/models/folder/root_folder.model.dart';
import 'package:immich_mobile/repositories/folder_api.repository.dart';
import 'package:logging/logging.dart';
final folderServiceProvider = Provider(
(ref) => FolderService(ref.watch(folderApiRepositoryProvider)),
);
class FolderService {
final FolderApiRepository _folderApiRepository;
final Logger _log = Logger("FolderService");
FolderService(this._folderApiRepository);
Future<RootFolder> getFolderStructure(SortOrder order) async {
final paths = await _folderApiRepository.getAllUniquePaths();
// Create folder structure
Map<String, List<RecursiveFolder>> folderMap = {};
for (String fullPath in paths) {
if (fullPath == '/') continue;
// Ensure the path starts with a slash
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
List<String> segments = fullPath.split('/')
..removeWhere((s) => s.isEmpty);
String currentPath = '';
for (int i = 0; i < segments.length; i++) {
String parentPath = currentPath.isEmpty ? '_root_' : currentPath;
currentPath =
i == 0 ? '/${segments[i]}' : '$currentPath/${segments[i]}';
if (!folderMap.containsKey(parentPath)) {
folderMap[parentPath] = [];
}
if (!folderMap[parentPath]!.any((f) => f.name == segments[i])) {
folderMap[parentPath]!.add(
RecursiveFolder(
path: parentPath == '_root_' ? '' : parentPath,
name: segments[i],
subfolders: [],
),
);
// Sort folders based on order parameter
folderMap[parentPath]!.sort(
(a, b) => order == SortOrder.desc
? b.name.compareTo(a.name)
: a.name.compareTo(b.name),
);
}
}
}
void attachSubfolders(RecursiveFolder folder) {
String fullPath = folder.path.isEmpty
? '/${folder.name}'
: '${folder.path}/${folder.name}';
if (folderMap.containsKey(fullPath)) {
folder.subfolders.addAll(folderMap[fullPath]!);
// Sort subfolders based on order parameter
folder.subfolders.sort(
(a, b) => order == SortOrder.desc
? b.name.compareTo(a.name)
: a.name.compareTo(b.name),
);
for (var subfolder in folder.subfolders) {
attachSubfolders(subfolder);
}
}
}
List<RecursiveFolder> rootSubfolders = folderMap['_root_'] ?? [];
// Sort root subfolders based on order parameter
rootSubfolders.sort(
(a, b) => order == SortOrder.desc
? b.name.compareTo(a.name)
: a.name.compareTo(b.name),
);
for (var folder in rootSubfolders) {
attachSubfolders(folder);
}
return RootFolder(
subfolders: rootSubfolders,
);
}
Future<List<Asset>> getFolderAssets(
RootFolder folder, SortOrder order) async {
try {
if (folder is RecursiveFolder) {
String fullPath =
folder.path.isEmpty ? folder.name : '${folder.path}/${folder.name}';
fullPath = fullPath[0] == '/' ? fullPath.substring(1) : fullPath;
var result = await _folderApiRepository.getAssetsForPath(fullPath);
if (order == SortOrder.desc) {
result.sort((a, b) => b.fileCreatedAt.compareTo(a.fileCreatedAt));
} else {
result.sort((a, b) => a.fileCreatedAt.compareTo(b.fileCreatedAt));
}
return result;
}
final result = await _folderApiRepository.getAssetsForPath('/');
return result;
} catch (e, stack) {
_log.severe(
"Failed to fetch assets for folder ${folder is RecursiveFolder ? folder.name : "root"}",
e,
stack,
);
return [];
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5,23 +5,23 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
url: "https://pub.dev"
source: hosted
version: "72.0.0"
version: "76.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
version: "0.3.3"
analyzer:
dependency: "direct overridden"
description:
name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
version: "6.11.0"
analyzer_plugin:
dependency: "direct overridden"
description:
@ -250,10 +250,10 @@ packages:
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
connectivity_plus:
dependency: "direct main"
description:
@ -900,18 +900,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
@ -940,10 +940,10 @@ packages:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
version: "0.1.3-main.0"
maplibre_gl:
dependency: "direct main"
description:
@ -1452,7 +1452,7 @@ packages:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
version: "0.0.0"
socket_io_client:
dependency: "direct main"
description:
@ -1513,10 +1513,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
version: "1.12.0"
state_notifier:
dependency: transitive
description:
@ -1545,10 +1545,10 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.0"
sync_http:
dependency: transitive
description:
@ -1577,10 +1577,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.7.3"
thumbhash:
dependency: "direct main"
description:
@ -1737,10 +1737,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev"
source: hosted
version: "14.2.5"
version: "14.3.0"
wakelock_plus:
dependency: "direct main"
description:
@ -1793,10 +1793,10 @@ packages:
dependency: transitive
description:
name: webdriver
sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.4"
win32:
dependency: transitive
description:

View file

@ -83,7 +83,7 @@ dependencies:
# Taken from https://github.com/Myzel394/locus/blob/445013d22ec1d759027d4303bd65b30c5c8588c8/pubspec.yaml#L105
dependency_overrides:
# TODO: remove once Isar is updated
analyzer: ^6.3.0
analyzer: ^6.0.0
# TODO: remove once analyzer override is removed
meta: ^1.11.0
# TODO: remove once analyzer override is removed

View file

@ -9,7 +9,11 @@ function dart {
wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache
patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch
cd ../../../../
cd ../../
wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
cd ../../
npx --yes @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
# Post generate patches

View file

@ -0,0 +1,194 @@
{{>header}}
{{>part_of}}
{{#operations}}
class {{{classname}}} {
{{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
{{#operation}}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
///
/// Note: This method returns the HTTP [Response].
{{/notes}}
{{^notes}}
{{#summary}}
///
/// Note: This method returns the HTTP [Response].
{{/summary}}
{{^summary}}
/// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response].
{{/summary}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<Response> {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
// ignore: prefer_const_declarations
final apiPath = r'{{{path}}}'{{#pathParams}}
.replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}};
// ignore: prefer_final_locals
Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}};
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
{{#hasQueryParams}}
{{#queryParams}}
{{^required}}
if ({{{paramName}}} != null) {
{{/required}}
queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}}));
{{^required}}
}
{{/required}}
{{/queryParams}}
{{/hasQueryParams}}
{{#hasHeaderParams}}
{{#headerParams}}
{{#required}}
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
{{/required}}
{{^required}}
if ({{{paramName}}} != null) {
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/required}}
{{/headerParams}}
{{/hasHeaderParams}}
const contentTypes = <String>[{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}];
{{#isMultipart}}
bool hasFields = false;
final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath));
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{#isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field;
mp.files.add({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
if (hasFields) {
postBody = mp;
}
{{/isMultipart}}
{{^isMultipart}}
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
{{/isMultipart}}
return apiClient.invokeAPI(
apiPath,
'{{{httpMethod}}}',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}});
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
{{#returnType}}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
{{#native_serialization}}
{{#isArray}}
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List)
.cast<{{{returnBaseType}}}>()
.{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}};
{{/isArray}}
{{^isArray}}
{{#isMap}}
return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),);
{{/isMap}}
{{^isMap}}
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}};
{{/isMap}}{{/isArray}}{{/native_serialization}}
}
return null;
{{/returnType}}
}
{{/operation}}
}
{{/operations}}

View file

@ -0,0 +1,29 @@
--- api.mustache 2025-01-22 05:50:25
+++ api.mustache.modified 2025-01-22 05:52:23
@@ -51,7 +51,7 @@
{{/allParams}}
Future<Response> {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
// ignore: prefer_const_declarations
- final path = r'{{{path}}}'{{#pathParams}}
+ final apiPath = r'{{{path}}}'{{#pathParams}}
.replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}};
// ignore: prefer_final_locals
@@ -90,7 +90,7 @@
{{#isMultipart}}
bool hasFields = false;
- final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path));
+ final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath));
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
@@ -121,7 +121,7 @@
{{/isMultipart}}
return apiClient.invokeAPI(
- path,
+ apiPath,
'{{{httpMethod}}}',
queryParams,
postBody,

21
web/package-lock.json generated
View file

@ -16,6 +16,8 @@
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
@ -1669,6 +1671,25 @@
"@photo-sphere-viewer/video-plugin": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/resolution-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.11.5.tgz",
"integrity": "sha512-Dbvp5bBtozD3IWt1Q0wORVaZBcB1bV9xUeoOS9A7F7b3EkQ2pkC5/jot/1AyM4wtU5wJ63NWHskQ1d7m6WWazQ==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.11.5",
"@photo-sphere-viewer/settings-plugin": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/settings-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.11.5.tgz",
"integrity": "sha512-ZgYaWjiBMhsoRH5ddW3h+v4J4LPmofsT7BBRq5UCssWw2Fsrvv7mFFRi4UbZ1qzeKmvNUOr8BaFQgX1ZLvUWfQ==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz",

View file

@ -72,6 +72,8 @@
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",

View file

@ -24,7 +24,7 @@
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
panorama={data}
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
originalPanorama={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
/>
{:catch}
{$t('errors.failed_to_load_asset')}

View file

@ -7,18 +7,21 @@
type AdapterConstructor,
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte';
interface Props {
panorama: string | { source: string };
originalImageUrl?: string;
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
}
let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
@ -30,9 +33,33 @@
viewer = new Viewer({
adapter,
plugins,
plugins: [
SettingsPlugin,
[
ResolutionPlugin,
{
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
resolutions: [
{
id: 'default',
label: 'Default',
panorama,
},
...(originalPanorama
? [
{
id: 'original',
label: 'Original',
panorama: originalPanorama,
},
]
: []),
],
},
],
...plugins,
],
container,
panorama,
touchmoveTwoFingers: false,
mousewheelCtrlKey: false,
navbar,
@ -40,15 +67,14 @@
maxFov: 120,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
if (originalImageUrl && !$alwaysLoadOriginalFile) {
if (originalPanorama && !$alwaysLoadOriginalFile) {
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
// Replace the preview with the original
viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => {
viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {});
});
void resolutionPlugin.setResolution('original');
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
}
};

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils';
import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
@ -22,7 +22,13 @@
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer panorama={{ source: getAssetOriginalUrl(assetId) }} plugins={[videoPlugin]} {adapter} navbar />
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]}
{adapter}
navbar
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}

View file

@ -92,6 +92,7 @@
let personMerge1: PersonResponseDto | undefined = $state();
let personMerge2: PersonResponseDto | undefined = $state();
let potentialMergePeople: PersonResponseDto[] = $state([]);
let isSuggestionSelectedByUser = $state(false);
let personName = '';
let suggestedPeople: PersonResponseDto[] = $state([]);
@ -233,15 +234,22 @@
personName = person.name;
personMerge1 = person;
personMerge2 = person2;
isSuggestionSelectedByUser = true;
viewMode = PersonPageViewMode.SUGGEST_MERGE;
};
const changeName = async () => {
viewMode = PersonPageViewMode.VIEW_ASSETS;
person.name = personName;
try {
isEditingName = false;
isEditingName = false;
if (isSuggestionSelectedByUser) {
// User canceled the merge
isSuggestionSelectedByUser = false;
return;
}
try {
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
notificationController.show({