Compare commits

..

30 Commits

Author SHA1 Message Date
mertalev 02addf7491 bump player 2026-03-03 20:04:55 -05:00
Savely Krasovsky 56f14162f6 chore: bump base images manually (#26670) 2026-03-04 00:54:55 +00:00
renovate[bot] 8abbbc49cf chore(deps): update dependency opentofu to v1.11.5 (#26655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 16:53:01 +00:00
Thomas 4eb08eee18 fix(mobile): video state (#26574)
Consolidate video state into a single asset-scoped provider, and reduce
dependency on global state generally. Overall this should fix a few
timing issues and race conditions with videos specifically, and make
future changes in this area easier.
2026-03-03 10:28:07 -06:00
Mees Frensel 0560f98c2d chore(web): clarify locale settings description (#25562) 2026-03-03 12:52:17 +01:00
Brandon Annin 49ad411d50 fix(docs): add ocr to job flow diagram (#26505) 2026-03-03 12:43:59 +01:00
renovate[bot] 2478cc40f4 chore(deps): update dependency terragrunt to v0.99.4 (#26658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:42:11 +00:00
Joe Babbitt 44eeb1e088 fix: implement existing withStacked on searchAssetBuilder (#26607)
Co-authored-by: Joe <code@joebabbitt.com>
2026-03-03 11:41:29 +00:00
Min Idzelis a868ae3ad0 perf: move album fetching into detail panel (#26632) 2026-03-03 12:25:03 +01:00
renovate[bot] acac0d4f37 chore(deps): update github-actions (#26656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-03-03 11:14:12 +00:00
Michel Heusschen 8c40a28fef fix(server): clean up edited thumbnail when deleting asset (#26664) 2026-03-03 12:08:07 +01:00
renovate[bot] b2081eda1e fix(deps): update typescript-projects (#26657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-03 12:06:22 +01:00
renovate[bot] 9670c853c6 chore(deps): update docker.io/valkey/valkey:9 docker digest to 2bce660 (#26652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 12:02:04 +01:00
renovate[bot] cc2dacb308 chore(deps): update prom/prometheus docker digest to 4a61322 (#26653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:23:13 +01:00
renovate[bot] 15fc6b18f3 chore(deps): update dependency @types/node to ^24.10.14 (#26654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 09:22:58 +00:00
Mees Frensel a284e38890 fix(web): timeline and asset viewer RTL support (#26513) 2026-03-03 09:01:54 +01:00
Thomas 05010c3a84 fix(mobile): asset viewer hero animation (#26545)
The image in the photo view has no height, and is therefore entirely
unconstrained. This causes the image to take up the full height of the
viewport during the hero animation, which can make look out of sync. In
some other cases, it can stretch or resize the image to fill the entire
viewport.
2026-03-02 22:26:53 -06:00
Min Idzelis 4da3d68a67 refactor: use keyed each for face bounding boxes (#26648) 2026-03-02 22:16:13 -06:00
Min Idzelis 20c639e52a refactor: extract shared ContentMetrics for overlay position calculations (#26310) 2026-03-02 21:49:56 -06:00
Luis Nachtigall 6deb97d5bc fix(mobile): android detect supported version for special format column (#26633)
* fix(android): detect supported version for special format column

* fix(android): remove unnecessary suppression for new API in special format check

* fix(android): change visibility of hasSpecialFormatColumn method to private
2026-03-02 17:06:35 -05:00
Snowknight26 b282d83e95 fix(web): show shared link download button when logged in (#26629) 2026-03-02 22:00:23 +01:00
Jason Rasmussen 5bc08f8654 refactor: queue names (#26650) 2026-03-02 15:46:26 -05:00
shenlong f54924d46a refactor: simplify video zooming (#26527)
fix: simplify video zooming

# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-03-02 17:53:49 +00:00
Michel Heusschen dffe4d1d5c refactor(web): remove resize observer action (#26647) 2026-03-02 14:45:34 +00:00
Min Idzelis 7f47cdd645 feat: enhance face-editor positioning (#26303)
feat: enhance face-editor positioning - less overlap

test: timeline with actual video
2026-03-02 09:44:59 -05:00
Min Idzelis 625b30c50a test: stack editor e2e tests (#26526)
* feat: add responsive layout to broken asset

* test: stack editor e2e tests
2026-03-02 09:43:56 -05:00
Min Idzelis 8619d14eca feat: add responsive layout to broken asset (#26384) 2026-03-02 09:27:40 -05:00
Min Idzelis 062546c168 refactor: rename image cancel method (#26381) 2026-03-02 09:23:20 -05:00
Michel Heusschen ea668d6b22 refactor(web): convert memory observer to an attachment (#26646) 2026-03-02 09:20:13 -05:00
Michel Heusschen f06af2c600 refactor(web): dedupe isAllUserOwned logic (#26645) 2026-03-02 09:18:32 -05:00
129 changed files with 3563 additions and 3300 deletions
+1 -2
View File
@@ -24,8 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
# sha is pinning to a commit instead of a tag since the action does not tag versions uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.13", "@types/node": "^24.10.14",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
+2 -2
View File
@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "0.98.0" terragrunt = "0.99.4"
opentofu = "1.11.4" opentofu = "1.11.5"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"
+1 -1
View File
@@ -155,7 +155,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702 image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
user: '1000:1000' user: '1000:1000'
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
+4 -3
View File
@@ -67,7 +67,8 @@ graph TD
C --> D["Thumbnail Generation (Large, small, blurred and person)"] C --> D["Thumbnail Generation (Large, small, blurred and person)"]
D --> E[Smart Search] D --> E[Smart Search]
D --> F[Face Detection] D --> F[Face Detection]
D --> G[Video Transcoding] D --> G[OCR]
E --> H[Duplicate Detection] D --> H[Video Transcoding]
F --> I[Facial Recognition] E --> I[Duplicate Detection]
F --> J[Facial Recognition]
``` ```
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis: redis:
container_name: immich-e2e-redis container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.10.13", "@types/node": "^24.10.14",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -1,66 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
@@ -0,0 +1,167 @@
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
export type MockStack = {
id: string;
primaryAssetId: string;
assets: AssetResponseDto[];
brokenAssetIds: Set<string>;
assetMap: Map<string, AssetResponseDto>;
};
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const assetId = faker.string.uuid();
const now = new Date().toISOString();
return {
id: assetId,
deviceAssetId: `device-${assetId}`,
ownerId,
owner: {
id: ownerId,
email: 'admin@immich.cloud',
name: 'Admin',
profileImagePath: '',
profileChangedAt: now,
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
deviceId: `device-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
updatedAt: now,
createdAt: now,
isFavorite: false,
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: '0:00:00.00000',
exifInfo: {
make: null,
model: null,
exifImageWidth: 3000,
exifImageHeight: 4000,
fileSizeInByte: null,
orientation: null,
dateTimeOriginal: now,
modifyDate: null,
timeZone: null,
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: null,
longitude: null,
city: null,
country: null,
state: null,
description: null,
},
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: null,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: faker.string.alphanumeric({ length: 28 }),
width: 3000,
height: 4000,
isEdited: false,
};
};
export const createMockStack = (
primaryAssetDto: AssetResponseDto,
additionalAssets: AssetResponseDto[],
brokenAssetIds?: Set<string>,
): MockStack => {
const stackId = faker.string.uuid();
const allAssets = [primaryAssetDto, ...additionalAssets];
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
primaryAssetDto.stack = {
id: stackId,
assetCount: allAssets.length,
primaryAssetId: primaryAssetDto.id,
};
return {
id: stackId,
primaryAssetId: primaryAssetDto.id,
assets: allAssets,
brokenAssetIds: resolvedBrokenIds,
assetMap,
};
};
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
await context.route('**/api/stacks/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const stackResponse: StackResponseDto = {
id: mockStack.id,
primaryAssetId: mockStack.primaryAssetId,
assets: mockStack.assets,
};
return route.fulfill({
status: 200,
contentType: 'application/json',
json: stackResponse,
});
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetId = segments.at(-1);
if (assetId && mockStack.assetMap.has(assetId)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: mockStack.assetMap.get(assetId),
});
}
return route.fallback();
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
return route.fallback();
}
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
return route.fulfill({ status: 404 });
}
const asset = mockStack.assetMap.get(match.groups.assetId)!;
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
const body =
match.groups.size === 'preview'
? await randomPreview(match.groups.assetId, ratio)
: await randomThumbnail(match.groups.assetId, ratio);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body,
});
});
};
@@ -0,0 +1,127 @@
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
export type MockPerson = {
id: string;
name: string;
birthDate: string | null;
isHidden: boolean;
thumbnailPath: string;
updatedAt: string;
};
export const createMockPeople = (count: number): MockPerson[] => {
const names = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'Diana Prince',
'Eve Adams',
'Frank Castle',
'Grace Lee',
'Hank Pym',
'Iris West',
'Jack Ryan',
];
return Array.from({ length: count }, (_, index) => ({
id: `person-${index}`,
name: names[index % names.length],
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
}));
};
export type FaceCreateCapture = {
requests: Array<{
assetId: string;
personId: string;
x: number;
y: number;
width: number;
height: number;
imageWidth: number;
imageHeight: number;
}>;
};
export const setupFaceEditorMockApiRoutes = async (
context: BrowserContext,
mockPeople: MockPerson[],
faceCreateCapture: FaceCreateCapture,
) => {
await context.route('**/api/people?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
hasNextPage: false,
hidden: 0,
people: mockPeople,
total: mockPeople.length,
},
});
});
await context.route('**/api/faces', async (route, request) => {
if (request.method() !== 'POST') {
return route.fallback();
}
const body = request.postDataJSON();
faceCreateCapture.requests.push(body);
return route.fulfill({
status: 201,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};
@@ -12,6 +12,7 @@ import {
TimelineData, TimelineData,
} from 'src/ui/generators/timeline'; } from 'src/ui/generators/timeline';
import { sleep } from 'src/ui/specs/timeline/utils'; import { sleep } from 'src/ui/specs/timeline/utils';
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
export class TimelineTestContext { export class TimelineTestContext {
slowBucket = false; slowBucket = false;
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
return route.continue(); return route.continue();
}); });
await context.route('**/api/assets/*/video/playback*', async (route) => {
return route.fulfill({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: MINIMAL_MP4_BUFFER,
});
});
await context.route('**/api/albums/**', async (route, request) => { await context.route('**/api/albums/**', async (route, request) => {
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/); const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) { if (albumsMatch) {
@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});
@@ -0,0 +1,285 @@
import { expect, Page, test } from '@playwright/test';
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction(
() => {
const selector = document.querySelector('#face-selector') as HTMLElement | null;
if (!selector) {
return false;
}
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
},
undefined,
{ timeout: 1000, polling: 50 },
);
};
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.keyboard.press('i');
await page.locator('#detail-panel').waitFor({ state: 'visible' });
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await waitForSelectorTransition(page);
};
test.describe.configure({ mode: 'parallel' });
test.describe('face-editor', () => {
const fixture = setupAssetViewerFixture(777);
const rng = new SeededRandom(777);
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
mockPeople = createMockPeople(8);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
type ScreenRect = { top: number; left: number; width: number; height: number };
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
const canvasBox = await page.locator('#face-editor').boundingBox();
if (!canvasBox) {
throw new Error('Canvas element not found');
}
const left = Number(await dataEl.getAttribute('data-face-left'));
const top = Number(await dataEl.getAttribute('data-face-top'));
const width = Number(await dataEl.getAttribute('data-face-width'));
const height = Number(await dataEl.getAttribute('data-face-height'));
return {
top: canvasBox.y + top,
left: canvasBox.x + left,
width,
height,
};
};
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
const box = await page.locator('#face-selector').boundingBox();
if (!box) {
throw new Error('Face selector element not found');
}
return { top: box.y, left: box.x, width: box.width, height: box.height };
};
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
return overlapX * overlapY;
};
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
const faceBox = await getFaceBoxRect(page);
const centerX = faceBox.left + faceBox.width / 2;
const centerY = faceBox.top + faceBox.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
};
test('Face editor opens with person list', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search filters people by name', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Alice');
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
await searchInput.clear();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search with no results shows empty message', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Nonexistent Person XYZ');
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
}
});
test('Selecting a person shows confirmation dialog', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
});
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
});
test('Cancel button closes face editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
});
test('Selector does not overlap face box on initial open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 0, 150);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 200, 0);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -300, -300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 300, 300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector stays within viewport bounds', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -400, -400);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Face box is draggable on the canvas', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeDrag = await getFaceBoxRect(page);
await dragFaceBox(page, 100, 50);
const afterDrag = await getFaceBoxRect(page);
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
});
@@ -0,0 +1,84 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer stack', () => {
const fixture = setupAssetViewerFixture(888);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
primaryAssetDto.tags = [
{
id: faker.string.uuid(),
name: '1',
value: 'test/1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.tags = [
{
id: faker.string.uuid(),
name: '2',
value: 'test/2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('stack slideshow is visible', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackAssets = stackSlideshow.locator('[data-asset]');
await expect(stackAssets).toHaveCount(mockStack.assets.length);
});
test('tags of primary asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
+116
View File
@@ -0,0 +1,116 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils';
export type AssetViewerTestFixture = {
adminUserId: string;
timelineRestData: TimelineData;
assets: TimelineAssetConfig[];
testContext: TimelineTestContext;
changes: Changes;
primaryAsset: TimelineAssetConfig;
primaryAssetDto: AssetResponseDto;
};
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
const rng = new SeededRandom(seed);
const testContext = new TimelineTestContext();
const fixture: AssetViewerTestFixture = {
adminUserId: undefined!,
timelineRestData: undefined!,
assets: [],
testContext,
changes: {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
primaryAsset: undefined!,
primaryAssetDto: undefined!,
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
fixture.adminUserId = faker.string.uuid();
testContext.adminId = fixture.adminUserId;
fixture.timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: fixture.adminUserId,
});
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
fixture.assets.push(...timeBucket);
}
fixture.primaryAsset = selectRandom(
fixture.assets.filter((a) => a.isImage),
rng,
);
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, fixture.adminUserId);
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
});
test.afterEach(() => {
fixture.testContext.slowBucket = false;
fixture.changes.albumAdditions = [];
fixture.changes.assetDeletions = [];
fixture.changes.assetArchivals = [];
fixture.changes.assetFavorites = [];
});
return fixture;
}
export async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
export async function enableTagsPreference(context: BrowserContext) {
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { defaultAssetOrder: 'desc' },
folders: { enabled: false, sidebarWeb: false },
memories: { enabled: true, duration: 5 },
people: { enabled: true, sidebarWeb: false },
sharedLinks: { enabled: true, sidebarWeb: false },
ratings: { enabled: false },
tags: { enabled: true, sidebarWeb: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
},
});
});
}
+4 -4
View File
@@ -871,8 +871,8 @@
"current_pin_code": "Current PIN code", "current_pin_code": "Current PIN code",
"current_server_address": "Current server address", "current_server_address": "Current server address",
"custom_date": "Custom date", "custom_date": "Custom date",
"custom_locale": "Custom Locale", "custom_locale": "Custom locale",
"custom_locale_description": "Format dates and numbers based on the language and the region", "custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url": "Custom URL", "custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…", "cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}", "cutoff_day": "{count, plural, one {day} other {days}}",
@@ -895,8 +895,6 @@
"deduplication_criteria_2": "Count of EXIF data", "deduplication_criteria_2": "Count of EXIF data",
"deduplication_info": "Deduplication Info", "deduplication_info": "Deduplication Info",
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
"default_locale": "Default Locale",
"default_locale_description": "Format dates and numbers based on your browser locale",
"delete": "Delete", "delete": "Delete",
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally", "delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
"delete_action_prompt": "{count} deleted", "delete_action_prompt": "{count} deleted",
@@ -2338,6 +2336,8 @@
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_biometric": "Use biometric", "use_biometric": "Use biometric",
"use_browser_locale": "Use browser locale",
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
"use_current_connection": "Use current connection", "use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"user": "User", "user": "User",
+3 -3
View File
@@ -16,9 +16,9 @@ config_roots = [
[tools] [tools]
node = "24.13.1" node = "24.13.1"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.30.0" pnpm = "10.30.3"
terragrunt = "0.98.0" terragrunt = "0.99.4"
opentofu = "1.11.4" opentofu = "1.11.5"
java = "21.0.2" java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]
@@ -7,6 +7,7 @@ import android.database.Cursor
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.ext.SdkExtensions
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE) add(MediaStore.MediaColumns.IS_FAVORITE)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN) add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos // Fallback: read XMP from MediaStore to detect Motion Photos
// only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.XMP) add(MediaStore.MediaColumns.XMP)
} }
}.toTypedArray() }.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024 const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
// _special_format requires S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private fun hasSpecialFormatColumn(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
} }
protected fun getCursor( protected fun getCursor(
@@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget {
stackIndex.value = 0; stackIndex.value = 0;
ref.read(currentAssetProvider.notifier).set(newAsset); ref.read(currentAssetProvider.notifier).set(newAsset);
if (newAsset.isVideo || newAsset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
// Wait for page change animation to finish, then precache the next image // Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () { Timer(const Duration(milliseconds: 400), () {
@@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget { class NativeVideoViewerPage extends HookConsumerWidget {
@@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final videoId = asset.id.toString();
final controller = useState<NativeVideoPlayerController?>(null); final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true); final shouldPlayOnForeground = useRef(true);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetProvider)); final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset; final isCurrent = currentAsset.value == asset;
@@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget {
} }
}); });
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async { void onPlaybackReady() async {
final videoController = controller.value; final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) { if (videoController == null || !isCurrent || !context.mounted) {
return; return;
} }
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); final notifier = ref.read(videoPlayerProvider(videoId).notifier);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; notifier.onNativePlaybackReady();
isVideoReady.value = true; isVideoReady.value = true;
try { try {
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo); final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
if (autoPlayVideo) { if (autoPlayVideo) {
await videoController.play(); await notifier.play();
} }
await videoController.setVolume(0.9); await notifier.setVolume(1);
} catch (error) { } catch (error) {
log.severe('Error playing video: $error'); log.severe('Error playing video: $error');
} }
} }
void onPlaybackStatusChanged() { void onPlaybackStatusChanged() {
final videoController = controller.value; if (!context.mounted) return;
if (videoController == null || !context.mounted) { ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
} }
void onPlaybackPositionChanged() { void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position if (!context.mounted) return;
if (seekDebouncer.isActive) { ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
} }
void onPlaybackEnded() { void onPlaybackEnded() {
final videoController = controller.value; if (!context.mounted) return;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped && ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
final videoController = controller.value;
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) { !ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false; ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
} }
@@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget {
if (controller.value != null || !context.mounted) { if (controller.value != null || !context.mounted) {
return; return;
} }
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource; final source = await videoSource;
if (source == null) { if (source == null) {
return; return;
} }
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackReady.addListener(onPlaybackReady);
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}), }),
); );
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo); final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(loopVideo)); await notifier.setLoop(loopVideo);
controller.value = nc; controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
} }
ref.listen(currentAssetProvider, (_, value) { ref.listen(currentAssetProvider, (_, value) {
@@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
} }
// Delay the video playback to avoid a stutter in the swipe animation // Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer( Timer(
Platform.isIOS Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor) ? Duration(milliseconds: 300 * playbackDelayFactor)
@@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
playerController.stop().catchError((error) { playerController.stop().catchError((error) {
log.fine('Error stopping video: $error'); log.fine('Error stopping video: $error');
}); });
WakelockPlus.disable();
}; };
}, const []); }, const []);
useOnAppLifecycleStateChange((_, state) async { useOnAppLifecycleStateChange((_, state) async {
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play(); await notifier.play();
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying(); final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) { if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true; shouldPlayOnForeground.value = true;
await controller.value?.pause(); await notifier.pause();
} else { } else {
shouldPlayOnForeground.value = false; shouldPlayOnForeground.value = false;
} }
@@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
), ),
), ),
), ),
if (showControls) const Center(child: CustomVideoPlayerControls()), if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
], ],
); );
} }
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
if (!context.mounted) {
return;
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
} }
-4
View File
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
@@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget {
final asset = currentMemory.value.assets[otherIndex]; final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset; currentAsset.value = asset;
ref.read(currentAssetProvider.notifier).set(asset); ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
} }
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
@@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.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/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
/// Expects [currentAssetNotifier] to be set before navigating to this page /// Expects the current asset to be set via [assetViewerProvider] before navigating to this page
@RoutePage() @RoutePage()
class DriftMemoryPage extends HookConsumerWidget { class DriftMemoryPage extends HookConsumerWidget {
final List<DriftMemory> memories; final List<DriftMemory> memories;
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
static void setMemory(WidgetRef ref, DriftMemory memory) { static void setMemory(WidgetRef ref, DriftMemory memory) {
if (memory.assets.isNotEmpty) { if (memory.assets.isNotEmpty) {
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first); ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
if (memory.assets.first.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
} }
} }
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
final asset = currentMemory.value.assets[otherIndex]; final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset; currentAsset.value = asset;
ref.read(currentAssetNotifier.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
// if (asset.isVideo || asset.isMotionPhoto) {
if (asset.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
} }
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
children: [ children: [
Container( Container(
color: Colors.black, color: Colors.black,
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0), child: DriftMemoryCard(
asset: asset,
title: title,
showTitle: index == 0,
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
),
), ),
Positioned.fill( Positioned.fill(
child: Row( child: Row(
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_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/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
} }
List<Widget> _buildMenuChildren() { List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier); final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return []; if (asset == null) return [];
final user = ref.read(currentUserProvider); final user = ref.read(currentUserProvider);
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
} }
void _openAlbumSelector() { void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset == null) { if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return; return;
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
} }
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async { Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier); final latest = ref.read(assetViewerProvider).currentAsset;
if (latest == null) { if (latest == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) { if (asset == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget { class EditImageActionButton extends ConsumerWidget {
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier); final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
onPress() { onPress() {
if (currentAsset == null) { if (currentAsset == null) {
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/models/activities/activity.model.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/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider); final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id)); final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
@@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.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/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget { class SimilarPhotosActionButton extends ConsumerWidget {
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.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.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
return; return;
} }
final asset = ref.read(currentAssetNotifier); final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) { if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -1,5 +1,6 @@
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/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
@@ -11,16 +12,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetDetails extends ConsumerWidget { class AssetDetails extends ConsumerWidget {
final BaseAsset asset;
final double minHeight; final double minHeight;
const AssetDetails({required this.minHeight, super.key}); const AssetDetails({super.key, required this.asset, required this.minHeight});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
if (asset == null) {
return const SizedBox.shrink();
}
return Container( return Container(
constraints: BoxConstraints(minHeight: minHeight), constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -31,12 +31,12 @@ class AssetDetails extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const DragHandle(), const DragHandle(),
const DateTimeDetails(), DateTimeDetails(asset: asset, exifInfo: exifInfo),
const PeopleDetails(), PeopleDetails(asset: asset),
const LocationDetails(), LocationDetails(asset: asset, exifInfo: exifInfo),
const TechnicalDetails(), TechnicalDetails(asset: asset, exifInfo: exifInfo),
const RatingDetails(), RatingDetails(exifInfo: exifInfo),
const AppearsInDetails(), AppearsInDetails(asset: asset),
SizedBox(height: context.padding.bottom + 48), SizedBox(height: context.padding.bottom + 48),
], ],
), ),
@@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.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/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class AppearsInDetails extends ConsumerWidget { class AppearsInDetails extends ConsumerWidget {
const AppearsInDetails({super.key}); final BaseAsset asset;
const AppearsInDetails({super.key, required this.asset});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); if (!asset.hasRemote) return const SizedBox.shrink();
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
String? remoteAssetId; final remoteAssetId = switch (asset) {
if (asset is RemoteAsset) { RemoteAsset(:final id) => id,
remoteAssetId = asset.id; LocalAsset(:final remoteAssetId) => remoteAssetId,
} else if (asset is LocalAsset) { };
remoteAssetId = asset.remoteAssetId;
}
if (remoteAssetId == null) return const SizedBox.shrink(); if (remoteAssetId == null) return const SizedBox.shrink();
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.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';
@@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = ''; const _kSeparator = '';
class DateTimeDetails extends ConsumerWidget { class DateTimeDetails extends ConsumerWidget {
const DateTimeDetails({super.key}); final BaseAsset asset;
final ExifInfo? exifInfo;
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = this.asset;
if (asset == null) return const SizedBox.shrink(); final exifInfo = this.exifInfo;
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return Column( return Column(
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final currentDescription = widget.exif.description ?? '';
final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context, context: context,
); );
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
errorBorder: InputBorder.none, errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none,
), ),
onTapOutside: (_) => saveDescription(currentExifInfo?.description), onTapOutside: (_) => saveDescription(widget.exif.description),
), ),
), ),
); );
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class LocationDetails extends ConsumerStatefulWidget { class LocationDetails extends ConsumerStatefulWidget {
const LocationDetails({super.key}); final BaseAsset asset;
final ExifInfo? exifInfo;
const LocationDetails({super.key, required this.asset, this.exifInfo});
@override @override
ConsumerState createState() => _LocationDetailsState(); ConsumerState createState() => _LocationDetailsState();
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
_mapController = controller; _mapController = controller;
} }
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override @override
void initState() { void didUpdateWidget(LocationDetails oldWidget) {
super.initState(); super.didUpdateWidget(oldWidget);
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true); if (widget.exifInfo != oldWidget.exifInfo) {
final exif = widget.exifInfo;
if (exif != null && exif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!)));
}
}
} }
void editLocation() async { void editLocation() async {
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier); final asset = widget.asset;
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final exifInfo = widget.exifInfo;
final hasCoordinates = exifInfo?.hasCoordinates ?? false; final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets // Guard local assets
@@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
@@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart'; import 'package:immich_mobile/utils/people.utils.dart';
class PeopleDetails extends ConsumerStatefulWidget { class PeopleDetails extends ConsumerWidget {
const PeopleDetails({super.key}); final BaseAsset asset;
const PeopleDetails({super.key, required this.asset});
@override @override
ConsumerState createState() => _PeopleDetailsState(); Widget build(BuildContext context, WidgetRef ref) {
} final asset = this.asset;
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset is! RemoteAsset) { if (asset is! RemoteAsset) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -1,16 +1,18 @@
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/constants/enums.dart';
import 'package:immich_mobile/domain/models/exif.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/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
class RatingDetails extends ConsumerWidget { class RatingDetails extends ConsumerWidget {
const RatingDetails({super.key}); final ExifInfo? exifInfo;
const RatingDetails({super.key, this.exifInfo});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
if (!isRatingEnabled) return const SizedBox.shrink(); if (!isRatingEnabled) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0), padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column( child: Column(
@@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = ''; const _kSeparator = '';
class TechnicalDetails extends ConsumerWidget { class TechnicalDetails extends ConsumerWidget {
const TechnicalDetails({super.key}); final BaseAsset asset;
final ExifInfo? exifInfo;
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final exifInfo = this.exifInfo;
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo); final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
@@ -12,16 +12,15 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -53,8 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0; double _snapOffset = 0.0;
DragStartDetails? _dragStart; DragStartDetails? _dragStart;
@@ -79,7 +76,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose(); _proxyScrollController.dispose();
_scaleBoundarySub?.cancel(); _scaleBoundarySub?.cancel();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose(); super.dispose();
} }
@@ -249,17 +245,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true; ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) { void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = _isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.covering ||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed); _viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) { if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false); if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause(); final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return; return;
} }
@@ -294,22 +289,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_listenForScaleBoundaries(controller); _listenForScaleBoundaries(controller);
} }
Widget _buildPhotoView( Widget _buildPhotoView({
BaseAsset displayAsset, required BaseAsset asset,
BaseAsset asset, { required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrentPage, required bool isCurrent,
required bool showingDetails,
required bool isPlayingMotionVideo, required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration, required BoxDecoration backgroundDecoration,
}) { }) {
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData; final size = context.sizeData;
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView( return PhotoView(
key: Key(displayAsset.heroTag), key: Key(asset.heroTag),
index: widget.index, index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size), imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
@@ -317,7 +310,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
enablePanAlways: true, enablePanAlways: true,
disableScaleGestures: showingDetails, disableScaleGestures: _showingDetails,
scaleStateChangedCallback: _onScaleStateChanged, scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild, onPageBuild: _onPageBuild,
onDragStart: _onDragStart, onDragStart: _onDragStart,
@@ -325,25 +318,30 @@ class _AssetPageState extends ConsumerState<AssetPage> {
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel, onDragCancel: _onDragCancel,
onTapUp: _onTapUp, onTapUp: _onTapUp,
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => SizedBox( errorBuilder: (_, __, ___) => SizedBox(
width: size.width, width: size.width,
height: size.height, height: size.height,
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
), ),
); );
} }
return PhotoView.customChild( return PhotoView.customChild(
key: Key(displayAsset.heroTag), key: Key(asset.heroTag),
childSize: asset.width != null && asset.height != null
? Size(asset.width!.toDouble(), asset.height!.toDouble())
: null,
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel, onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
scaleStateChangedCallback: _onScaleStateChanged,
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
basePosition: Alignment.center, basePosition: Alignment.center,
disableScaleGestures: true, disableScaleGestures: _showingDetails,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
tightMode: true, tightMode: true,
@@ -351,14 +349,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
enablePanAlways: true, enablePanAlways: true,
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag), key: _NativeVideoViewerKey(asset.heroTag),
asset: displayAsset, asset: asset,
scaleStateNotifier: _videoScaleStateNotifier, isCurrent: isCurrent,
disableScaleGestures: showingDetails,
image: Image( image: Image(
image: getFullImageProvider(displayAsset, size: context.sizeData), image: getFullImageProvider(asset, size: size),
height: context.height,
width: context.width,
fit: BoxFit.contain, fit: BoxFit.contain,
alignment: Alignment.center, alignment: Alignment.center,
), ),
@@ -384,6 +379,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
displayAsset = stackChildren.elementAt(stackIndex); displayAsset = stackChildren.elementAt(stackIndex);
} }
final isCurrent = currentHeroTag == displayAsset.heroTag;
final viewportWidth = MediaQuery.widthOf(context); final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context); final viewportHeight = MediaQuery.heightOf(context);
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
@@ -397,16 +394,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.snapPosition.snapOffset = _snapOffset; _proxyScrollController.snapPosition.snapOffset = _snapOffset;
} }
return ProviderScope( return Stack(
overrides: [
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
currentAssetExifProvider.overrideWith((ref) {
final a = ref.watch(currentAssetNotifier);
if (a == null) return Future.value(null);
return ref.watch(assetServiceProvider).getExif(a);
}),
],
child: Stack(
children: [ children: [
Offstage( Offstage(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -424,10 +412,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
width: viewportWidth, width: viewportWidth,
height: viewportHeight, height: viewportHeight,
child: _buildPhotoView( child: _buildPhotoView(
displayAsset, asset: displayAsset,
asset, heroAttributes: isCurrent
isCurrentPage: currentHeroTag == asset.heroTag, ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
showingDetails: _showingDetails, : null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo, isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
), ),
@@ -445,7 +434,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0, opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2, duration: Durations.short2,
child: AssetDetails(minHeight: viewportHeight - snapTarget), child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
), ),
), ),
], ],
@@ -454,8 +443,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
], ],
), ),
), ),
], if (stackChildren != null && stackChildren.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: context.padding.bottom,
child: AssetStackRow(stack: stackChildren),
), ),
],
); );
} }
} }
@@ -1,42 +1,29 @@
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/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showingDetails) {
return const SizedBox.shrink();
}
return _StackList(stack: stackChildren);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack; final List<RemoteAsset> stack;
const _StackList({required this.stack}); const AssetStackRow({super.key, required this.stack});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Center( if (stack.isEmpty) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Padding( child: Padding(
@@ -51,6 +38,8 @@ class _StackList extends ConsumerWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
class _StackItemState extends ConsumerState<_StackItem> { class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() { void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset); final notifier = ref.read(assetViewerProvider.notifier);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index); notifier.setAsset(widget.asset);
notifier.setStackIndex(widget.index);
} }
@override @override
@@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget {
} }
static void _setAsset(WidgetRef ref, BaseAsset asset) { static void _setAsset(WidgetRef ref, BaseAsset asset) {
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
// which could be stack children as well
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
// Hide controls by default for videos // Hide controls by default for videos
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
} }
@@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _pageController = PageController(initialPage: widget.initialIndex); late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
StreamSubscription? _reloadSubscription; StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive; KeepAliveLink? _stackChildrenKeepAlive;
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final target = page + direction; final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) { if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target); _pageController.jumpToPage(target);
_onAssetChanged(target);
} }
} }
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void initState() { void initState() {
super.initState(); super.initState();
final asset = ref.read(currentAssetNotifier); final asset = ref.read(assetViewerProvider).currentAsset;
assert(asset != null, "Current asset should not be null when opening the AssetViewer"); assert(asset != null, "Current asset should not be null when opening the AssetViewer");
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
@@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose(); super.dispose();
} }
// The normal onPageChange callback listens to OnScrollUpdate events, and will
// round the current page and update whenever that value changes. In practise,
// this means that the page will change when swiped half way, and may flip
// whilst dragging.
//
// Changing the page at the end of a scroll should be more robust, and allow
// the page to be dragged more than half way whilst keeping the current video
// playing, and preventing the video on the next page from becoming ready
// unnecessarily.
bool _onScrollEnd(ScrollEndNotification notification) {
if (notification.depth != 0) return false;
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
}
void _onAssetInit(Duration timeStamp) { void _onAssetInit(Duration timeStamp) {
_preloader.preload(widget.initialIndex, context.sizeData); _preloader.preload(widget.initialIndex, context.sizeData);
_handleCasting(); _handleCasting();
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleCasting() { void _handleCasting() {
if (!ref.read(castProvider).isCasting) return; if (!ref.read(castProvider).isCasting) return;
final asset = ref.read(currentAssetNotifier); final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return; if (asset == null) return;
if (asset is RemoteAsset) { if (asset is RemoteAsset) {
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
var index = _pageController.page?.round() ?? 0; var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) { if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag); final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) { if (newIndex != null && newIndex != index) {
index = newIndex; index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index); _pageController.jumpToPage(index);
} }
} }
if (index >= totalAssets) { if (index >= totalAssets) {
index = totalAssets - 1; index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index); _pageController.jumpToPage(index);
} }
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final newAsset = await timelineService.getAssetAsync(index); final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return; if (newAsset == null) return;
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed // Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return; if (newAsset.heroTag == currentAsset?.heroTag) return;
@@ -258,10 +273,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_setSystemUIMode(controls, details); _setSystemUIMode(controls, details);
}); });
return PopScope( return Scaffold(
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
child: Scaffold(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(), appBar: const ViewerTopAppBar(),
extendBody: true, extendBody: true,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
@@ -276,7 +290,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bottomNavigationBar: const ViewerBottomAppBar(), bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack( body: Stack(
children: [ children: [
PhotoViewGestureDetectorScope( NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal, axis: Axis.horizontal,
child: PageView.builder( child: PageView.builder(
controller: _pageController, controller: _pageController,
@@ -286,11 +302,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
? const FastScrollPhysics() ? const FastScrollPhysics()
: const FastClampingScrollPhysics(), : const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets, itemCount: ref.read(timelineServiceProvider).totalAssets,
onPageChanged: (index) => _onAssetChanged(index),
itemBuilder: (context, index) => itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
), ),
), ),
),
if (!CurrentPlatform.isIOS) if (!CurrentPlatform.isIOS)
IgnorePointer( IgnorePointer(
child: AnimatedContainer( child: AnimatedContainer(
@@ -301,7 +317,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
), ),
], ],
), ),
),
); );
} }
} }
@@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) { if (asset == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
color: Colors.black.withAlpha(125), color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (asset.isVideo) const VideoControls(), if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled) if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
], ],
@@ -1,111 +1,110 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { class NativeVideoViewer extends ConsumerStatefulWidget {
if (asset is RemoteAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
LocalAsset localAsset => localAsset.remoteId == asset.id,
_ => false,
};
} else if (asset is LocalAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
LocalAsset localAsset => localAsset.id == asset.id,
_ => false,
};
}
return false;
}
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset; final BaseAsset asset;
final bool isCurrent;
final bool showControls; final bool showControls;
final int playbackDelayFactor;
final Widget image; final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({ const NativeVideoViewer({
super.key, super.key,
required this.asset, required this.asset,
required this.image, required this.image,
this.isCurrent = false,
this.showControls = true, this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
final controller = useState<NativeVideoPlayerController?>(null); }
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
// Used to track whether the video should play when the app class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
// is brought back to the foreground static final _log = Logger('NativeVideoViewer');
final shouldPlayOnForeground = useRef(true);
// When a video is opened through the timeline, `isCurrent` will immediately be true. NativeVideoPlayerController? _controller;
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. late final Future<VideoSource?> _videoSource;
// If the swipe is completed, `isCurrent` will be true for video B after a delay. Timer? _loadTimer;
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. bool _isVideoReady = false;
final currentAsset = useState(ref.read(currentAssetNotifier)); bool _shouldPlayOnForeground = true;
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
// Used to show the placeholder during hero animations for remote videos to avoid a stutter VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
final isVisible = useState(Platform.isIOS && asset.hasLocal);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); @override
void initState() {
Future<VideoSource?> createSource() async { super.initState();
if (!context.mounted) { WidgetsBinding.instance.addObserver(this);
return null; _videoSource = _createSource();
} }
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset; @override
if (!context.mounted) { void didUpdateWidget(NativeVideoViewer oldWidget) {
return null; super.didUpdateWidget(oldWidget);
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
return;
} }
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_loadTimer?.cancel();
_removeListeners();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
switch (state) {
case AppLifecycleState.resumed:
if (_shouldPlayOnForeground) await _notifier.play();
case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground) await _notifier.pause();
default:
}
}
Future<VideoSource?> _createSource() async {
if (!mounted) return null;
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
if (!mounted) return null;
try { try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id); final file = await StorageRepository().getFileForAsset(id);
if (!context.mounted) { if (!mounted) return null;
return null;
}
if (file == null) { if (file == null) {
throw Exception('No file found for the video'); throw Exception('No file found for the video');
@@ -113,16 +112,14 @@ class NativeVideoViewer extends HookConsumerWidget {
// Pass a file:// URI so Android's Uri.parse doesn't // Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers. // interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init( return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file, type: VideoSourceType.file,
); );
return source;
} }
final remoteId = (videoAsset as RemoteAsset).id; final remoteId = (videoAsset as RemoteAsset).id;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo); final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
@@ -130,340 +127,107 @@ class NativeVideoViewer extends HookConsumerWidget {
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl'; : '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init( return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) { } catch (error) {
log.severe('Error creating video source for asset ${videoAsset.name}: $error'); _log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null; return null;
} }
} }
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource()); void _onPlaybackReady() async {
final aspectRatio = useState<double?>(null); if (!mounted || !widget.isCurrent) return;
useMemoized(() async { _notifier.onNativePlaybackReady();
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try { // onPlaybackReady may be called multiple times, usually when more data
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset); // loads. If this is not the first time that the player has become ready, we
} catch (error) { // should not autoplay.
log.severe('Error getting aspect ratio for asset ${asset.name}: $error'); if (_isVideoReady) return;
}
}, [asset.heroTag]);
void checkIfBuffering() { setState(() => _isVideoReady = true);
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider); if (ref.read(assetViewerProvider).showingDetails) return;
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
return;
}
try {
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) { if (autoPlayVideo) await _notifier.play();
await videoController.play();
}
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
} }
void onPlaybackStatusChanged() { void _onPlaybackEnded() {
final videoController = controller.value; if (!mounted) return;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); _notifier.onNativePlaybackEnded();
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false; ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
} }
} }
void removeListeners(NativeVideoPlayerController controller) { void _onPlaybackPositionChanged() {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged); if (!mounted) return;
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged); _notifier.onNativePositionChanged();
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
} }
void initController(NativeVideoPlayerController nc) async { void _onPlaybackStatusChanged() {
if (controller.value != null || !context.mounted) { if (!mounted) return;
return; _notifier.onNativeStatusChanged();
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null || !context.mounted) {
return;
} }
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); void _removeListeners() {
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); _controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
nc.onPlaybackReady.addListener(onPlaybackReady); _controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
nc.onPlaybackEnded.addListener(onPlaybackEnded); _controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
}
void _loadVideo() async {
final nc = _controller;
if (nc == null || nc.videoSource != null || !mounted) return;
final source = await _videoSource;
if (source == null || !mounted) return;
unawaited( unawaited(
nc.loadVideoSource(source).catchError((error) { nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error'); _log.severe('Error loading video source: $error');
}), }),
); );
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo); final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo)); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
} }
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) { void _initController(NativeVideoPlayerController nc) {
Size? videoContextSize; if (_controller != null || !mounted) return;
if (videoAspectRatio == null || context == null) {
return null; _notifier.attachController(nc);
}
final contextAspectRatio = context.width / context.height; nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
if (videoAspectRatio > contextAspectRatio) { nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
videoContextSize = Size(context.width, context.width / aspectRatio.value!); nc.onPlaybackReady.addListener(_onPlaybackReady);
} else { nc.onPlaybackEnded.addListener(_onPlaybackEnded);
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
} _controller = nc;
return videoContextSize;
if (widget.isCurrent) _loadVideo();
} }
ref.listen(currentAssetNotifier, (_, value) { @override
final playerController = controller.value; Widget build(BuildContext context) {
if (playerController != null && value != asset) { // Prevent the provider from being disposed whilst the widget is alive.
removeListeners(playerController); ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
}
if (value != null) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
isVisible.value = _isCurrentAsset(value, asset);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo; return Stack(
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await controller.value?.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return SizedBox(
width: context.width,
height: context.height,
child: Stack(
children: [ children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. Center(child: widget.image),
if (!isVisible.value || controller.value == null) Center(child: image), if (!isCasting)
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain( Visibility.maintain(
visible: isVisible.value, visible: _isVideoReady,
child: PhotoView.customChild( child: NativeVideoPlayerView(onViewReady: _initController),
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
), ),
), if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
if (showControls) const Center(child: VideoViewerControls()),
], ],
),
); );
} }
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
} }
@@ -1,29 +1,26 @@
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/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.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/asset.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget { class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration; final Duration hideTimerDuration;
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); final videoPlayerName = asset.heroTag;
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final assetIsVideo = asset.isVideo;
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
if (showingDetails) { final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
showControls = false;
}
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final cast = ref.watch(castProvider); final cast = ref.watch(castProvider);
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final state = ref.read(videoPlaybackValueProvider).state; final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused // Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false); ref.read(assetViewerProvider.notifier).setControls(false);
} }
}); });
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them /// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() { void showControlsAndStartHideTimer() {
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
ref.read(assetViewerProvider.notifier).setControls(true); ref.read(assetViewerProvider.notifier).setControls(true);
} }
// When we change position, show or hide timer // When playback starts, reset the hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
showControlsAndStartHideTimer(); if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
}); });
/// Toggles between playing and pausing depending on the state of the video /// Toggles between playing and pausing depending on the state of the video
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
if (cast.isCasting) { if (cast.isCasting) {
if (cast.castState == CastState.playing) { switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause(); ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) { case CastState.paused:
ref.read(castProvider.notifier).play(); ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) { default:
// resend the play command since its finished
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
return;
}
// ref.read(castProvider.notifier).loadMedia(asset, true);
} }
return; return;
} }
if (state == VideoPlaybackState.playing) { final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
ref.read(videoPlayerControlsProvider.notifier).pause(); switch (status) {
} else if (state == VideoPlaybackState.completed) { case VideoPlaybackStatus.playing:
ref.read(videoPlayerControlsProvider.notifier).restart(); notifier.pause();
} else { case VideoPlaybackStatus.completed:
ref.read(videoPlayerControlsProvider.notifier).play(); notifier.restart();
default:
notifier.play();
} }
} }
void toggleControlsVisibility() { void toggleControlsVisibility() {
if (showBuffering) { if (showBuffering) return;
return;
}
if (showControls) { if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false); ref.read(assetViewerProvider.notifier).setControls(false);
} else { } else {
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
CenterPlayButton( CenterPlayButton(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
iconColor: Colors.white, iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed, isFinished: status == VideoPlaybackStatus.completed,
isPlaying: isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls, show: assetIsVideo && showControls,
onPressed: togglePlay, onPressed: togglePlay,
), ),
@@ -1,7 +1,6 @@
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/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
class ViewerBottomAppBar extends ConsumerWidget { class ViewerBottomAppBar extends ConsumerWidget {
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
if (!showControls) {
opacity = 0.0;
}
return IgnorePointer( return IgnorePointer(
ignoring: opacity < 1.0, ignoring: opacity < 1.0,
child: AnimatedOpacity( child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
opacity: opacity,
duration: Durations.short2,
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
); );
} }
} }
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.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/asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) { if (asset == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) { if (asset == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id)); ref.watch(albumActivityProvider(album.id, asset.id));
} }
if (!showControls) { final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
opacity = 0.0; double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
}
final originalTheme = context.themeData; final originalTheme = context.themeData;
@@ -6,7 +6,7 @@ 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/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
final RemoteAsset asset; final RemoteAsset asset;
final String title; final String title;
final bool showTitle; final bool showTitle;
final bool isCurrent;
final Function()? onVideoEnded; final Function()? onVideoEnded;
const DriftMemoryCard({ const DriftMemoryCard({
required this.asset, required this.asset,
required this.title, required this.title,
required this.showTitle, required this.showTitle,
this.isCurrent = false,
this.onVideoEnded, this.onVideoEnded,
super.key, super.key,
}); });
@@ -37,33 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
SizedBox.expand(child: _BlurredBackdrop(asset: asset)), SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final r = asset.width != null && asset.height != null
? asset.width! / asset.height!
: constraints.maxWidth / constraints.maxHeight;
// Determine the fit using the aspect ratio // Determine the fit using the aspect ratio
BoxFit fit = BoxFit.contain; BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) { if (asset.width != null && asset.height != null) {
final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight; final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction // Look for a 25% difference in either direction
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) { if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
// Cover to look nice if we have nearly the same aspect ratio // Cover to look nice if we have nearly the same aspect ratio
fit = BoxFit.cover; fit = BoxFit.cover;
} }
} }
if (asset.isImage) { if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
} else { return Center(
return SizedBox( child: AspectRatio(
width: context.width, aspectRatio: r,
height: context.height,
child: NativeVideoViewer( child: NativeVideoViewer(
key: ValueKey(asset.id), key: ValueKey(asset.id),
asset: asset, asset: asset,
isCurrent: isCurrent,
showControls: false, showControls: false,
playbackDelayFactor: 2, image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain), ),
), ),
); );
}
}, },
), ),
if (showTitle) if (showTitle)
@@ -1,5 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.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/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState { class AssetViewerState {
@@ -68,6 +70,12 @@ class AssetViewerState {
class AssetViewerStateNotifier extends Notifier<AssetViewerState> { class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override @override
AssetViewerState build() { AssetViewerState build() {
ref.listen(_watchedCurrentAssetProvider, (_, next) {
final updated = next.valueOrNull;
if (updated != null) {
state = state.copyWith(currentAsset: updated);
}
});
return const AssetViewerState(); return const AssetViewerState();
} }
@@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
state = const AssetViewerState(); state = const AssetViewerState();
} }
void setAsset(BaseAsset? asset) { void setAsset(BaseAsset asset) {
if (asset == state.currentAsset) { if (asset == state.currentAsset) return;
return;
}
state = state.copyWith(currentAsset: asset, stackIndex: 0); state = state.copyWith(currentAsset: asset, stackIndex: 0);
} }
@@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
} }
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) { if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
} }
} }
@@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
} }
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new); final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return const Stream.empty();
return ref.read(assetServiceProvider).watchAsset(asset);
});
@@ -1,71 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
final Duration position;
final bool pause;
final bool restarted;
}
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
final Ref ref;
VideoPlaybackControls get value => state;
set value(VideoPlaybackControls value) {
state = value;
}
void reset() {
state = videoPlayerControlsDefault;
}
Duration get position => state.position;
bool get paused => state.pause;
set position(Duration value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(position: value, pause: state.pause);
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: true);
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: false);
}
void togglePlay() {
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
}
void restart() {
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
ref.read(videoPlaybackValueProvider.notifier).value = ref
.read(videoPlaybackValueProvider.notifier)
.value
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
}
}
@@ -0,0 +1,200 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum VideoPlaybackStatus { paused, playing, buffering, completed }
class VideoPlayerState {
final Duration position;
final Duration duration;
final VideoPlaybackStatus status;
const VideoPlayerState({required this.position, required this.duration, required this.status});
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
return VideoPlayerState(
position: position ?? this.position,
duration: duration ?? this.duration,
status: status ?? this.status,
);
}
}
const _defaultState = VideoPlayerState(
position: Duration.zero,
duration: Duration.zero,
status: VideoPlaybackStatus.paused,
);
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
ref,
name,
) {
return VideoPlayerNotifier();
});
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
static final _log = Logger('VideoPlayerNotifier');
VideoPlayerNotifier() : super(_defaultState);
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
@override
void dispose() {
_bufferingTimer?.cancel();
_seekTimer?.cancel();
WakelockPlus.disable();
_controller = null;
super.dispose();
}
Future<void> pause() async {
if (_controller == null) return;
_bufferingTimer?.cancel();
try {
await _controller!.pause();
await _flushSeek();
} catch (e) {
_log.severe('Error pausing video: $e');
}
}
Future<void> play() async {
if (_controller == null) return;
try {
await _flushSeek();
await _controller!.play();
} catch (e) {
_log.severe('Error playing video: $e');
}
_startBufferingTimer();
}
Future<void> _flushSeek() async {
final timer = _seekTimer;
if (timer == null || !timer.isActive) return;
timer.cancel();
await _controller?.seekTo(state.position.inMilliseconds);
}
void seekTo(Duration position) {
if (_controller == null) return;
state = state.copyWith(position: position);
_seekTimer?.cancel();
_seekTimer = Timer(const Duration(milliseconds: 100), () {
_controller?.seekTo(position.inMilliseconds);
});
}
Future<void> restart() async {
seekTo(Duration.zero);
await play();
}
Future<void> setVolume(double volume) async {
try {
await _controller?.setVolume(volume);
} catch (e) {
_log.severe('Error setting volume: $e');
}
}
Future<void> setLoop(bool loop) async {
try {
await _controller?.setLoop(loop);
} catch (e) {
_log.severe('Error setting loop: $e');
}
}
void onNativePlaybackReady() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
final videoInfo = _controller?.videoInfo;
if (playbackInfo == null || videoInfo == null) return;
state = state.copyWith(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
status: _mapStatus(playbackInfo.status),
);
}
void onNativePositionChanged() {
if (!mounted || (_seekTimer?.isActive ?? false)) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return;
if (state.status == VideoPlaybackStatus.buffering) {
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
} else {
state = state.copyWith(position: position);
}
_startBufferingTimer();
}
void onNativeStatusChanged() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final newStatus = _mapStatus(playbackInfo.status);
switch (newStatus) {
case VideoPlaybackStatus.playing:
WakelockPlus.enable();
_startBufferingTimer();
default:
onNativePlaybackEnded();
}
if (state.status != newStatus) {
state = state.copyWith(status: newStatus);
}
}
void onNativePlaybackEnded() {
WakelockPlus.disable();
_bufferingTimer?.cancel();
}
void _startBufferingTimer() {
_bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status == VideoPlaybackStatus.playing) {
state = state.copyWith(status: VideoPlaybackStatus.buffering);
}
});
}
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
PlaybackStatus.playing => VideoPlaybackStatus.playing,
PlaybackStatus.paused => VideoPlaybackStatus.paused,
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
};
}
@@ -1,88 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
class VideoPlaybackValue {
/// The current position of the video
final Duration position;
/// The total duration of the video
final Duration duration;
/// The current state of the video playback
final VideoPlaybackState state;
/// The volume of the video
final double volume;
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
final Ref ref;
VideoPlaybackValue get value => state;
set value(VideoPlaybackValue value) {
state = value;
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
}
set status(VideoPlaybackState value) {
if (state.state == value) return;
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
}
void reset() {
state = videoPlaybackValueDefault;
}
}
@@ -8,9 +8,9 @@ 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/services/asset.service.dart'; import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
Set<BaseAsset> _getAssets(ActionSource source) { Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) { return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
BaseAsset asset => {asset}, BaseAsset asset => {asset},
null => const {}, null => const {},
}, },
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
// does not update the currentAsset which means // does not update the currentAsset which means
// the exif provider will not be refreshed automatically // the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
ref.invalidate(currentAssetExifProvider); final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
ref.invalidate(assetExifProvider(currentAsset));
}
} }
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id); final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) { if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent); ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
} }
} }
@@ -1,52 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new); final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
return ref.watch(assetServiceProvider).getExif(asset);
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
KeepAliveLink? _keepAliveLink;
StreamSubscription<BaseAsset?>? _assetSubscription;
@override
BaseAsset? build() => null;
void setAsset(BaseAsset asset) {
_keepAliveLink?.close();
_assetSubscription?.cancel();
state = asset;
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
if (updatedAsset != null) {
state = updatedAsset;
}
});
_keepAliveLink = ref.keepAlive();
}
void dispose() {
_keepAliveLink?.close();
_assetSubscription?.cancel();
}
}
class ScopedAssetNotifier extends CurrentAssetNotifier {
final BaseAsset _asset;
ScopedAssetNotifier(this._asset);
@override
BaseAsset? build() {
setAsset(_asset);
return _asset;
}
}
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
final currentAsset = ref.watch(currentAssetNotifier);
if (currentAsset == null) {
return null;
}
return ref.watch(assetServiceProvider).getExif(currentAsset);
}); });
-15
View File
@@ -1,15 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_hooks/flutter_hooks.dart';
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
void useInterval(Duration delay, VoidCallback callback) {
final savedCallback = useRef(callback);
savedCallback.value = callback;
useEffect(() {
final timer = Timer.periodic(delay, (_) => savedCallback.value());
return timer.cancel;
}, [delay]);
}
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
padding: const EdgeInsets.only(top: 40.0), padding: const EdgeInsets.only(top: 40.0),
child: Column( child: Column(
children: [ children: [
if (asset.isVideo) const VideoControls(), if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
BottomNavigationBar( BottomNavigationBar(
elevation: 0.0, elevation: 0.0,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class CustomVideoPlayerControls extends HookConsumerWidget { class CustomVideoPlayerControls extends HookConsumerWidget {
final String videoId;
final Duration hideTimerDuration; final Duration hideTimerDuration;
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); const CustomVideoPlayerControls({
super.key,
required this.videoId,
this.hideTimerDuration = const Duration(seconds: 5),
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo)); final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
final showControls = ref.watch(showControlsProvider); final showControls = ref.watch(showControlsProvider);
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
final cast = ref.watch(castProvider); final cast = ref.watch(castProvider);
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final state = ref.read(videoPlaybackValueProvider).state; final s = ref.read(videoPlayerProvider(videoId)).status;
// Do not hide on paused // Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(showControlsProvider.notifier).show = false; ref.read(showControlsProvider.notifier).show = false;
} }
}); });
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them /// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() { void showControlsAndStartHideTimer() {
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
ref.read(showControlsProvider.notifier).show = true; ref.read(showControlsProvider.notifier).show = true;
} }
// When we change position, show or hide timer // When playback starts, reset the hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
showControlsAndStartHideTimer(); if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
}); });
/// Toggles between playing and pausing depending on the state of the video /// Toggles between playing and pausing depending on the state of the video
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
return; return;
} }
if (state == VideoPlaybackState.playing) { final notifier = ref.read(videoPlayerProvider(videoId).notifier);
ref.read(videoPlayerControlsProvider.notifier).pause(); if (status == VideoPlaybackStatus.playing) {
} else if (state == VideoPlaybackState.completed) { notifier.pause();
ref.read(videoPlayerControlsProvider.notifier).restart(); } else if (status == VideoPlaybackStatus.completed) {
notifier.restart();
} else { } else {
ref.read(videoPlayerControlsProvider.notifier).play(); notifier.play();
} }
} }
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
child: CenterPlayButton( child: CenterPlayButton(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
iconColor: Colors.white, iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed, isFinished: status == VideoPlaybackStatus.completed,
isPlaying: isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls, show: assetIsVideo && showControls,
onPressed: togglePlay, onPressed: togglePlay,
), ),
@@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
/// The video controls for the [videoPlayerControlsProvider] /// The video controls for the [videoPlayerProvider]
class VideoControls extends ConsumerWidget { class VideoControls extends ConsumerWidget {
const VideoControls({super.key}); final String videoPlayerName;
const VideoControls({super.key, required this.videoPlayerName});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isPortrait = context.orientation == Orientation.portrait; final isPortrait = context.orientation == Orientation.portrait;
return isPortrait return isPortrait
? const VideoPosition() ? VideoPosition(videoPlayerName: videoPlayerName)
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition()); : Padding(
padding: const EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(videoPlayerName: videoPlayerName),
);
} }
} }
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget { class VideoPosition extends HookConsumerWidget {
const VideoPosition({super.key}); final String videoPlayerName;
const VideoPosition({super.key, required this.videoPlayerName});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
final (position, duration) = isCasting final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration))); : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true); final wasPlaying = useRef<bool>(true);
return duration == Duration.zero return duration == Duration.zero
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
activeColor: Colors.white, activeColor: Colors.white,
inactiveColor: whiteOpacity75, inactiveColor: whiteOpacity75,
onChangeStart: (value) { onChangeStart: (value) {
final state = ref.read(videoPlaybackValueProvider).state; final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
wasPlaying.value = state != VideoPlaybackState.paused; wasPlaying.value = status != VideoPlaybackStatus.paused;
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
}, },
onChangeEnd: (value) { onChangeEnd: (value) {
if (wasPlaying.value) { if (wasPlaying.value) {
ref.read(videoPlayerControlsProvider.notifier).play(); ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
} }
}, },
onChanged: (value) { onChanged: (value) {
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
return; return;
} }
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
}, },
), ),
), ),
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
if (memories[memoryIndex].assets.isNotEmpty) { if (memories[memoryIndex].assets.isNotEmpty) {
final asset = memories[memoryIndex].assets[0]; final asset = memories[memoryIndex].assets[0];
ref.read(currentAssetProvider.notifier).set(asset); ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
} }
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
}, },
@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
Widget _buildChild() { Widget _buildChild() {
return widget.hasCustomChild return widget.hasCustomChild
? widget.customChild! ? SizedBox(
width: scaleBoundaries.childSize.width * scale,
height: scaleBoundaries.childSize.height * scale,
child: widget.customChild!,
)
: Image( : Image(
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null, key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
image: widget.imageProvider!, image: widget.imageProvider!,
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
gaplessPlayback: widget.gaplessPlayback ?? false, gaplessPlayback: widget.gaplessPlayback ?? false,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale, width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.cover, fit: BoxFit.contain,
isAntiAlias: widget.filterQuality == FilterQuality.high, isAntiAlias: widget.filterQuality == FilterQuality.high,
); );
} }
-1
View File
@@ -121,7 +121,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata *AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
*AssetsApi* | [**viewAssetTile**](doc//AssetsApi.md#viewassettile) | **GET** /assets/{id}/tiles/{level}/{col}/{row} | View an asset tile
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth *AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
-87
View File
@@ -1836,91 +1836,4 @@ class AssetsApi {
} }
return null; return null;
} }
/// View an asset tile
///
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [num] col (required):
///
/// * [String] id (required):
///
/// * [num] level (required):
///
/// * [num] row (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> viewAssetTileWithHttpInfo(num col, String id, num level, num row, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/tiles/{level}/{col}/{row}'
.replaceAll('{col}', col.toString())
.replaceAll('{id}', id)
.replaceAll('{level}', level.toString())
.replaceAll('{row}', row.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// View an asset tile
///
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
///
/// Parameters:
///
/// * [num] col (required):
///
/// * [String] id (required):
///
/// * [num] level (required):
///
/// * [num] row (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> viewAssetTile(num col, String id, num level, num row, { String? key, String? slug, }) async {
final response = await viewAssetTileWithHttpInfo(col, id, level, row, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
} }
+2 -2
View File
@@ -1241,8 +1241,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: e132bc3 ref: "0cc59ab"
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e resolved-ref: "0cc59abad8216d4bb26d959252c6aca1a8d05ef9"
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: 'e132bc3' ref: '0cc59ab'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
-97
View File
@@ -4397,103 +4397,6 @@
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
"/assets/{id}/tiles/{level}/{col}/{row}": {
"get": {
"description": "Download a specific tile from an image at the specified level - must currently be 0 - and position",
"operationId": "viewAssetTile",
"parameters": [
{
"name": "col",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "level",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "row",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "View an asset tile",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2.7.0",
"state": "Added"
},
{
"version": "v2.7.0",
"state": "Stable"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Stable"
}
},
"/assets/{id}/video/playback": { "/assets/{id}/video/playback": {
"get": { "get": {
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.", "description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
+1 -1
View File
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.13", "@types/node": "^24.10.14",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
@@ -4313,27 +4313,6 @@ export function viewAsset({ edited, id, key, size, slug }: {
...opts ...opts
})); }));
} }
/**
* View an asset tile
*/
export function viewAssetTile({ col, id, key, level, row, slug }: {
col: number;
id: string;
key?: string;
level: number;
row: number;
slug?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/tiles/${encodeURIComponent(level)}/${encodeURIComponent(col)}/${encodeURIComponent(row)}${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/** /**
* Play asset video * Play asset video
*/ */
-7
View File
@@ -55,13 +55,6 @@ export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
export const getAssetPlaybackPath = (id: string) => export const getAssetPlaybackPath = (id: string) =>
`/assets/${id}/video/playback`; `/assets/${id}/video/playback`;
export const getAssetTilePath = (
id: string,
level: number,
col: number,
row: number
) => `/assets/${id}/tiles/${level}/${col}/${row}`;
export const getUserProfileImagePath = (userId: string) => export const getUserProfileImagePath = (userId: string) =>
`/users/${userId}/profile-image`; `/users/${userId}/profile-image`;
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.5.6", "version": "2.5.6",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937", "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }
+996 -920
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp \ COREPACK_HOME=/tmp \
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
+1 -1
View File
@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^24.10.13", "@types/node": "^24.10.14",
"@types/nodemailer": "^7.0.0", "@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0", "@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
-1
View File
@@ -62,7 +62,6 @@ export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const FACE_THUMBNAIL_SIZE = 250; export const FACE_THUMBNAIL_SIZE = 250;
export const TILE_TARGET_SIZE = 1024;
type ModelInfo = { dimSize: number }; type ModelInfo = { dimSize: number };
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = { export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
@@ -7,7 +7,6 @@ import {
Next, Next,
Param, Param,
ParseFilePipe, ParseFilePipe,
ParseIntPipe,
Post, Post,
Put, Put,
Query, Query,
@@ -186,29 +185,6 @@ export class AssetMediaController {
} }
} }
@Get(':id/tiles/:level/:col/:row')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'View an asset tile',
description: 'Download a specific tile from an image at the specified level - must currently be 0 - and position',
history: new HistoryBuilder().added('v2.7.0').stable('v2.7.0'),
})
async viewAssetTile(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('level', ParseIntPipe) level: number,
@Param('col', ParseIntPipe) col: number,
@Param('row', ParseIntPipe) row: number,
@Res() res: Response,
@Next() next: NextFunction,
) {
if (level !== 0) {
throw new Error(`Invalid level ${level}`);
}
await sendFile(res, next, () => this.service.viewAssetTile(auth, id, level, col, row), this.logger);
}
@Get(':id/video/playback') @Get(':id/video/playback')
@FileResponse() @FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true }) @Authenticated({ permission: Permission.AssetView, sharedLink: true })
-14
View File
@@ -120,10 +120,6 @@ export class StorageCore {
); );
} }
static getTilesFolder(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}_tiles`);
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) { static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`); return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
} }
@@ -157,16 +153,6 @@ export class StorageCore {
}); });
} }
async moveAssetTiles(asset: StorageAsset) {
const oldDir = getAssetFile(asset.files, AssetFileType.Tiles, { isEdited: false });
return this.moveFile({
entityId: asset.id,
pathType: AssetPathType.Tiles,
oldPath: oldDir?.path || null,
newPath: StorageCore.getTilesFolder(asset),
})
}
async moveAssetVideo(asset: StorageAsset) { async moveAssetVideo(asset: StorageAsset) {
return this.moveFile({ return this.moveFile({
entityId: asset.id, entityId: asset.id,
-4
View File
@@ -45,8 +45,6 @@ export enum AssetFileType {
Preview = 'preview', Preview = 'preview',
Thumbnail = 'thumbnail', Thumbnail = 'thumbnail',
Sidecar = 'sidecar', Sidecar = 'sidecar',
/** Folder structure containing tiles of the image */
Tiles = 'tiles',
} }
export enum AlbumUserRole { export enum AlbumUserRole {
@@ -373,8 +371,6 @@ export enum ManualJobName {
export enum AssetPathType { export enum AssetPathType {
Original = 'original', Original = 'original',
/** Folder structure containing tiles of the image */
Tiles = 'tiles',
EncodedVideo = 'encoded_video', EncodedVideo = 'encoded_video',
} }
@@ -1128,19 +1128,6 @@ export class AssetRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getForTiles(id: string) {
// TODO: we don't actually need original path and file name. Plain 'select asset_file.path from asset_file where type = tiles and assetId = id;'?
return this.db
.selectFrom('asset')
.where('asset.id', '=', id)
.leftJoin('asset_file', (join) =>
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.Tiles),
)
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getForVideo(id: string) { async getForVideo(id: string) {
return this.db return this.db
@@ -182,24 +182,6 @@ export class MediaRepository {
await decoded.toFile(output); await decoded.toFile(output);
} }
/**
* For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files/0' directory containing tiles
*/
async generateTiles(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
// size is intended tile size, don't resize input image.
const pipeline = await this.getImageDecodingPipeline(input, { ...options, size: undefined });
await pipeline
.toFormat(options.format) // TODO: set quality and chroma ss?
.tile({
depth: 'one',
size: options.size,
})
.toFile(output);
// TODO: move <uuid>_tiles_files/0 dir to <uuid>_tiles
// TODO: delete <uuid>_tiles_files/vips-properties.xml
// TODO: delete <uuid>_tiles.dzi
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, { let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
@@ -720,38 +720,6 @@ describe(AssetMediaService.name, () => {
}); });
}); });
describe('getAssetTile', () => {
it('should require asset.view permissions', async () => {
await expect(sut.viewAssetTile(authStub.admin, 'id', 0, 0, 0)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
});
it('should throw an error if the asset tiles dir could not be found', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: null });
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).rejects.toBeInstanceOf(NotFoundException);
});
it('should get tile file', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Tiles, path: '/path/to/asset_tiles' }).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: asset.files[0].path });
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).resolves.toEqual(
new ImmichFileResponse({
path: `${asset.files[0].path}_files/0/0_0.jpeg`,
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
}),
);
expect(mocks.asset.getForTiles).toHaveBeenCalledWith(asset.id);
});
});
describe('playbackVideo', () => { describe('playbackVideo', () => {
it('should require asset.view permissions', async () => { it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
@@ -262,26 +262,6 @@ export class AssetMediaService extends BaseService {
}); });
} }
async viewAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
// TODO: get tile info { width, height } and check against col, row to return NotFound instead of 500 when tile can't be found in sendFile.
const { path } = await this.assetRepository.getForTiles(id);
if (!path) {
throw new NotFoundException('Asset tiles not found');
}
// By definition of the tiles format, it's always .jpeg; should ImageFormat.Jpeg be used?
const tilePath = `${path}_files/${level}/${col}_${row}.jpeg`;
return new ImmichFileResponse({
path: tilePath,
contentType: mimeTypes.lookup(tilePath),
cacheControl: CacheControl.PrivateWithCache,
});
}
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
@@ -566,6 +566,8 @@ describe(AssetService.name, () => {
.file({ type: AssetFileType.Thumbnail }) .file({ type: AssetFileType.Thumbnail })
.file({ type: AssetFileType.Preview }) .file({ type: AssetFileType.Preview })
.file({ type: AssetFileType.FullSize }) .file({ type: AssetFileType.FullSize })
.file({ type: AssetFileType.Preview, isEdited: true })
.file({ type: AssetFileType.Thumbnail, isEdited: true })
.build(); .build();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
+2 -1
View File
@@ -1246,7 +1246,8 @@ describe(MediaService.name, () => {
expect.any(String), expect.any(String),
); );
expect(mocks.media.copyTagGroup).toHaveBeenCalledExactlyOnceWith('XMP-GPano', asset.originalPath, expect.any(String)); expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String));
}); });
it('should respect encoding options when generating full-size preview', async () => { it('should respect encoding options when generating full-size preview', async () => {
+11 -50
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE, TILE_TARGET_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
@@ -33,7 +33,6 @@ import {
DecodeToBufferOptions, DecodeToBufferOptions,
GenerateThumbnailOptions, GenerateThumbnailOptions,
ImageDimensions, ImageDimensions,
ImageOptions,
JobItem, JobItem,
JobOf, JobOf,
VideoFormat, VideoFormat,
@@ -164,7 +163,6 @@ export class MediaService extends BaseService {
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format); await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
await this.storageCore.moveAssetTiles(asset);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.Success; return JobStatus.Success;
@@ -278,8 +276,8 @@ export class MediaService extends BaseService {
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const generateFullsize = const generateFullsize =
(image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath)) || ((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') &&
asset.exifInfo.projectionType === 'EQUIRECTANGULAR' || !mimeTypes.isWebSupportedImage(asset.originalPath)) ||
useEdits; useEdits;
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
@@ -385,60 +383,23 @@ export class MediaService extends BaseService {
); );
} }
// TODO: probably extract to helper method
// TODO: handle cropped panoramas. Tile as normal but save some offset?
let tileInfo: UpsertFileOptions | undefined;
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
// TODO: get uncropped width from asset (FullPanoWidthPixels if present). -> TODO find out why i wrote this down as a todo
const originalSize = asset.exifInfo.exifImageWidth!;
// Get the number of tiles at the exact target size, rounded up (to at least 1 tile).
const numTilesExact = Math.ceil(originalSize / TILE_TARGET_SIZE);
// Then round up to the nearest power of 2 (photo-sphere-viewer requirement).
const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact)));
const tileSize = Math.ceil(originalSize / numTiles);
tileInfo = {
assetId: asset.id,
type: AssetFileType.Tiles,
path: StorageCore.getTilesFolder(asset),
isEdited: false,
isProgressive: false,
isTransparent: false,
};
const tilesOptions = {
...baseOptions,
quality: image.preview.quality,
format: ImageFormat.Jpeg,
size: tileSize,
};
promises.push(this.mediaRepository.generateTiles(data, tilesOptions, tileInfo.path));
console.log('Tile info for DB:', {
width: originalSize,
cols: numTiles,
rows: numTiles / 2,
});
}
const outputs = await Promise.all(promises); const outputs = await Promise.all(promises);
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
await this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path); const promises = [
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
fullsizeFile
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
: Promise.resolve(),
];
await Promise.all(promises);
} }
const decodedDimensions = { width: info.width, height: info.height }; const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
const files = [previewFile, thumbnailFile];
if (fullsizeFile) {
files.push(fullsizeFile);
}
if (tileInfo) {
files.push(tileInfo);
}
return { return {
files, files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer, thumbhash: outputs[0] as Buffer,
fullsizeDimensions, fullsizeDimensions,
}; };
+1 -2
View File
@@ -21,12 +21,11 @@ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }), fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }), previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
tilesPath: getAssetFile(files, AssetFileType.Tiles, { isEdited: false }),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }), sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
}); });
export const addAssets = async ( export const addAssets = async (
+1
View File
@@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))), qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
) )
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.$if(!!options.withExif, withExifInner) .$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
expect(result).toEqual({ total: 0 }); expect(result).toEqual({ total: 0 });
}); });
}); });
describe('withStacked option', () => {
it('should exclude stacked assets when withStacked is false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
}); });
@@ -53,7 +53,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getForOriginal: vitest.fn(), getForOriginal: vitest.fn(),
getForOriginals: vitest.fn(), getForOriginals: vitest.fn(),
getForThumbnail: vitest.fn(), getForThumbnail: vitest.fn(),
getForTiles: vitest.fn(),
getForVideo: vitest.fn(), getForVideo: vitest.fn(),
getForEdit: vitest.fn(), getForEdit: vitest.fn(),
getForOcr: vitest.fn(), getForOcr: vitest.fn(),
@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => { export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
return { return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateTiles: vitest.fn().mockImplementation(() => Promise.resolve()),
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()), writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()), copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
-1
View File
@@ -31,7 +31,6 @@
"@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/core": "^5.14.0",
"@photo-sphere-viewer/equirectangular-tiles-adapter": "^5.14.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
"@photo-sphere-viewer/markers-plugin": "^5.14.0", "@photo-sphere-viewer/markers-plugin": "^5.14.0",
"@photo-sphere-viewer/resolution-plugin": "^5.14.0", "@photo-sphere-viewer/resolution-plugin": "^5.14.0",
@@ -1,156 +0,0 @@
type Config = IntersectionObserverActionProperties & {
observer?: IntersectionObserver;
};
type TrackedProperties = {
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
disabled?: boolean;
/** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type TaskKey = HTMLElement | string;
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
return (
a?.bottom === b?.bottom &&
a?.top === b?.top &&
a?.left === b?.left &&
a?.right == b?.right &&
a?.threshold === b?.threshold &&
a?.root === b?.root
);
}
const elementToConfig = new Map<TaskKey, Config>();
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
if (!target.isConnected) {
elementToConfig.get(key)?.observer?.unobserve(target);
return;
}
const {
root,
threshold,
top = '0px',
right = '0px',
bottom = '0px',
left = '0px',
onSeparate,
onIntersect,
} = properties;
const rootMargin = `${top} ${right} ${bottom} ${left}`;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
// This IntersectionObserver is limited to observing a single element, the one the
// action is attached to. If there are multiple entries, it means that this
// observer is being notified of multiple events that have occurred quickly together,
// and the latest element is the one we are interested in.
entries.sort((a, b) => a.time - b.time);
const latestEntry = entries.pop();
if (latestEntry?.isIntersecting) {
onIntersect?.(latestEntry);
} else {
onSeparate?.(target);
}
},
{
rootMargin,
threshold,
root,
},
);
observer.observe(target);
elementToConfig.set(key, { ...properties, observer });
};
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
if (properties.disabled) {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
} else {
elementToConfig.set(key, properties);
observe(key, element, properties);
}
}
function _intersectionObserver(
key: HTMLElement | string,
element: HTMLElement,
properties: IntersectionObserverActionProperties,
) {
configure(key, element, properties);
return {
update(properties: IntersectionObserverActionProperties) {
const config = elementToConfig.get(key);
if (!config) {
return;
}
if (isEquivalent(config, properties)) {
return;
}
configure(key, element, properties);
},
destroy: () => {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
},
};
}
/**
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
* @param element
* @param properties One or multiple configurations for the IntersectionObserver(s)
* @returns
*/
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
) {
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
// so accept an array when multiple configurations are needed.
if (Array.isArray(properties)) {
if (!properties.every((p) => p.key)) {
throw new Error('Multiple configurations must specify key');
}
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
return {
update: (properties: IntersectionObserverActionProperties[]) => {
for (const [i, props] of properties.entries()) {
observers[i].update(props);
}
},
destroy: () => {
for (const observer of observers) {
observer.destroy();
}
},
};
}
return _intersectionObserver(properties.key || element, element, properties);
}
-43
View File
@@ -1,43 +0,0 @@
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
/**
* Installs a resizeObserver on the given element - when the element changes
* size, invokes a callback function with the width/height. Intended as a
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
* an iframe to measure the size of the element, which can be bad for
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
* bind:clientWidth to use an internal resize observer.
*
* TODO: When svelte5 is ready, go back to bind:clientWidth and
* bind:clientHeight.
*/
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
if (!observer) {
callbacks = new WeakMap();
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const onResize = callbacks.get(entry.target as HTMLElement);
if (onResize) {
onResize({
target: entry.target as HTMLElement,
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
}
}
});
}
callbacks.set(element, onResize);
observer.observe(element);
return {
destroy: () => {
callbacks.delete(element);
observer.unobserve(element);
},
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { imageManager } from '$lib/managers/ImageManager.svelte'; import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import type { HTMLImgAttributes } from 'svelte/elements'; import type { HTMLImgAttributes } from 'svelte/elements';
@@ -28,7 +28,7 @@
onDestroy(() => { onDestroy(() => {
destroyed = true; destroyed = true;
if (capturedSource !== undefined) { if (capturedSource !== undefined) {
imageManager.cancelPreloadUrl(capturedSource); cancelImageUrl(capturedSource);
} }
}); });
@@ -4,7 +4,6 @@
import { SettingInputFieldType } from '$lib/constants'; import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getQueueName } from '$lib/utils';
import { QueueName, type SystemConfigJobDto } from '@immich/sdk'; import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -30,6 +29,27 @@
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto { function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
return jobName in configToEdit.job; return jobName in configToEdit.job;
} }
const queueTitles: Record<QueueName, string> = $derived({
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
[QueueName.Sidecar]: $t('admin.sidecar_job'),
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
[QueueName.FaceDetection]: $t('admin.face_detection'),
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
[QueueName.Migration]: $t('admin.migration_job'),
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
[QueueName.Search]: $t('search'),
[QueueName.Library]: $t('external_libraries'),
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflows'),
[QueueName.Editor]: $t('editor'),
});
</script> </script>
<div> <div>
@@ -41,7 +61,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
{disabled} {disabled}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })} label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
description="" description=""
bind:value={configToEdit.job[queueName].concurrency} bind:value={configToEdit.job[queueName].concurrency}
required={true} required={true}
@@ -50,7 +70,7 @@
{:else} {:else}
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })} label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
description="" description=""
value={1} value={1}
disabled={true} disabled={true}
@@ -18,6 +18,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
@@ -36,6 +37,7 @@
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui'; import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import { import {
mdiArrowLeft, mdiArrowLeft,
mdiArrowRight,
mdiCompare, mdiCompare,
mdiDotsVertical, mdiDotsVertical,
mdiImageSearch, mdiImageSearch,
@@ -84,7 +86,7 @@
const Close: ActionItem = $derived({ const Close: ActionItem = $derived({
title: $t('go_back'), title: $t('go_back'),
type: $t('assets'), type: $t('assets'),
icon: mdiArrowLeft, icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
$if: () => !!onClose, $if: () => !!onClose,
onAction: () => onClose?.(), onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }], shortcuts: [{ key: 'Escape' }],
@@ -30,7 +30,6 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetTypeEnum, AssetTypeEnum,
getAllAlbums,
getAssetInfo, getAssetInfo,
getStack, getStack,
type AlbumResponseDto, type AlbumResponseDto,
@@ -105,7 +104,6 @@
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset); const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset); const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
@@ -147,7 +145,7 @@
} }
}; };
onMount(async () => { onMount(() => {
syncAssetViewerOpenClass(true); syncAssetViewerOpenClass(true);
unsubscribes.push( unsubscribes.push(
slideshowState.subscribe((value) => { slideshowState.subscribe((value) => {
@@ -166,8 +164,6 @@
} }
}), }),
); );
await onAlbumAddAssets();
}); });
onDestroy(() => { onDestroy(() => {
@@ -180,18 +176,6 @@
syncAssetViewerOpenClass(false); syncAssetViewerOpenClass(false);
}); });
const onAlbumAddAssets = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => { const closeViewer = () => {
onClose?.(asset); onClose?.(asset);
}; };
@@ -363,7 +347,6 @@
const refresh = async () => { const refresh = async () => {
await refreshStack(); await refreshStack();
await onAlbumAddAssets();
ocrManager.clear(); ocrManager.clear();
if (!sharedLink) { if (!sharedLink) {
if (previewStackedAsset) { if (previewStackedAsset) {
@@ -441,7 +424,7 @@
</script> </script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} /> <CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} /> <OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement /> <svelte:document bind:fullscreenElement />
@@ -586,7 +569,7 @@
> >
{#if showDetailPanel} {#if showDetailPanel}
<div class="w-90 h-full"> <div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} /> <DetailPanel {asset} currentAlbum={album} />
</div> </div>
{:else if assetViewerManager.isShowEditor} {:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full"> <div class="w-100 h-full">
@@ -17,9 +17,16 @@
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils'; import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import { import {
mdiCalendar, mdiCalendar,
@@ -38,16 +45,16 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte'; import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null; currentAlbum?: AlbumResponseDto | null;
} }
let { asset, albums = [], currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false); let showAssetPath = $state(false);
let showEditFaces = $state(false); let showEditFaces = $state(false);
@@ -74,14 +81,33 @@
let previousId: string | undefined = $state(); let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos()); let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return [];
}
try {
return await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
return [];
}
};
let albums = $derived(refreshAlbums());
$effect(() => { $effect(() => {
if (!previousId) { if (!previousId) {
previousId = asset.id; previousId = asset.id;
return;
} }
if (asset.id !== previousId) {
if (asset.id === previousId) {
return;
}
showEditFaces = false; showEditFaces = false;
previousId = asset.id; previousId = asset.id;
}
}); });
const getMegapixel = (width: number, height: number): number | undefined => { const getMegapixel = (width: number, height: number): number | undefined => {
@@ -119,6 +145,8 @@
}; };
</script> </script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
<section class="relative p-2"> <section class="relative p-2">
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<IconButton <IconButton
@@ -502,7 +530,8 @@
</section> </section>
{/if} {/if}
{#if albums.length > 0} {#await albums then albums}
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg"> <section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4"> <div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text> <Text size="small" color="muted">{$t('appears_in')}</Text>
@@ -532,7 +561,8 @@
</a> </a>
{/each} {/each}
</section> </section>
{/if} {/if}
{/await}
{#if $preferences?.tags?.enabled} {#if $preferences?.tags?.enabled}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> <section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -134,7 +134,7 @@
></div> ></div>
{/if} {/if}
</Button> </Button>
<span class="text-sm text-white text-left">{ratio.label}</span> <span class="text-sm text-white">{ratio.label}</span>
</HStack> </HStack>
{/each} {/each}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More