forked from Cutlery/immich
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c692aad8 | |||
| 2e99ce994b | |||
| 26c3635291 | |||
| 484f1256ea | |||
| d51a666692 | |||
| 73f20ef4e7 | |||
| e1f66ac4da | |||
| 2d95715ae8 | |||
| 692b8b189a | |||
| 7d51fba1c0 | |||
| 4c9ac82fdc |
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.13.6
|
||||
@@ -0,0 +1 @@
|
||||
3.13.6
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
C:/Users/alext/fvm/versions/3.13.6
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+1
-20
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user