1
0
forked from Cutlery/immich

Compare commits

..

11 Commits

Author SHA1 Message Date
Alex Tran c3c692aad8 Revert "Revert "fix(server): on_asset_update event sends varying data types (#7179)""
This reverts commit 26c3635291.
2024-02-22 09:24:41 -06:00
Alex Tran 2e99ce994b Merge branch 'fix-stacking' of github.com:immich-app/immich into fix-stacking 2024-02-22 09:21:55 -06:00
Alex Tran 26c3635291 Revert "fix(server): on_asset_update event sends varying data types (#7179)"
This reverts commit 4b46bb49d7.
2024-02-22 09:20:16 -06:00
Alex Tran 484f1256ea Merge branch 'fix-stacking' of github.com:immich-app/immich into fix-stacking 2024-02-22 09:11:06 -06:00
Alex Tran d51a666692 revert 2024-02-22 09:10:58 -06:00
Alex Tran 73f20ef4e7 Merge branch 'main' of github.com:immich-app/immich into fix-stacking 2024-02-22 08:40:45 -06:00
Alex Tran e1f66ac4da Merge branch 'main' of github.com:immich-app/immich into fix-stacking 2024-02-22 08:34:00 -06:00
Alex Tran 2d95715ae8 revert 2024-02-21 22:18:53 -06:00
Alex Tran 692b8b189a fixed 2024-02-21 22:12:50 -06:00
Alex 7d51fba1c0 log to track 2024-02-21 16:27:09 -06:00
Alex Tran 4c9ac82fdc fix(server): stack info in asset response for mobile 2024-02-21 12:38:31 -06:00
43 changed files with 562 additions and 787 deletions
-1
View File
@@ -4,7 +4,6 @@ name: immich-e2e
x-server-build: &server-common
image: immich-server:latest
container_name: immich-e2e-server
build:
context: ../
dockerfile: server/Dockerfile
-51
View File
@@ -1,51 +0,0 @@
import {
deleteAssets,
getAuditFiles,
updateAsset,
type LoginResponseDto,
} from '@immich/sdk';
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/audit', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
await fileUtils.reset();
admin = await apiUtils.adminSetup();
});
describe('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset, _] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) }
),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) }
),
]);
const body = await getAuditFiles({
headers: asBearerAuth(admin.accessToken),
});
expect(body.orphans).toHaveLength(0);
expect(body.extras).toHaveLength(0);
});
});
});
+1 -15
View File
@@ -17,17 +17,14 @@ import {
updatePerson,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'child_process';
import { spawn } from 'child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest';
const execPromise = promisify(exec);
export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) =>
@@ -38,9 +35,6 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
const serverContainerName = 'immich-e2e-server';
const uploadMediaDir = '/usr/src/app/upload/upload';
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
@@ -56,14 +50,6 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null;
export const fileUtils = {
reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"`
);
},
};
export const dbUtils = {
createFace: async ({
assetId,
+1
View File
@@ -0,0 +1 @@
3.13.6
+1
View File
@@ -0,0 +1 @@
3.13.6
+1
View File
@@ -0,0 +1 @@
C:/Users/alext/fvm/versions/3.13.6
+1 -1
View File
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3
@@ -1,179 +0,0 @@
import 'dart:async';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:video_player/video_player.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
import 'package:wakelock_plus/wakelock_plus.dart';
/// Provides the initialized video player controller
/// If the asset is local, use the local file
/// Otherwise, use a video player with a URL
ChewieController? useChewieController(
Asset asset, {
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
bool showOptions = true,
bool showControlsOnInitialize = false,
bool autoPlay = true,
bool autoInitialize = true,
bool allowFullScreen = false,
bool allowedScreenSleep = false,
bool showControls = true,
Widget? customControls,
Widget? placeholder,
Duration hideControlsTimer = const Duration(seconds: 1),
VoidCallback? onPlaying,
VoidCallback? onPaused,
VoidCallback? onVideoEnded,
}) {
return use(
_ChewieControllerHook(
asset: asset,
placeholder: placeholder,
showOptions: showOptions,
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
autoPlay: autoPlay,
allowFullScreen: allowFullScreen,
customControls: customControls,
hideControlsTimer: hideControlsTimer,
showControlsOnInitialize: showControlsOnInitialize,
showControls: showControls,
autoInitialize: autoInitialize,
allowedScreenSleep: allowedScreenSleep,
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
),
);
}
class _ChewieControllerHook extends Hook<ChewieController?> {
final Asset asset;
final EdgeInsets controlsSafeAreaMinimum;
final bool showOptions;
final bool showControlsOnInitialize;
final bool autoPlay;
final bool autoInitialize;
final bool allowFullScreen;
final bool allowedScreenSleep;
final bool showControls;
final Widget? customControls;
final Widget? placeholder;
final Duration hideControlsTimer;
final VoidCallback? onPlaying;
final VoidCallback? onPaused;
final VoidCallback? onVideoEnded;
const _ChewieControllerHook({
required this.asset,
this.controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
this.showOptions = true,
this.showControlsOnInitialize = false,
this.autoPlay = true,
this.autoInitialize = true,
this.allowFullScreen = false,
this.allowedScreenSleep = false,
this.showControls = true,
this.customControls,
this.placeholder,
this.hideControlsTimer = const Duration(seconds: 3),
this.onPlaying,
this.onPaused,
this.onVideoEnded,
});
@override
createState() => _ChewieControllerHookState();
}
class _ChewieControllerHookState
extends HookState<ChewieController?, _ChewieControllerHook> {
ChewieController? chewieController;
VideoPlayerController? videoPlayerController;
@override
void initHook() async {
super.initHook();
unawaited(_initialize());
}
@override
void dispose() {
chewieController?.dispose();
videoPlayerController?.dispose();
super.dispose();
}
@override
ChewieController? build(BuildContext context) {
return chewieController;
}
/// Initializes the chewie controller and video player controller
Future<void> _initialize() async {
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
// Use a local file for the video player controller
final file = await hook.asset.local!.file;
if (file == null) {
throw Exception('No file found for the video');
}
videoPlayerController = VideoPlayerController.file(file);
} else {
// Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken);
videoPlayerController = VideoPlayerController.networkUrl(
url,
httpHeaders: {"x-immich-user-token": accessToken},
);
}
videoPlayerController!.addListener(() {
final value = videoPlayerController!.value;
if (value.isPlaying) {
WakelockPlus.enable();
hook.onPlaying?.call();
} else if (!value.isPlaying) {
WakelockPlus.disable();
hook.onPaused?.call();
}
if (value.position == value.duration) {
WakelockPlus.disable();
hook.onVideoEnded?.call();
}
});
await videoPlayerController!.initialize();
setState(() {
chewieController = ChewieController(
videoPlayerController: videoPlayerController!,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
showOptions: hook.showOptions,
showControlsOnInitialize: hook.showControlsOnInitialize,
autoPlay: hook.autoPlay,
autoInitialize: hook.autoInitialize,
allowFullScreen: hook.allowFullScreen,
allowedScreenSleep: hook.allowedScreenSleep,
showControls: hook.showControls,
customControls: hook.customControls,
placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer,
);
});
}
}
@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget {
@@ -66,9 +66,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
children: [
if (_displayBufferingIndicator)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
child: ImmichLoadingIndicator(),
)
else
_buildHitArea(),
@@ -81,7 +79,6 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
@override
void dispose() {
_dispose();
super.dispose();
}
@@ -95,7 +92,6 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) {
_dispose();
@@ -110,10 +106,12 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
return GestureDetector(
onTap: () {
if (!_latestValue.isPlaying) {
if (_latestValue.isPlaying) {
ref.read(showControlsProvider.notifier).show = false;
} else {
_playPause();
ref.read(showControlsProvider.notifier).show = false;
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
@@ -133,11 +131,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
}
Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
_latestValue = controller.value;
controller.addListener(_updateState);
_latestValue = controller.value;
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
@@ -170,8 +167,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer;
_hideTimer?.cancel();
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
? ChewieController.defaultHideControlsTimer
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false;
});
@@ -23,7 +23,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -47,15 +46,17 @@ import 'package:openapi/api.dart' show ThumbnailFormat;
@RoutePage()
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
final Asset Function(int index) loadAsset;
final int totalAssets;
final int initialIndex;
final int heroOffset;
final bool showStack;
final RenderList renderList;
GalleryViewerPage({
super.key,
required this.renderList,
this.initialIndex = 0,
required this.initialIndex,
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex);
@@ -68,8 +69,6 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false);
@@ -138,7 +137,7 @@ class GalleryViewerPage extends HookConsumerWidget {
debugPrint('Error precaching next image: $exception, $stackTrace');
}
if (index < totalAssets.value && index >= 0) {
if (index < totalAssets && index >= 0) {
final asset = loadAsset(index);
precacheImage(
ImmichImage.imageProvider(asset: asset),
@@ -200,14 +199,16 @@ class GalleryViewerPage extends HookConsumerWidget {
force: force,
);
if (isDeleted && isParent) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(deleteAsset);
if (totalAssets.value == 1) {
if (totalAssets == 1) {
// Handle only one asset
context.popRoute();
} else {
// Go to next page otherwise
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.fastLinearToSlowEaseIn,
);
}
totalAssets.value -= 1;
}
return isDeleted;
}
@@ -703,18 +704,6 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -750,7 +739,7 @@ class GalleryViewerPage extends HookConsumerWidget {
? const ScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: totalAssets.value,
itemCount: totalAssets,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
final next = currentIndex.value < value ? value + 1 : value - 1;
@@ -805,9 +794,7 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0,
basePosition: Alignment.center,
child: VideoViewerPage(
onPlaying: () {
isPlayingVideo.value = true;
},
onPlaying: () => isPlayingVideo.value = true,
onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false,
@@ -1,15 +1,23 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
// ignore: must_be_immutable
class VideoViewerPage extends HookWidget {
class VideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final Widget? placeholder;
@@ -34,49 +42,211 @@ class VideoViewerPage extends HookWidget {
});
@override
Widget build(BuildContext context) {
final controller = useChewieController(
asset,
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
placeholder: placeholder,
showControls: showControls && !isMotionVideo,
hideControlsTimer: hideControlsTimer,
customControls: const VideoPlayerControls(),
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
);
// Loading
return PopScope(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Builder(
builder: (context) {
if (controller == null) {
return Stack(
children: [
if (placeholder != null) placeholder!,
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
],
);
}
final size = MediaQuery.of(context).size;
return SizedBox(
height: size.height,
width: size.width,
child: Chewie(
controller: controller,
),
);
},
Widget build(BuildContext context, WidgetRef ref) {
if (asset.isLocal && asset.livePhotoVideoId == null) {
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: videoFile.when(
data: (data) => VideoPlayer(
file: data,
isMotionVideo: false,
onVideoEnded: () {},
),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
),
loading: () => showDownloadingIndicator
? const Center(child: ImmichLoadingIndicator())
: Container(),
),
),
);
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final String videoUrl = isMotionVideo
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoPlayer(
url: videoUrl,
accessToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,
onPlaying: onPlaying,
placeholder: placeholder,
hideControlsTimer: hideControlsTimer,
showControls: showControls,
showDownloadingIndicator: showDownloadingIndicator,
),
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
showDownloadingIndicator)
? 1.0
: 0.0,
child: SizedBox(
height: context.height,
width: context.width,
child: const Center(
child: ImmichLoadingIndicator(),
),
),
),
],
);
}
}
final _fileFamily =
FutureProvider.family<File, AssetEntity>((ref, entity) async {
final file = await entity.file;
if (file == null) {
throw Exception();
}
return file;
});
class VideoPlayer extends StatefulWidget {
final String? url;
final String? accessToken;
final File? file;
final bool isMotionVideo;
final VoidCallback? onVideoEnded;
final Duration hideControlsTimer;
final bool showControls;
final Function()? onPlaying;
final Function()? onPaused;
/// The placeholder to show while the video is loading
/// usually, a thumbnail of the video
final Widget? placeholder;
final bool showDownloadingIndicator;
const VideoPlayer({
super.key,
this.url,
this.accessToken,
this.file,
this.onVideoEnded,
required this.isMotionVideo,
this.onPlaying,
this.onPaused,
this.placeholder,
this.hideControlsTimer = const Duration(
seconds: 5,
),
this.showControls = true,
this.showDownloadingIndicator = true,
});
@override
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded?.call();
}
}
});
}
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
}
}
_createChewieController() {
chewieController = ChewieController(
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
showOptions: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: widget.showControls && !widget.isMotionVideo,
customControls: const VideoPlayerControls(),
hideControlsTimer: widget.hideControlsTimer,
);
}
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
height: context.height,
width: context.width,
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: context.height,
width: context.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
if (widget.showDownloadingIndicator)
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
}
}
@@ -311,12 +311,4 @@ class RenderList {
GroupAssetsBy groupBy,
) =>
_buildRenderList(assets, null, groupBy);
/// Deletes an asset from the render list and clears the buffer
/// This is only a workaround for deleted images still appearing in the gallery
void deleteAsset(Asset deleteAsset) {
allAssets?.remove(deleteAsset);
_buf.clear();
_bufOffset = 0;
}
}
@@ -2,7 +2,6 @@ import 'dart:collection';
import 'dart:developer';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -12,7 +11,6 @@ import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@@ -589,7 +587,6 @@ class _AssetRow extends StatelessWidget {
key: key,
children: assets.mapIndexed((int index, Asset asset) {
final bool last = index + 1 == assetsPerRow;
final isSelected = isSelectionActive && selectedAssets.contains(asset);
return Container(
width: width * widthDistribution[index],
height: width,
@@ -597,37 +594,18 @@ class _AssetRow extends StatelessWidget {
bottom: margin,
right: last ? 0.0 : margin,
),
child: GestureDetector(
onTap: () {
if (selectionActive) {
if (isSelected) {
onDeselect?.call(asset);
} else {
onSelect?.call(asset);
}
} else {
context.pushRoute(
GalleryViewerRoute(
renderList: renderList,
initialIndex: absoluteOffset + index,
heroOffset: heroOffset,
showStack: showStack,
),
);
}
},
onLongPress: () {
onSelect?.call(asset);
HapticFeedback.heavyImpact();
},
child: ThumbnailImage(
asset: asset,
multiselectEnabled: selectionActive,
isSelected: isSelected,
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
showStack: showStack,
),
child: ThumbnailImage(
asset: asset,
index: absoluteOffset + index,
loadAsset: renderList.loadAsset,
totalAssets: renderList.totalAssets,
multiselectEnabled: selectionActive,
isSelected: isSelectionActive && selectedAssets.contains(asset),
onSelect: () => onSelect?.call(asset),
onDeselect: () => onDeselect?.call(asset),
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
showStack: showStack,
),
);
}).toList(),
@@ -1,42 +1,39 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
import 'package:isar/isar.dart';
/// Shows the thumbnail images in the asset grid view
class ThumbnailImage extends StatelessWidget {
/// The asset to show the thumbnail image for
final Asset asset;
/// Whether to show the storage indicator icont over the image or not
final int index;
final Asset Function(int index) loadAsset;
final int totalAssets;
final bool showStorageIndicator;
/// Whether to show the show stack icon over the image or not
final bool showStack;
/// Whether to show the checkmark indicating that this image is selected
final bool isSelected;
/// Can override [isSelected] and never show the selection indicator
final bool multiselectEnabled;
/// If we are allowed to deselect this image
final bool canDeselect;
/// The offset index to apply to this hero tag for animation
final Function? onSelect;
final Function? onDeselect;
final int heroOffset;
const ThumbnailImage({
super.key,
required this.asset,
required this.index,
required this.loadAsset,
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.isSelected = false,
this.multiselectEnabled = false,
this.onDeselect,
this.onSelect,
this.heroOffset = 0,
this.canDeselect = true,
});
@override
@@ -149,7 +146,11 @@ class ThumbnailImage extends StatelessWidget {
}
return Container(
decoration: BoxDecoration(
color: canDeselect ? assetContainerColor : Colors.grey,
border: Border.all(
width: 0,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
@@ -163,52 +164,79 @@ class ThumbnailImage extends StatelessWidget {
);
}
return Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
decoration: BoxDecoration(
border: multiselectEnabled && isSelected
? Border.all(
color: canDeselect ? assetContainerColor : Colors.grey,
width: 8,
)
: const Border(),
),
child: buildImage(),
),
if (multiselectEnabled)
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
return GestureDetector(
onTap: () {
if (multiselectEnabled) {
if (isSelected) {
onDeselect?.call();
} else {
onSelect?.call();
}
} else {
context.pushRoute(
GalleryViewerRoute(
initialIndex: index,
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
),
if (showStorageIndicator)
Positioned(
right: 8,
bottom: 5,
child: Icon(
storageIcon(asset),
color: Colors.white,
size: 18,
);
}
},
onLongPress: () {
onSelect?.call();
HapticFeedback.heavyImpact();
},
child: Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
decoration: BoxDecoration(
border: multiselectEnabled && isSelected
? Border.all(
color: onDeselect == null
? Colors.grey
: assetContainerColor,
width: 8,
)
: const Border(),
),
child: buildImage(),
),
if (asset.isFavorite)
const Positioned(
left: 8,
bottom: 5,
child: Icon(
Icons.favorite,
color: Colors.white,
size: 18,
if (multiselectEnabled)
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
),
),
),
if (!asset.isImage) buildVideoIcon(),
if (asset.stackChildrenCount > 0) buildStackIcon(),
],
if (showStorageIndicator)
Positioned(
right: 8,
bottom: 5,
child: Icon(
storageIcon(asset),
color: Colors.white,
size: 18,
),
),
if (asset.isFavorite)
const Positioned(
left: 8,
bottom: 5,
child: Icon(
Icons.favorite,
color: Colors.white,
size: 18,
),
),
if (!asset.isImage) buildVideoIcon(),
if (asset.stackChildrenCount > 0) buildStackIcon(),
],
),
);
}
}
+2 -8
View File
@@ -11,7 +11,6 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
@@ -179,16 +178,11 @@ class MapPage extends HookConsumerWidget {
return;
}
// Since we only have a single asset, we can just show GroupAssetBy.none
final renderList = await RenderList.fromAssets(
[asset],
GroupAssetsBy.none,
);
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,
renderList: renderList,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0,
),
);
@@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget {
LayoutBuilder(
builder: (context, constraints) {
// Determine the fit using the aspect ratio
BoxFit fit = BoxFit.contain;
BoxFit fit = BoxFit.fitWidth;
if (asset.width != null && asset.height != null) {
final aspectRatio = asset.width! / asset.height!;
final aspectRatio = asset.height! / asset.width!;
final phoneAspectRatio =
constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction
-1
View File
@@ -9,7 +9,6 @@ import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_options_page.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/map/views/map_location_picker_page.dart';
import 'package:immich_mobile/modules/map/views/map_page.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
+17 -11
View File
@@ -162,8 +162,9 @@ abstract class _$AppRouter extends RootStackRouter {
routeData: routeData,
child: GalleryViewerPage(
key: args.key,
renderList: args.renderList,
initialIndex: args.initialIndex,
loadAsset: args.loadAsset,
totalAssets: args.totalAssets,
heroOffset: args.heroOffset,
showStack: args.showStack,
),
@@ -792,8 +793,9 @@ class FavoritesRoute extends PageRouteInfo<void> {
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute({
Key? key,
required RenderList renderList,
int initialIndex = 0,
required int initialIndex,
required Asset Function(int) loadAsset,
required int totalAssets,
int heroOffset = 0,
bool showStack = false,
List<PageRouteInfo>? children,
@@ -801,8 +803,9 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute.name,
args: GalleryViewerRouteArgs(
key: key,
renderList: renderList,
initialIndex: initialIndex,
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
@@ -818,25 +821,28 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
class GalleryViewerRouteArgs {
const GalleryViewerRouteArgs({
this.key,
required this.renderList,
this.initialIndex = 0,
required this.initialIndex,
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
});
final Key? key;
final RenderList renderList;
final int initialIndex;
final Asset Function(int) loadAsset;
final int totalAssets;
final int heroOffset;
final bool showStack;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, renderList: $renderList, initialIndex: $initialIndex, heroOffset: $heroOffset, showStack: $showStack}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
}
}
@@ -1387,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused,
Widget? placeholder,
bool showControls = true,
Duration hideControlsTimer = const Duration(milliseconds: 1500),
Duration hideControlsTimer = const Duration(seconds: 5),
bool showDownloadingIndicator = true,
List<PageRouteInfo>? children,
}) : super(
@@ -1423,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
this.hideControlsTimer = const Duration(milliseconds: 1500),
this.hideControlsTimer = const Duration(seconds: 5),
this.showDownloadingIndicator = true,
});
-5
View File
@@ -171,11 +171,6 @@ class Asset {
int? stackCount;
/// Aspect ratio of the asset
@ignore
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
@@ -58,6 +58,18 @@ class AssetService {
final assetDto = await _apiService.assetApi
.getAllAssets(userId: user.id, updatedAfter: since);
if (assetDto == null) return (null, null);
print("AssetDto length: ${assetDto.length} ");
for (final e in assetDto) {
print("AssetDto: ${e.stackParentId}");
var b = Asset.remote(e);
print("e.stackParentId ${e.stackParentId}");
print("e.id ${e.id}");
print(
"e.stackParentId == e.id ? null : e.stackParentId, ${e.stackParentId == e.id ? null : e.stackParentId}",
);
print("Mapped asset ${b.stackParentId}");
}
return (assetDto.map(Asset.remote).toList(), deleted.ids);
}
@@ -82,6 +94,7 @@ class AssetService {
if (assets == null) {
return null;
}
allAssets.addAll(assets.map(Asset.remote));
if (assets.length < chunkSize) {
break;
@@ -1,40 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class DelayedLoadingIndicator extends StatelessWidget {
/// The delay to avoid showing the loading indicator
final Duration delay;
/// Defaults to using the [ImmichLoadingIndicator]
final Widget? child;
/// An optional fade in duration to animate the loading
final Duration? fadeInDuration;
const DelayedLoadingIndicator({
super.key,
this.delay = const Duration(seconds: 3),
this.child,
this.fadeInDuration,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
}
return Container(key: const ValueKey('hiding'));
},
),
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square(
@@ -9,12 +9,7 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox(
decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
child: const Center(
child: DelayedLoadingIndicator(
delay: Duration(seconds: 1),
fadeInDuration: Duration(milliseconds: 400),
),
),
child: const Center(child: ImmichLoadingIndicator()),
),
),
);
@@ -32,19 +27,19 @@ class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
class _LoadingOverlayState
extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
late final _isLoading = ValueNotifier(false)..addListener(_listener);
OverlayEntry? _loadingOverlay;
late final _isProcessing = ValueNotifier(false)..addListener(_listener);
OverlayEntry? overlayEntry;
void _listener() {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isLoading.value) {
_loadingOverlay?.remove();
_loadingOverlay = _loadingEntry;
if (_isProcessing.value) {
overlayEntry?.remove();
overlayEntry = _loadingEntry;
Overlay.of(context).insert(_loadingEntry);
} else {
_loadingOverlay?.remove();
_loadingOverlay = null;
overlayEntry?.remove();
overlayEntry = null;
}
});
});
@@ -52,17 +47,17 @@ class _LoadingOverlayState
@override
ValueNotifier<bool> build(BuildContext context) {
return _isLoading;
return _isProcessing;
}
@override
void dispose() {
_isLoading.dispose();
_isProcessing.dispose();
super.dispose();
}
@override
Object? get debugValue => _isLoading.value;
Object? get debugValue => _isProcessing.value;
@override
String get debugLabel => 'useProcessingOverlay<>';
+26 -50
View File
@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "6.1.4"
file_selector_linux:
dependency: transitive
description:
@@ -569,10 +569,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.1.1"
flutter_web_auth:
dependency: "direct main"
description:
@@ -619,10 +619,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02
url: "https://pub.dev"
source: hosted
version: "11.0.0"
version: "10.1.0"
geolocator_android:
dependency: transitive
description:
@@ -651,10 +651,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.2.0"
geolocator_windows:
dependency: transitive
description:
@@ -860,30 +860,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
url: "https://pub.dev"
source: hosted
version: "2.0.1"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
url: "https://pub.dev"
source: hosted
version: "2.0.1"
lints:
dependency: transitive
description:
@@ -931,18 +907,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.16"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.5.0"
meta:
dependency: "direct overridden"
description:
@@ -1026,10 +1002,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.8.3"
path_provider:
dependency: "direct main"
description:
@@ -1162,10 +1138,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
url: "https://pub.dev"
source: hosted
version: "3.1.4"
version: "3.1.2"
plugin_platform_interface:
dependency: transitive
description:
@@ -1194,10 +1170,10 @@ packages:
dependency: transitive
description:
name: process
sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "4.2.4"
provider:
dependency: transitive
description:
@@ -1322,10 +1298,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.3.0"
shared_preferences_platform_interface:
dependency: transitive
description:
@@ -1346,10 +1322,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.3.0"
shelf:
dependency: transitive
description:
@@ -1655,10 +1631,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
url: "https://pub.dev"
source: hosted
version: "13.0.0"
version: "11.10.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1703,10 +1679,10 @@ packages:
dependency: transitive
description:
name: webdriver
sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.2"
win32:
dependency: transitive
description:
+2 -2
View File
@@ -32,8 +32,8 @@ dependencies:
git:
url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
geolocator: ^11.0.0 # used to move to current location in map view
flutter_udid: ^3.0.0
geolocator: ^10.1.0 # used to move to current location in map view
flutter_udid: ^2.1.1
package_info_plus: ^5.0.1
url_launcher: ^6.2.4
http: 0.13.5
+1 -1
View File
@@ -167,7 +167,7 @@ export class AuditService {
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
this.assetRepository.getAll(options, { withDeleted: true }),
);
let assetCount = 0;
@@ -157,7 +157,9 @@ type BaseAssetSearchOptions = SearchDateOptions &
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions;
export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions &
SearchOneToOneRelationOptions &
SearchRelationOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@@ -116,9 +116,17 @@ export class AssetService {
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllByFileCreationDate(
{ take: dto.take ?? 1000, skip: dto.skip },
{ ...dto, userIds: [userId], withDeleted: true, orderDirection: 'DESC', withExif: true, isVisible: true },
{
...dto,
userIds: [userId],
withDeleted: true,
orderDirection: 'DESC',
withExif: true,
isVisible: true,
withStacked: true,
},
);
return assets.items.map((asset) => mapAsset(asset));
return assets.items.map((asset) => mapAsset(asset, { withStack: true }));
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
+4 -4
View File
@@ -32,7 +32,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
"integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
"integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -112,8 +112,8 @@
desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
bind:value={config.machineLearning.facialRecognition.minScore}
step="0.1"
min={0}
max={1}
min="0"
max="1"
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minScore !==
savedConfig.machineLearning.facialRecognition.minScore}
@@ -125,8 +125,8 @@
desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.1"
min={0}
max={2}
min="0"
max="2"
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.maxDistance !==
savedConfig.machineLearning.facialRecognition.maxDistance}
@@ -138,7 +138,7 @@
desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
bind:value={config.machineLearning.facialRecognition.minFaces}
step="1"
min={1}
min="1"
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minFaces !==
savedConfig.machineLearning.facialRecognition.minFaces}
@@ -84,26 +84,7 @@
};
</script>
<section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration: 500 }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>Storage Template
</a>
and its
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>implications
</a>
</p>
</div>
<section class="dark:text-immich-dark-fg">
{#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
<SettingSwitch
@@ -1,16 +1,23 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiPause,
mdiPlay,
mdiShuffle,
mdiShuffleDisabled,
} from '@mdi/js';
const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
const { slideshowShuffle } = slideshowStore;
const { restartProgress, stopProgress } = slideshowStore;
let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar;
let showSettings = false;
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
@@ -47,27 +54,25 @@
</script>
<div class="m-4 flex gap-2">
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
{#if $slideshowShuffle}
<CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
{:else}
<CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
{/if}
<CircleIconButton
buttonSize="50"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
<CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
<CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
</div>
{#if showSettings}
<SlideshowSettings onClose={() => (showSettings = false)} />
{/if}
<ProgressBar
autoplay
hidden={!$showProgressBar}
duration={$slideshowDelay}
bind:this={progressBar}
bind:status={progressBarStatus}
on:done={() => dispatch('next')}
duration={5000}
/>
@@ -1,72 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let checked = false;
export let disabled = false;
const dispatch = createEventDispatcher<{ toggle: boolean }>();
const onToggle = (event: Event) => dispatch('toggle', (event.target as HTMLInputElement).checked);
</script>
<label class="relative inline-block h-[10px] w-[36px] flex-none">
<input
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
type="checkbox"
bind:checked
on:click={onToggle}
{disabled}
/>
{#if disabled}
<span class="slider slider-disabled cursor-not-allowed" />
{:else}
<span class="slider slider-enabled cursor-pointer" />
{/if}
</label>
<style>
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
input:disabled {
cursor: not-allowed;
}
.slider:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
input:checked + .slider-disabled {
background-color: gray;
}
input:checked + .slider-enabled {
background-color: #adcbfa;
}
</style>
@@ -456,9 +456,9 @@
asset={$viewingAsset}
{isShared}
{album}
on:previous={handlePrevious}
on:next={handleNext}
on:close={handleClose}
on:previous={() => handlePrevious()}
on:next={() => handleNext()}
on:close={() => handleClose()}
on:action={({ detail: action }) => handleAction(action.type, action.asset)}
/>
{/if}
@@ -52,7 +52,7 @@
};
</script>
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
<div class="relative w-full" use:clickOutside on:outclick={handleOutClick}>
<div>
{#if isOpen}
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
@@ -96,7 +96,7 @@
<div
role="listbox"
transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
class="absolute text-left w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
>
{#if filteredOptions.length === 0}
<div class="px-4 py-2 font-medium">No results</div>
@@ -15,29 +15,20 @@
*/
export let autoplay = false;
/**
* Duration in milliseconds
* @default 5000
*/
export let duration = 5000;
/**
* Progress bar status
*/
export let status: ProgressBarStatus = ProgressBarStatus.Paused;
export let hidden = false;
export let duration = 5;
const onChange = () => {
progress = setDuration(duration);
play();
};
let progress = setDuration(duration);
$: duration, onChange();
$: {
if ($progress === 1) {
dispatch('done');
}
}
let progress = tweened<number>(0, {
duration: (from: number, to: number) => (to ? duration * (to - from) : 0),
});
const dispatch = createEventDispatcher<{
done: void;
@@ -76,13 +67,17 @@
progress.set(0);
};
function setDuration(newDuration: number) {
return tweened<number>(0, {
duration: (from: number, to: number) => (to ? newDuration * 1000 * (to - from) : 0),
export const setDuration = (newDuration: number) => {
progress = tweened<number>(0, {
duration: (from: number, to: number) => (to ? newDuration * (to - from) : 0),
});
}
};
progress.subscribe((value) => {
if (value === 1) {
dispatch('done');
}
});
</script>
{#if !hidden}
<span class="absolute left-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`} />
{/if}
<span class="absolute left-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`} />
@@ -13,8 +13,8 @@
export let inputType: SettingInputFieldType;
export let value: string | number;
export let min = Number.MIN_SAFE_INTEGER;
export let max = Number.MAX_SAFE_INTEGER;
export let min = Number.MIN_SAFE_INTEGER.toString();
export let max = Number.MAX_SAFE_INTEGER.toString();
export let step = '1';
export let label = '';
export let desc = '';
@@ -25,23 +25,15 @@
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
if (inputType === SettingInputFieldType.NUMBER) {
let newValue = Number(value) || 0;
if (newValue < min) {
newValue = min;
}
if (newValue > max) {
newValue = max;
}
value = newValue;
value = Number(value) || 0;
}
};
</script>
<div class="mb-4 w-full">
<div class={`flex h-[26px] place-items-center gap-1`}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={label}>{label}</label>
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
@@ -71,8 +63,8 @@
id={label}
name={label}
type={inputType}
min={min.toString()}
max={max.toString()}
{min}
{max}
{step}
{required}
{value}
@@ -25,9 +25,7 @@
<div class="mb-4 w-full">
<div class={`flex h-[26px] place-items-center gap-1`}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select"
>{label}</label
>
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
{#if isEdited}
<div
@@ -2,7 +2,6 @@
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
import Slider from '$lib/components/elements/slider.svelte';
export let title: string;
export let subtitle = '';
@@ -34,5 +33,66 @@
<slot />
</div>
<Slider bind:checked {disabled} on:click={onToggle} />
<label class="relative inline-block h-[10px] w-[36px] flex-none">
<input
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
type="checkbox"
bind:checked
on:click={onToggle}
{disabled}
/>
{#if disabled}
<span class="slider slider-disabled cursor-not-allowed" />
{:else}
<span class="slider slider-enabled cursor-pointer" />
{/if}
</label>
</div>
<style>
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
input:disabled {
cursor: not-allowed;
}
.slider:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
input:checked + .slider-disabled {
background-color: gray;
}
input:checked + .slider-enabled {
background-color: #adcbfa;
}
</style>
@@ -45,8 +45,7 @@
/>
<div
class="relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 text-white dark:bg-immich-dark-gray"
class:dark:text-black={uploadAsset.state === UploadState.STARTED}
class="relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 text-white dark:bg-immich-dark-gray dark:text-black"
>
{#if uploadAsset.state === UploadState.STARTED}
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`} />
@@ -1,37 +0,0 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { slideshowStore } from '../stores/slideshow.store';
import Button from './elements/buttons/button.svelte';
const { slideshowShuffle, slideshowDelay, showProgressBar } = slideshowStore;
export let onClose = () => {};
</script>
<FullScreenModal on:clickOutside={onClose} on:escape={onClose}>
<div
class="flex w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
Slideshow Settings
</h1>
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
<SettingSwitch title="Shuffle" bind:checked={$slideshowShuffle} />
<SettingSwitch title="Show Progress Bar" bind:checked={$showProgressBar} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="Delay"
desc="Number of seconds to display each image"
min={1}
bind:value={$slideshowDelay}
/>
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
</div>
</div>
</FullScreenModal>
@@ -80,7 +80,7 @@
checked={$locale == undefined}
on:toggle={handleToggleLocaleBrowser}
>
<p class="mt-2 dark:text-gray-400">{selectedDate}</p>
<p class="mt-2">{selectedDate}</p>
</SettingSwitch>
</div>
{#if $locale !== undefined}
-5
View File
@@ -14,9 +14,6 @@ function createSlideshowStore() {
const slideshowShuffle = persisted<boolean>('slideshow-shuffle', true);
const slideshowState = writable<SlideshowState>(SlideshowState.None);
const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true);
const slideshowDelay = persisted<number>('slideshow-delay', 5, {});
return {
restartProgress: {
subscribe: restartState.subscribe,
@@ -42,8 +39,6 @@ function createSlideshowStore() {
},
slideshowShuffle,
slideshowState,
slideshowDelay,
showProgressBar,
};
}