Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas
412633d0aa Merge branch 'main' into feat-no-thumbhash-cache 2025-09-15 15:30:09 +01:00
Thomas Way
2bba33f834 feat(web): don't animate cached thumbnails
Thumbnails for assets always are displayed with a thumbhash which fades out
over 100ms, even if the thumbnail is cached and ready immediately. This can be
a bit distracting and make Immich feel 'slow', or inefficient as it feels like
the thumbnails are always being reloaded. Skipping the thumbhash and animation
for cached thumbnails makes it feel much more responsive.
2025-08-10 20:59:02 +01:00
28 changed files with 147 additions and 150 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.90",
"version": "2.2.89",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -1,8 +1,4 @@
[
{
"label": "v1.142.1",
"url": "https://v1.142.1.archive.immich.app"
},
{
"label": "v1.142.0",
"url": "https://v1.142.0.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.142.1",
"version": "1.142.0",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -54,6 +54,12 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
private var foregroundFuture: ListenableFuture<Void>? = null
init {
if (!loader.initialized()) {
loader.startInitialization(ctx)
}
}
companion object {
private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif"
private const val NOTIFICATION_ID = 100
@@ -62,10 +68,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
override fun startWork(): ListenableFuture<Result> {
Log.i(TAG, "Starting background upload worker")
if (!loader.initialized()) {
loader.startInitialization(ctx)
}
val notificationChannel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_ID,

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3015,
"android.injected.version.name" => "1.142.1",
"android.injected.version.code" => 3014,
"android.injected.version.name" => "1.142.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.142.1"
version_number: "1.142.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -1,20 +1,22 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:platform/platform.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final Platform _platform;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
_localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -69,7 +71,7 @@ class AssetService {
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
isFlipped = _platform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {

View File

@@ -1,24 +1,29 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
import 'package:platform/platform.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi;
final Platform _platform;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
: _localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi;
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required NativeSyncApi nativeSyncApi,
Platform? platform,
}) : _localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi,
_platform = platform ?? const LocalPlatform();
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -48,14 +53,14 @@ class LocalSyncService {
final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
if (_platform.isAndroid) {
for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
}
if (CurrentPlatform.isIOS) {
if (_platform.isIOS) {
// On iOS, we need to full sync albums that are marked as cloud as the delta sync
// does not include changes for cloud albums. If ignoreIcloudAssets is enabled,
// remove the albums from the local database from the previous sync

View File

@@ -1,9 +0,0 @@
import 'package:flutter/foundation.dart';
extension CurrentPlatform on TargetPlatform {
@pragma('vm:prefer-inline')
static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS;
@pragma('vm:prefer-inline')
static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android;
}

View File

@@ -1,20 +1,22 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftLocalAlbumRepository(this._db) : super(_db);
final Platform _platform;
const DriftLocalAlbumRepository(this._db, {Platform? platform})
: _platform = platform ?? const LocalPlatform(),
super(_db);
Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
final assetCount = _db.localAlbumAssetEntity.assetId.count();
@@ -59,7 +61,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS
// That is not the case on Android since asset <-> album has one:one mapping
final assetsToDelete = CurrentPlatform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId);
final assetsToDelete = _platform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId);
await _deleteAssets(assetsToDelete);
await _db.managers.localAlbumEntity
@@ -142,7 +144,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
}
});
if (CurrentPlatform.isAndroid) {
if (_platform.isAndroid) {
// On Android, an asset can only be in one album
// So, get the albums that are marked for deletion
// and delete all the assets that are in those albums
@@ -263,7 +265,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value();
}
if (CurrentPlatform.isAndroid) {
if (_platform.isAndroid) {
return _deleteAssets(assetIds);
}

View File

@@ -3,8 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -36,6 +34,21 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
}
Future<void> startBackup() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await ref.read(driftBackupProvider.notifier).cancel();
}
@override
Widget build(BuildContext context) {
final selectedAlbum = ref
@@ -43,24 +56,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser == null) {
return;
}
await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await backupNotifier.cancel();
}
return Scaffold(
appBar: AppBar(
elevation: 0,

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -23,7 +22,6 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage");
@override
void initState() {
super.initState();
@@ -51,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
(_) async {
@@ -60,17 +57,13 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
infoProvider.getServerInfo();
if (Store.isBetaTimelineEnabled) {
await Future.wait([backgroundManager.syncLocal(), backgroundManager.syncRemote()]);
await Future.wait([
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
]);
await backgroundManager.syncLocal();
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
} catch (e) {
log.severe('Failed establishing connection to the server: $e');
@@ -113,17 +106,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
}
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
final isEnableBackup = Store.get(StoreKey.enableBackup, false);
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
notifier.handleBackupResume(currentUser.id);
}
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
@@ -31,6 +30,7 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart';
@RoutePage()
class AssetViewerPage extends StatelessWidget {
@@ -53,9 +53,10 @@ class AssetViewerPage extends StatelessWidget {
class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex;
final Platform? platform;
final int? heroOffset;
const AssetViewer({super.key, required this.initialIndex, this.heroOffset});
const AssetViewer({super.key, required this.initialIndex, this.platform, this.heroOffset});
@override
ConsumerState createState() => _AssetViewerState();
@@ -85,6 +86,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription;
late Platform platform;
late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown;
@@ -112,6 +114,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex);
platform = widget.platform ?? const LocalPlatform();
totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
@@ -635,7 +638,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
scrollPhysics: platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,

View File

@@ -98,16 +98,16 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -35,7 +35,7 @@ class Timeline extends StatelessWidget {
this.showStorageIndicator,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
this.bottomSheet = const GeneralBottomSheet(),
this.groupBy,
this.withScrubber = true,
});

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -19,6 +18,7 @@ import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
@@ -144,42 +144,32 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final backgroundManager = _ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
try {
await Future.wait([
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
_safeRun(backgroundManager.syncRemote(), "syncRemote"),
]);
await Future.wait([
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
]);
// Run operations sequentially with state checks and error handling for each
await _safeRun(backgroundManager.syncLocal(), "syncLocal");
await _safeRun(backgroundManager.syncRemote(), "syncRemote");
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
if (isAlbumLinkedSyncEnable) {
await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
}
// Handle backup resume only if still active
if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
"handleBackupResume",
);
}
}
} catch (e, stackTrace) {
_log.severe("Error during background sync", e, stackTrace);
}
}
Future<void> _resumeBackup() async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
"handleBackupResume",
);
}
}
}
// Helper method to check if operations should continue
bool _shouldContinueOperation() {
return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) &&

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.142.1
- API version: 1.142.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -1429,7 +1429,7 @@ packages:
source: hosted
version: "5.0.1"
platform:
dependency: transitive
dependency: "direct main"
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.142.1+3015
version: 1.142.0+3014
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -57,6 +57,7 @@ dependencies:
photo_manager: ^3.6.4
photo_manager_image_provider: ^2.2.0
pinput: ^5.0.1
platform: ^3.1.6
punycode: ^1.0.0
riverpod_annotation: ^2.6.1
scrollable_positioned_list: ^0.3.8

View File

@@ -9858,7 +9858,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.142.1",
"version": "1.142.0",
"contact": {}
},
"tags": [],

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.142.1",
"version": "1.142.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.142.1
* 1.142.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.142.1",
"version": "1.142.0",
"description": "",
"author": "",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.142.1",
"version": "1.142.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

View File

@@ -45,15 +45,4 @@ describe('Thumbnail component', () => {
const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0);
});
it('shows thumbhash while image is loading', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const sut = render(Thumbnail, {
asset,
selected: true,
});
const thumbhash = sut.getByTestId('thumbhash');
expect(thumbhash).not.toBeFalsy();
});
});

View File

@@ -20,6 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isCached } from '$lib/utils/cache';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
@@ -75,6 +76,12 @@
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
const thumbnailURL = getAssetThumbnailUrl({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let element: HTMLElement | undefined = $state();
let mouseOver = $state(false);
@@ -313,7 +320,7 @@
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
url={thumbnailURL}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -344,17 +351,31 @@
</div>
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{#if asset.thumbhash}
{#await isCached(new Request(thumbnailURL))}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
></canvas>
{:then cached}
{#if !cached && !loaded && !thumbError}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
{/await}
{/if}
</div>

View File

@@ -0,0 +1,18 @@
let cache: Cache | undefined;
const getCache = async () => {
cache ||= await openCache();
return cache;
};
const openCache = async () => {
const [key] = await caches.keys();
if (key) {
return caches.open(key);
}
};
export const isCached = async (req: Request) => {
const cache = await getCache();
return !!(await cache?.match(req));
};

View File

@@ -29,6 +29,6 @@ export const TUNABLES = {
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
},
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 1000),
},
};