Compare commits

...

6 Commits
v2.4.0 ... main

Author SHA1 Message Date
Alex 125de91c71
fix: merged video in On This Device played with incorrect dimension (#24656)
* fix: merged video in On This Device played with incorrect dimension

* chore: pr feedback
2025-12-18 20:59:58 -06:00
Timon c9b58f5893
fix(web): auto-start slideshow when confirming settings modal (#24629)
feat(web): auto-start slideshow when confirming settings modal
2025-12-18 21:58:22 +00:00
Timon 640fd7308b
fix(mobile): infinite loading screen when hiding UI in map viewer on iOS (#24563)
* fix with logging

* remove logging

* analyze
2025-12-18 21:07:58 +00:00
shenlong 557a79f747
chore(mobile): log failures from share upload intent (#24680)
chore: log failures from share intent upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-18 14:30:55 -06:00
Yaros 5ade152bc5
fix(web): shared link expiry does not save (#24569)
* fix(web): shared link expiry does not save

* chore: fix lint errors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-18 06:19:31 +00:00
bo0tzz 827bf1ef18
fix: pass bumped version through outputs (#24649) 2025-12-17 17:06:54 -06:00
7 changed files with 114 additions and 41 deletions

View File

@ -45,6 +45,7 @@ jobs:
needs: [merge_translations] needs: [merge_translations]
outputs: outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }} ref: ${{ steps.push-tag.outputs.commit_long_sha }}
version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token permissions: {} # No job-level permissions are needed because it uses the app-token
steps: steps:
- name: Generate a token - name: Generate a token
@ -80,13 +81,16 @@ jobs:
MOBILE_BUMP: ${{ inputs.mobileBump }} MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
- name: Commit and tag - name: Commit and tag
id: push-tag id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with: with:
default_author: github_actions default_author: github_actions
message: 'chore: version ${{ env.IMMICH_VERSION }}' message: 'chore: version ${{ steps.output.outputs.version }}'
tag: ${{ env.IMMICH_VERSION }} tag: ${{ steps.output.outputs.version }}
push: true push: true
build_mobile: build_mobile:
@ -119,7 +123,7 @@ jobs:
prepare_release: prepare_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build_mobile needs: [build_mobile, bump_version]
permissions: permissions:
actions: read # To download the app artifact actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token # No content permissions are needed because it uses the app-token
@ -147,7 +151,7 @@ jobs:
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
draft: true draft: true
tag_name: ${{ env.IMMICH_VERSION }} tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true generate_release_notes: true
body_path: misc/release/notes.tmpl body_path: misc/release/notes.tmpl

View File

@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService { class AssetService {
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
@ -58,44 +60,48 @@ class AssetService {
} }
Future<double> getAspectRatio(BaseAsset asset) async { Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped; final dimension = asset is LocalAsset
double? width; ? await _getLocalAssetDimensions(asset)
double? height; : await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) { if (dimension.width == null || dimension.height == null || dimension.height == 0) {
final exif = await getExif(asset); return 1.0;
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
} }
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) { if (width == null || height == null) {
if (asset.hasRemote) { final fetched = await _localAssetRepository.get(asset.id);
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; width = fetched?.width?.toDouble();
final remoteAsset = await _remoteAssetRepository.get(id); height = fetched?.height?.toDouble();
width = remoteAsset?.width?.toDouble(); orientation = fetched?.orientation ?? 0;
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
} }
final orientedWidth = isFlipped ? height : width; // On Android, local assets need orientation correction for 90°/270° rotations
final orientedHeight = isFlipped ? width : height; // On iOS, the Photos framework pre-corrects dimensions
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return orientedWidth / orientedHeight; return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
} }
return 1.0; final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
} }
Future<List<(String, String)>> getPlaces(String userId) { Future<List<(String, String)>> getPlaces(String userId) {

View File

@ -12,6 +12,8 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -114,6 +116,14 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return; return;
} }
// When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
return;
}
final bounds = await controller.getVisibleRegion(); final bounds = await controller.getVisibleRegion();
unawaited( unawaited(
_reloadMutex.run(() async { _reloadMutex.run(() async {

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,21 @@ 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,
]; ];
if (task.status == TaskStatus.failed) {
String? error;
final exception = task.exception;
if (exception != null && exception is TaskHttpException) {
final message = tryJsonDecode(exception.description)?['message'] as String?;
if (message != null) {
final responseCode = exception.httpResponseCode;
error = "${exception.exceptionType}, response code $responseCode: $message";
}
}
error ??= task.exception?.toString();
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
}
} }
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {

View File

@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1); verify(() => mockLocalAssetRepository.get('local-1')).called(1);
}); });
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async { test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@ -112,7 +131,9 @@ void main() {
expect(result, 1.0); expect(result, 1.0);
}); });
test('handles local asset with remoteId and uses exif from remote', () async { test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset( final localAsset = TestUtils.createLocalAsset(
id: 'local-1', id: 'local-1',
remoteId: 'remote-1', remoteId: 'remote-1',
@ -121,9 +142,24 @@ void main() {
orientation: 0, orientation: 0,
); );
final exif = const ExifInfo(orientation: '6'); final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset); final result = await sut.getAspectRatio(localAsset);

View File

@ -4,7 +4,6 @@
import { SharedLinkType } from '@immich/sdk'; import { SharedLinkType } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui'; import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js'; import { mdiLink } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@ -19,7 +18,6 @@
let allowDownload = $state(true); let allowDownload = $state(true);
let allowUpload = $state(false); let allowUpload = $state(false);
let showMetadata = $state(true); let showMetadata = $state(true);
let expirationOption: number = $state(0);
let password = $state(''); let password = $state('');
let slug = $state(''); let slug = $state('');
let expiresAt = $state<string | null>(null); let expiresAt = $state<string | null>(null);
@ -37,7 +35,7 @@
type: shareType, type: shareType,
albumId, albumId,
assetIds, assetIds,
expiresAt: expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined, expiresAt,
allowUpload, allowUpload,
description, description,
password, password,

View File

@ -14,7 +14,7 @@
} from '@mdi/js'; } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte'; import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
const { const {
slideshowDelay, slideshowDelay,
@ -23,6 +23,7 @@
slideshowLook, slideshowLook,
slideshowTransition, slideshowTransition,
slideshowAutoplay, slideshowAutoplay,
slideshowState,
} = slideshowStore; } = slideshowStore;
interface Props { interface Props {
@ -69,6 +70,7 @@
$slideshowLook = tempSlideshowLook; $slideshowLook = tempSlideshowLook;
$slideshowTransition = tempSlideshowTransition; $slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay; $slideshowAutoplay = tempSlideshowAutoplay;
$slideshowState = SlideshowState.PlaySlideshow;
onClose(); onClose();
}; };
</script> </script>