diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 10dc88088f..72c816cc93 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -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 }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 680cd0318c..3e55a13869 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f333fcebf4..ad9fd95b88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..7199043658 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 23c1862c19..fbda3c2983 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -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: diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts index c87427ceab..5e4895d708 100644 --- a/e2e/src/generators.ts +++ b/e2e/src/generators.ts @@ -26,6 +26,5 @@ export const makeRandomImage = () => { if (!value) { throw new Error('Ran out of random asset data'); } - return value; }; diff --git a/i18n/.prettierrc b/i18n/.prettierrc new file mode 100644 index 0000000000..30581eb7d1 --- /dev/null +++ b/i18n/.prettierrc @@ -0,0 +1,5 @@ +{ + "jsonRecursiveSort": true, + "jsonSortOrder": "{\"/.*/\": \"lexical\"}", + "plugins": ["prettier-plugin-sort-json"] +} diff --git a/i18n/en.json b/i18n/en.json index 29b00723fe..6f8a0d53ed 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/package.json b/i18n/package.json new file mode 100644 index 0000000000..19d78c49b7 --- /dev/null +++ b/i18n/package.json @@ -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" + } +} diff --git a/mise.toml b/mise.toml index 276ce87d51..a4f597662a 100644 --- a/mise.toml +++ b/mise.toml @@ -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" diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index f1a46a2fef..63e84080df 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.* !default.perspectivev3 fastlane/report.xml -Gemfile.lock \ No newline at end of file +Gemfile.lock +certs/ \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d167d5fb2d..9c31ced00d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -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 diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 91ca50a2c0..c4505137d2 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -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 } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index c49ac49cce..1194331a6d 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -360,6 +360,7 @@ extension on Iterable { name: e.name, updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(), assetCount: e.assetCount, + isIosSharedAlbum: e.isCloud, ), ).toList(); } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 96630f1eba..e866a965c4 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -79,6 +79,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); + TimelineService map(String userId, LatLngBounds bounds) => TimelineService(_timelineRepository.map(userId, bounds, groupBy)); } diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 707d3326a4..641a5359f6 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { assetCount: assetCount, backupSelection: backupSelection, linkedRemoteAlbumId: linkedRemoteAlbumId, + isIosSharedAlbum: isIosSharedAlbum, ); } } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 4d30e09716..8cbce084cd 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -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> 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 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(); + } } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index d21e1e905b..66ae47a0b5 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { + // Sort assets by date descending and group by day + final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final Map 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), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 86c80253dc..a1d7e55f32 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -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(), diff --git a/mobile/lib/presentation/pages/cleanup_preview.page.dart b/mobile/lib/presentation/pages/cleanup_preview.page.dart new file mode 100644 index 0000000000..556ed6412f --- /dev/null +++ b/mobile/lib/presentation/pages/cleanup_preview.page.dart @@ -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 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(), TimelineOrigin.search); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653..ac20e73190 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -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, diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart new file mode 100644 index 0000000000..5b3b152f34 --- /dev/null +++ b/mobile/lib/providers/cleanup.provider.dart @@ -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 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? 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((ref) { + return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); +}); + +class CleanupNotifier extends StateNotifier { + 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 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 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(); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9c4a193381..9468b105e5 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 939bf73369..b287d73114 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo { ); } +/// generated route for +/// [CleanupPreviewPage] +class CleanupPreviewRoute extends PageRouteInfo { + CleanupPreviewRoute({ + Key? key, + required List assets, + List? 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(); + return CleanupPreviewPage(key: args.key, assets: args.assets); + }, + ); +} + +class CleanupPreviewRouteArgs { + const CleanupPreviewRouteArgs({this.key, required this.assets}); + + final Key? key; + + final List assets; + + @override + String toString() { + return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}'; + } +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart new file mode 100644 index 0000000000..6a4318d209 --- /dev/null +++ b/mobile/lib/services/cleanup.service.dart @@ -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((ref) { + return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); +}); + +class CleanupService { + final DriftLocalAssetRepository _localAssetRepository; + final AssetMediaRepository _assetMediaRepository; + + const CleanupService(this._localAssetRepository, this._assetMediaRepository); + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) { + return _localAssetRepository.getRemovalCandidates( + userId, + cutoffDate, + filterType: filterType, + keepFavorites: keepFavorites, + ); + } + + Future deleteLocalAssets(List 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; + } +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 35cdc7addf..30a9702b53 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -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 migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -86,6 +86,10 @@ Future 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 _populateLocalAssetTime(Drift db) async { } } +Future _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 migrateDeviceAssetToSqlite(Isar db, Drift drift) async { try { final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll(); diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart index 596e46d934..84128ddde2 100644 --- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -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)); diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart new file mode 100644 index 0000000000..7acb04686b --- /dev/null +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -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 createState() => _FreeUpSpaceSettingsState(); +} + +class _FreeUpSpaceSettingsState extends ConsumerState { + 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 _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 _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 _deleteAssets() async { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isEmpty || state.selectedDate == null) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final confirmed = await showDialog( + 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( + context: context, + builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount), + ); + } + + setState(() => _currentStep = CleanupStep.selectDate); + } + + void _showAssetsPreview(List 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 = []; + 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( + 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), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/makefile b/mobile/makefile index b90e95c902..3b211bcd09 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -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 diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart new file mode 100644 index 0000000000..0d686fbc09 --- /dev/null +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.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 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 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 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 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 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); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce68fa76c3..d4ffc00639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 33aaa744b0..c7ec4739ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - cli - docs - e2e + - i18n - open-api/typescript-sdk - server - plugins diff --git a/web/package.json b/web/package.json index 39d9921080..ef48a8a92f 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/cast/cast-button.svelte b/web/src/lib/cast/cast-button.svelte deleted file mode 100644 index 392418daa5..0000000000 --- a/web/src/lib/cast/cast-button.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST} - void GCastDestination.showCastDialog()} - aria-label={$t('cast')} - /> -{/if} diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index e0e7e1eff7..ae8d1199e0 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,4 +1,5 @@ -{#if action.$if?.() ?? true} +{#if icon && isEnabled(action)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/ActionMenuItem.svelte b/web/src/lib/components/ActionMenuItem.svelte new file mode 100644 index 0000000000..d50d50bf0b --- /dev/null +++ b/web/src/lib/components/ActionMenuItem.svelte @@ -0,0 +1,16 @@ + + +{#if icon && isEnabled(action)} + onAction(action)} /> +{/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte index 844c4c0bf8..619d2f6c27 100644 --- a/web/src/lib/components/TableButton.svelte +++ b/web/src/lib/components/TableButton.svelte @@ -10,6 +10,6 @@ const { title, icon, onAction } = $derived(action); -{#if action.$if?.() ?? true} +{#if icon && (action.$if?.() ?? true)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index d5fdb36822..b7fcaa88ec 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,6 +1,6 @@ + {#if sharedLink.allowUpload} - 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(); - - - - - diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte deleted file mode 100644 index f790569703..0000000000 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - -{#if !menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte deleted file mode 100644 index ba23570d36..0000000000 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte deleted file mode 100644 index ee09c2976b..0000000000 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - onClick(!isPlaying)} -/> diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte deleted file mode 100644 index 99b6c1dcde..0000000000 --- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 08957a5340..60bde6e114 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,16 +1,13 @@ + +
- {#if showCloseButton} - - {/if} +
-
- +
+ - {#if asset.isOffline} - assetViewerManager.toggleDetailPanel()} - aria-label={$t('asset_offline')} - /> - {/if} - {#if asset.livePhotoVideoId} - {@render motionPhoto?.()} - {/if} + + + + {#if asset.type === AssetTypeEnum.Image}
{/if} @@ -461,8 +459,7 @@ 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} 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 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts deleted file mode 100644 index fd1a40e4db..0000000000 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ /dev/null @@ -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(); - 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(); - }); -}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2607f6de79..baf46052be 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -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; + }); {#if imageError} -
+
{/if} - - +
- {#if !imageLoaded}
@@ -258,7 +223,7 @@ > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} 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), }; } diff --git a/web/src/lib/components/layouts/ErrorLayout.svelte b/web/src/lib/components/layouts/ErrorLayout.svelte index 1df1dbf422..f121684236 100644 --- a/web/src/lib/components/layouts/ErrorLayout.svelte +++ b/web/src/lib/components/layouts/ErrorLayout.svelte @@ -1,7 +1,19 @@ -
+
- + - +
-
-
-
-
-
-

- 🚨 {$t('error_title')} -

-
- handleCopy()} - /> -
-
+
+
+ + + + + {$t('error_title')} + + + -
+ + {error?.message} (HTTP {error?.code}) + {#if error?.stack} + +
{error.stack}
+ {/if} +
-
-
-

{error?.message} ({error?.code})

- {#if error?.stack} - -
{error?.stack || 'No stack'}
- {/if} -
-
- -
- - -
-
+ + + + + {$t('get_help')} + + + + + + {$t('read_changelog')} + + + + + + {$t('check_logs')} + + + +
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index cfe11e1026..34c6ee18db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -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 @@

- {#if currentTimelineAssets.some(({ isVideo }) => isVideo)} + {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
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 }} Promise.resolve(false)} onNext={() => Promise.resolve(false)} onRandom={() => Promise.resolve(undefined)} - onClose={() => {}} /> {/await} {/await} diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte index 95b4b9ad43..dc5a2d7c0f 100644 --- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte +++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte @@ -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, diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index c695cafc76..f71944d20c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -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), + }); {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 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)); @@ -158,7 +161,7 @@ {/if}
- +
(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)} @@ -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)} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index cc2c3ef162..f2ef209ad5 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -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 = () => {}, diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 9f8b5fe36b..8500345df4 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -1,32 +1,31 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} { + handleAction(action); + assetCacheManager.invalidate(); + }} onUndoDelete={handleUndoDelete} - onPrevious={handlePrevious} - onNext={handleNext} + onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)} + onNext={() => handleNavigateToAsset(assetCursor.nextAsset)} onRandom={handleRandom} onClose={handleClose} /> diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index 29f2bab610..b1b1640798 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -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; } diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2afeebc559..16155d44c0 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -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), + }); 1} {onNext} {onPrevious} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 3d2c0a1bc2..49e05d4566 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -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', diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index 63aae0419c..325e7bad5e 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -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(); diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts new file mode 100644 index 0000000000..0b5e697683 --- /dev/null +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -0,0 +1,60 @@ +import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; + +const defaultSerializer = (params: K) => JSON.stringify(params); + +class AsyncCache { + #cache = new Map(); + + async getOrFetch( + params: K, + fetcher: (params: K) => Promise, + keySerializer: (params: K) => string = defaultSerializer, + updateCache: boolean, + ): Promise { + 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(); + #ocrCache = new AsyncCache(); + + 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(); diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts new file mode 100644 index 0000000000..a68c07d505 --- /dev/null +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -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(); diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 56470eac35..7b482faa76 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -3,15 +3,8 @@ import { PersistedLocalStorage } from '$lib/utils/persisted'; const isShowDetailPanel = new PersistedLocalStorage('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; diff --git a/web/src/lib/managers/cast-manager.svelte.ts b/web/src/lib/managers/cast-manager.svelte.ts index 227bd3faea..81aedd9d85 100644 --- a/web/src/lib/managers/cast-manager.svelte.ts +++ b/web/src/lib/managers/cast-manager.svelte.ts @@ -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; diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 6038c3c3f0..f9fa87e0cf 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -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]; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index b0dc30dc6e..7625659e94 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -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('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(); } diff --git a/web/src/lib/modals/AssetChangeDateModal.svelte b/web/src/lib/modals/AssetChangeDateModal.svelte index 7034493924..e94f1f7afc 100644 --- a/web/src/lib/modals/AssetChangeDateModal.svelte +++ b/web/src/lib/modals/AssetChangeDateModal.svelte @@ -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 })); - onClose(false)} size="small"> - - - - {#if timezoneInput} -
- -
- {/if} -
- - - - - - -
+ onClose(false)} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid || !selectedOption} + size="small" +> + + + {#if timezoneInput} +
+ +
+ {/if} +
diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts index 65b77ce5cf..dfb86b6744 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts @@ -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()); diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte index 8eb1a481cc..e60e4cb8a5 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte @@ -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 })); - onClose(false)} size="small"> - - - - - {#if showRelative} - - - {:else} - - - {/if} -
- (lastSelectedTimezone = option as ZoneOption)} - > -
- -
- - - - - - -
+ onClose(false)} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid} + size="small" +> + + + + {#if showRelative} + + + {:else} + + + {/if} +
+ (lastSelectedTimezone = option as ZoneOption)} + > +
+ +
diff --git a/web/src/lib/modals/NavigateToDateModal.svelte b/web/src/lib/modals/NavigateToDateModal.svelte index 365cbdb21c..51f968c6df 100644 --- a/web/src/lib/modals/NavigateToDateModal.svelte +++ b/web/src/lib/modals/NavigateToDateModal.svelte @@ -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 })); - onClose()}> - - - - - - - - - - - + onClose()} + {onSubmit} + submitText={$t('confirm')} + disabled={!date.isValid || !selectedOption} + size="medium" +> + - - + - - + + + + + diff --git a/web/src/lib/modals/ProfileImageCropperModal.svelte b/web/src/lib/modals/ProfileImageCropperModal.svelte index 7f7050f663..f7cc09f0ea 100644 --- a/web/src/lib/modals/ProfileImageCropperModal.svelte +++ b/web/src/lib/modals/ProfileImageCropperModal.svelte @@ -85,7 +85,7 @@
- +
diff --git a/web/src/lib/services/app.service.ts b/web/src/lib/services/app.service.ts new file mode 100644 index 0000000000..6597b2fb5e --- /dev/null +++ b/web/src/lib/services/app.service.ts @@ -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 }; +}; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index a64da2a6d6..81b74e51e2 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -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) => { diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 99ee1b8c46..00e0224a0e 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -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(); - const preloadAssets = writable([]); + const viewState = writable(false); - const viewingAssetMutex = new Mutex(); const gridScrollTarget = writable(); - 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, diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 169f42409c..3bc8665279 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -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'); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3491f95d65..d06289cf9e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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) => { 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; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index aa96d56aec..9d69653439 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -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 { - 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); }; diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index ebc97dfde0..7d42d8c613 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -50,4 +50,13 @@ export class InvocationTracker { isActive() { return this.invocationsStarted !== this.invocationsEnded; } + + async invoke(invocable: () => Promise) { + const invocation = this.startInvocation(); + try { + return await invocable(); + } finally { + invocation.endInvocation(); + } + } } diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index daf1d04ed5..b6c0cad616 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -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 | 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() { diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 1a19d3c134..61cd1b8df0 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -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 }); } diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9279893fbb..5d31bc2229 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,7 +1,7 @@ @@ -592,7 +595,7 @@ {#if viewMode === AlbumPageViewMode.VIEW} goto(backUrl)}> {#snippet trailing()} - + {#if isEditor} {#snippet empty()} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index fd443a6470..27dc10be57 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,15 +1,18 @@ {#if featureFlagsManager.value.map} @@ -85,7 +141,7 @@ {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} onNext={navigateNext} onPrevious={navigatePrevious} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index b58210187b..0cc30c2c0a 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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) { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index 06f075feb6..15f4b233eb 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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), + }); @@ -85,7 +92,7 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} {onNext} {onPrevious} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d921392512..1c7a190b08 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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]);