diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 9c1a503cda..4cf6742f24 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -1,5 +1,6 @@ import { Action, + AssetBulkUploadCheckItem, AssetBulkUploadCheckResult, AssetMediaResponseDto, AssetMediaStatus, @@ -11,7 +12,7 @@ import { getSupportedMediaTypes, } from '@immich/sdk'; import byteSize from 'byte-size'; -import { Presets, SingleBar } from 'cli-progress'; +import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; import { Stats, createReadStream } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; @@ -90,23 +91,23 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas return { newFiles: files, duplicates: [] }; } - const progressBar = new SingleBar( - { format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, + const multiBar = new MultiBar( + { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, Presets.shades_classic, ); - progressBar.start(files.length, 0); + const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' }); + const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' }); const newFiles: string[] = []; const duplicates: Asset[] = []; - const queue = new Queue( - async (filepaths: string[]) => { - const dto = await Promise.all( - filepaths.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })), - ); - const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } }); + const checkBulkUploadQueue = new Queue( + async (assets: AssetBulkUploadCheckItem[]) => { + const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } }); + const results = response.results as AssetBulkUploadCheckResults; + for (const { id: filepath, assetId, action } of results) { if (action === Action.Accept) { newFiles.push(filepath); @@ -115,19 +116,46 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas duplicates.push({ id: assetId as string, filepath }); } } - progressBar.increment(filepaths.length); + + checkProgressBar.increment(assets.length); + }, + { concurrency, retry: 3 }, + ); + + const results: { id: string; checksum: string }[] = []; + let checkBulkUploadRequests: AssetBulkUploadCheckItem[] = []; + + const queue = new Queue( + async (filepath: string): Promise => { + const dto = { id: filepath, checksum: await sha1(filepath) }; + + results.push(dto); + checkBulkUploadRequests.push(dto); + if (checkBulkUploadRequests.length === 5000) { + const batch = checkBulkUploadRequests; + checkBulkUploadRequests = []; + void checkBulkUploadQueue.push(batch); + } + + hashProgressBar.increment(); return results; }, { concurrency, retry: 3 }, ); - for (const items of chunk(files, concurrency)) { - await queue.push(items); + for (const item of files) { + void queue.push(item); } await queue.drained(); - progressBar.stop(); + if (checkBulkUploadRequests.length > 0) { + void checkBulkUploadQueue.push(checkBulkUploadRequests); + } + + await checkBulkUploadQueue.drained(); + + multiBar.stop(); console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`); @@ -201,8 +229,8 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo { concurrency, retry: 3 }, ); - for (const filepath of files) { - await queue.push(filepath); + for (const item of files) { + void queue.push(item); } await queue.drained(); diff --git a/cli/src/queue.ts b/cli/src/queue.ts index c700028a15..0b6d628146 100644 --- a/cli/src/queue.ts +++ b/cli/src/queue.ts @@ -72,8 +72,8 @@ export class Queue { * @returns Promise - The returned Promise will be resolved when all tasks in the queue have been processed by a worker. * This promise could be ignored as it will not lead to a `unhandledRejection`. */ - async drained(): Promise { - await this.queue.drain(); + drained(): Promise { + return this.queue.drained(); } /** diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index fa7d110f94..96e324f0d9 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -94,7 +94,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:2659f4c2ebb718e7695cb9b25ffa7d6be64db013daba13e05c875451cf51b0d3 + image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts index d700aa73b2..301c6d3bf0 100644 --- a/e2e/src/cli/specs/upload.e2e-spec.ts +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -103,7 +103,7 @@ describe(`immich upload`, () => { describe(`immich upload /path/to/file.jpg`, () => { it('should upload a single file', async () => { const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]), ); @@ -126,7 +126,7 @@ describe(`immich upload`, () => { const expectedCount = Object.entries(files).filter((entry) => entry[1]).length; const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]), ); @@ -154,7 +154,7 @@ describe(`immich upload`, () => { cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]); const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]), ); @@ -169,7 +169,7 @@ describe(`immich upload`, () => { it('should skip a duplicate file', async () => { const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); - expect(first.stderr).toBe(''); + expect(first.stderr).toContain('{message}'); expect(first.stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]), ); @@ -179,7 +179,7 @@ describe(`immich upload`, () => { expect(assets.total).toBe(1); const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); - expect(second.stderr).toBe(''); + expect(second.stderr).toContain('{message}'); expect(second.stdout.split('\n')).toEqual( expect.arrayContaining([ expect.stringContaining('Found 0 new files and 1 duplicate'), @@ -205,7 +205,7 @@ describe(`immich upload`, () => { `${testAssetDir}/albums/nature/silver_fir.jpg`, '--dry-run', ]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]), ); @@ -217,7 +217,7 @@ describe(`immich upload`, () => { it('dry run should handle duplicates', async () => { const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); - expect(first.stderr).toBe(''); + expect(first.stderr).toContain('{message}'); expect(first.stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]), ); @@ -227,7 +227,7 @@ describe(`immich upload`, () => { expect(assets.total).toBe(1); const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']); - expect(second.stderr).toBe(''); + expect(second.stderr).toContain('{message}'); expect(second.stdout.split('\n')).toEqual( expect.arrayContaining([ expect.stringContaining('Found 8 new files and 1 duplicate'), @@ -241,7 +241,7 @@ describe(`immich upload`, () => { describe('immich upload --recursive', () => { it('should upload a folder recursively', async () => { const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]), ); @@ -267,7 +267,7 @@ describe(`immich upload`, () => { expect.stringContaining('Successfully updated 9 assets'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -283,7 +283,7 @@ describe(`immich upload`, () => { expect(response1.stdout.split('\n')).toEqual( expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]), ); - expect(response1.stderr).toBe(''); + expect(response1.stderr).toContain('{message}'); expect(response1.exitCode).toBe(0); const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -299,7 +299,7 @@ describe(`immich upload`, () => { expect.stringContaining('Successfully updated 9 assets'), ]), ); - expect(response2.stderr).toBe(''); + expect(response2.stderr).toContain('{message}'); expect(response2.exitCode).toBe(0); const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -325,7 +325,7 @@ describe(`immich upload`, () => { expect.stringContaining('Would have updated albums of 9 assets'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -351,7 +351,7 @@ describe(`immich upload`, () => { expect.stringContaining('Successfully updated 9 assets'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -377,7 +377,7 @@ describe(`immich upload`, () => { expect.stringContaining('Would have updated albums of 9 assets'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -408,7 +408,7 @@ describe(`immich upload`, () => { expect.stringContaining('Deleting assets that have been uploaded'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -434,7 +434,7 @@ describe(`immich upload`, () => { expect.stringContaining('Would have deleted 9 local assets'), ]), ); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(exitCode).toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); @@ -493,7 +493,7 @@ describe(`immich upload`, () => { '2', ]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([ 'Found 9 new files and 0 duplicates', @@ -534,7 +534,7 @@ describe(`immich upload`, () => { 'silver_fir.jpg', ]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([ 'Found 8 new files and 0 duplicates', @@ -555,7 +555,7 @@ describe(`immich upload`, () => { '!(*_*_*).jpg', ]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([ 'Found 1 new files and 0 duplicates', @@ -577,7 +577,7 @@ describe(`immich upload`, () => { '--dry-run', ]); - expect(stderr).toBe(''); + expect(stderr).toContain('{message}'); expect(stdout.split('\n')).toEqual( expect.arrayContaining([ 'Found 8 new files and 0 duplicates', diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000000..ea6dd795b5 --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -0,0 +1,32 @@ +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- \ No newline at end of file diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index 8da8875290..78c37cc2a3 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096M android.useAndroidX=true android.enableJetifier=true android.nonTransitiveRClass=false -android.nonFinalResIds=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 37e98a7f70..d63928b5b8 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -20,7 +20,7 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); -const Color blackOpacity40 = Color.fromARGB((0.40 * 255) ~/ 1, 0, 0, 0); +const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0); final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index f550857b9d..eadaf0bf9f 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; @@ -327,39 +328,51 @@ class BottomGalleryBar extends ConsumerWidget { child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - child: Column( - children: [ - Visibility( - visible: showVideoPlayerControls, - child: const VideoControls(), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [blackOpacity90, Colors.transparent], ), - BottomNavigationBar( - backgroundColor: Colors.black.withOpacity(0.4), - unselectedIconTheme: const IconThemeData(color: Colors.white), - selectedIconTheme: const IconThemeData(color: Colors.white), - unselectedLabelStyle: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - height: 2.3, - ), - selectedLabelStyle: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - height: 2.3, - ), - unselectedFontSize: 14, - selectedFontSize: 14, - selectedItemColor: Colors.white, - unselectedItemColor: Colors.white, - showSelectedLabels: true, - showUnselectedLabels: true, - items: - albumActions.map((e) => e.keys.first).toList(growable: false), - onTap: (index) { - albumActions[index].values.first.call(index); - }, + ), + position: DecorationPosition.background, + child: Padding( + padding: EdgeInsets.only(top: 40.0), + child: Column( + children: [ + if (showVideoPlayerControls) const VideoControls(), + BottomNavigationBar( + elevation: 0.0, + backgroundColor: Colors.transparent, + unselectedIconTheme: const IconThemeData(color: Colors.white), + selectedIconTheme: const IconThemeData(color: Colors.white), + unselectedLabelStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + height: 2.3, + ), + selectedLabelStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + height: 2.3, + ), + unselectedFontSize: 14, + selectedFontSize: 14, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.white, + showSelectedLabels: true, + showUnselectedLabels: true, + items: albumActions + .map((e) => e.keys.first) + .toList(growable: false), + onTap: (index) { + albumActions[index].values.first.call(index); + }, + ), + ], ), - ], + ), ), ), ); diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart index 4a334cd7cc..a34aab7d12 100644 --- a/mobile/lib/widgets/asset_viewer/formatted_duration.dart +++ b/mobile/lib/widgets/asset_viewer/formatted_duration.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; @pragma('vm:prefer-inline') String _formatDuration(Duration position) { @@ -24,8 +23,8 @@ class FormattedDuration extends StatelessWidget { _formatDuration(data), style: const TextStyle( fontSize: 14.0, - color: whiteOpacity75, - fontWeight: FontWeight.normal, + color: Colors.white, + fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index c96d58d374..e4d78324c8 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; /// The video controls for the [videoPlayerControlsProvider] @@ -12,24 +10,11 @@ class VideoControls extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait; - return AnimatedOpacity( - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - duration: const Duration(milliseconds: 100), - child: isPortrait - ? const ColoredBox( - color: blackOpacity40, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 24.0), - child: VideoPosition(), - ), - ) - : const ColoredBox( - color: blackOpacity40, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 128.0), - child: VideoPosition(), - ), - ), - ); + return isPortrait + ? const VideoPosition() + : const Padding( + padding: EdgeInsets.symmetric(horizontal: 60.0), + child: VideoPosition(), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_mute_button.dart b/mobile/lib/widgets/asset_viewer/video_mute_button.dart deleted file mode 100644 index da0f6f3174..0000000000 --- a/mobile/lib/widgets/asset_viewer/video_mute_button.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; - -class VideoMuteButton extends ConsumerWidget { - const VideoMuteButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return IconButton( - icon: ref.watch( - videoPlayerControlsProvider.select((value) => value.mute), - ) - ? const Icon(Icons.volume_off) - : const Icon(Icons.volume_up), - onPressed: () => - ref.read(videoPlayerControlsProvider.notifier).toggleMute(), - color: Colors.white, - padding: const EdgeInsets.all(0), - alignment: Alignment.centerRight, - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index 0512785782..ef309b9c85 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_mute_button.dart'; class VideoPosition extends HookConsumerWidget { const VideoPosition({super.key}); @@ -20,38 +19,52 @@ class VideoPosition extends HookConsumerWidget { final wasPlaying = useRef(true); return duration == Duration.zero ? const _VideoPositionPlaceholder() - : Row( + : Column( children: [ - FormattedDuration(position), - Expanded( - child: Slider( - value: min( - position.inMicroseconds / duration.inMicroseconds * 100, - 100, - ), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChangeStart: (value) { - final state = ref.read(videoPlaybackValueProvider).state; - wasPlaying.value = state != VideoPlaybackState.paused; - ref.read(videoPlayerControlsProvider.notifier).pause(); - }, - onChangeEnd: (value) { - if (wasPlaying.value) { - ref.read(videoPlayerControlsProvider.notifier).play(); - } - }, - onChanged: (position) { - ref.read(videoPlayerControlsProvider.notifier).position = - position; - }, + Padding( + // align with slider's inherent padding + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FormattedDuration(position), + FormattedDuration(duration), + ], ), ), - FormattedDuration(duration), - const VideoMuteButton(), + Row( + children: [ + Expanded( + child: Slider( + value: min( + position.inMicroseconds / duration.inMicroseconds * 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + onChangeStart: (value) { + final state = + ref.read(videoPlaybackValueProvider).state; + wasPlaying.value = state != VideoPlaybackState.paused; + ref.read(videoPlayerControlsProvider.notifier).pause(); + }, + onChangeEnd: (value) { + if (wasPlaying.value) { + ref.read(videoPlayerControlsProvider.notifier).play(); + } + }, + onChanged: (position) { + ref + .read(videoPlayerControlsProvider.notifier) + .position = position; + }, + ), + ), + ], + ), ], ); } @@ -64,22 +77,33 @@ class _VideoPositionPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { - return const Row( + return const Column( children: [ - FormattedDuration(Duration.zero), - Expanded( - child: Slider( - value: 0.0, - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChanged: _onChangedDummy, + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FormattedDuration(Duration.zero), + FormattedDuration(Duration.zero), + ], ), ), - FormattedDuration(Duration.zero), - VideoMuteButton(), + Row( + children: [ + Expanded( + child: Slider( + value: 0.0, + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + onChanged: _onChangedDummy, + ), + ), + ], + ), ], ); } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index fe11d17b5f..468a6ad88d 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -114,7 +114,12 @@ export interface ImageBuffer { } export interface VideoCodecSWConfig { - getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; + getCommand( + target: TranscodeTarget, + videoStream: VideoStreamInfo, + audioStream: AudioStreamInfo, + format?: VideoFormat, + ): TranscodeCommand; } export interface VideoCodecHWConfig extends VideoCodecSWConfig { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index df1a04dff8..069376b8d3 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -487,6 +487,22 @@ describe(MediaService.name, () => { }), ); }); + it('should not skip intra frames for MTS file', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.objectContaining({ + inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'], + outputOptions: expect.any(Array), + progress: expect.any(Object), + twoPass: false, + }), + ); + }); it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 0aa6bd03fb..770e26b243 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -239,7 +239,7 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const mainVideoStream = this.getMainStream(videoStreams); if (!mainVideoStream) { throw new Error(`No video streams found for asset ${asset.id}`); @@ -248,9 +248,14 @@ export class MediaService extends BaseService { const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - - const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format); + const thumbnailOptions = thumbnailConfig.getCommand( + TranscodeTarget.VIDEO, + mainVideoStream, + mainAudioStream, + format, + ); + this.logger.error(format.formatName); await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index f61b472b75..98d3c7fdbb 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -6,6 +6,7 @@ import { TranscodeCommand, VideoCodecHWConfig, VideoCodecSWConfig, + VideoFormat, VideoStreamInfo, } from 'src/interfaces/media.interface'; @@ -77,9 +78,14 @@ export class BaseConfig implements VideoCodecSWConfig { return handler; } - getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { + getCommand( + target: TranscodeTarget, + videoStream: VideoStreamInfo, + audioStream?: AudioStreamInfo, + format?: VideoFormat, + ) { const options = { - inputOptions: this.getBaseInputOptions(videoStream), + inputOptions: this.getBaseInputOptions(videoStream, format), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, @@ -101,7 +107,7 @@ export class BaseConfig implements VideoCodecSWConfig { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - getBaseInputOptions(videoStream: VideoStreamInfo): string[] { + getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] { return this.getInputThreadOptions(); } @@ -377,8 +383,11 @@ export class ThumbnailConfig extends BaseConfig { return new ThumbnailConfig(config); } - getBaseInputOptions(): string[] { - return ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int']; + getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] { + // skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details. + return format?.formatName === 'mpegts' + ? ['-sws_flags accurate_rnd+full_chroma_int'] + : ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int']; } getBaseOutputOptions() { diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 082959c227..de11c23f0a 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -95,6 +95,13 @@ export const probeStub = { ...probeStubDefault, videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }], }), + videoStreamMTS: Object.freeze({ + ...probeStubDefault, + format: { + ...probeStubDefaultFormat, + formatName: 'mpegts', + }, + }), videoStreamHDR: Object.freeze({ ...probeStubDefault, videoStreams: [ diff --git a/web/package-lock.json b/web/package-lock.json index 63e7c05ca4..e9f26bee80 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,7 +36,7 @@ "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.5", - "@sveltejs/enhanced-img": "^0.3.0", + "@sveltejs/enhanced-img": "^0.3.9", "@sveltejs/kit": "^2.7.2", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/jest-dom": "^6.4.2", @@ -53,7 +53,7 @@ "dotenv": "^16.4.5", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", "globals": "^15.9.0", @@ -68,7 +68,7 @@ "tailwindcss": "^3.4.1", "tslib": "^2.6.2", "typescript": "^5.5.0", - "vite": "^5.1.4", + "vite": "^5.4.4", "vitest": "^2.0.5" } }, diff --git a/web/package.json b/web/package.json index af5e87c57e..c0c600f5bc 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.5", - "@sveltejs/enhanced-img": "^0.3.0", + "@sveltejs/enhanced-img": "^0.3.9", "@sveltejs/kit": "^2.7.2", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/jest-dom": "^6.4.2", @@ -45,7 +45,7 @@ "dotenv": "^16.4.5", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", "globals": "^15.9.0", @@ -60,7 +60,7 @@ "tailwindcss": "^3.4.1", "tslib": "^2.6.2", "typescript": "^5.5.0", - "vite": "^5.1.4", + "vite": "^5.4.4", "vitest": "^2.0.5" }, "type": "module", diff --git a/web/src/lib/actions/__test__/focus-trap-test.svelte b/web/src/lib/actions/__test__/focus-trap-test.svelte index 207c880cd9..e1cb6fa4fb 100644 --- a/web/src/lib/actions/__test__/focus-trap-test.svelte +++ b/web/src/lib/actions/__test__/focus-trap-test.svelte @@ -1,16 +1,20 @@ - + {#if show}
text - +
diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts index ff80454ef3..0e6dec8e81 100644 --- a/web/src/lib/actions/autogrow.ts +++ b/web/src/lib/actions/autogrow.ts @@ -1,7 +1,19 @@ -export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { - if (!textarea) { - return; - } - textarea.style.height = height; - textarea.style.height = `${textarea.scrollHeight}px`; +import { tick } from 'svelte'; +import type { Action } from 'svelte/action'; + +type Parameters = { + height?: string; + value: string; // added to enable reactivity +}; + +export const autoGrowHeight: Action = (textarea, { height = 'auto' }) => { + const update = () => { + void tick().then(() => { + textarea.style.height = height; + textarea.style.height = `${textarea.scrollHeight}px`; + }); + }; + + update(); + return { update }; }; diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts index 3b45e7fe52..89b7b76d24 100644 --- a/web/src/lib/actions/context-menu-navigation.ts +++ b/web/src/lib/actions/context-menu-navigation.ts @@ -10,7 +10,7 @@ interface Options { /** * The container element that with direct children that should be navigated. */ - container: HTMLElement; + container?: HTMLElement; /** * Indicates if the dropdown is open. */ @@ -52,7 +52,11 @@ export const contextMenuNavigation: Action = (node, option await tick(); } - const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[]; + if (!container) { + return; + } + + const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[]; if (children.length === 0) { return; } diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts index 8f8ed62ed0..cd4214f700 100644 --- a/web/src/lib/actions/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -6,8 +6,15 @@ import type { Action } from 'svelte/action'; * @param node Element which listens for keyboard events * @param container Element containing the list of elements */ -export const listNavigation: Action = (node, container: HTMLElement) => { +export const listNavigation: Action = ( + node: HTMLElement, + container?: HTMLElement, +) => { const moveFocus = (direction: 'up' | 'down') => { + if (!container) { + return; + } + const children = Array.from(container?.children); if (children.length === 0) { return; diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte index a2fbbe787a..6eb603263e 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte @@ -7,13 +7,17 @@ import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let user: UserResponseDto; - export let onSuccess: () => void; - export let onFail: () => void; - export let onCancel: () => void; + interface Props { + user: UserResponseDto; + onSuccess: () => void; + onFail: () => void; + onCancel: () => void; + } - let forceDelete = false; - let deleteButtonDisabled = false; + let { user, onSuccess, onFail, onCancel }: Props = $props(); + + let forceDelete = $state(false); + let deleteButtonDisabled = $state(false); let userIdInput: string = ''; const handleDeleteUser = async () => { @@ -47,12 +51,14 @@ {onCancel} disabled={deleteButtonDisabled} > - + {#snippet promptSnippet()}
{#if forceDelete}

- - {message} + + {#snippet children({ message })} + {message} + {/snippet}

{:else} @@ -60,9 +66,10 @@ - {message} + {#snippet children({ message })} + {message} + {/snippet}

{/if} @@ -73,7 +80,7 @@ label={$t('admin.user_delete_immediately_checkbox')} labelClass="text-sm dark:text-immich-dark-fg" bind:checked={forceDelete} - on:change={() => { + onchange={() => { deleteButtonDisabled = forceDelete; }} /> @@ -92,9 +99,9 @@ aria-describedby="confirm-user-desc" name="confirm-user-id" type="text" - on:input={handleConfirm} + oninput={handleConfirm} /> {/if}
-
+ {/snippet} diff --git a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte index 69d3706230..f71d8a3e44 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte @@ -1,10 +1,18 @@ -
- + {@render children?.()}
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 81c23e927b..0e39647c75 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -19,22 +19,37 @@ import JobTileButton from './job-tile-button.svelte'; import JobTileStatus from './job-tile-status.svelte'; - export let title: string; - export let subtitle: string | undefined; - export let description: Component | undefined; - export let jobCounts: JobCountsDto; - export let queueStatus: QueueStatusDto; - export let icon: string; - export let disabled = false; + interface Props { + title: string; + subtitle: string | undefined; + description: Component | undefined; + jobCounts: JobCountsDto; + queueStatus: QueueStatusDto; + icon: string; + disabled?: boolean; + allText: string | undefined; + refreshText: string | undefined; + missingText: string; + onCommand: (command: JobCommandDto) => void; + } - export let allText: string | undefined; - export let refreshText: string | undefined; - export let missingText: string; - export let onCommand: (command: JobCommandDto) => void; + let { + title, + subtitle, + description, + jobCounts, + queueStatus, + icon, + disabled = false, + allText, + refreshText, + missingText, + onCommand, + }: Props = $props(); - $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed; - $: isIdle = !queueStatus.isActive && !queueStatus.isPaused; - $: multipleButtons = allText || refreshText; + let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed); + let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused); + let multipleButtons = $derived(allText || refreshText); const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6'; @@ -67,7 +82,7 @@ title={$t('clear_message')} size="12" padding="1" - on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })} + onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })} />
@@ -87,8 +102,9 @@ {/if} {#if description} + {@const SvelteComponent = description}
- +
{/if} @@ -118,7 +134,7 @@ onCommand({ command: JobCommand.Start, force: false })} + onClick={() => onCommand({ command: JobCommand.Start, force: false })} > {$t('disabled').toUpperCase()} @@ -127,20 +143,20 @@ {#if !disabled && !isIdle} {#if waitingCount > 0} - onCommand({ command: JobCommand.Empty, force: false })}> + onCommand({ command: JobCommand.Empty, force: false })}> {$t('clear').toUpperCase()} {/if} {#if queueStatus.isPaused} {@const size = waitingCount > 0 ? '24' : '48'} - onCommand({ command: JobCommand.Resume, force: false })}> + onCommand({ command: JobCommand.Resume, force: false })}> {$t('resume').toUpperCase()} {:else} - onCommand({ command: JobCommand.Pause, force: false })}> + onCommand({ command: JobCommand.Pause, force: false })}> {$t('pause').toUpperCase()} @@ -149,25 +165,25 @@ {#if !disabled && multipleButtons && isIdle} {#if allText} - onCommand({ command: JobCommand.Start, force: true })}> + onCommand({ command: JobCommand.Start, force: true })}> {allText} {/if} {#if refreshText} - onCommand({ command: JobCommand.Start, force: undefined })}> + onCommand({ command: JobCommand.Start, force: undefined })}> {refreshText} {/if} - onCommand({ command: JobCommand.Start, force: false })}> + onCommand({ command: JobCommand.Start, force: false })}> {missingText} {/if} {#if !disabled && !multipleButtons && isIdle} - onCommand({ command: JobCommand.Start, force: false })}> + onCommand({ command: JobCommand.Start, force: false })}> {$t('start').toUpperCase()} diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 67d672d398..9b4f3ffdd6 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -25,7 +25,11 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; - export let jobs: AllJobStatusResponseDto; + interface Props { + jobs: AllJobStatusResponseDto; + } + + let { jobs = $bindable() }: Props = $props(); interface JobDetails { title: string; @@ -56,8 +60,7 @@ await handleCommand(jobId, dto); }; - // svelte-ignore reactive_declaration_non_reactive_property - $: jobDetails = >>{ + let jobDetails: Partial> = { [JobName.ThumbnailGeneration]: { icon: mdiFileJpgBox, title: $getJobName(JobName.ThumbnailGeneration), @@ -142,7 +145,8 @@ missingText: $t('missing'), }, }; - $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; + + let jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) { const title = jobDetails[jobId]?.title; diff --git a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte index 8a74d2c5ad..b47df1daae 100644 --- a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte +++ b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte @@ -7,12 +7,13 @@ - - {message} - + {#snippet children({ message })} + + {message} + + {/snippet} diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte index 25afbc6d4b..a72ada2ca5 100644 --- a/web/src/lib/components/admin-page/restore-dialogue.svelte +++ b/web/src/lib/components/admin-page/restore-dialogue.svelte @@ -5,10 +5,14 @@ import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; - export let user: UserResponseDto; - export let onSuccess: () => void; - export let onFail: () => void; - export let onCancel: () => void; + interface Props { + user: UserResponseDto; + onSuccess: () => void; + onFail: () => void; + onCancel: () => void; + } + + let { user, onSuccess, onFail, onCancel }: Props = $props(); const handleRestoreUser = async () => { try { @@ -32,11 +36,13 @@ onConfirm={handleRestoreUser} {onCancel} > - + {#snippet promptSnippet()}

- - {message} + + {#snippet children({ message })} + {message} + {/snippet}

-
+ {/snippet} diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index 35afc0962d..feab6a9c6d 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -7,14 +7,20 @@ import StatsCard from './stats-card.svelte'; import { t } from 'svelte-i18n'; - export let stats: ServerStatsResponseDto = { - photos: 0, - videos: 0, - usage: 0, - usageByUser: [], - }; + interface Props { + stats?: ServerStatsResponseDto; + } - $: zeros = (value: number) => { + let { + stats = { + photos: 0, + videos: 0, + usage: 0, + usageByUser: [], + }, + }: Props = $props(); + + const zeros = (value: number) => { const maxLength = 13; const valueLength = value.toString().length; const zeroLength = maxLength - valueLength; @@ -23,7 +29,7 @@ }; const TiB = 1024 ** 4; - $: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0); + let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte index 31baa0afdd..14d32c055f 100644 --- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte +++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte @@ -2,18 +2,22 @@ import Icon from '$lib/components/elements/icon.svelte'; import { ByteUnit } from '$lib/utils/byte-units'; - export let icon: string; - export let title: string; - export let value: number; - export let unit: ByteUnit | undefined = undefined; + interface Props { + icon: string; + title: string; + value: number; + unit?: ByteUnit | undefined; + } - $: zeros = () => { + let { icon, title, value, unit = undefined }: Props = $props(); + + const zeros = $derived(() => { const maxLength = 13; const valueLength = value.toString().length; const zeroLength = maxLength - valueLength; return '0'.repeat(zeroLength); - }; + });
diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 19a8580d6b..199db0b571 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -1,5 +1,3 @@ - - {#if savedConfig && defaultConfig} - + {@render children({ savedConfig, defaultConfig })} {/if} diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 9b0e4b3270..7f94dfa253 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -2,9 +2,7 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { type SystemConfigDto } from '@immich/sdk'; import { isEqual } from 'lodash-es'; @@ -12,15 +10,20 @@ import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } - let isConfirmOpen = false; + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + let isConfirmOpen = $state(false); const handleToggleOverride = () => { // click runs before bind @@ -48,29 +51,31 @@ onCancel={() => (isConfirmOpen = false)} onConfirm={() => handleSave(true)} > - + {#snippet promptSnippet()}

{$t('admin.authentication_settings_disable_all')}

- - - {message} - + + {#snippet children({ message })} + + {message} + + {/snippet}

-
+ {/snippet} {/if}
-
+ e.preventDefault()}>

- - - {message} - + + {#snippet children({ message })} + + {message} + + {/snippet}

@@ -147,7 +154,7 @@ handleToggleOverride()} + onToggle={() => handleToggleOverride()} bind:checked={config.oauth.mobileOverrideEnabled} /> diff --git a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte index 05543f1124..3ec477e29c 100644 --- a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte +++ b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte @@ -3,33 +3,40 @@ import { isEqual } from 'lodash-es'; import { fade } from 'svelte/transition'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; - import SettingInputField, { - SettingInputFieldType, - } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { SettingInputFieldType } from '$lib/constants'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } - $: cronExpressionOptions = [ + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + let cronExpressionOptions = $derived([ { text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, { text: $t('interval.night_at_twoam'), value: '0 02 * * *' }, { text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, - ]; + ]); + + const onsubmit = (event: Event) => { + event.preventDefault(); + };
- +
- + {#snippet descriptionSnippet()}

- - - {message} -
-
+ + {#snippet children({ message })} + + {message} +
+
+ {/snippet}

-
+ {/snippet} { + event.preventDefault(); + };
- +

- - {#if tag === 'h264-link'} - - {message} - - {:else if tag === 'hevc-link'} - - {message} - - {:else if tag === 'vp9-link'} - - {message} - - {/if} + + {#snippet children({ tag, message })} + {#if tag === 'h264-link'} + + {message} + + {:else if tag === 'hevc-link'} + + {message} + + {:else if tag === 'vp9-link'} + + {message} + + {/if} + {/snippet}

@@ -60,7 +69,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.transcoding_constant_rate_factor')} - desc={$t('admin.transcoding_constant_rate_factor_description')} + description={$t('admin.transcoding_constant_rate_factor_description')} bind:value={config.ffmpeg.crf} required={true} isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf} @@ -186,7 +195,7 @@ inputType={SettingInputFieldType.TEXT} {disabled} label={$t('admin.transcoding_max_bitrate')} - desc={$t('admin.transcoding_max_bitrate_description')} + description={$t('admin.transcoding_max_bitrate_description')} bind:value={config.ffmpeg.maxBitrate} isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate} /> @@ -195,7 +204,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.transcoding_threads')} - desc={$t('admin.transcoding_threads_description')} + description={$t('admin.transcoding_threads_description')} bind:value={config.ffmpeg.threads} isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads} /> @@ -329,7 +338,7 @@ { + event.preventDefault(); + };
- +
{ + event.preventDefault(); + };
- + {#each jobNames as jobName}
{#if isSystemConfigJobDto(jobName)} @@ -46,7 +53,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} - desc="" + description="" bind:value={config.job[jobName].concurrency} required={true} isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)} @@ -55,7 +62,7 @@ { + event.preventDefault(); + };
- +
- + {#snippet descriptionSnippet()}

- - - {message} - + + {#snippet children({ message })} + + {message} + + {/snippet}

-
+ {/snippet}
diff --git a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte index 6e71ba926c..29a1c65162 100644 --- a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte +++ b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte @@ -8,17 +8,25 @@ import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import { t } from 'svelte-i18n'; - export let savedConfig: SystemConfigDto; - export let defaultConfig: SystemConfigDto; - export let config: SystemConfigDto; // this is the config that is being edited - export let disabled = false; - export let onReset: SettingsResetEvent; - export let onSave: SettingsSaveEvent; + interface Props { + savedConfig: SystemConfigDto; + defaultConfig: SystemConfigDto; + config: SystemConfigDto; + disabled?: boolean; + onReset: SettingsResetEvent; + onSave: SettingsSaveEvent; + } + + let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props(); + + const onsubmit = (event: Event) => { + event.preventDefault(); + };
- +
{ + event.preventDefault(); + };
- +
-

- - {message} - -

+ {#snippet descriptionSnippet()} +

+ + {#snippet children({ message })} + {message} + {/snippet} + +

+ {/snippet}
@@ -100,7 +111,7 @@ step="0.0005" min={0.001} max={0.1} - desc={$t('admin.machine_learning_max_detection_distance_description')} + description={$t('admin.machine_learning_max_detection_distance_description')} disabled={disabled || !$featureFlags.duplicateDetection} isEdited={config.machineLearning.duplicateDetection.maxDistance !== savedConfig.machineLearning.duplicateDetection.maxDistance} @@ -142,7 +153,7 @@ { + event.preventDefault(); + };
- +
@@ -38,7 +45,7 @@ - + {#snippet subtitleSnippet()}

- - - {message} - + + {#snippet children({ message })} + + {message} + + {/snippet}

-
+ {/snippet}
{ + event.preventDefault(); + };
- +
{ + event.preventDefault(); + };
- +
{ if (isSending) { @@ -65,11 +68,15 @@ isSending = false; } }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + };
- +
@@ -85,7 +92,7 @@ inputType={SettingInputFieldType.TEXT} required label={$t('host')} - desc={$t('admin.notification_email_host_description')} + description={$t('admin.notification_email_host_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.host} isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host} @@ -95,7 +102,7 @@ inputType={SettingInputFieldType.NUMBER} required label={$t('port')} - desc={$t('admin.notification_email_port_description')} + description={$t('admin.notification_email_port_description')} disabled={disabled || !config.notifications.smtp.enabled} bind:value={config.notifications.smtp.transport.port} isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port} @@ -104,7 +111,7 @@
-
{/if} diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index d0444f3599..3f71bbe632 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -5,13 +5,18 @@ import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte'; import { t } from 'svelte-i18n'; - export let album: AlbumResponseDto; - export let preload = false; - let className = ''; - export { className as class }; + interface Props { + album: AlbumResponseDto; + preload?: boolean; + class?: string; + } - $: alt = album.albumName || $t('unnamed_album'); - $: thumbnailUrl = album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null; + let { album, preload = false, class: className = '' }: Props = $props(); + + let alt = $derived(album.albumName || $t('unnamed_album')); + let thumbnailUrl = $derived( + album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null, + ); {#if thumbnailUrl} diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index b3ad688a30..46b424f93a 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -4,9 +4,13 @@ import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte'; import { t } from 'svelte-i18n'; - export let id: string; - export let description: string; - export let isOwned: boolean; + interface Props { + id: string; + description: string; + isOwned: boolean; + } + + let { id, description = $bindable(), isOwned }: Props = $props(); const handleUpdateDescription = async (newDescription: string) => { try { diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 3ec1842757..884de8c2a2 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -23,24 +23,38 @@ import { notificationController, NotificationType } from '../shared-components/notification/notification'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - export let album: AlbumResponseDto; - export let order: AssetOrder | undefined; - export let user: UserResponseDto; // Declare user as a prop - export let onChangeOrder: (order: AssetOrder) => void; - export let onClose: () => void; - export let onToggleEnabledActivity: () => void; - export let onShowSelectSharedUser: () => void; - export let onRemove: (userId: string) => void; - export let onRefreshAlbum: () => void; + interface Props { + album: AlbumResponseDto; + order: AssetOrder | undefined; + user: UserResponseDto; + onChangeOrder: (order: AssetOrder) => void; + onClose: () => void; + onToggleEnabledActivity: () => void; + onShowSelectSharedUser: () => void; + onRemove: (userId: string) => void; + onRefreshAlbum: () => void; + } - let selectedRemoveUser: UserResponseDto | null = null; + let { + album, + order, + user, + onChangeOrder, + onClose, + onToggleEnabledActivity, + onShowSelectSharedUser, + onRemove, + onRefreshAlbum, + }: Props = $props(); + + let selectedRemoveUser: UserResponseDto | null = $state(null); const options: Record = { [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') }, [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') }, }; - $: selectedOption = order ? options[order] : options[AssetOrder.Desc]; + let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]); const handleToggle = async (returnedOption: RenderedOption): Promise => { if (selectedOption === returnedOption) { @@ -125,7 +139,7 @@
{$t('people').toUpperCase()}
-
- createAlbumAndRedirect()}> + createAlbumAndRedirect()}>
@@ -184,7 +164,7 @@