diff --git a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts index b69bd099ed..e3bd98db28 100644 --- a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/admin/database-backups', () => { let cookie: string | undefined; @@ -13,6 +13,9 @@ describe('/admin/database-backups', () => { admin = await utils.adminSetup({ onboarding: false, }); + }); + + beforeEach(async () => { await utils.resetBackups(admin.accessToken); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 74c2832c3e..7e51b40f63 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -568,6 +568,8 @@ export const utils = { name: ManualJobName.BackupDatabase, }); + await utils.waitForQueueFinish(accessToken, 'backupDatabase'); + return utils.poll( () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), ({ status, body }) => status === 200 && body.backups.length === 1, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 9255eff44b..f7ebc349f6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -23,6 +23,8 @@ import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap +private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024 + private class RemoteRequest(val cancellationSignal: CancellationSignal) class RemoteImagesImpl(context: Context) : RemoteImageApi { @@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher { private val onComplete: () -> Unit, ) : UrlRequest.Callback() { private var buffer: NativeByteBuffer? = null - private var wrapped: ByteBuffer? = null private var error: Exception? = null override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { @@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher { } try { + // Content-Length is a size hint only. With Content-Encoding (gzip/br/...), + // Cronet auto-decompresses and writes decompressed bytes to our buffer, which + // may exceed the wire/compressed Content-Length. Always use the growable + // buffer path so we can't overflow. val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 - if (contentLength > 0) { - buffer = NativeByteBuffer(contentLength + 1) - wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1) - request.read(wrapped) - } else { - buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE) - request.read(buffer!!.wrapRemaining()) - } + // Cap the up-front alloc: Content-Length is untrusted and can be huge or near + // Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over. + val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE + buffer = NativeByteBuffer(initialSize) + request.read(buffer!!.wrapRemaining()) } catch (e: Exception) { error = e return request.cancel() @@ -263,14 +265,14 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val buf = if (wrapped == null) { - buffer!!.run { - advance(byteBuffer.position()) - ensureHeadroom() - wrapRemaining() - } - } else { - wrapped + // Always pass a fresh wrap so byteBuffer.position() represents only the + // bytes Cronet wrote in this iteration. Reusing the caller-supplied + // ByteBuffer breaks advance(): Cronet's position keeps accumulating + // across reads, which would double-count previous iterations' bytes. + val buf = buffer!!.run { + advance(byteBuffer.position()) + ensureHeadroom() + wrapRemaining() } request.read(buf) } catch (e: Exception) { @@ -280,7 +282,6 @@ private class CronetImageFetcher : ImageFetcher { } override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { - wrapped?.let { buffer!!.advance(it.position()) } onSuccess(buffer!!) onComplete() } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index ec96729d8f..40b71bd6c2 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { var domainAlbum = PlatformAlbum( id: album.localIdentifier, - name: album.localizedTitle!, + name: album.localizedTitle ?? album.localIdentifier, updatedAt: nil, isCloud: isCloud, assetCount: Int64(assets.count) diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 877145c322..473bd52b03 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } enum AssetDateAggregation { start, end } + +enum SlideshowLook { contain, cover, blurredBackground } + +enum SlideshowDirection { forward, backward, shuffle } diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index a810877dcc..b370825fdd 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset { final String ownerId; final String? stackId; final DateTime? uploadedAt; + final DateTime? deletedAt; const RemoteAsset({ required this.id, @@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset { super.livePhotoVideoId, this.stackId, required super.isEdited, + this.deletedAt, }) : localAssetId = localId; @override @@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset { @override bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; + bool get isTrashed => deletedAt != null; + @override String toString() { return '''Asset { @@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset { thumbHash == other.thumbHash && visibility == other.visibility && stackId == other.stackId && - uploadedAt == other.uploadedAt; + uploadedAt == other.uploadedAt && + deletedAt == other.deletedAt; } @override @@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset { thumbHash.hashCode ^ visibility.hashCode ^ stackId.hashCode ^ - uploadedAt.hashCode; + uploadedAt.hashCode ^ + deletedAt.hashCode; RemoteAsset copyWith({ String? id, @@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset { String? livePhotoVideoId, String? stackId, bool? isEdited, + DateTime? deletedAt, }) { return RemoteAsset( id: id ?? this.id, @@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset { livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, stackId: stackId ?? this.stackId, isEdited: isEdited ?? this.isEdited, + deletedAt: deletedAt ?? this.deletedAt, ); } } @@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset { required super.createdAt, required super.updatedAt, super.uploadedAt, + super.deletedAt, super.width, super.height, super.durationMs, @@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset { DateTime? createdAt, DateTime? updatedAt, DateTime? uploadedAt, + DateTime? deletedAt, int? width, int? height, int? durationMs, @@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, uploadedAt: uploadedAt ?? this.uploadedAt, + deletedAt: deletedAt ?? this.deletedAt, width: width ?? this.width, height: height ?? this.height, durationMs: durationMs ?? this.durationMs, diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index beca1c21e7..e639b7b7e4 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; import 'package:immich_mobile/domain/models/config/viewer_config.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; class AppConfig { final ThemeConfig theme; @@ -12,6 +13,7 @@ class AppConfig { final TimelineConfig timeline; final ImageConfig image; final ViewerConfig viewer; + final SlideshowConfig slideshow; const AppConfig({ this.theme = const .new(), @@ -20,6 +22,7 @@ class AppConfig { this.timeline = const .new(), this.image = const .new(), this.viewer = const .new(), + this.slideshow = const .new(), }); AppConfig copyWith({ @@ -29,6 +32,7 @@ class AppConfig { TimelineConfig? timeline, ImageConfig? image, ViewerConfig? viewer, + SlideshowConfig? slideshow, }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, @@ -36,6 +40,7 @@ class AppConfig { timeline: timeline ?? this.timeline, image: image ?? this.image, viewer: viewer ?? this.viewer, + slideshow: slideshow ?? this.slideshow, ); @override @@ -47,12 +52,13 @@ class AppConfig { other.map == map && other.timeline == timeline && other.image == image && - other.viewer == viewer); + other.viewer == viewer && + other.slideshow == slideshow); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow); @override String toString() => - 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)'; + 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)'; } diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart new file mode 100644 index 0000000000..74c0ac9d38 --- /dev/null +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -0,0 +1,48 @@ +import 'package:immich_mobile/constants/enums.dart'; + +class SlideshowConfig { + final bool transition; + final bool repeat; + final int duration; + final SlideshowLook look; + final SlideshowDirection direction; + + const SlideshowConfig({ + this.transition = true, + this.repeat = true, + this.duration = 5, + this.look = SlideshowLook.contain, + this.direction = SlideshowDirection.forward, + }); + + SlideshowConfig copyWith({ + bool? transition, + bool? repeat, + int? duration, + SlideshowLook? look, + SlideshowDirection? direction, + }) => SlideshowConfig( + transition: transition ?? this.transition, + repeat: repeat ?? this.repeat, + duration: duration ?? this.duration, + look: look ?? this.look, + direction: direction ?? this.direction, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SlideshowConfig && + other.transition == transition && + other.repeat == repeat && + other.duration == duration && + other.look == look && + other.direction == direction); + + @override + int get hashCode => Object.hash(transition, repeat, duration, look, direction); + + @override + String toString() => + 'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 61a3cebc8a..04ef506f89 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -64,7 +64,19 @@ enum MetadataKey { ), cleanupKeepAlbumIds>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)), cleanupCutoffDaysAgo(.appConfig, 'cleanup.cutoffDaysAgo', -1), - cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false); + cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false), + + // Slideshow + slideshowTransition(.appConfig, 'slideshow.transition', true), + slideshowRepeat(.appConfig, 'slideshow.repeat', true), + slideshowDuration(.appConfig, 'slideshow.duration', 5), + slideshowLook(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)), + slideshowDirection( + .appConfig, + 'slideshow.direction', + SlideshowDirection.forward, + _EnumCodec(SlideshowDirection.values), + ); final MetadataDomain domain; final String name; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 63281e49da..f2a3fcc2c0 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -29,6 +29,9 @@ enum StoreKey { readonlyModeEnabled._(138), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 8644667168..ad1cec5641 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { localId: localId, stackId: stackId, isEdited: isEdited, + deletedAt: deletedAt, ); } diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index d8c8f55898..b5801b9b9c 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -139,6 +139,13 @@ extension on MetadataDomain { autoPlayVideo: repo._read(.viewerAutoPlayVideo), tapToNavigate: repo._read(.viewerTapToNavigate), ), + slideshow: .new( + transition: repo._read(.slideshowTransition), + repeat: repo._read(.slideshowRepeat), + duration: repo._read(.slideshowDuration), + look: repo._read(.slideshowLook), + direction: repo._read(.slideshowDirection), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart new file mode 100644 index 0000000000..4c4ee48cf9 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -0,0 +1,376 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/settings.page.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +@RoutePage() +class DriftSlideshowPage extends ConsumerStatefulWidget { + final TimelineService timeline; + + const DriftSlideshowPage({super.key, required this.timeline}); + + @override + ConsumerState createState() => _DriftSlideshowPageState(); +} + +class _DriftSlideshowPageState extends ConsumerState { + late SlideshowConfig _config; + late final PageController _pageController; + late final Stopwatch _stopwatch; + late Timer _timer; + late int _index; + late int _nextIndex; + bool _paused = false; + bool _showAppBar = false; + + @override + initState() { + super.initState(); + _config = ref.read(appConfigProvider.select((s) => s.slideshow)); + final asset = ref.read(assetViewerProvider).currentAsset; + _index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0; + _pageController = PageController(initialPage: _index); + _stopwatch = Stopwatch(); + _createTimer(); + _updateNextIndex(); + ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + unawaited(WakelockPlus.enable()); + } + + @override + dispose() { + _timer.cancel(); + _stopwatch.stop(); + _pageController.dispose(); + unawaited(WakelockPlus.disable()); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + void _play() { + final asset = widget.timeline.getAssetSafe(_index)!; + + if (asset.isImage) { + _createTimer(); + } else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).play(); + } else { + _nextPage(); + } + + _updateNextIndex(); + + setState(() { + _paused = false; + }); + } + + void _pause() { + _timer.cancel(); + _stopwatch.stop(); + + final asset = widget.timeline.getAssetSafe(_index)!; + + if (!asset.isImage) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).pause(); + } + + setState(() { + _paused = true; + }); + } + + void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) { + if (_config == next) { + return; + } + + final durationChanged = _config.duration != next.duration; + _config = next; + _updateNextIndex(); + + final asset = widget.timeline.getAssetSafe(_index); + if (durationChanged && !_paused && asset?.isImage == true) { + _timer.cancel(); + _createTimer(); + } + + setState(() {}); + } + + void _updateNextIndex() { + _nextIndex = switch (_config.direction) { + SlideshowDirection.forward => _index + 1, + SlideshowDirection.backward => _index - 1, + SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!, + }; + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + widget.timeline.preloadAssets(_nextIndex); + } + } + + void _nextPage() async { + if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) { + if (_config.repeat) { + final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; + await widget.timeline.preloadAssets(wrapped); + _pageController.jumpToPage(wrapped); + } else { + setState(() { + _paused = true; + }); + } + return; + } + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + await widget.timeline.preloadAssets(_nextIndex); + } + + if (_config.direction == SlideshowDirection.shuffle || !_config.transition) { + _pageController.jumpToPage(_nextIndex); + } else { + unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn)); + } + } + + void _createTimer() { + _timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () { + _stopwatch.stop(); + _stopwatch.reset(); + _nextPage(); + }); + + _stopwatch.start(); + } + + void _pageChanged(int page) { + final asset = widget.timeline.getAssetSafe(page)!; + + setState(() { + _index = page; + + if (!asset.isImage) { + _paused = false; + } + }); + + _timer.cancel(); + _stopwatch.stop(); + _stopwatch.reset(); + + if (!_paused && asset.isImage) { + _createTimer(); + } + + _updateNextIndex(); + } + + void _onTapUp() async { + await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge); + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _showAppBar = !_showAppBar; + }); + }); + } + + Widget _getProgressBar(BuildContext context) { + final asset = widget.timeline.getAssetSafe(_index); + + if (asset == null) { + return Container(); + } + + if (asset.isImage) { + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + + return TweenAnimationBuilder( + key: Key(_index.toString()), + tween: Tween(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: value, + ), + ); + } else { + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: + ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds / + asset.duration.inMilliseconds, + ); + } + } + + Widget _getBlur(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return Container(); + } + + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider(asset, size: Size(context.width, context.height)), + fit: BoxFit.cover, + ), + ), + child: Container(color: Colors.black.withValues(alpha: 0.2)), + ), + ); + } + + Widget _getPhotoView(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + final scale = _config.look == SlideshowLook.cover + ? PhotoViewComputedScale.covered + : PhotoViewComputedScale.contained; + final isCurrent = _index == index; + final imageProvider = getFullImageProvider(asset, size: context.sizeData); + + if (asset.isImage) { + final zoomOut = index % 2 == 1; + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble(); + + return TweenAnimationBuilder( + tween: Tween( + begin: progress, + end: _paused + ? progress + : zoomOut + ? 0.0 + : 1.0, + ), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => PhotoView( + imageProvider: imageProvider, + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: scale * (1.0 + value / 10.0), + controller: PhotoViewController(), + onTapUp: (_, _, _) => _onTapUp(), + ), + ); + } else { + final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); + final position = ref.read(videoPlayerProvider(asset.heroTag)).position; + + if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { + _nextPage(); + } else if (status == VideoPlaybackStatus.playing) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false); + } + + return PhotoView.customChild( + onTapUp: (_, _, _) => _onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5), + child: IgnorePointer( + ignoring: !_showAppBar, + child: AnimatedOpacity( + opacity: _showAppBar ? 1.0 : 0.0, + duration: Durations.short2, + child: Column( + children: [ + AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("slideshow".t(context: context)), + actions: [ + IconButton( + onPressed: _paused ? _play : _pause, + icon: Icon(_paused ? Icons.play_arrow : Icons.pause), + ), + IconButton( + onPressed: () { + _pause(); + context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer)); + }, + icon: const Icon(Icons.settings), + ), + ], + ), + _getProgressBar(context), + ], + ), + ), + ), + ), + extendBody: true, + extendBodyBehindAppBar: true, + backgroundColor: Colors.black, + body: PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: const FastClampingScrollPhysics(), + itemCount: widget.timeline.totalAssets, + onPageChanged: _pageChanged, + itemBuilder: (context, index) => Stack( + children: [ + if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), + _getPhotoView(context, index), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 1ca875e483..5ed61c3bbe 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; - final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; if (iconOnly) { + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return IconButton( onPressed: onPressed, icon: Icon(iconData, size: iconSize, color: iconColor), @@ -46,17 +47,21 @@ class BaseActionButton extends ConsumerWidget { } if (menuItem) { - final theme = context.themeData; - final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; + final iconColor = this.iconColor; return MenuItemButton( - style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), - leadingIcon: Icon(iconData, color: effectiveIconColor), + style: MenuItemButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + leadingIcon: Icon(iconData, color: iconColor, size: 20), onPressed: onPressed, - child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)), + child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)), ); } + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: MaterialButton( diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index d2df013369..267a9f55e6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget { final ActionSource source; final bool iconOnly; final bool menuItem; + final bool useShortLabel; - const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + const DeletePermanentActionButton({ + super.key, + required this.source, + this.iconOnly = false, + this.menuItem = false, + this.useShortLabel = false, + }); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget { return BaseActionButton( maxWidth: 110.0, iconData: Icons.delete_forever, - label: "delete_permanently".t(context: context), + label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context), iconOnly: iconOnly, menuItem: menuItem, onPressed: () => _onTap(context, ref), diff --git a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart new file mode 100644 index 0000000000..1713718967 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class RestoreActionButton extends ConsumerWidget { + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).restoreTrash(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.history_rounded, + label: 'restore'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100.0, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart new file mode 100644 index 0000000000..479cf2dfe9 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart @@ -0,0 +1,34 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SlideshowActionButton extends ConsumerWidget { + final bool iconOnly; + final bool menuItem; + + const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) { + if (!context.mounted) { + return; + } + + context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.slideshow, + label: "slideshow".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index ff09d15496..e317c598f5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,15 +2,19 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -33,23 +37,31 @@ class ViewerBottomBar extends ConsumerWidget { final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); final serverInfo = ref.watch(serverInfoProvider); + final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; final actions = [ - const ShareActionButton(source: ActionSource.viewer), + if (isInTrash && isOwner && asset.hasRemote) + const RestoreActionButton(source: ActionSource.viewer) + else + const ShareActionButton(source: ActionSource.viewer), if (!isInLockedView) ...[ - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - // edit sync was added in 2.6.0 - if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) - const EditImageActionButton(), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), - + if (!isInTrash) ...[ + if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), + // edit sync was added in 2.6.0 + if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) + const EditImageActionButton(), + if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + ], if (isOwner) ...[ - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + if (asset.isLocalOnly) + const DeleteLocalActionButton(source: ActionSource.viewer) + else if (asset.isTrashed) + const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true) + else + const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), ], ], ]; diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 308f6a72a3..5a79485daf 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 8720cc4253..286e874e1b 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState { }, flightShuttleBuilder: (context, animation, direction, from, to) { void animationStatusListener(AnimationStatus status) { + if (!mounted) { + return; + } final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse; if (_hideIndicators != heroInFlight) { setState(() => _hideIndicators = heroInFlight); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1cc5faa733..b39a568e26 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; @@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 72054cf194..a4b538d789 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftSlideshowPage] +class DriftSlideshowRoute extends PageRouteInfo { + DriftSlideshowRoute({ + Key? key, + required TimelineService timeline, + List? children, + }) : super( + DriftSlideshowRoute.name, + args: DriftSlideshowRouteArgs(key: key, timeline: timeline), + initialChildren: children, + ); + + static const String name = 'DriftSlideshowRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftSlideshowPage(key: args.key, timeline: args.timeline); + }, + ); +} + +class DriftSlideshowRouteArgs { + const DriftSlideshowRouteArgs({this.key, required this.timeline}); + + final Key? key; + + final TimelineService timeline; + + @override + String toString() { + return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! DriftSlideshowRouteArgs) return false; + return key == other.key && timeline == other.timeline; + } + + @override + int get hashCode => key.hashCode ^ timeline.hashCode; +} + /// generated route for /// [DriftTrashPage] class DriftTrashRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index d527f3a59e..b9cff613fd 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -21,11 +21,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -72,6 +74,7 @@ enum ActionButtonType { similarPhotos, setProfilePicture, viewInTimeline, + slideshow, download, upload, openInBrowser, @@ -81,6 +84,7 @@ enum ActionButtonType { moveToLockFolder, removeFromLockFolder, removeFromAlbum, + restoreTrash, trash, deleteLocal, deletePermanent, @@ -112,12 +116,17 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.isTrashEnabled, + context.isTrashEnabled && // + context.timelineOrigin != TimelineOrigin.trash, + ActionButtonType.restoreTrash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // - context.asset.hasRemote && // - !context.isTrashEnabled || - context.isInLockedView, + context.asset.hasRemote && // + (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), ActionButtonType.delete => context.isOwner && // !context.isInLockedView && // @@ -172,6 +181,7 @@ enum ActionButtonType { context.timelineOrigin != TimelineOrigin.localAlbum && context.isOwner, ActionButtonType.cast => context.isCasting || context.asset.hasRemote, + ActionButtonType.slideshow => true, }; } @@ -193,6 +203,7 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unarchive => UnArchiveActionButton( source: context.source, @@ -201,6 +212,11 @@ enum ActionButtonType { ), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.restoreTrash => RestoreActionButton( + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.deletePermanent => DeletePermanentActionButton( source: context.source, iconOnly: iconOnly, @@ -292,6 +308,7 @@ enum ActionButtonType { ActionButtonType.moveToLockFolder => 10, ActionButtonType.deleteLocal => 10, ActionButtonType.delete => 10, + ActionButtonType.restoreTrash => 10, // 90: advancedInfo ActionButtonType.advancedInfo => 90, // 1: others @@ -309,13 +326,15 @@ class ActionButtonBuilder { ActionButtonType.delete, ActionButtonType.archive, ActionButtonType.unarchive, + ActionButtonType.restoreTrash, + ActionButtonType.deletePermanent, }; static List build(ActionButtonContext context) { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -331,7 +350,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true)); + result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); lastGroup = type.kebabMenuGroup; } diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 50746f5cbd..2fc136302d 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { @@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState context.maybePop(), ), actions: [ + IconButton( + onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))), + icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows), + ), if (currentAlbum.isActivityEnabled && currentAlbum.isShared) IconButton( icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index a2bca2745f..f3b9039b2b 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; class AssetViewerSettings extends StatelessWidget { @@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget { const ImageViewerQualitySetting(), const ImageViewerTapToNavigateSetting(), const VideoViewerSettings(), + const SlideshowSettings(), ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart new file mode 100644 index 0000000000..4e566e6065 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; +import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +class SlideshowSettings extends HookConsumerWidget { + const SlideshowSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final slideshow = ref.read(appConfigProvider).slideshow; + final useTransition = useState(slideshow.transition); + final useRepeat = useState(slideshow.repeat); + final useDuration = useState(slideshow.duration); + final useLook = useState(slideshow.look); + final useDirection = useState(slideshow.direction); + + useValueChanged(useTransition.value, (_, __) { + ref.read(metadataProvider).write(.slideshowTransition, useTransition.value); + }); + useValueChanged(useRepeat.value, (_, __) { + ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value); + }); + useValueChanged(useDuration.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDuration, useDuration.value); + }); + useValueChanged(useLook.value, (_, __) { + ref.read(metadataProvider).write(.slideshowLook, useLook.value); + }); + useValueChanged(useDirection.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDirection, useDirection.value); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingGroupTitle( + title: 'slideshow'.t(context: context), + icon: Icons.slideshow_outlined, + ), + SettingsSwitchListTile( + valueNotifier: useTransition, + title: "show_slideshow_transition".t(context: context), + enabled: useDirection.value != SlideshowDirection.shuffle, + ), + SettingsSwitchListTile( + valueNotifier: useRepeat, + title: "slideshow_repeat".t(context: context), + subtitle: "slideshow_repeat_description".t(context: context), + ), + SettingsSliderListTile( + valueNotifier: useDuration, + text: "duration".t(context: context), + minValue: 5, + noDivisons: 5, + maxValue: 30, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'look'.t(context: context)), + ), + SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'contain'.t(context: context), + value: SlideshowLook.contain, + ), + SettingsRadioGroup( + title: 'cover'.t(context: context), + value: SlideshowLook.cover, + ), + SettingsRadioGroup( + title: 'blurred_background'.t(context: context), + value: SlideshowLook.blurredBackground, + ), + ], + groupBy: useLook.value, + onRadioChanged: (value) { + if (value != null) { + useLook.value = value; + } + }, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'direction'.t(context: context)), + ), + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'forward'.t(context: context), + value: SlideshowDirection.forward, + ), + SettingsRadioGroup( + title: 'backward'.t(context: context), + value: SlideshowDirection.backward, + ), + SettingsRadioGroup( + title: 'shuffle'.t(context: context), + value: SlideshowDirection.shuffle, + ), + ], + groupBy: useDirection.value, + onRadioChanged: (value) { + if (value != null) { + useDirection.value = value; + } + }, + ), + ), + ], + ); + } +} diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 79f4e04b52..0a6020762a 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -37,6 +38,7 @@ RemoteAsset createRemoteAsset({ DateTime? updatedAt, DateTime? uploadedAt, bool isFavorite = false, + DateTime? deletedAt, }) { return RemoteAsset( id: 'remote-id', @@ -50,6 +52,7 @@ RemoteAsset createRemoteAsset({ uploadedAt: uploadedAt ?? DateTime.now(), isFavorite: isFavorite, isEdited: false, + deletedAt: deletedAt, ); } @@ -458,6 +461,62 @@ void main() { expect(ActionButtonType.trash.shouldShow(context), isFalse); }); + + test('should not show when asset is already trashed', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.trash.shouldShow(context), isFalse); + }); + }); + + group('restoreTrash button', () { + test('should show when owner, not locked, has remote, and is in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue); + }); + + test('should not show when not in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.main, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse); + }); }); group('deletePermanent button', () { @@ -494,6 +553,24 @@ void main() { expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); }); + + test('should show when asset is trashed even with trash enabled', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); + }); }); group('delete button', () { diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index e0e13eeaf6..860ba24eaf 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -37,29 +37,24 @@ ภาษาไทย

-## تنصل +> [!WARNING] +> ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك +> -- ⚠️ هذا التطبيق قيد التطوير النشط للغاية -- ⚠️ توقع الأخطاء والتغييرات العاجلة -- ⚠️ **لا تستخدم التطبيق باعتباره الطريقة الوحيدة لتخزين الصور ومقاطع الفيديو الخاصة بك** -- ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك +> [!NOTE] +> يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، على https://immich.app/ +## روابط -## محتوى - -- [الوثائق الرسمية](https://docs.immich.app) -- [خريطة الطريق](https://github.com/orgs/immich-app/projects/1) -- [تجريبي](#demo) -- [سمات](#features) +- [الوثائق الرسمية](https://docs.immich.app/) - [مقدمة](https://docs.immich.app/overview/introduction) - [تعليمات التحميل](https://docs.immich.app/install/requirements) +- [خريطة الطريق](https://immich.app/roadmap) +- [تجريبي](#تجريبي) +- [سمات](#سمات) +- [الترجمات](https://docs.immich.app/developer/translations) - [قواعد المساهمة](https://docs.immich.app/overview/support-the-project) -## توثيق - -يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، هنا -https://immich.app - ## تجريبي يمكنك الوصول إلى العرض التوضيحي على الويب على diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index d09362aa0f..49aebd9c10 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -37,26 +37,24 @@ ภาษาไทย

-## Avís legal +> [!WARNING] +> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos! +> -- ⚠️ El projecte està en desenvolupament **molt actiu**. -- ⚠️ Espereu errors i canvis que poden trencar coses. -- ⚠️ **No utilitzeu l'aplicació com a única manera de guardar les vostres fotos i vídeos!** +> [!NOTE] +> Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/. ## Contingut -- [Documentació oficial](https://docs.immich.app) -- [Mapa de ruta](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Funcionalitats](#funcionalitats) +- [Documentació](https://docs.immich.app/) - [Introducció](https://docs.immich.app/overview/introduction) - [Instal·lació](https://docs.immich.app/install/requirements) +- [Mapa de ruta](https://immich.app/roadmap) +- [Demo](#demo) +- [Funcionalitats](#funcionalitats) +- [Traduccions](https://docs.immich.app/developer/translations) - [Directrius de contribució](https://docs.immich.app/overview/support-the-project) -## Documentació - -Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/. - ## Demo Podeu accedir a la demostració web a https://demo.immich.app. Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor". diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 488b05abcc..11f9e24686 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -38,7 +38,9 @@ ภาษาไทย

-- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos! +> [!WARNING] +> ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos! +> > [!NOTE] > Die Hauptdokumentation, einschließlich der Installationsanleitungen, befinden sich unter https://immich.app/. @@ -49,7 +51,7 @@ - [Offizielle Dokumentation](https://docs.immich.app) - [Über Immich](https://docs.immich.app/overview/introduction) - [Installation](https://docs.immich.app/install/requirements) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Roadmap](https://immich.app/roadmap) - [Demo](#demo) - [Funktionen](#funktionen) - [Übersetzungen](https://docs.immich.app/developer/translations) diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index 032f8c50a8..2aa4d16ecf 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -37,27 +37,24 @@ ภาษาไทย

-## Advertencia +> [!WARNING] +> ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos. +> -- ⚠️ El proyecto está en **activo desarrollo**. -- ⚠️ Es probable que haya errores y cambios disruptivos. -- ⚠️ **¡No utilices la aplicación como única forma de almacenar tus fotos y videos!** -- ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos. +> [!NOTE] +> Puedes encontrar la documentación oficial, incluidas las guías de instalación, en . ## Contenido -- [Documentación oficial](https://docs.immich.app) -- [Hoja de ruta](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Funciones](#funciones) +- [Documentación](https://docs.immich.app/) - [Introducción](https://docs.immich.app/overview/introduction) - [Instalación](https://docs.immich.app/install/requirements) +- [Hoja de ruta](https://immich.app/roadmap) +- [Demo](#demo) +- [Funciones](#funciones) +- [Traducciones](https://docs.immich.app/developer/translations) - [Directrices para contribuir](https://docs.immich.app/overview/support-the-project) -## Documentación - -Puedes encontrar la documentación oficial, incluidas las guías de instalación, en . - ## Demo Puedes acceder a la demostración web en . Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`. diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index 349a0c49ce..e4fef66525 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -37,27 +37,24 @@ ภาษาไทย

-## Clause de non-responsabilité +> [!WARNING] +> ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos ! +> -- ⚠️ Le projet est en **très fort** développement. -- ⚠️ Attendez-vous à rencontrer des bogues et des changements importants. -- ⚠️ **N'utilisez pas cette application comme seul support de sauvegarde de vos photos et vos vidéos.** -- ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.seagate.com/fr/fr/blog/what-is-a-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos ! +> [!NOTE] +> Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/. ## Sommaire -- [Documentation officielle](https://docs.immich.app) -- [Feuille de route](https://github.com/orgs/immich-app/projects/1) -- [Démo](#démo) -- [Fonctionnalités](#fonctionnalités) +- [Documentation](https://docs.immich.app/) - [Introduction](https://docs.immich.app/overview/introduction) - [Installation](https://docs.immich.app/install/requirements) +- [Feuille de route](https://immich.app/roadmap) +- [Démo](#démo) +- [Fonctionnalités](#fonctionnalités) +- [Traductions](https://docs.immich.app/developer/translations) - [Contribution](https://docs.immich.app/overview/support-the-project) -## Documentation - -Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/. - ## Démo Vous pouvez accéder à la démo en ligne sur https://demo.immich.app. Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur` diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 711840fd9d..5d8d8b0880 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -38,12 +38,9 @@ ภาษาไทย

-## Avvertenze - -- ⚠️ Il progetto è in fase di sviluppo **molto attivo**. -- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes). -- ⚠️ **Non usare l’app come unico modo per archiviare le tue foto e i tuoi video.** -- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni! +> [!WARNING] +> ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni! +> > [!NOTE] > La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/. diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 0e74077895..8c2088247a 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -36,27 +36,24 @@ ภาษาไทย

-## 免責事項 +> [!WARNING] +> ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください! +> -- ⚠️ このプロジェクトは **非常に活発に** 開発中です。 -- ⚠️ バグの存在や変更が入ることも予想されます。 -- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。** -- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください! +> [!NOTE] +> インストールガイドを含む主なドキュメントは、https://immich.app/ です。 ## コンテンツ -- [公式ドキュメント](https://docs.immich.app) -- [ロードマップ](https://github.com/orgs/immich-app/projects/1) -- [デモ](#デモ) -- [機能](#機能) +- [公式ドキュメント](https://docs.immich.app/) - [紹介](https://docs.immich.app/overview/introduction) - [インストール](https://docs.immich.app/install/requirements) +- [ロードマップ](https://immich.app/roadmap) +- [デモ](#デモ) +- [機能](#機能) +- [翻訳](https://docs.immich.app/developer/translations) - [コントリビューションガイド](https://docs.immich.app/overview/support-the-project) -## ドキュメント - -インストールガイドを含む主なドキュメントは、https://immich.app/ です。 - ## デモ web デモは https://demo.immich.app からアクセスできます。モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index c2dfd11dd3..66ff4c79c3 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -39,12 +39,9 @@

-## 주의 사항 - -- ⚠️ 이 프로젝트는 **매우 활발하게** 개발 중입니다. -- ⚠️ 버그와 잦은 변경 사항이 있을 것으로 예상됩니다. -- ⚠️ **사진과 동영상을 이 앱에만 단독으로 저장하지 마세요.** -- ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요! +> [!WARNING] +> ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요! +> > [!NOTE] > 설치하는 방법을 포함한 주요 문서는 https://immich.app/ 에서 확인할 수 있습니다. @@ -57,7 +54,7 @@ - [로드맵](https://immich.app/roadmap) - [데모](#데모) - [기능](#기능) -- [번역](https://docs.immich.app/developer/tranlations) +- [번역](https://docs.immich.app/developer/translations) - [기여](https://docs.immich.app/overview/support-the-project) ## 데모 diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index ac72e9d238..ef7591b618 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -37,27 +37,24 @@ ภาษาไทย

-## Disclaimer +> [!WARNING] +> ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's! +> -- ⚠️ Het project wordt momenteel **zeer actief** ontwikkeld. -- ⚠️ Verwacht bugs en ingrijpende wijzigingen. -- ⚠️ **Gebruik de app niet als de enige manier om uw foto's en video's op te slaan.** -- ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's! +> [!NOTE] +> De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/. ## Inhoud -- [Officiële documentatie](https://docs.immich.app) -- [Toekomstplannen](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Functies](#functies) +- [Officiële documentatie](https://docs.immich.app/) - [Introductie](https://docs.immich.app/overview/introduction) - [Installatie](https://docs.immich.app/install/requirements) +- [Toekomstplannen](https://immich.app/roadmap) +- [Demo](#demo) +- [Functies](#functies) +- [Vertalingen](https://docs.immich.app/developer/translations) - [Richtlijnen voor bijdragen](https://docs.immich.app/overview/support-the-project) -## Documentatie - -De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/. - ## Demo Je kunt de demo [hier](https://demo.immich.app/) bekijken. Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL`. diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index d6f51cd779..1e1861a797 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -40,16 +40,9 @@

-## Avisos - -- ⚠️ Este projeto está sob **desenvolvimento constante**. -- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a - compatibilidade com versões anteriores). -- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e - vídeos.** -- ⚠️ Sempre siga o plano - [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup - para as suas mídias preciosas! +> [!WARNING] +> ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas! +> > [!NOTE] > Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 9c60e5f772..765e876f37 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -39,13 +39,9 @@ ภาษาไทย

-## Предупреждение - -- ⚠️ Этот проект находится **в очень активной** разработке. -- ⚠️ Ожидайте недоработки и глобальные изменения. -- ⚠️ **Не используйте это приложение как единственное хранилище своих фото и видео.** -- ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/ "Стратегии резервного копирования: Почему стратегия резервного копирования «3-2-1» — лучшая") для ваших драгоценных фотографий и видео! - +> [!WARNING] +> ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших драгоценных фотографий и видео! +> > [!NOTE] > Инструкции по установке и документация по ссылке https://immich.app/ @@ -55,7 +51,7 @@ - [Официальная документация](https://docs.immich.app) - [Введение](https://docs.immich.app/overview/introduction) - [Установка](https://docs.immich.app/install/requirements) -- [План разработки](https://github.com/orgs/immich-app/projects/1) +- [План разработки](https://immich.app/roadmap) - [Демо](#demo) - [Возможности](#features) - [Перевод](https://docs.immich.app/developer/translations) diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index a421c23c2e..b3a3cc804f 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -38,27 +38,24 @@ ภาษาไทย

-## Ansvarsfriskrivning +> [!WARNING] +> ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor! +> -- ⚠️ Projektet är under **mycket aktiv** utveckling. -- ⚠️ Förvänta dig buggar och brytande förändringar. -- ⚠️ **Använd inte appen som enda lagringssätt för dina foton och videor.** -- ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor! +> [!NOTE] +> Dokumentation och installationsguider hittas på https://immich.app/. ## Innehåll -- [Officiell Dokumentation](https://docs.immich.app) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Funktioner](#features) +- [Officiell Dokumentation](https://docs.immich.app/) - [Introduktion](https://docs.immich.app/overview/introduction) - [Installation](https://docs.immich.app/install/requirements) +- [Roadmap](https://immich.app/roadmap) +- [Demo](#demo) +- [Funktioner](#funktioner) +- [Översättningar](https://docs.immich.app/developer/translations) - [Riktlinjer för Bidrag](https://docs.immich.app/overview/support-the-project) -## Dokumentation - -Dokumentation och installationsguider hittas på https://imiich.app/. - ## Demo Ett webb-demo finns att testa på https://demo.immich.app. Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL` diff --git a/readme_i18n/README_tr_TR.md b/readme_i18n/README_tr_TR.md index 46aef49745..5ca61df023 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -37,26 +37,24 @@ ภาษาไทย

-## Feragatname +> [!WARNING] +> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos! +> -- ⚠️ Proje **çok aktif** bir şekilde geliştirilmektedir. -- ⚠️ Hatalar ve uygulama yapısını bozan değişiklikler olabilir. -- ⚠️ **Uygulamayı, fotoğraflarınızı ve videolarınızı saklamanın tek yöntemi olarak kullanmayın!** +> [!NOTE] +> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz. -## Content +## Bağlantılar -- [Resmi Belgeler](https://docs.immich.app) -- [Yol Haritası](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Özellikler](#özellikler) +- [Resmi Belgeler](https://docs.immich.app/) - [Giriş](https://docs.immich.app/overview/introduction) - [Kurulum](https://docs.immich.app/install/requirements) +- [Yol Haritası](https://immich.app/roadmap) +- [Demo](#demo) +- [Özellikler](#özellikler) +- [Çeviriler](https://docs.immich.app/developer/translations) - [Katkı Sağlama Rehberi](https://docs.immich.app/overview/support-the-project) -## Belgeler - -Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz. - ## Demo Web demo adresi: https://demo.immich.app. Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz. diff --git a/readme_i18n/README_uk_UA.md b/readme_i18n/README_uk_UA.md index 297054ee42..6b5849b01c 100644 --- a/readme_i18n/README_uk_UA.md +++ b/readme_i18n/README_uk_UA.md @@ -39,12 +39,9 @@ ภาษาไทย

-## Застереження - -- ⚠️ Цей проєкт перебуває **в дуже активній** розробці. -- ⚠️ Очікуйте безліч помилок і глобальних змін. -- ⚠️ **Не використовуйте цей застосунок як єдине сховище своїх фото та відео.** -- ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео! +> [!WARNING] +> ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео! +> > [!NOTE] > Основну документацію, зокрема посібники зі встановлення, можна знайти за адресою https://immich.app/. diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md index b6b22ff610..54e8a9bb90 100644 --- a/readme_i18n/README_vi_VN.md +++ b/readme_i18n/README_vi_VN.md @@ -41,12 +41,9 @@

-## Tuyên bố miễn trừ trách nhiệm - -- ⚠️ Dự án đang được phát triển **rất tích cực**. -- ⚠️ Dự kiến ​​sẽ có lỗi và thay đổi đột ngột. -- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.** -- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn! +> [!WARNING] +> ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn! +> > [!NOTE] > Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/. diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index b48e69f94d..6bd82b13e2 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -43,12 +43,9 @@

-## 免责声明 - -- ⚠️ 本项目正在 **非常活跃** 地开发中。 -- ⚠️ 可能存在 bug 或者随时有重大变更。 -- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。** -- ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案! +> [!WARNING] +> ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案! +> > [!NOTE] > 完整的项目文档以及安装教程请参见:。 diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index a94e5aa9f6..5bb5276db7 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -171,8 +171,8 @@ export class JobRepository { options: this.getJobOptions(item) || undefined, } as JobItem & { data: any; options: JobsOptions | undefined }; - if (job.options?.jobId) { - // need to use add() instead of addBulk() for jobId deduplication + if (job.options?.jobId || job.options?.deduplication) { + // need to use add() instead of addBulk() for jobId/deduplication to take effect promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); } else { itemsByQueue[queueName] = itemsByQueue[queueName] || []; @@ -230,10 +230,13 @@ export class JobRepository { return { priority: 1 }; } case JobName.FacialRecognitionQueueAll: { - return { jobId: JobName.FacialRecognitionQueueAll }; + return { deduplication: { id: JobName.FacialRecognitionQueueAll } }; } case JobName.VersionCheck: { - return { jobId: JobName.VersionCheck }; + return { deduplication: { id: JobName.VersionCheck } }; + } + case JobName.DatabaseBackup: { + return { deduplication: { id: JobName.DatabaseBackup } }; } default: { return null; diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 49778b5193..6792e8ecf4 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -132,7 +132,7 @@ export class MachineLearningRepository { private async check(url: string) { let healthy = false; try { - const response = await fetch(new URL('/ping', url), { + const response = await fetch(new URL('ping', url), { signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), }); if (response.ok) { @@ -170,7 +170,7 @@ export class MachineLearningRepository { ...this.config.urls.filter((url) => !this.isHealthy(url)), ]) { try { - const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); + const response = await fetch(new URL('predict', url), { method: 'POST', body: formData }); if (response.ok) { this.setHealthy(url, true); return response.json(); diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 8fd43f82d1..d9cd7c9c83 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,3 +1,54 @@ + + @@ -122,15 +134,20 @@ draggable="false">{$t('view_all')} -
+
{#each recents as item (item.data.id)} - + {/each}
@@ -140,3 +157,15 @@ {/if} + +{#if assetViewerManager.isViewing} + {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} + + assetViewerManager.showAssetViewer(false)} + /> + + {/await} +{/if}