Compare commits

...

5 Commits

Author SHA1 Message Date
Yaros
f29f884f0b chore: listen to changes 2026-07-14 17:34:43 +02:00
Yaros
52562c4972 chore: share photoview instances 2026-07-14 17:26:15 +02:00
Yaros
fc65603699 fix: disable slideshow crossfade on reduced motion 2026-07-11 09:58:56 +02:00
Alex
557189d7a8 chore: add Adam to mobile's codeowner (#29816) 2026-07-10 15:10:52 -05:00
Mees Frensel
19313e75fd chore(web): fix testing deprecations (#29799) 2026-07-10 14:30:12 +02:00
11 changed files with 150 additions and 69 deletions

View File

@@ -4,4 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/mobile/ @shenlong-tanwen @santoshakil @agg23

View File

@@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
int? _crossfadeFromIndex;
int? _crossfadeToIndex;
int _zoomCycle = 0;
bool _disableAnimations = false;
@override
initState() {
@@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
unawaited(WakelockPlus.enable());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_disableAnimations = MediaQuery.disableAnimationsOf(context);
}
@override
dispose() {
_timer.cancel();
@@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _crossFadeToPage(int page) {
if (_disableAnimations) {
_pageController.jumpToPage(page);
return;
}
final previousIndex = _index;
_pageController.jumpToPage(page);
setState(() {
@@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
return _SlideshowProgressBar(
key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
durationMs: _config.duration * 1000,
elapsedMs: _stopwatch.elapsedMilliseconds,
paused: _paused,
color: context.colorScheme.primary,
);
} else {
return LinearProgressIndicator(
@@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: initialScale,
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
);
if (_disableAnimations) {
return buildPhotoView(scale);
}
final zoomOut = _zoomCycle.isOdd;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
@@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
: 1.0,
),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value * _kenBurnsZoom),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
@@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
);
}
}
/// Progress bar for image slides, driven by an explicit [AnimationController].
///
/// [TweenAnimationBuilder] creates its controller internally with the default
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
/// slide duration regardless of that setting.
class _SlideshowProgressBar extends StatefulWidget {
final int durationMs;
final int elapsedMs;
final bool paused;
final Color color;
const _SlideshowProgressBar({
super.key,
required this.durationMs,
required this.elapsedMs,
required this.paused,
required this.color,
});
@override
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
}
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: widget.durationMs),
animationBehavior: AnimationBehavior.preserve,
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
if (!widget.paused) {
_controller.forward();
}
}
@override
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.durationMs != oldWidget.durationMs) {
_controller.duration = Duration(milliseconds: widget.durationMs);
}
if (widget.paused != oldWidget.paused) {
widget.paused ? _controller.stop() : _controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => LinearProgressIndicator(
color: widget.color,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: _controller.value,
),
);
}
}

View File

@@ -7,6 +7,16 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
describe('AssetViewerNavBar component', () => {
const additionalProps = {
preAction: () => {},
@@ -24,15 +34,6 @@ describe('AssetViewerNavBar component', () => {
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
});
afterEach(() => {

View File

@@ -4,16 +4,14 @@ import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });

View File

@@ -4,6 +4,11 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
@@ -26,10 +31,6 @@ vi.hoisted(() => {
describe('Thumbnail component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
});
it('should only contain a single tabbable element (the container)', () => {

View File

@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
// wtf
await tick();

View File

@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
await locale.set('en');
});

View File

@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
it('loads a month', async () => {
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
});
it('cancels month loading', async () => {
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
expect(abortSpy).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
});
it('allows loading a canceled month', async () => {
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
expect(previousMonthSpy).not.toHaveBeenCalled();
});
it('skips removed assets', async () => {

View File

@@ -2,17 +2,15 @@ import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('SharedLinkService', () => {
beforeAll(() => {
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
});
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
describe('SharedLinkService', () => {
describe('asUrl', () => {
it('should properly encode characters in slug', () => {
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');

View File

@@ -1,6 +1,10 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
@@ -41,10 +45,6 @@ describe('getShortDateRange', () => {
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {

View File

@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
// The last task will be executed after 200ms and will finish at 400ms
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toBeCalled();
expect(finished).not.toHaveBeenCalled();
expect(started).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(100);