Merge branch 'main' into feat/integrity-checks-izzy

This commit is contained in:
Paul Makles
2026-01-08 10:58:00 +00:00
committed by GitHub
88 changed files with 2774 additions and 1063 deletions
+1 -34
View File
@@ -30,18 +30,6 @@ on:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
FASTLANE_TEAM_ID:
required: true
pull_request:
@@ -240,35 +228,14 @@ jobs:
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate and Provisioning Profiles
- name: Import Certificate
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
+1
View File
@@ -64,6 +64,7 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+2 -5
View File
@@ -298,9 +298,9 @@ jobs:
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile
run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format
run: pnpm --filter=immich-web format:i18n
run: pnpm --filter=immich-i18n format:fix
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
@@ -572,11 +572,8 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
with:
python-version: 3.11
#cache: 'uv'
- name: Install dependencies
run: |
uv sync --extra cpu
+31
View File
@@ -0,0 +1,31 @@
# Contributing to Immich
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
## Getting started
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
## General
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
## Finding work
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
## Use of generative AI
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
## Feature freezes
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
* Sharing/Asset ownership
* (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
+4
View File
@@ -4,6 +4,10 @@ sidebar_position: 2
# Setup
:::warning
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
:::
:::note
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
-1
View File
@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
if (!value) {
throw new Error('Ran out of random asset data');
}
return value;
};
+5
View File
@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}
+28
View File
@@ -746,6 +746,18 @@
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
"clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches",
@@ -835,9 +847,13 @@
"current_device": "Current device",
"current_pin_code": "Current PIN code",
"current_server_address": "Current server address",
"custom_date": "Custom date",
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL",
"cutoff_date_description": "Remove photos and videos older than",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
@@ -1161,6 +1177,7 @@
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
@@ -1174,6 +1191,9 @@
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
@@ -1291,6 +1311,8 @@
"json_error": "JSON error",
"keep": "Keep",
"keep_all": "Keep All",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_this_delete_others": "Keep this, delete others",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
@@ -1461,6 +1483,7 @@
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to",
"move_to_device_trash": "Move to device trash",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
@@ -1644,6 +1667,7 @@
"photos_and_videos": "Photos & Videos",
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
"photos_from_previous_years": "Photos from previous years",
"photos_only": "Photos only",
"pick_a_location": "Pick a location",
"pick_custom_range": "Custom range",
"pick_date_range": "Select a date range",
@@ -1825,9 +1849,11 @@
"saved_settings": "Saved settings",
"say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred",
"scan": "Scan",
"scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan",
"scan_settings": "Scan Settings",
"scanning": "Scanning",
"scanning_for_album": "Scanning for album...",
"search": "Search",
"search_albums": "Search albums",
@@ -1899,6 +1925,7 @@
"select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_cutoff_date": "Select cutoff date",
"select_face": "Select face",
"select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer",
@@ -2267,6 +2294,7 @@
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
"view": "View",
"view_album": "View Album",
"view_all": "View All",
+13
View File
@@ -0,0 +1,13 @@
{
"name": "immich-i18n",
"version": "1.0.0",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1"
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }
[tasks."i18n:format-fix"]
dir = "i18n"
run = "pnpm dlx sort-json *.json"
run = "pnpm run format:fix"
+2 -1
View File
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
!default.perspectivev3
fastlane/report.xml
Gemfile.lock
Gemfile.lock
certs/
+72 -28
View File
@@ -44,7 +44,7 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "")
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
@@ -54,7 +54,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: profile_name_main,
targets: ["Runner"]
)
@@ -65,7 +65,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
@@ -76,7 +76,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
@@ -87,7 +87,10 @@ end
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil
version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
@@ -115,9 +118,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{app_identifier}" => profile_name_main,
"#{app_identifier}.ShareExtension" => profile_name_share,
"#{app_identifier}.Widget" => profile_name_widget
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -136,20 +139,35 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -157,20 +175,33 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for production bundle IDs
configure_code_signing
configure_code_signing(
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload with version number
build_and_upload(
api_key: api_key,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -215,13 +246,26 @@ end
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds)
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
@@ -233,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
+4
View File
@@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
@@ -360,6 +360,7 @@ extension on Iterable<PlatformAlbum> {
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
assetCount: e.assetCount,
isIosSharedAlbum: e.isCloud,
),
).toList();
}
@@ -79,6 +79,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
}
@@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
isIosSharedAlbum: isIosSharedAlbum,
);
}
}
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.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/infrastructure/entities/local_album.entity.dart';
@@ -126,4 +127,49 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
]);
Expression<bool> whereClause =
_db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull();
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
}
if (keepFavorites) {
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
}
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
}
}
@@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final Map<DateTime, int> bucketCounts = {};
for (final asset in sorted) {
final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day);
bucketCounts[date] = (bucketCounts[date] ?? 0) + 1;
}
final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList();
return (
bucketSource: () => Stream.value(buckets),
assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)),
origin: origin,
);
}
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
@@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -22,6 +23,7 @@ enum SettingSection {
advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"),
languages('language', Icons.language, "setting_languages_subtitle"),
networking('networking_settings', Icons.wifi, "networking_subtitle"),
notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"),
@@ -38,6 +40,7 @@ enum SettingSection {
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup =>
Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(),
SettingSection.freeUpSpace => const FreeUpSpaceSettings(),
SettingSection.languages => const LanguageSettings(),
SettingSection.networking => const NetworkingSettings(),
SettingSection.notifications => const NotificationSetting(),
@@ -0,0 +1,42 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class CleanupPreviewPage extends StatelessWidget {
final List<LocalAsset> assets;
const CleanupPreviewPage({super.key, required this.assets});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})),
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: context.colorScheme.surface,
),
body: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.fromAssetsWithBuckets(assets.cast<BaseAsset>(), TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true),
),
);
}
}
@@ -42,6 +42,7 @@ class Timeline extends StatelessWidget {
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
});
final Widget? topSliverWidget;
@@ -54,6 +55,7 @@ class Timeline extends StatelessWidget {
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
@override
Widget build(BuildContext context) {
@@ -73,6 +75,7 @@ class Timeline extends StatelessWidget {
groupBy: groupBy,
),
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
@@ -89,6 +92,17 @@ class Timeline extends StatelessWidget {
}
}
class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
@override
bool build() => true;
@override
void setReadonlyMode(bool value) {}
@override
void toggleReadonlyMode() {}
}
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
+106
View File
@@ -0,0 +1,106 @@
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/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepFavorites = true,
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
bool? keepFavorites,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepFavorites: keepFavorites ?? this.keepFavorites,
);
}
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
}
Future<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
}
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepFavorites: state.keepFavorites,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
}
}
Future<int> deleteAssets() async {
if (state.assetsToDelete.isEmpty) {
return 0;
}
state = state.copyWith(isDeleting: true);
try {
final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList());
state = state.copyWith(assetsToDelete: [], isDeleting: false);
return deletedCount;
} catch (e) {
state = state.copyWith(isDeleting: false);
rethrow;
}
}
void reset() {
state = const CleanupState();
}
}
+2
View File
@@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
@@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+37
View File
@@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [CleanupPreviewPage]
class CleanupPreviewRoute extends PageRouteInfo<CleanupPreviewRouteArgs> {
CleanupPreviewRoute({
Key? key,
required List<LocalAsset> assets,
List<PageRouteInfo>? children,
}) : super(
CleanupPreviewRoute.name,
args: CleanupPreviewRouteArgs(key: key, assets: assets),
initialChildren: children,
);
static const String name = 'CleanupPreviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<CleanupPreviewRouteArgs>();
return CleanupPreviewPage(key: args.key, assets: args.assets);
},
);
}
class CleanupPreviewRouteArgs {
const CleanupPreviewRouteArgs({this.key, required this.assets});
final Key? key;
final List<LocalAsset> assets;
@override
String toString() {
return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}';
}
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
+45
View File
@@ -0,0 +1,45 @@
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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
final cleanupServiceProvider = Provider<CleanupService>((ref) {
return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider));
});
class CleanupService {
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
);
}
Future<int> deleteLocalAssets(List<String> localIds) async {
if (localIds.isEmpty) {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
return 0;
}
}
+24 -1
View File
@@ -31,7 +31,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 19;
const int targetVersion = 20;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -86,6 +86,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;
@@ -258,6 +262,25 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
}
}
Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
await db.batch((batch) {
for (final album in albums) {
batch.update(
db.localAlbumEntity,
LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)),
where: (t) => t.id.equals(album.id),
);
}
});
dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums");
} catch (error) {
dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
@@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -41,6 +42,13 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest);
}
Widget buildSubtitle() {
return Text(
album.isIosSharedAlbum ? '${album.assetCount} (iCloud Shared Album)' : album.assetCount.toString(),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
return GestureDetector(
onDoubleTap: () {
ref.watch(hapticFeedbackProvider.notifier).selectionClick();
@@ -73,8 +81,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
}
},
leading: buildIcon(),
title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(album.assetCount.toString()),
title: Text(album.name, style: context.textTheme.titleSmall),
subtitle: buildSubtitle(),
trailing: IconButton(
onPressed: () {
context.pushRoute(LocalTimelineRoute(album: album));
@@ -0,0 +1,702 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
const FreeUpSpaceSettings({super.key});
@override
ConsumerState<FreeUpSpaceSettings> createState() => _FreeUpSpaceSettingsState();
}
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
void _resetState() {
ref.read(cleanupProvider.notifier).reset();
_hasScanned = false;
}
CleanupStep get _calculatedStep {
final state = ref.read(cleanupProvider);
if (state.assetsToDelete.isNotEmpty) {
return CleanupStep.delete;
}
if (state.selectedDate != null) {
return CleanupStep.filterOptions;
}
return CleanupStep.selectDate;
}
void _goToFiltersStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.filterOptions);
}
void _goToScanStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.scan);
}
void _setPresetDate(int daysAgo) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final date = DateTime.now().subtract(Duration(days: daysAgo));
ref.read(cleanupProvider.notifier).setSelectedDate(date);
setState(() => _hasScanned = false);
}
bool _isPresetSelected(int? daysAgo) {
final state = ref.read(cleanupProvider);
if (state.selectedDate == null) return false;
final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000);
// Check if dates match (ignoring time component)
return state.selectedDate!.year == expectedDate.year &&
state.selectedDate!.month == expectedDate.month &&
state.selectedDate!.day == expectedDate.day;
}
Future<void> _selectDate() async {
final state = ref.read(cleanupProvider);
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: state.selectedDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
}
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
await ref.read(cleanupProvider.notifier).scanAssets();
final state = ref.read(cleanupProvider);
setState(() {
_hasScanned = true;
if (state.assetsToDelete.isNotEmpty) {
_currentStep = CleanupStep.delete;
}
});
}
Future<void> _deleteAssets() async {
final state = ref.read(cleanupProvider);
if (state.assetsToDelete.isEmpty || state.selectedDate == null) {
return;
}
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) =>
_DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!),
);
if (confirmed != true) {
return;
}
final deletedCount = await ref.read(cleanupProvider.notifier).deleteAssets();
if (mounted && deletedCount > 0) {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
await showDialog<void>(
context: context,
builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount),
);
}
setState(() => _currentStep = CleanupStep.selectDate);
}
void _showAssetsPreview(List<LocalAsset> assets) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
context.pushRoute(CleanupPreviewRoute(assets: assets));
}
@override
Widget build(BuildContext context) {
final state = ref.watch(cleanupProvider);
final hasDate = state.selectedDate != null;
final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty;
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) {
case StepState.complete:
return StepStyle(
color: context.colorScheme.primary,
indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500),
);
case StepState.disabled:
return StepStyle(
color: context.colorScheme.onSurface.withValues(alpha: 0.38),
indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500),
);
case StepState.indexed:
case StepState.editing:
case StepState.error:
if (isDestructive) {
return StepStyle(
color: context.colorScheme.error,
indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500),
);
}
return StepStyle(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500),
);
}
}
final step1State = hasDate ? StepState.complete : StepState.indexed;
final step2State = hasDate ? StepState.complete : StepState.disabled;
final step3State = hasAssets
? StepState.complete
: hasDate
? StepState.indexed
: StepState.disabled;
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
String getFilterSubtitle() {
final parts = <String>[];
switch (state.filterType) {
case AssetFilterType.all:
parts.add('all'.t(context: context));
case AssetFilterType.photosOnly:
parts.add('photos_only'.t(context: context));
case AssetFilterType.videosOnly:
parts.add('videos_only'.t(context: context));
}
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
}
return parts.join('');
}
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
_resetState();
}
},
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)),
),
child: Text(
'free_up_space_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
),
),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
onStepTapped: (step) {
// Only allow going back or to completed steps
if (step <= _calculatedStep.index) {
setState(() => _currentStep = CleanupStep.values[step]);
}
},
controlsBuilder: (_, __) => const SizedBox.shrink(),
steps: [
// Step 1: Select Cutoff Date
Step(
stepStyle: styleForState(step1State),
title: Text(
'select_cutoff_date'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step1State == StepState.complete
? context.colorScheme.primary
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
DateFormat.yMMMd().format(state.selectedDate!),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge),
const SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.4,
children: [
_DatePresetCard(
value: '30',
unit: 'cutoff_day'.t(context: context, args: {'count': '30'}),
onTap: () => _setPresetDate(30),
isSelected: _isPresetSelected(30),
),
_DatePresetCard(
value: '60',
unit: 'cutoff_day'.t(context: context, args: {'count': '60'}),
onTap: () => _setPresetDate(60),
isSelected: _isPresetSelected(60),
),
_DatePresetCard(
value: '90',
unit: 'cutoff_day'.t(context: context, args: {'count': '90'}),
onTap: () => _setPresetDate(90),
isSelected: _isPresetSelected(90),
),
_DatePresetCard(
value: '1',
unit: 'cutoff_year'.t(context: context, args: {'count': '1'}),
onTap: () => _setPresetDate(365),
isSelected: _isPresetSelected(365),
),
_DatePresetCard(
value: '2',
unit: 'cutoff_year'.t(context: context, args: {'count': '2'}),
onTap: () => _setPresetDate(730),
isSelected: _isPresetSelected(730),
),
_DatePresetCard(
value: '3',
unit: 'cutoff_year'.t(context: context, args: {'count': '3'}),
onTap: () => _setPresetDate(1095),
isSelected: _isPresetSelected(1095),
),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: Text('custom_date'.t(context: context)),
style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: hasDate ? () => _goToFiltersStep() : null,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: true,
state: step1State,
),
// Step 2: Select Filter Options
Step(
stepStyle: styleForState(step2State),
title: Text(
'filter_options'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step2State == StepState.complete
? context.colorScheme.primary
: step2State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
getFilterSubtitle(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge),
const SizedBox(height: 16),
SegmentedButton<AssetFilterType>(
segments: [
ButtonSegment(
value: AssetFilterType.all,
label: Text('all'.t(context: context)),
icon: const Icon(Icons.photo_library),
),
ButtonSegment(
value: AssetFilterType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetFilterType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.filterType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.labelLarge,
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: hasDate,
state: step2State,
),
// Step 3: Scan Assets
Step(
stepStyle: styleForState(step3State),
title: Text(
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step3State == StepState.complete
? context.colorScheme.primary
: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: _hasScanned
? Text(
'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
style: context.textTheme.bodyMedium?.copyWith(
color: state.assetsToDelete.isNotEmpty
? context.colorScheme.primary
: context.colorScheme.onSurface.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
children: [
Text(
'cleanup_step3_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
if (CurrentPlatform.isIOS) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Row(
children: [
Icon(Icons.info_outline, color: context.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
'cleanup_icloud_shared_albums_excluded'.t(context: context),
style: context.textTheme.labelLarge,
),
),
],
),
),
],
const SizedBox(height: 16),
state.isScanning
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: context.colorScheme.primary.withAlpha(50),
),
)
: ElevatedButton.icon(
onPressed: state.isScanning ? null : _scanAssets,
icon: const Icon(Icons.search),
label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
if (_hasScanned && state.assetsToDelete.isEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Row(
children: [
const Icon(Icons.info, color: Colors.orange),
const SizedBox(width: 12),
Expanded(
child: Text(
'cleanup_no_assets_found'.t(context: context),
style: context.textTheme.bodyMedium,
),
),
],
),
),
],
],
),
isActive: hasDate,
state: step3State,
),
// Step 4: Delete Assets
Step(
stepStyle: styleForState(step4State, isDestructive: true),
title: Text(
'move_to_device_trash'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step4State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.error,
),
),
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.errorContainer.withValues(alpha: 0.3),
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
),
child: hasAssets
? Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
)
: null,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _showAssetsPreview(state.assetsToDelete),
icon: const Icon(Icons.preview),
label: Text('preview'.t(context: context)),
style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: state.isDeleting ? null : _deleteAssets,
icon: state.isDeleting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.delete_forever),
label: Text(
state.isDeleting
? 'cleanup_deleting'.t(context: context)
: 'move_to_device_trash'.t(context: context),
),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
foregroundColor: context.colorScheme.onError,
minimumSize: const Size(double.infinity, 56),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
],
),
isActive: hasAssets,
state: step4State,
),
],
),
],
),
),
);
}
}
class _DeleteConfirmationDialog extends StatelessWidget {
final int assetCount;
final DateTime cutoffDate;
const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('cleanup_confirm_prompt_title'.t(context: context)),
content: Text(
'cleanup_confirm_description'.t(
context: context,
args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: Text('cancel'.t(context: context)),
),
ElevatedButton(
onPressed: () => context.pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
foregroundColor: context.colorScheme.onError,
),
child: Text('confirm'.t(context: context)),
),
],
);
}
}
class _DeleteSuccessDialog extends StatelessWidget {
final int deletedCount;
const _DeleteSuccessDialog({required this.deletedCount});
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48),
title: Text('success'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}),
style: context.textTheme.labelLarge?.copyWith(fontSize: 16),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'cleanup_trash_hint'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => context.pop(),
child: Text('done'.t(context: context)),
),
],
);
}
}
class _DatePresetCard extends StatelessWidget {
final String value;
final String unit;
final VoidCallback onTap;
final bool isSelected;
const _DatePresetCard({required this.value, required this.unit, required this.onTap, required this.isSelected});
@override
Widget build(BuildContext context) {
return Material(
color: isSelected ? context.colorScheme.primaryContainer.withAlpha(100) : context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: isSelected ? context.colorScheme.primary : Colors.transparent, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurface,
),
),
Text(
unit,
style: context.textTheme.bodySmall?.copyWith(
color: isSelected
? context.colorScheme.primary
: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
),
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ migration:
dart run drift_dev make-migrations
translation:
npm --prefix ../web run format:i18n
npm --prefix ../i18n run format:fix
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart
@@ -0,0 +1,438 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.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/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.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
void main() {
late Drift db;
late DriftLocalAssetRepository repository;
setUp(() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftLocalAssetRepository(db);
});
tearDown(() async {
await db.close();
});
group('getRemovalCandidates', () {
final userId = 'user-123';
final otherUserId = 'user-456';
final now = DateTime(2024, 1, 15);
final cutoffDate = DateTime(2024, 1, 10);
final beforeCutoff = DateTime(2024, 1, 5);
final afterCutoff = DateTime(2024, 1, 12);
Future<void> insertUser(String id, String email) async {
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
}
setUp(() async {
await insertUser(userId, 'user@test.com');
await insertUser(otherUserId, 'other@test.com');
});
Future<void> insertLocalAsset({
required String id,
required String checksum,
required DateTime createdAt,
required AssetType type,
required bool isFavorite,
}) async {
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
name: 'asset_$id.jpg',
checksum: Value(checksum),
type: type,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
isFavorite: Value(isFavorite),
),
);
}
Future<void> insertRemoteAsset({
required String id,
required String checksum,
required String ownerId,
DateTime? deletedAt,
}) async {
await db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: id,
name: 'remote_$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
ownerId: ownerId,
visibility: AssetVisibility.timeline,
deletedAt: Value(deletedAt),
),
);
}
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
await db
.into(db.localAlbumEntity)
.insert(
LocalAlbumEntityCompanion.insert(
id: id,
name: name,
updatedAt: Value(now),
backupSelection: BackupSelection.none,
isIosSharedAlbum: Value(isIosSharedAlbum),
),
);
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) async {
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
test('returns only assets that match all criteria', () async {
// Asset 1: Should be included - backed up, before cutoff, correct owner, not deleted, not favorite
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
// Asset 2: Should NOT be included - not backed up (no remote asset)
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
// Asset 3: Should NOT be included - after cutoff date
await insertLocalAsset(
id: 'local-3',
checksum: 'checksum-3',
createdAt: afterCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
// Asset 4: Should NOT be included - different owner
await insertLocalAsset(
id: 'local-4',
checksum: 'checksum-4',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-4', checksum: 'checksum-4', ownerId: otherUserId);
// Asset 5: Should NOT be included - remote asset is deleted
await insertLocalAsset(
id: 'local-5',
checksum: 'checksum-5',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-5', checksum: 'checksum-5', ownerId: userId, deletedAt: now);
// Asset 6: Should NOT be included - is favorite (when keepFavorites=true)
await insertLocalAsset(
id: 'local-6',
checksum: 'checksum-6',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: true,
);
await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-1');
});
test('includes favorites when keepFavorites is false', () async {
await insertLocalAsset(
id: 'local-favorite',
checksum: 'checksum-fav',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: true,
);
await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-favorite');
expect(candidates[0].isFavorite, true);
});
test('filters by photos only', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
});
test('filters by videos only', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.videosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-video');
expect(candidates[0].type, AssetType.video);
});
test('returns both photos and videos with filterType.all', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
expect(candidates.length, 2);
final ids = candidates.map((a) => a.id).toSet();
expect(ids, containsAll(['local-photo', 'local-video']));
});
test('excludes assets in iOS shared albums', () async {
// Regular album
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// iOS shared album
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
// Asset in regular album (should be included)
await insertLocalAsset(
id: 'local-regular',
checksum: 'checksum-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-regular', checksum: 'checksum-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-regular');
// Asset in iOS shared album (should be excluded)
await insertLocalAsset(
id: 'local-shared',
checksum: 'checksum-shared',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-regular');
});
test('includes assets at exact cutoff date', () async {
await insertLocalAsset(
id: 'local-exact',
checksum: 'checksum-exact',
createdAt: cutoffDate,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-exact');
});
test('returns empty list when no assets match criteria', () async {
// Only assets after cutoff
await insertLocalAsset(
id: 'local-after',
checksum: 'checksum-after',
createdAt: afterCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
test('handles multiple assets with same checksum', () async {
// Two local assets with same checksum (edge case, but should handle it)
await insertLocalAsset(
id: 'local-dup1',
checksum: 'checksum-dup',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertLocalAsset(
id: 'local-dup2',
checksum: 'checksum-dup',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 2);
expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
});
test('includes assets not in any album', () async {
// Asset not in any album should be included
await insertLocalAsset(
id: 'local-no-album',
checksum: 'checksum-no-album',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-no-album');
});
test('excludes asset that is in both regular and iOS shared album', () async {
// Regular album
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// iOS shared album
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
// Asset in BOTH albums - should be excluded because it's in an iOS shared album
await insertLocalAsset(
id: 'local-both',
checksum: 'checksum-both',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
test('excludes assets with null checksum (not backed up)', () async {
// Asset with null checksum cannot be matched to remote asset
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'local-null-checksum',
name: 'asset_null.jpg',
checksum: const Value.absent(), // null checksum
type: AssetType.image,
createdAt: Value(beforeCutoff),
updatedAt: Value(beforeCutoff),
isFavorite: const Value(false),
),
);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
});
}
+14 -15
View File
@@ -292,6 +292,15 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)
i18n:
devDependencies:
prettier:
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-sort-json:
specifier: ^4.1.1
version: 4.1.1(prettier@3.7.4)
open-api/typescript-sdk:
dependencies:
'@oazapfts/runtime':
@@ -717,8 +726,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.52.0
version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
specifier: ^0.53.3
version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -752,9 +761,6 @@ importers:
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.46.1)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -3069,8 +3075,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.52.0':
resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==}
'@immich/ui@0.53.3':
resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==}
peerDependencies:
svelte: ^5.0.0
@@ -5611,9 +5617,6 @@ packages:
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
@@ -15075,7 +15078,7 @@ snapshots:
dependencies:
svelte: 5.46.1
'@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
'@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0
@@ -17992,10 +17995,6 @@ snapshots:
async-lock@1.4.1: {}
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
async@0.2.10: {}
async@3.2.6: {}
+1
View File
@@ -2,6 +2,7 @@ packages:
- cli
- docs
- e2e
- i18n
- open-api/typescript-sdk
- server
- plugins
+2 -4
View File
@@ -17,8 +17,7 @@
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --check .",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
"format:fix": "prettier --write .",
"test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
@@ -28,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.52.0",
"@immich/ui": "^0.53.3",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -40,7 +39,6 @@
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",
"async-mutex": "^0.5.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4",
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import { mdiCast, mdiCastConnected } from '@mdi/js';
import { CastDestinationType, castManager } from '$lib/managers/cast-manager.svelte';
import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
import { IconButton } from '@immich/ui';
onMount(async () => {
await castManager.initialize();
});
</script>
{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST}
<IconButton
shape="round"
variant="ghost"
size="medium"
color={castManager.isCasting ? 'primary' : 'secondary'}
icon={castManager.isCasting ? mdiCastConnected : mdiCast}
onclick={() => void GCastDestination.showCastDialog()}
aria-label={$t('cast')}
/>
{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { isEnabled } from '$lib/utils';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
@@ -9,6 +10,6 @@
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
{#if icon && isEnabled(action)}
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
@@ -0,0 +1,16 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { isEnabled } from '$lib/utils';
import { type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, onAction } = $derived(action);
</script>
{#if icon && isEnabled(action)}
<MenuOption {icon} text={title} onClick={() => onAction(action)} />
{/if}
+1 -1
View File
@@ -10,6 +10,6 @@
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
{#if icon && (action.$if?.() ?? true)}
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
@@ -9,6 +9,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
@@ -58,6 +59,8 @@
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}
};
const { Cast } = $derived(getGlobalActions($t));
</script>
<svelte:document
@@ -116,7 +119,7 @@
{/snippet}
{#snippet trailing()}
<CastButton />
<ActionButton action={Cast} />
{#if sharedLink.allowUpload}
<IconButton
@@ -5,8 +5,6 @@ import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackRespon
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.FAVORITE]: { asset: TimelineAsset };
[AssetAction.UNFAVORITE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
@@ -1,23 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiArrowLeft}
aria-label={$t('go_back')}
onclick={onClose}
/>
@@ -1,35 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDownload } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: TimelineAsset;
menuItem?: boolean;
}
let { asset, menuItem = false }: Props = $props();
const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id }));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
{#if !menuItem}
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiDownload}
aria-label={$t('download')}
onclick={onDownloadFile}
/>
{:else}
<MenuOption icon={mdiDownload} text={$t('download')} onClick={onDownloadFile} />
{/if}
@@ -1,51 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset, onAction }: Props = $props();
const toggleFavorite = async () => {
try {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite,
},
});
asset = { ...asset, isFavorite: data.isFavorite };
onAction({
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
asset: toTimelineAsset(asset),
});
toastManager.success(asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
aria-label={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
onclick={toggleFavorite}
/>
@@ -1,21 +0,0 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiMotionPauseOutline, mdiMotionPlayOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
isPlaying: boolean;
onClick: (shouldPlay: boolean) => void;
}
let { isPlaying, onClick }: Props = $props();
</script>
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={isPlaying ? mdiMotionPauseOutline : mdiMotionPlayOutline}
aria-label={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
onclick={() => onClick(!isPlaying)}
/>
@@ -1,22 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { IconButton } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
const onAction = () => {
assetViewerManager.toggleDetailPanel();
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onAction }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiInformationOutline}
onclick={onAction}
aria-label={$t('info')}
/>
@@ -1,16 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import CloseAction from '$lib/components/asset-viewer/actions/close-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@@ -20,18 +17,17 @@
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { getAssetJobName, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -44,9 +40,9 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
import {
mdiAlertOutline,
mdiArrowLeft,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
@@ -61,7 +57,6 @@
mdiUpload,
mdiVideoOutline,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -69,7 +64,6 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showCloseButton?: boolean;
showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
@@ -79,8 +73,7 @@
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
// export let showEditorHandler: () => void;
onClose: () => void;
motionPhoto?: Snippet;
onClose?: () => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -90,7 +83,6 @@
album = null,
person = null,
stack = null,
showCloseButton = true,
showSlideshow = false,
onZoomImage,
onCopyImage,
@@ -100,18 +92,27 @@
onRunJob,
onPlaySlideshow,
onClose,
motionPhoto,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Share } = $derived(getAssetActions($t, asset));
const Close: ActionItem = {
title: $t('go_back'),
type: $t('assets'),
icon: mdiArrowLeft,
$if: () => !!onClose,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
};
const { Cast } = $derived(getGlobalActions($t));
const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } =
$derived(getAssetActions($t, asset));
// $: showEditorButton =
// isOwner &&
@@ -124,30 +125,25 @@
// !asset.livePhotoVideoId;
</script>
<CommandPaletteDefaultProvider
name={$t('assets')}
actions={withoutIcons([Close, Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info])}
/>
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
>
<div class="dark">
{#if showCloseButton}
<CloseAction {onClose} />
{/if}
<ActionButton action={Close} />
</div>
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton />
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<ActionButton action={Cast} />
<ActionButton action={Share} />
{#if asset.isOffline}
<IconButton
shape="round"
color="danger"
icon={mdiAlertOutline}
onclick={() => assetViewerManager.toggleDetailPanel()}
aria-label={$t('asset_offline')}
/>
{/if}
{#if asset.livePhotoVideoId}
{@render motionPhoto?.()}
{/if}
<ActionButton action={Offline} />
<ActionButton action={PlayMotionPhoto} />
<ActionButton action={StopMotionPhoto} />
{#if asset.type === AssetTypeEnum.Image}
<IconButton
class="hidden sm:flex"
@@ -170,16 +166,12 @@
/>
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if asset.hasMetadata}
<ShowDetailAction />
{/if}
<ActionButton action={SharedLinkDownload} />
<ActionButton action={Info} />
<ActionButton action={Favorite} />
<ActionButton action={Unfavorite} />
{#if isOwner}
<FavoriteAction {asset} {onAction} />
<RatingAction {asset} {onAction} />
{/if}
@@ -190,9 +182,8 @@
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
<ActionMenuItem action={Download} />
{#if !isLocked}
{#if asset.isTrashed}
@@ -2,7 +2,6 @@
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
@@ -11,18 +10,19 @@
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -54,19 +54,23 @@
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
};
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
cursor: AssetCursor;
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
onUndoDelete?: OnUndoDelete | undefined;
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
album?: AlbumResponseDto;
person?: PersonResponseDto;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
@@ -74,17 +78,15 @@
}
let {
asset = $bindable(),
preloadAssets = $bindable([]),
cursor,
showNavigation = true,
withStacked = false,
isShared = false,
album = null,
person = null,
preAction = undefined,
onAction = undefined,
onUndoDelete = undefined,
showCloseButton,
album,
person,
preAction,
onAction,
onUndoDelete,
onClose,
onNext,
onPrevious,
@@ -103,8 +105,8 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false);
@@ -135,7 +137,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(toTimelineAsset(stack.assets[1]));
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
@@ -150,16 +152,8 @@
}
};
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
};
onMount(async () => {
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -203,7 +197,7 @@
};
const closeViewer = () => {
onClose(asset);
onClose?.(asset);
};
const closeEditor = () => {
@@ -212,7 +206,9 @@
});
};
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -222,38 +218,37 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
let hasNext = false;
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
});
};
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -355,23 +350,8 @@
selectedEditType = type;
};
const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack());
}
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
assetViewerManager.closeActivityPanel();
@@ -383,17 +363,43 @@
}
});
// primarily, this is reactive on `asset`
$effect(() => {
handlePromiseError(handleGetAllAlbums());
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
asset = update;
}
};
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />
@@ -411,7 +417,6 @@
{album}
{person}
{stack}
{showCloseButton}
showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
@@ -420,17 +425,10 @@
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={closeViewer}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}
{setPlayOriginalVideo}
>
{#snippet motionPhoto()}
<MotionPhotoAction
isPlaying={shouldPlayMotionPhoto}
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
/>
{/snippet}
</AssetViewerNavBar>
/>
</div>
{/if}
@@ -461,8 +459,7 @@
<PhotoViewer
bind:zoomToggle
bind:copyImage
asset={previewStackedAsset}
{preloadAssets}
cursor={{ ...cursor, current: previewStackedAsset }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
@@ -486,7 +483,7 @@
{:else}
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
{#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
@@ -494,7 +491,7 @@
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
@@ -507,8 +504,7 @@
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
{preloadAssets}
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
@@ -1,210 +0,0 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
import type { MockInstance } from 'vitest';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;
vi.mock('$lib/utils', async (originalImport) => {
const meta = await originalImport<typeof import('$lib/utils')>();
return {
...meta,
getAssetOriginalUrl: vi.fn(),
getAssetThumbnailUrl: vi.fn(),
};
});
describe('PhotoViewer component', () => {
let getAssetOriginalUrlSpy: MockInstance;
let getAssetThumbnailUrlSpy: MockInstance;
beforeAll(() => {
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
vi.stubGlobal('cast', {
framework: {
CastState: {
NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE',
},
RemotePlayer: vi.fn().mockImplementation(() => ({})),
RemotePlayerEventType: {
ANY_CHANGE: 'anyChanged',
},
RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })),
CastContext: {
getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })),
},
CastContextEventType: {
SESSION_STATE_CHANGED: 'sessionstatechanged',
CAST_STATE_CHANGED: 'caststatechanged',
},
},
});
vi.stubGlobal('chrome', {
cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } },
});
});
beforeEach(() => {
Element.prototype.animate = getAnimateMock();
});
afterEach(() => {
vi.resetAllMocks();
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
});
@@ -6,32 +6,30 @@
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@@ -42,8 +40,7 @@
}
let {
asset,
preloadAssets = undefined,
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
@@ -54,8 +51,8 @@
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
@@ -82,25 +79,6 @@
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
@@ -155,23 +133,11 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
$effect(() => {
if (assetFileUrl) {
void cast(assetFileUrl);
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
}
});
@@ -191,7 +157,6 @@
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
@@ -199,27 +164,29 @@
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
}
lastUrl = imageLoaderUrl;
});
</script>
<svelte:document
@@ -232,19 +199,17 @@
]}
/>
{#if imageError}
<div class="h-full w-full">
<div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full select-none"
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
@@ -258,7 +223,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@@ -266,7 +231,7 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
@@ -298,6 +263,7 @@
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => cancelImageUrl(url),
destroy: () => preloadManager.cancelPreloadUrl(url),
};
}
@@ -1,7 +1,19 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils';
import { Icon, IconButton, Logo } from '@immich/ui';
import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
import {
Card,
CardBody,
CardFooter,
CardHeader,
CardTitle,
Icon,
IconButton,
Link,
Logo,
Text,
VStack,
} from '@immich/ui';
import { mdiAlarmLight, mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -19,90 +31,61 @@
};
</script>
<div class="h-dvh w-dvw">
<div class="flex flex-col h-dvh w-dvw">
<section>
<div class="flex place-items-center border-b px-6 py-4 dark:border-b-immich-dark-gray">
<a class="flex place-items-center gap-2 hover:cursor-pointer" href="/photos">
<Link href="/photos">
<Logo variant="inline" />
</a>
</Link>
</div>
</section>
<div class="fixed top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50">
<div>
<div
class="w-125 max-w-[95vw] rounded-3xl border shadow-sm dark:border-immich-dark-gray dark:text-immich-dark-fg bg-subtle/80"
>
<div>
<div class="flex items-center justify-between gap-4 px-4 py-4">
<h1 class="font-medium text-primary">
🚨 {$t('error_title')}
</h1>
<div class="flex justify-end">
<IconButton
shape="round"
color="primary"
icon={mdiContentCopy}
aria-label={$t('copy_error')}
onclick={() => handleCopy()}
/>
</div>
</div>
<div class="flex flex-1 w-full place-content-center place-items-center overflow-hidden bg-black/30">
<div class="max-w-[95vw]">
<Card color="secondary">
<CardHeader class="flex-row justify-between gap-12">
<CardTitle tag="h1" size="medium" class="text-primary flex place-items-center gap-4">
<Icon icon={mdiAlarmLight} color="red" size="32" />
{$t('error_title')}
</CardTitle>
<IconButton
shape="round"
color="primary"
icon={mdiContentCopy}
aria-label={$t('copy_error')}
onclick={handleCopy}
/>
</CardHeader>
<hr />
<CardBody class="flex flex-col gap-2">
<Text color="danger">{error?.message} (HTTP {error?.code})</Text>
{#if error?.stack}
<label for="stacktrace">{$t('stacktrace')}</label>
<pre id="stacktrace" class="text-xs">{error.stack}</pre>
{/if}
</CardBody>
<div class="immich-scrollbar max-h-[75vh] min-h-75 gap-4 overflow-y-auto p-4 pb-4">
<div class="flex w-full flex-col gap-2">
<p class="text-red-500">{error?.message} ({error?.code})</p>
{#if error?.stack}
<label for="stacktrace">{$t('stacktrace')}</label>
<pre id="stacktrace" class="text-xs">{error?.stack || 'No stack'}</pre>
{/if}
</div>
</div>
<hr />
<div class="flex place-content-center place-items-center justify-around">
<!-- href="https://github.com/immich-app/immich/issues/new" -->
<a
href="https://discord.immich.app"
target="_blank"
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon icon={mdiMessage} size="24" />
<p class="text-sm">{$t('get_help')}</p>
</div>
</a>
<a
href="https://github.com/immich-app/immich/releases"
target="_blank"
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon icon={mdiPartyPopper} size="24" />
<p class="text-sm">{$t('read_changelog')}</p>
</div>
</a>
<a
href="https://docs.immich.app/guides/docker-help"
target="_blank"
rel="noopener noreferrer"
class="flex grow basis-0 justify-center p-4"
>
<div class="flex flex-col place-content-center place-items-center gap-2">
<Icon icon={mdiCodeTags} size="24" />
<p class="text-sm">{$t('check_logs')}</p>
</div>
</a>
</div>
</div>
</div>
<CardFooter class="items-start">
<Link href="https://discord.immich.app" class="flex grow basis-0 justify-center">
<VStack>
<Icon icon={mdiMessage} size="24" />
<Text size="small" class="text-center">{$t('get_help')}</Text>
</VStack>
</Link>
<Link href="https://github.com/immich-app/immich/releases" class="flex grow basis-0 justify-center">
<VStack>
<Icon icon={mdiPartyPopper} size="24" />
<Text size="small" class="text-center">{$t('read_changelog')}</Text>
</VStack>
</Link>
<Link href="https://docs.immich.app/guides/docker-help" class="flex grow basis-0 justify-center">
<VStack>
<Icon icon={mdiCodeTags} size="24" />
<Text size="small" class="text-center">{$t('check_logs')}</Text>
</VStack>
</Link>
</CardFooter>
</Card>
</div>
</div>
</div>
@@ -32,7 +32,7 @@
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
@@ -67,7 +67,7 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
@@ -396,7 +396,7 @@
</p>
</div>
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)}
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
<div class="w-12.5 dark">
<IconButton
shape="round"
@@ -32,7 +32,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
let assets = $derived(sharedLink.assets);
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
@@ -68,7 +68,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(assets);
assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
};
const handleAction = async (action: Action) => {
@@ -145,13 +145,11 @@
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{asset}
showCloseButton={false}
cursor={{ current: asset }}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{/await}
{/await}
@@ -3,12 +3,12 @@
import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
import { Icon, type IconLike } from '@immich/ui';
interface Props {
text: string;
subtitle?: string;
icon?: string;
icon?: IconLike;
activeColor?: string;
textColor?: string;
onClick: () => void;
@@ -19,7 +19,7 @@
let {
text,
subtitle = '',
icon = '',
icon,
activeColor = 'bg-slate-300',
textColor = 'text-immich-fg dark:text-immich-dark-bg',
onClick,
@@ -13,7 +13,7 @@
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
@@ -27,7 +27,7 @@
interface Props {
initialAssetId?: string;
assets: TimelineAsset[] | AssetResponseDto[];
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@@ -229,7 +229,7 @@
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
assetInteraction.selectedAssets,
onReload,
);
@@ -242,7 +242,7 @@
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
@@ -424,6 +424,12 @@
selectAssetCandidates(lastAssetMouseEvent);
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<svelte:document
@@ -488,7 +494,7 @@
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
@@ -5,13 +5,14 @@
<script lang="ts">
import { page } from '$app/state';
import { clickOutside } from '$lib/actions/click-outside';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getGlobalActions } from '$lib/services/app.service';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
@@ -45,6 +46,8 @@
console.error('Failed to load notifications on mount', error);
}
});
const { Cast } = $derived(getGlobalActions($t));
</script>
<svelte:window bind:innerWidth />
@@ -158,7 +161,7 @@
{/if}
</div>
<CastButton />
<ActionButton action={Cast} />
<div
use:clickOutside={{
@@ -131,7 +131,7 @@
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-gray-200"
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-light"
>
{$remainingUploads.toLocaleString($locale)}
</button>
@@ -140,7 +140,7 @@
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-gray-200"
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-light"
>
{$stats.errors.toLocaleString($locale)}
</button>
@@ -7,13 +7,13 @@
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import { AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@@ -39,19 +39,13 @@
timelineManager?: TimelineManager;
options?: TimelineManagerOptions;
assetInteraction: AssetInteraction;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
withStacked?: boolean;
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
album?: AlbumResponseDto;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
person?: PersonResponseDto;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
@@ -82,9 +76,9 @@
withStacked = false,
showArchiveIcon = false,
isShared = false,
album = null,
album,
albumUsers = [],
person = null,
person,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onEscape = () => {},
@@ -1,32 +1,31 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
invisible: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
album?: AlbumResponseDto;
person?: PersonResponseDto;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
}
let {
@@ -35,48 +34,72 @@
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
album,
person,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false);
}
return asset;
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false);
}
return asset;
}
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
});
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
if (!targetAsset) {
return false;
}
release();
return !!earlierAsset;
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
return true;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
return { id: randomAsset.id };
}
};
@@ -98,7 +121,9 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@@ -110,8 +135,6 @@
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE:
case AssetAction.ADD: {
timelineManager.upsertAssets([action.asset]);
break;
@@ -173,21 +196,42 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
}
};
onDestroy(() => {
assetCacheManager.invalidate();
});
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (asset.id === assetCursor.current.id) {
void loadCloseAssets(asset);
}
};
onMount(() => {
const unsubscribes = [
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })),
];
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
cursor={assetCursor}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onAction={(action) => {
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onPrevious={handlePrevious}
onNext={handleNext}
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onRandom={handleRandom}
onClose={handleClose}
/>
@@ -3,7 +3,8 @@
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { handleDownloadAsset } from '$lib/services/asset.service';
import { downloadArchive } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDownload } from '@mdi/js';
@@ -24,7 +25,7 @@
if (assets.length === 1) {
clearSelect();
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
await downloadFile(asset);
await handleDownloadAsset(asset);
return;
}
@@ -5,6 +5,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
@@ -102,6 +103,12 @@
const handleStack = () => {
onStack(assets);
};
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<svelte:document
@@ -182,7 +189,7 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
-2
View File
@@ -3,8 +3,6 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
FAVORITE = 'favorite',
UNFAVORITE = 'unfavorite',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
+4
View File
@@ -7,6 +7,10 @@ describe('i18n', () => {
const languageFiles = readdirSync('../i18n').sort();
for (const filename of languageFiles) {
test(`${filename} should have a loader`, async () => {
if (!filename.endsWith('.json') || filename == 'package.json') {
return;
}
const code = filename.replaceAll('.json', '');
const item = langs.find((lang) => lang.weblateCode === code || lang.code === code);
expect(item, `${filename} has no loader`).toBeDefined();
@@ -0,0 +1,60 @@
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
class AsyncCache<V> {
#cache = new Map<string, V>();
async getOrFetch<K>(
params: K,
fetcher: (params: K) => Promise<V>,
keySerializer: (params: K) => string = defaultSerializer,
updateCache: boolean,
): Promise<V> {
const cacheKey = keySerializer(params);
const cached = this.#cache.get(cacheKey);
if (cached) {
return cached;
}
const value = await fetcher(params);
if (value && updateCache) {
this.#cache.set(cacheKey, value);
}
return value;
}
clear() {
this.#cache.clear();
}
}
class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
}
async getAssetOcr(id: string) {
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true);
}
clearAssetCache() {
this.#assetCache.clear();
}
clearOcrCache() {
this.#ocrCache.clear();
}
invalidate() {
this.clearAssetCache();
this.clearOcrCache();
}
}
export const assetCacheManager = new AssetCacheManager();
@@ -0,0 +1,38 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
img.src = url;
}
cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string | undefined) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();
@@ -3,15 +3,8 @@ import { PersistedLocalStorage } from '$lib/utils/persisted';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager {
#isShowActivityPanel = $state(false);
get isShowActivityPanel() {
return this.#isShowActivityPanel;
}
private set isShowActivityPanel(value: boolean) {
this.#isShowActivityPanel = value;
}
isShowActivityPanel = $state(false);
isPlayingMotionPhoto = $state(false);
get isShowDetailPanel() {
return isShowDetailPanel.current;
+4 -1
View File
@@ -1,3 +1,4 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
import { createSession, type SessionCreateResponseDto } from '@immich/sdk';
import { DateTime, Duration } from 'luxon';
@@ -57,9 +58,11 @@ class CastManager {
new GCastDestination(),
// Add other cast destinations here (ie FCast)
];
eventManager.on('AppInit', () => void this.initialize());
}
async initialize() {
private async initialize() {
// this goes first to prevent multiple calls to initialize
if (this.initialized) {
return;
@@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types';
import type {
AlbumResponseDto,
ApiKeyResponseDto,
AssetResponseDto,
LibraryResponseDto,
LoginResponseDto,
QueueResponseDto,
@@ -24,6 +25,7 @@ export type Events = {
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetUpdate: [AssetResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumUpdate: [AlbumResponseDto];
@@ -1,5 +1,6 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
@@ -93,6 +94,7 @@ export class TimelineManager extends VirtualScrollManager {
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
#unsubscribes: Array<() => void> = [];
get showAssetOwners() {
return this.#showAssetOwners.current;
@@ -108,6 +110,12 @@ export class TimelineManager extends VirtualScrollManager {
constructor() {
super();
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
eventManager.on('AssetUpdate', onAssetUpdate);
this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate));
}
override get scrollTop(): number {
@@ -269,6 +277,11 @@ export class TimelineManager extends VirtualScrollManager {
public override destroy() {
this.disconnect();
this.isInitialized = false;
for (const unsubscribe of this.#unsubscribes) {
unsubscribe();
}
super.destroy();
}
+24 -24
View File
@@ -5,7 +5,7 @@
import { getPreferredTimeZone, getTimezones, toIsoDate } from '$lib/modals/timezone-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset } from '@immich/sdk';
import { Button, HStack, Label, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { FormModal, Label } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@@ -28,7 +28,7 @@
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleClose = async () => {
const onSubmit = async () => {
if (!date.isValid || !selectedOption) {
onClose(false);
return;
@@ -49,25 +49,25 @@
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput
class="immich-form-input text-gray-700 w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
</div>
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal
title={$t('edit_date_and_time')}
icon={mdiCalendarEdit}
onClose={() => onClose(false)}
{onSubmit}
submitText={$t('confirm')}
disabled={!date.isValid || !selectedOption}
size="small"
>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput
class="immich-form-input text-gray-700 w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
</div>
{/if}
</FormModal>
@@ -17,8 +17,8 @@ describe('DateSelectionModal component', () => {
const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch');
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
const getCancelButton = () => screen.getByText('cancel');
const getConfirmButton = () => screen.getByText('confirm');
const getCancelButton = () => screen.getByRole('button', { name: /cancel/i });
const getConfirmButton = () => screen.getByRole('button', { name: /confirm/i });
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
@@ -8,7 +8,7 @@
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { Button, Field, HStack, Label, Modal, ModalBody, ModalFooter, Switch } from '@immich/ui';
import { Field, FormModal, Label, Switch } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@@ -30,7 +30,7 @@
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleConfirm = async () => {
const onSubmit = async () => {
const ids = getOwnedAssetsWithWarning(assets, $user);
try {
if (showRelative && (selectedDuration || selectedOption)) {
@@ -63,66 +63,62 @@
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<Field label={$t('edit_date_and_time_by_offset')}>
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} class="mb-2" />
</Field>
{#if showRelative}
<Label for="relativedatetime" class="block mb-1">{$t('offset')}</Label>
<DurationInput
class="immich-form-input w-full text-gray-700 mb-2"
id="relativedatetime"
bind:value={selectedDuration}
/>
{:else}
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
{/if}
<div class="w-full">
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => (lastSelectedTimezone = option as ZoneOption)}
></Combobox>
</div>
<!-- <Card color="secondary" class={!showRelative || !currentInterval ? 'invisible' : ''}>
<CardBody class="p-2">
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center">
<div class="col-span-2 immich-form-label" data-testid="interval-preview">Preview</div>
<Text size="small" class="-mt-2 immich-form-label col-span-2"
>Showing changes for first selected asset only</Text
>
<label class="immich-form-label" for="from">Before</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="from"
type="datetime-local"
readonly
bind:value={before}
/>
<label class="immich-form-label" for="to">After</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="to"
type="datetime-local"
readonly
bind:value={after}
/>
</div>
</CardBody>
</Card> -->
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{$t('cancel')}
</Button>
<Button shape="round" color="primary" fullWidth onclick={handleConfirm} disabled={!date.isValid}>
{$t('confirm')}
</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal
title={$t('edit_date_and_time')}
icon={mdiCalendarEdit}
onClose={() => onClose(false)}
{onSubmit}
submitText={$t('confirm')}
disabled={!date.isValid}
size="small"
>
<Field label={$t('edit_date_and_time_by_offset')}>
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} class="mb-2" />
</Field>
{#if showRelative}
<Label for="relativedatetime" class="block mb-1">{$t('offset')}</Label>
<DurationInput
class="immich-form-input w-full text-gray-700 mb-2"
id="relativedatetime"
bind:value={selectedDuration}
/>
{:else}
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
{/if}
<div class="w-full">
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => (lastSelectedTimezone = option as ZoneOption)}
></Combobox>
</div>
<!-- <Card color="secondary" class={!showRelative || !currentInterval ? 'invisible' : ''}>
<CardBody class="p-2">
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center">
<div class="col-span-2 immich-form-label" data-testid="interval-preview">Preview</div>
<Text size="small" class="-mt-2 immich-form-label col-span-2"
>Showing changes for first selected asset only</Text
>
<label class="immich-form-label" for="from">Before</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="from"
type="datetime-local"
readonly
bind:value={before}
/>
<label class="immich-form-label" for="to">After</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="to"
type="datetime-local"
readonly
bind:value={after}
/>
</div>
</CardBody>
</Card> -->
</FormModal>
+24 -23
View File
@@ -3,10 +3,11 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
import { FormModal, HStack, VStack } from '@immich/ui';
import { mdiNavigationVariantOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
timelineManager: TimelineManager;
onClose: (asset?: TimelineAsset) => void;
@@ -20,7 +21,7 @@
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption: ZoneOption | undefined = $derived(getPreferredTimeZone(initialDate, undefined, timezones));
const handleClose = async () => {
const onSubmit = async () => {
if (!date.isValid || !selectedOption) {
onClose();
return;
@@ -36,26 +37,26 @@
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('navigate_to_time')} icon={mdiNavigationVariantOutline} onClose={() => onClose()}>
<ModalBody>
<VStack fullWidth>
<HStack fullWidth>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
<HStack fullWidth>
<DateInput
class="immich-form-input text-gray-700 w-full"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<FormModal
title={$t('navigate_to_time')}
icon={mdiNavigationVariantOutline}
onClose={() => onClose()}
{onSubmit}
submitText={$t('confirm')}
disabled={!date.isValid || !selectedOption}
size="medium"
>
<VStack fullWidth>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
</ModalFooter>
</Modal>
<HStack fullWidth>
<DateInput
class="immich-form-input text-gray-700 w-full"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</HStack>
</VStack>
</FormModal>
@@ -85,7 +85,7 @@
<div
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
>
<PhotoViewer bind:element={imgElement} {asset} />
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
</div>
</div>
</ModalBody>
+19
View File
@@ -0,0 +1,19 @@
import { CastDestinationType, castManager } from '$lib/managers/cast-manager.svelte';
import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
import type { ActionItem } from '@immich/ui';
import { mdiCast, mdiCastConnected } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getGlobalActions = ($t: MessageFormatter) => {
const Cast: ActionItem = {
title: $t('cast'),
icon: castManager.isCasting ? mdiCastConnected : mdiCast,
color: castManager.isCasting ? 'primary' : 'secondary',
$if: () =>
castManager.availableDestinations.length > 0 &&
castManager.availableDestinations[0].type === CastDestinationType.GCAST,
onAction: () => void GCastDestination.showCastDialog(),
};
return { Cast };
};
+171 -5
View File
@@ -1,22 +1,188 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetVisibility,
copyAsset,
deleteAssets,
getAssetInfo,
getBaseUrl,
updateAsset,
type AssetResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAlertOutline,
mdiDownload,
mdiHeart,
mdiHeartOutline,
mdiInformationOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
type: $t('assets'),
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
return { Share };
const Download: ActionItem = {
title: $t('download'),
icon: mdiDownload,
shortcuts: { key: 'd', shift: true },
type: $t('assets'),
$if: () => !!currentAuthUser,
onAction: () => handleDownloadAsset(asset),
};
const SharedLinkDownload: ActionItem = {
...Download,
$if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload,
};
const PlayMotionPhoto: ActionItem = {
title: $t('play_motion_photo'),
icon: mdiMotionPlayOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = true;
},
};
const StopMotionPhoto: ActionItem = {
title: $t('stop_motion_photo'),
icon: mdiMotionPauseOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = false;
},
};
const Favorite: ActionItem = {
title: $t('to_favorite'),
icon: mdiHeartOutline,
type: $t('assets'),
$if: () => isOwner && !asset.isFavorite,
onAction: () => handleFavorite(asset),
shortcuts: [{ key: 'f' }],
};
const Unfavorite: ActionItem = {
title: $t('unfavorite'),
icon: mdiHeart,
type: $t('assets'),
$if: () => isOwner && asset.isFavorite,
onAction: () => handleUnfavorite(asset),
shortcuts: [{ key: 'f' }],
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
type: $t('assets'),
color: 'danger',
$if: () => !!asset.isOffline,
onAction: () => assetViewerManager.toggleDetailPanel(),
};
const Info: ActionItem = {
title: $t('info'),
icon: mdiInformationOutline,
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
};
return { Share, Download, SharedLinkDownload, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto };
};
export const handleDownloadAsset = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
const assets = [
{
filename: asset.originalFileName,
id: asset.id,
size: asset.exifInfo?.fileSizeInByte || 0,
},
];
const isAndroidMotionVideo = (asset: AssetResponseDto) => {
return asset.originalPath.includes('encoded-video');
};
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
size: motionAsset.exifInfo?.fileSizeInByte || 0,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
}
try {
toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } }));
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}
}
};
const handleFavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.success($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
const handleUnfavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.success($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
export const handleReplaceAsset = async (oldAssetId: string) => {
+2 -8
View File
@@ -1,19 +1,15 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Mutex } from 'async-mutex';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false);
const viewingAssetMutex = new Mutex();
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload);
const setAsset = (asset: AssetResponseDto) => {
viewingAssetStoreState.set(asset);
viewState.set(true);
};
@@ -30,8 +26,6 @@ function createAssetViewingStore() {
return {
asset: readonly(viewingAssetStoreState),
mutex: viewingAssetMutex,
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,
+136 -1
View File
@@ -1,6 +1,141 @@
import { getReleaseType } from '$lib/utils';
import { getAssetUrl, getReleaseType } from '$lib/utils';
import { AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('utils', () => {
describe(getAssetUrl.name, () => {
it('should return thumbnail URL for static images', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
// Should return a thumbnail URL (contains /thumbnail)
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
// Should return original URL (contains /original)
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return original URL for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated images in shared link with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
});
describe(getReleaseType.name, () => {
it('should return "major" for major version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
+44 -2
View File
@@ -1,10 +1,12 @@
import { defaultLang, langs, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
MemoryType,
QueueName,
finishOAuth,
@@ -17,13 +19,14 @@ import {
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
type MemoryResponseDto,
type PersonResponseDto,
type ServerVersionResponseDto,
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
@@ -192,6 +195,40 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null };
export const getAssetUrl = ({
asset,
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
const targetSize = targetImageSize(asset, forceOriginal);
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
};
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
};
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
@@ -404,3 +441,8 @@ export const getReleaseType = (
};
export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined }));
export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true;
+9 -44
View File
@@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, sleep, withError } from '$lib/utils';
import { downloadRequest, withError } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
@@ -23,7 +23,6 @@ import {
createStack,
deleteAssets,
deleteStacks,
getAssetInfo,
getBaseUrl,
getDownloadInfo,
getStack,
@@ -232,48 +231,6 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
}
};
export const downloadFile = async (asset: AssetResponseDto) => {
const $t = get(t);
const assets = [
{
filename: asset.originalFileName,
id: asset.id,
size: asset.exifInfo?.fileSizeInByte || 0,
},
];
const isAndroidMotionVideo = (asset: AssetResponseDto) => {
return asset.originalPath.includes('encoded-video');
};
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
size: motionAsset.exifInfo?.fileSizeInByte || 0,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
}
try {
toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } }));
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}
}
};
/**
* Returns the lowercase filename extension without a dot (.) and
* an empty string when not found.
@@ -557,6 +514,14 @@ export const delay = async (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
return currentAsset && assets[assets.indexOf(currentAsset) + 1];
};
export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
return currentAsset && assets[assets.indexOf(currentAsset) - 1];
};
export const canCopyImageToClipboard = (): boolean => {
return !!(navigator.clipboard && globalThis.ClipboardItem);
};
+9
View File
@@ -50,4 +50,13 @@ export class InvocationTracker {
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>) {
const invocation = this.startInvocation();
try {
return await invocable();
} finally {
invocation.endInvocation();
}
}
}
+6 -5
View File
@@ -1,8 +1,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { RouteId } from '$app/types';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
@@ -20,11 +20,12 @@ export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(u
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export const isAssetViewerRoute = (
target?: { route?: { id?: RouteId | null }; params?: Record<string, string> | null } | null,
) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) {
return assetId ? getAssetInfo({ id: assetId, slug, key }) : undefined;
return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }, false) : undefined;
}
function currentUrlWithoutAsset() {
+8 -2
View File
@@ -1,8 +1,14 @@
const broadcast = new BroadcastChannel('immich');
export function cancelImageUrl(url: string) {
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string) {
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate, goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
@@ -39,6 +39,7 @@
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -413,6 +414,8 @@
}
}
};
const { Cast } = $derived(getGlobalActions($t));
</script>
<OnEvents {onSharedLinkCreate} {onAlbumDelete} />
@@ -592,7 +595,7 @@
{#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
{#snippet trailing()}
<CastButton />
<ActionButton action={Cast} />
{#if isEditor}
<IconButton
@@ -16,7 +16,6 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -55,7 +54,6 @@
bind:timelineManager
{options}
{assetInteraction}
removeAction={AssetAction.UNFAVORITE}
onEscape={handleEscape}
>
{#snippet empty()}
@@ -1,15 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import type { PageData } from './$types';
interface Props {
@@ -64,6 +67,59 @@
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
}
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor <= 0) {
return;
}
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
{#if featureFlagsManager.value.map}
@@ -85,7 +141,7 @@
{#if $showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
@@ -22,7 +22,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
@@ -35,6 +35,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
type AssetResponseDto,
getPerson,
getTagById,
type MetadataSearchDto,
@@ -58,7 +59,7 @@
let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
@@ -121,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id));
};
const handleSetVisibility = (assetIds: string[]) => {
@@ -130,7 +131,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
};
async function onSearchQueryUpdate() {
@@ -162,7 +163,7 @@
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
searchResultAssets.push(...assets.items);
nextPage = Number(assets.nextPage) || 0;
} catch (error) {
@@ -5,10 +5,11 @@
import Portal from '$lib/elements/Portal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
data: PageData;
@@ -65,6 +66,12 @@
const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
};
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<UserPageLayout title={data.meta.title} scrollbar={true}>
@@ -85,7 +92,7 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
+1 -2
View File
@@ -145,7 +145,6 @@
icon: mdiThemeLightDark,
onAction: () => themeManager.toggleTheme(),
shortcuts: { shift: true, key: 't' },
isGlobal: true,
},
];
@@ -181,7 +180,7 @@
icon: mdiServer,
onAction: () => goto(AppRoute.ADMIN_STATS),
},
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
].map((route) => ({ ...route, type: $t('page'), $if: () => $user?.isAdmin }));
const commands = $derived([...userCommands, ...adminCommands]);
</script>