Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen
fa4c6aa562 chore: log failures from share intent upload 2025-12-12 11:12:27 -06:00
32 changed files with 200 additions and 374 deletions

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6 image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:

View File

@@ -62,7 +62,7 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

View File

@@ -30,7 +30,7 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

View File

@@ -571,7 +571,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with: # with:

View File

@@ -9,10 +9,8 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class AdvancedInfoActionButton extends ConsumerWidget { class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const AdvancedInfoActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -28,8 +26,6 @@ class AdvancedInfoActionButton extends ConsumerWidget {
maxWidth: 115.0, maxWidth: 115.0,
iconData: Icons.help_outline_rounded, iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context), label: "troubleshoot".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -35,10 +35,8 @@ Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required
class ArchiveActionButton extends ConsumerWidget { class ArchiveActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const ArchiveActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source); await performArchiveAction(context, ref, source: source);
@@ -49,8 +47,6 @@ class ArchiveActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.archive_outlined, iconData: Icons.archive_outlined,
label: "to_archive".t(context: context), label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -18,15 +18,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DeleteActionButton extends ConsumerWidget { class DeleteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool showConfirmation; final bool showConfirmation;
final bool iconOnly; const DeleteActionButton({super.key, required this.source, this.showConfirmation = false});
final bool menuItem;
const DeleteActionButton({
super.key,
required this.source,
this.showConfirmation = false,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -82,8 +74,6 @@ class DeleteActionButton extends ConsumerWidget {
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_sweep_outlined, iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context), label: "delete".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -14,10 +14,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// - Prompt to delete the asset locally /// - Prompt to delete the asset locally
class DeleteLocalActionButton extends ConsumerWidget { class DeleteLocalActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const DeleteLocalActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -57,8 +55,6 @@ class DeleteLocalActionButton extends ConsumerWidget {
maxWidth: 95.0, maxWidth: 95.0,
iconData: Icons.no_cell_outlined, iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context), label: "control_bottom_app_bar_delete_from_local".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -15,10 +15,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// - Prompt to delete the asset locally /// - Prompt to delete the asset locally
class DeletePermanentActionButton extends ConsumerWidget { class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const DeletePermanentActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -53,8 +51,6 @@ class DeletePermanentActionButton extends ConsumerWidget {
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_forever, iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context), label: "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -38,10 +38,8 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
class MoveToLockFolderActionButton extends ConsumerWidget { class MoveToLockFolderActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const MoveToLockFolderActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performMoveToLockFolderAction(context, ref, source: source); await performMoveToLockFolderAction(context, ref, source: source);
@@ -53,8 +51,6 @@ class MoveToLockFolderActionButton extends ConsumerWidget {
maxWidth: 115.0, maxWidth: 115.0,
iconData: Icons.lock_outline_rounded, iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context), label: "move_to_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -13,16 +11,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromAlbumActionButton extends ConsumerWidget { class RemoveFromAlbumActionButton extends ConsumerWidget {
final String albumId; final String albumId;
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromAlbumActionButton({ const RemoveFromAlbumActionButton({super.key, required this.albumId, required this.source});
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -32,10 +22,6 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'remove_from_album_action_prompt'.t( final successMessage = 'remove_from_album_action_prompt'.t(
context: context, context: context,
args: {'count': result.count.toString()}, args: {'count': result.count.toString()},
@@ -56,8 +42,6 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.remove_circle_outline, iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context), label: "remove_from_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
maxWidth: 100, maxWidth: 100,
); );

View File

@@ -10,15 +10,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromLockFolderActionButton extends ConsumerWidget { class RemoveFromLockFolderActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromLockFolderActionButton({ const RemoveFromLockFolderActionButton({super.key, required this.source});
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -49,8 +42,6 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget {
maxWidth: 100.0, maxWidth: 100.0,
iconData: Icons.lock_open_rounded, iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".t(context: context), label: "remove_from_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -31,10 +31,8 @@ class _SharePreparingDialog extends StatelessWidget {
class ShareActionButton extends ConsumerWidget { class ShareActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const ShareActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -76,8 +74,6 @@ class ShareActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context), label: 'share'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -7,10 +7,8 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class ShareLinkActionButton extends ConsumerWidget { class ShareLinkActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const ShareLinkActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async { _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -25,8 +23,6 @@ class ShareLinkActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.link_rounded, iconData: Icons.link_rounded,
label: "share_link".t(context: context), label: "share_link".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -13,10 +13,8 @@ import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget { class SimilarPhotosActionButton extends ConsumerWidget {
final String assetId; final String assetId;
final bool iconOnly;
final bool menuItem;
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false}); const SimilarPhotosActionButton({super.key, required this.assetId});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -46,8 +44,6 @@ class SimilarPhotosActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.compare, iconData: Icons.compare,
label: "view_similar_photos".t(context: context), label: "view_similar_photos".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
maxWidth: 100, maxWidth: 100,
); );

View File

@@ -15,10 +15,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// which will be permanently deleted after the number of days configure by the admin /// which will be permanently deleted after the number of days configure by the admin
class TrashActionButton extends ConsumerWidget { class TrashActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const TrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -50,8 +48,6 @@ class TrashActionButton extends ConsumerWidget {
maxWidth: 85.0, maxWidth: 85.0,
iconData: Icons.delete_outline_rounded, iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context), label: "control_bottom_app_bar_trash_from_immich".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -37,10 +37,8 @@ Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {requir
class UnArchiveActionButton extends ConsumerWidget { class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const UnArchiveActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async { Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source); await performUnArchiveAction(context, ref, source: source);
@@ -51,8 +49,6 @@ class UnArchiveActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.unarchive_outlined, iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context), label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -10,10 +10,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget { class UnStackActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const UnStackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -40,8 +38,6 @@ class UnStackActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.layers_clear_outlined, iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context), label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -10,10 +10,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UploadActionButton extends ConsumerWidget { class UploadActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); const UploadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -41,8 +39,6 @@ class UploadActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.backup_outlined, iconData: Icons.backup_outlined,
label: "upload".t(context: context), label: "upload".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@@ -42,8 +42,6 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
@@ -53,7 +51,6 @@ class ViewerBottomBar extends ConsumerWidget {
? const DeleteLocalActionButton(source: ActionSource.viewer) ? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
], ],
],
]; ];
return IgnorePointer( return IgnorePointer(
@@ -79,7 +76,7 @@ class ViewerBottomBar extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (asset.isVideo) const VideoControls(), if (asset.isVideo) const VideoControls(),
if (!isReadonlyModeEnabled) if (!isInLockedView && !isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
], ],
), ),

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -20,9 +21,14 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -42,8 +48,29 @@ class AssetDetailBottomSheet extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset is RemoteAsset && asset.stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
final actions = ActionButtonBuilder.build(buttonContext);
return BaseBottomSheet( return BaseBottomSheet(
actions: [], actions: actions,
slivers: const [_AssetDetailBottomSheet()], slivers: const [_AssetDetailBottomSheet()],
controller: controller, controller: controller,
initialChildSize: initialChildSize, initialChildSize: initialChildSize,

View File

@@ -1,16 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button.utils.dart';
@@ -30,28 +24,16 @@ class ViewerKebabMenu extends ConsumerWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin; final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final actionContext = ActionButtonContext( final kebabContext = ViewerKebabMenuButtonContext(
asset: asset, asset: asset,
isOwner: isOwner, isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
isCasting: isCasting, isCasting: isCasting,
timelineOrigin: timelineOrigin, timelineOrigin: timelineOrigin,
originalTheme: originalTheme, originalTheme: originalTheme,
); );
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
return MenuAnchor( return MenuAnchor(
consumeOutsideTap: true, consumeOutsideTap: true,

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>( final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -25,6 +26,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router; final AppRouter router;
final UploadService _uploadService; final UploadService _uploadService;
final ShareIntentService _shareIntentService; final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) { ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus); _uploadService.taskStatusStream.listen(_updateUploadStatus);
@@ -86,6 +88,8 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
for (final attachment in state) for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment, if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
]; ];
_logger.fine("Upload failed for asset: ${task.task.filename}, exception: ${task.exception}");
} }
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {

View File

@@ -17,10 +17,8 @@ class PersonApiRepository extends ApiRepository {
} }
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async { Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc); return _toPerson(dto);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
} }
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@@ -39,9 +40,6 @@ class ActionButtonContext {
final RemoteAlbum? currentAlbum; final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting; final bool advancedTroubleshooting;
final ActionSource source; final ActionSource source;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ActionButtonContext({ const ActionButtonContext({
required this.asset, required this.asset,
@@ -53,33 +51,27 @@ class ActionButtonContext {
required this.currentAlbum, required this.currentAlbum,
required this.advancedTroubleshooting, required this.advancedTroubleshooting,
required this.source, required this.source,
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.originalTheme,
}); });
} }
enum ActionButtonType { enum ActionButtonType {
openInfo, advancedInfo,
likeActivity,
share, share,
shareLink, shareLink,
cast,
similarPhotos, similarPhotos,
viewInTimeline,
download,
upload,
unstack,
archive, archive,
unarchive, unarchive,
moveToLockFolder, download,
removeFromLockFolder,
removeFromAlbum,
trash, trash,
deleteLocal,
deletePermanent, deletePermanent,
delete, delete,
advancedInfo; moveToLockFolder,
removeFromLockFolder,
deleteLocal,
upload,
removeFromAlbum,
unstack,
likeActivity;
bool shouldShow(ActionButtonContext context) { bool shouldShow(ActionButtonContext context) {
return switch (this) { return switch (this) {
@@ -146,163 +138,132 @@ enum ActionButtonType {
ActionButtonType.similarPhotos => ActionButtonType.similarPhotos =>
!context.isInLockedView && // !context.isInLockedView && //
context.asset is RemoteAsset, context.asset is RemoteAsset,
ActionButtonType.openInfo => true,
ActionButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
}; };
} }
ConsumerWidget buildButton( Widget buildButton(ActionButtonContext context) {
ActionButtonContext context, [
BuildContext? buildContext,
bool iconOnly = false,
bool menuItem = false,
]) {
return switch (this) { return switch (this) {
ActionButtonType.advancedInfo => AdvancedInfoActionButton( ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source),
source: context.source, ActionButtonType.share => ShareActionButton(source: context.source),
iconOnly: iconOnly, ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
menuItem: menuItem, ActionButtonType.archive => ArchiveActionButton(source: context.source),
), ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.download => DownloadActionButton(source: context.source),
ActionButtonType.shareLink => ShareLinkActionButton( ActionButtonType.trash => TrashActionButton(source: context.source),
source: context.source, ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source),
iconOnly: iconOnly, ActionButtonType.delete => DeleteActionButton(source: context.source),
menuItem: menuItem, ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source),
), ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source),
ActionButtonType.unarchive => UnArchiveActionButton( ActionButtonType.upload => UploadActionButton(source: context.source),
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deleteLocal => DeleteLocalActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton( ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id, albumId: context.currentAlbum!.id,
source: context.source, source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
), ),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source),
ActionButtonType.similarPhotos => SimilarPhotosActionButton( ActionButtonType.similarPhotos => SimilarPhotosActionButton(assetId: (context.asset as RemoteAsset).id),
assetId: (context.asset as RemoteAsset).id, };
iconOnly: iconOnly, }
menuItem: menuItem, }
),
ActionButtonType.openInfo => BaseActionButton( class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}
class ViewerKebabMenuButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ViewerKebabMenuButtonContext({
required this.asset,
required this.isOwner,
required this.isCasting,
required this.timelineOrigin,
this.originalTheme,
});
}
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
cast,
download;
/// Defines which group each button belongs to.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
bool shouldShow(ViewerKebabMenuButtonContext context) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => true,
ViewerKebabMenuButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
};
}
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
label: 'info'.tr(), label: 'info'.tr(),
iconData: Icons.info_outline, iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color, iconColor: context.originalTheme?.iconTheme.color,
menuItem: true, menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
), ),
ActionButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.tr(), ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.t(context: buildContext),
iconData: Icons.image_search, iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color, iconColor: context.originalTheme?.iconTheme.color,
iconOnly: iconOnly, menuItem: true,
menuItem: menuItem, onPressed: () async {
onPressed: buildContext == null
? null
: () async {
await buildContext.maybePop(); await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt)); EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
}, },
), ),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem), ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
}; };
} }
/// Defines which group each button belongs to for kebab menu.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get kebabMenuGroup => switch (this) {
// 0: info
ActionButtonType.openInfo => 0,
// 10: move,remove, and delete
ActionButtonType.trash => 10,
ActionButtonType.deletePermanent => 10,
ActionButtonType.removeFromLockFolder => 10,
ActionButtonType.removeFromAlbum => 10,
ActionButtonType.unstack => 10,
ActionButtonType.archive => 10,
ActionButtonType.unarchive => 10,
ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10,
// 90: advancedInfo
ActionButtonType.advancedInfo => 90,
// 1: others
_ => 1,
};
} }
class ActionButtonBuilder { class ViewerKebabMenuButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values; static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes; final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
ActionButtonType.share,
ActionButtonType.moveToLockFolder,
ActionButtonType.upload,
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
};
static List<Widget> build(ActionButtonContext context) { if (visibleButtons.isEmpty) return [];
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
if (visibleButtons.isEmpty) {
return [];
}
final List<Widget> result = []; final List<Widget> result = [];
int? lastGroup; int? lastGroup;
for (final type in visibleButtons) { for (final type in visibleButtons) {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) { if (lastGroup != null && type.group != lastGroup) {
result.add(const Divider(height: 1)); result.add(const Divider(height: 1));
} }
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); result.add(type.buildButton(context, buildContext).build(buildContext, ref));
lastGroup = type.kebabMenuGroup; lastGroup = type.group;
} }
return result; return result;

View File

@@ -1034,10 +1034,7 @@ describe(MetadataService.name, () => {
}); });
it('should use Duration from exif', async () => { it('should use Duration from exif', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
...assetStub.image,
originalPath: '/original/path.webp',
});
mockReadTags({ Duration: 123 }, {}); mockReadTags({ Duration: 123 }, {});
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -1049,7 +1046,6 @@ describe(MetadataService.name, () => {
it('should prefer Duration from exif over sidecar', async () => { it('should prefer Duration from exif over sidecar', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image, ...assetStub.image,
originalPath: '/original/path.webp',
files: [ files: [
{ {
id: 'some-id', id: 'some-id',
@@ -1067,16 +1063,6 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
}); });
it('should ignore all Duration tags for definitely static images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng);
mockReadTags({ Duration: 123 }, { Duration: 456 });
await sut.handleMetadataExtraction({ id: assetStub.imageDng.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null }));
});
it('should ignore Duration from exif for videos', async () => { it('should ignore Duration from exif for videos', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
mockReadTags({ Duration: 123 }, {}); mockReadTags({ Duration: 123 }, {});

View File

@@ -32,7 +32,6 @@ import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database'; import { isAssetChecksumConstraint } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled } from 'src/utils/misc'; import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
@@ -487,8 +486,7 @@ export class MetadataService extends BaseService {
} }
// prefer duration from video tags // prefer duration from video tags
// don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s) if (videoTags) {
if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
delete mediaTags.Duration; delete mediaTags.Duration;
} }

View File

@@ -152,26 +152,6 @@ describe('mimeTypes', () => {
} }
}); });
describe('animated image', () => {
for (const img of ['a.avif', 'a.gif', 'a.webp']) {
it('should identify animated image mime types as such', () => {
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeTruthy();
});
}
for (const img of ['a.cr3', 'a.jpg', 'a.tiff']) {
it('should identify static image mime types as such', () => {
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeFalsy();
});
}
for (const extension of Object.keys(mimeTypes.video)) {
it('should not identify video mime types as animated', () => {
expect(mimeTypes.isPossiblyAnimatedImage(extension)).toBeFalsy();
});
}
});
describe('video', () => { describe('video', () => {
it('should contain only lowercase mime types', () => { it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video); const keys = Object.keys(mimeTypes.video);

View File

@@ -64,11 +64,6 @@ const image: Record<string, string[]> = {
'.tiff': ['image/tiff'], '.tiff': ['image/tiff'],
}; };
const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']);
const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)),
);
const extensionOverrides: Record<string, string> = { const extensionOverrides: Record<string, string> = {
'image/jpeg': '.jpg', 'image/jpeg': '.jpg',
}; };
@@ -124,7 +119,6 @@ export const mimeTypes = {
isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isImage: (filename: string) => isType(filename, image), isImage: (filename: string) => isType(filename, image),
isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage), isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage),
isPossiblyAnimatedImage: (filename: string) => isType(filename, possiblyAnimatedImage),
isProfile: (filename: string) => isType(filename, profile), isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar), isSidecar: (filename: string) => isType(filename, sidecar),
isVideo: (filename: string) => isType(filename, video), isVideo: (filename: string) => isType(filename, video),

View File

@@ -17,12 +17,11 @@
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton, Logo } from '@immich/ui'; import { IconButton, Logo } from '@immich/ui';
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js'; import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ThemeButton from '../shared-components/theme-button.svelte'; import ThemeButton from '../shared-components/theme-button.svelte';
import AlbumSummary from './album-summary.svelte'; import AlbumSummary from './album-summary.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
interface Props { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
@@ -33,8 +32,7 @@
const album = sharedLink.album as AlbumResponseDto; const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
const options = $derived({ albumId: album.id, order: album.order }); const options = $derived({ albumId: album.id, order: album.order });
let timelineManager = $state<TimelineManager>() as TimelineManager; let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -47,16 +45,6 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] }); dragAndDropFilesStore.set({ isDragging: false, files: [] });
} }
}); });
const handleStartSlideshow = async () => {
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}
};
</script> </script>
<svelte:document <svelte:document
@@ -129,14 +117,6 @@
{/if} {/if}
{#if album.assetCount > 0 && sharedLink.allowDownload} {#if album.assetCount > 0 && sharedLink.allowDownload}
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('slideshow')}
onclick={handleStartSlideshow}
icon={mdiPresentationPlay}
/>
<IconButton <IconButton
shape="round" shape="round"
color="secondary" color="secondary"

View File

@@ -16,7 +16,7 @@
{#if downloadManager.isDownloading} {#if downloadManager.isDownloading}
<div <div
transition:fly={{ x: -100, duration: 350 }} transition:fly={{ x: -100, duration: 350 }}
class="fixed bottom-10 start-2 max-h-67.5 w-79 z-60 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle" class="fixed bottom-10 start-2 max-h-67.5 w-79 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
> >
<Heading size="tiny">{$t('downloading')}</Heading> <Heading size="tiny">{$t('downloading')}</Heading>
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm"> <div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">

View File

@@ -79,30 +79,10 @@
searchStore.isSearchEnabled = false; searchStore.isSearchEnabled = false;
}; };
const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => {
const searchType = getSearchType();
switch (searchType) {
case 'smart': {
return { query: term };
}
case 'metadata': {
return { originalFileName: term };
}
case 'description': {
return { description: term };
}
case 'ocr': {
return { ocr: term };
}
default: {
return { query: term };
}
}
};
const onHistoryTermClick = async (searchTerm: string) => { const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm; value = searchTerm;
await handleSearch(buildSearchPayload(searchTerm)); const searchPayload = { query: searchTerm };
await handleSearch(searchPayload);
}; };
const onFilterClick = async () => { const onFilterClick = async () => {
@@ -132,7 +112,29 @@
}; };
const onSubmit = () => { const onSubmit = () => {
handlePromiseError(handleSearch(buildSearchPayload(value))); const searchType = getSearchType();
let payload = {} as SmartSearchDto | MetadataSearchDto;
switch (searchType) {
case 'smart': {
payload = { query: value } as SmartSearchDto;
break;
}
case 'metadata': {
payload = { originalFileName: value } as MetadataSearchDto;
break;
}
case 'description': {
payload = { description: value } as MetadataSearchDto;
break;
}
case 'ocr': {
payload = { ocr: value } as MetadataSearchDto;
break;
}
}
handlePromiseError(handleSearch(payload));
saveSearchTerm(value); saveSearchTerm(value);
}; };