Compare commits

..

2 Commits

Author SHA1 Message Date
Santo Shakil 215559d86a fix(mobile): show the real date for android local photos with no exif
photos with no exif date showed their copy-to-phone date in the "on this device" albums instead of the real date. fall back to the earlier of date_modified/date_added (matches the server + ios), plus a migration to fix the rows already saved wrong.
2026-06-18 20:48:24 +06:00
renovate[bot] 9a3071ae5c chore(deps): lock file maintenance machine-learning (mise) (#29152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 19:34:45 -04:00
16 changed files with 352 additions and 679 deletions
+14 -14
View File
@@ -5,38 +5,38 @@ version = "3.11.15"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.uv]]
@@ -174,9 +174,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
}
// Date taken is milliseconds since epoch, Date added is seconds since epoch
// Date taken is in ms; date added/modified in seconds. No-EXIF (date taken <= 0)
// falls back to the earliest of modified/added to match the server + iOS.
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: c.getLong(dateAddedColumn)
?: minOf(c.getLong(dateModifiedColumn), c.getLong(dateAddedColumn))
// Date modified is seconds since epoch
val modifiedAt = c.getLong(dateModifiedColumn)
val width = c.getInt(widthColumn).toLong()
+25 -1
View File
@@ -12,13 +12,14 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
@@ -31,10 +32,33 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
if (!await _migrateTo27(drift)) {
return;
}
}
await Store.put(StoreKey.version, targetVersion);
return;
}
Future<bool> _migrateTo27(Drift drift) async {
// Android-only: no-EXIF photos got a wrong createdAt (DATE_ADDED copy-time instead
// of the real DATE_MODIFIED). Those rows can't self-heal -- the local sync only
// updates an asset when its updatedAt (DATE_MODIFIED) changes, which it never does
// here. A createdAt later than updatedAt is the copy-time signature, so clamp it
// back to updatedAt (the real date, == the new minOf(DATE_MODIFIED, DATE_ADDED)).
if (!CurrentPlatform.isAndroid) {
return true;
}
try {
await drift.customStatement('UPDATE local_asset_entity SET created_at = updated_at WHERE created_at > updated_at');
return true;
} catch (_) {
return false;
}
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) {
+22 -8
View File
@@ -807,9 +807,6 @@ importers:
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.9(svelte@5.56.2(@typescript-eslint/types@8.61.0))
custom-media-element:
specifier: ^1.4.6
version: 1.4.6
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -828,9 +825,12 @@ importers:
happy-dom:
specifier: ^20.0.0
version: 20.10.3
hls-video-element:
specifier: ^1.5.11
version: 1.5.11
hls.js:
specifier: 1.7.0-beta.1.0.canary.11837
version: 1.7.0-beta.1.0.canary.11837
specifier: ^1.6.16
version: 1.6.16
intl-messageformat:
specifier: ^11.0.0
version: 11.2.8
@@ -8327,8 +8327,11 @@ packages:
history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
hls.js@1.7.0-beta.1.0.canary.11837:
resolution: {integrity: sha512-JXSTYLvxCpJeD8xgYlIYzEA0Ag+1Vnkakl7y8JiS0RtgNPFgtsGjqqxCPFyYCmumi1CON6VtksohqeJoiBAKmw==}
hls-video-element@1.5.11:
resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
@@ -9339,6 +9342,9 @@ packages:
media-chrome@4.19.2:
resolution: {integrity: sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==}
media-tracks@0.3.5:
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -21704,7 +21710,13 @@ snapshots:
tiny-warning: 1.0.3
value-equal: 1.0.1
hls.js@1.7.0-beta.1.0.canary.11837: {}
hls-video-element@1.5.11:
dependencies:
custom-media-element: 1.4.6
hls.js: 1.6.16
media-tracks: 0.3.5
hls.js@1.6.16: {}
hogan.js@3.0.2:
dependencies:
@@ -22813,6 +22825,8 @@ snapshots:
transitivePeerDependencies:
- react
media-tracks@0.3.5: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
+2 -2
View File
@@ -40,14 +40,14 @@
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.42.0",
"@zoom-image/svelte": "^0.3.0",
"custom-media-element": "^1.4.6",
"dom-to-image": "^2.6.0",
"fabric": "^7.0.0",
"geo-coordinates-parser": "^1.7.4",
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
"happy-dom": "^20.0.0",
"hls.js": "1.7.0-beta.1.0.canary.11837",
"hls-video-element": "^1.5.11",
"hls.js": "^1.6.16",
"intl-messageformat": "^11.0.0",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
-6
View File
@@ -176,9 +176,3 @@
@apply bg-subtle rounded-lg;
}
}
immich-video > video {
width: 100%;
height: 100%;
object-fit: var(--media-object-fit, contain);
}
@@ -5,11 +5,9 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import '$lib/components/asset-viewer/immich-video-element';
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
import VideoQualityMenu from '$lib/components/asset-viewer/VideoQualityMenu.svelte';
import { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner, shortcuts } from '@immich/ui';
import {
@@ -25,6 +23,9 @@
mdiVolumeMedium,
mdiVolumeMute,
} from '@mdi/js';
import 'hls-video-element';
import type HlsVideoElement from 'hls-video-element';
import Hls, { AbrController, Events, type FragLoadedData, type FragLoadingData, type HlsConfig } from 'hls.js';
import 'media-chrome/media-control-bar';
import 'media-chrome/media-controller';
import 'media-chrome/media-fullscreen-button';
@@ -34,10 +35,11 @@
import 'media-chrome/media-time-display';
import 'media-chrome/media-volume-range';
import 'media-chrome/menu/media-playback-rate-menu';
import 'media-chrome/menu/media-rendition-menu';
import 'media-chrome/menu/media-settings-menu';
import 'media-chrome/menu/media-settings-menu-button';
import 'media-chrome/menu/media-settings-menu-item';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -71,6 +73,7 @@
onClose = () => {},
}: Props = $props();
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived.by(() => {
if (featureFlagsManager.value.realtimeTranscoding) {
@@ -85,29 +88,182 @@
});
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
let showVideo = $state(false);
let focusedAssetId = $state<string>();
let hasFocused = $state(false);
let activeSession: { assetId: string; id: string } | undefined;
let rebuildCount = 0;
const controller = $derived(videoSessionManager.get(assetId)); // <immich-video> self-acquires the controller for the asset
const videoPlayer = $derived(controller?.element);
const MAX_REBUILDS = 1;
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
// it emergency switches to a different variant. This extends the delay even further due to
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
//
// It can also emergency switch between fragments: while a switch's first segment is still loading,
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
// This can cause multiple redundant transcoding restarts when it occurs.
// Hold the committed level until its first fragment lands, then resume normal ABR.
class NoAbandonAbrController extends AbrController {
private switchTarget = -1;
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
if (data.frag.sn === 'initSegment') {
this.switchTarget = data.frag.level;
}
}
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
if (data.frag.sn !== 'initSegment') {
this.switchTarget = -1;
}
super.onFragLoaded(event, data);
}
override get nextAutoLevel(): number {
const level = super.nextAutoLevel;
const target = this.hls.levels[this.switchTarget];
// Hold the committed level, but only while hls.js still considers it healthy.
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
return this.switchTarget;
}
return level;
}
override set nextAutoLevel(level: number) {
super.nextAutoLevel = level;
}
}
const hlsConfig: Partial<HlsConfig> = {
abrController: NoAbandonAbrController,
highBufferWatchdogPeriod: 10,
detectStallWithCurrentTimeMs: 10_000,
maxBufferHole: 0.5,
maxBufferLength: 30,
maxMaxBufferLength: 60,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 30_000,
maxLoadTimeMs: 60_000,
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
},
},
useMediaCapabilities: false,
};
const releaseSession = () => {
const session = activeSession;
if (!session) {
return;
}
activeSession = undefined;
const url = getAssetHlsSessionUrl(session.assetId, session.id);
void fetch(url, { method: 'DELETE' }).catch(() => console.warn('Failed to release HLS session', session));
};
const isHlsElement = (el: HTMLVideoElement | undefined): el is HlsVideoElement => {
return el?.tagName === 'HLS-VIDEO';
};
const wireHlsListeners = (el: HlsVideoElement, assetId: string, resumeTime?: number) => {
const api = el.api;
if (!api) {
return;
}
// This is a hack to make the rendition menu use `api.currentLevel` instead of `api.nextLevel`.
// `api.nextLevel` makes the player request the next segment followed by the current segment.
// That backward request causes the server to restart transcoding for no reason.
Object.defineProperty(api, 'nextLevel', {
configurable: true,
get: () => api.currentLevel,
set: (level: number) => {
api.currentLevel = level;
},
});
// eslint-disable-next-line @typescript-eslint/no-misused-promises
api.on(Hls.Events.MANIFEST_PARSED, async () => {
// Defer hls.js's first fragment load until we filter out suboptimal variants
api.stopLoad();
const id = api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
if (id) {
activeSession = { assetId, id };
}
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
for (let i = api.levels.length - 1; i >= 0; i--) {
if (!keep.has(i)) {
api.removeLevel(i);
}
}
api.startLoad(resumeTime);
});
api.on(Hls.Events.FRAG_LOADED, () => (rebuildCount = 0));
api.on(Hls.Events.ERROR, (_, data) => {
// 404 on a fragment can mean the server-side session has expired. Refetch
// master for a new session, but give up if it still 404s.
if (
!data.fatal ||
data.details !== Hls.ErrorDetails.FRAG_LOAD_ERROR ||
data.response?.code !== 404 ||
rebuildCount++ >= MAX_REBUILDS
) {
console.error('HLS error', JSON.stringify(data));
return;
}
console.warn('Error loading segment, starting new session');
activeSession = undefined;
resumeTime = el.currentTime;
el.load();
// wireHlsListeners must run after el.api is repopulated.
queueMicrotask(() => wireHlsListeners(el, assetId, resumeTime));
});
};
onMount(() => {
showVideo = true;
});
// A hover-warmed element is already past `canplay` and won't fire it again, so kick playback ourselves once we adopt it
$effect(() => {
if (videoPlayer && videoPlayer.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
void handleCanPlay(videoPlayer);
// reactive on `assetFileUrl` changes
if (videoPlayer && assetFileUrl) {
hasFocused = false;
rebuildCount = 0;
releaseSession();
if (isHlsElement(videoPlayer)) {
videoPlayer.config = hlsConfig;
videoPlayer.src = assetFileUrl;
const el = videoPlayer;
queueMicrotask(() => wireHlsListeners(el, assetId));
} else {
videoPlayer.load();
}
}
return releaseSession;
});
const onPlaying = () => {
if (focusedAssetId !== assetId) {
videoPlayer?.focus();
focusedAssetId = assetId;
const onPagehide = (event: PageTransitionEvent) => {
if (!event.persisted) {
releaseSession();
}
};
$effect(() => {
window.addEventListener('pagehide', onPagehide);
return () => window.removeEventListener('pagehide', onPagehide);
});
onDestroy(() => {
if (videoPlayer) {
videoPlayer.src = '';
}
});
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused) {
@@ -196,23 +352,53 @@
class="dark h-full max-w-full"
style:aspect-ratio={aspectRatio}
defaultduration={asset.duration! / 1000}
{...useSwipe(onSwipe)}
>
<immich-video
slot="media"
asset-id={assetId}
cache-key={cacheKey ?? ''}
play-original={playOriginalVideo}
class="h-full"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
oncanplay={(event: Event) => handleCanPlay(event.currentTarget as HTMLVideoElement)}
onended={onVideoEnded}
onseeking={onSeeking}
onplaying={onPlaying}
onclose={onClose}
></immich-video>
{#if featureFlagsManager.value.realtimeTranscoding}
<hls-video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e: Event) => handleCanPlay(e.currentTarget as HTMLVideoElement)}
onended={onVideoEnded}
onseeking={onSeeking}
onplaying={(e: Event) => {
if (!hasFocused) {
(e.currentTarget as HTMLElement).focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
></hls-video>
{:else}
<video
bind:this={videoPlayer}
slot="media"
src={assetFileUrl}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onseeking={onSeeking}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
></video>
{/if}
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
@@ -225,11 +411,14 @@
<span slot="title">{$t('media_chrome.playback_rate')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
{#if featureFlagsManager.value.realtimeTranscoding && controller}
{#if featureFlagsManager.value.realtimeTranscoding}
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
{$t('video_quality')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<VideoQualityMenu video={controller} slot="submenu" />
<media-rendition-menu slot="submenu" hidden>
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('video_quality')}</span>
</media-rendition-menu>
</media-settings-menu-item>
{/if}
</media-settings-menu>
@@ -1,57 +0,0 @@
<script lang="ts">
import type { VideoController } from '$lib/utils/video/controller.svelte';
import { Icon } from '@immich/ui';
import { mdiChevronLeft } from '@mdi/js';
import 'media-chrome/menu/media-chrome-menu';
import 'media-chrome/menu/media-chrome-menu-item';
import { t } from 'svelte-i18n';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLElement> {
video: VideoController;
}
let { video, ...rest }: Props = $props();
const options = $derived(
video.levels
.map((level, idx) => ({ idx, label: Math.min(level.width, level.height) }))
.sort((a, b) => b.label - a.label),
);
const autoLevel = $derived(video.selectedLevel === -1 ? options.find(({ idx }) => idx === video.level) : undefined);
const autoLabel = $derived(autoLevel ? `${$t('media_chrome.auto')} (${autoLevel.label}p)` : $t('media_chrome.auto'));
let menu = $state<HTMLElement>();
$effect(() => {
menu?.dispatchEvent(new CustomEvent('addmenuitem', { detail: autoLabel }));
});
const onChange = (event: Event) => {
video.level = Number((event.currentTarget as HTMLElement & { value: string }).value);
};
</script>
<media-chrome-menu bind:this={menu} {...rest} hidden onchange={onChange}>
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('video_quality')}</span>
<media-chrome-menu-item part="menu-item radio" type="radio" value="-1" checked={video.selectedLevel === -1}>
<span>{autoLabel}</span>
</media-chrome-menu-item>
{#each options as option (option.idx)}
<media-chrome-menu-item
part="menu-item radio"
type="radio"
value={`${option.idx}`}
checked={video.selectedLevel === option.idx}
>
<span>{option.label}p</span>
</media-chrome-menu-item>
{/each}
</media-chrome-menu>
<style>
media-chrome-menu-item {
padding: 0.4em 0.8em 0.4em 1em;
}
</style>
@@ -1,84 +0,0 @@
import { CustomVideoElement } from 'custom-media-element';
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
import type { VideoController } from '$lib/utils/video/controller.svelte';
/**
* Video backed by either HLS or a progressive stream based on feature flags and user preferences. Can be managed with
* `videoSessionManager.get(assetId)`, this manager being what allows it to reparent the underlying video element.
*/
class ImmichVideoElement extends CustomVideoElement {
static override get observedAttributes() {
return [...super.observedAttributes, 'asset-id', 'play-original'];
}
#controller: VideoController | undefined;
#mountedAssetId: string | undefined;
#remountScheduled = false;
override connectedCallback() {
super.connectedCallback();
this.#mount();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.#unmount();
}
override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
super.attributeChangedCallback(name, oldValue, newValue);
if (!this.isConnected || !this.#controller || oldValue === newValue) {
return;
}
if (name === 'play-original') {
this.#controller.playOriginal = newValue === 'true';
} else if (name === 'asset-id' && !this.#remountScheduled) {
this.#remountScheduled = true;
queueMicrotask(() => {
this.#remountScheduled = false;
if (this.isConnected) {
this.#unmount();
this.#mount();
}
});
}
}
#mount() {
const assetId = this.getAttribute('asset-id');
if (!assetId) {
return;
}
const controller = videoSessionManager.acquire({
assetId,
cacheKey: this.getAttribute('cache-key') || null,
playOriginal: this.getAttribute('play-original') === 'true',
});
this.#controller = controller;
this.#mountedAssetId = assetId;
const video = controller.element;
video.slot = 'media';
video.loop = this.loop;
video.autoplay = this.autoplay;
video.muted = this.muted || this.hasAttribute('muted');
video.poster = this.getAttribute('poster') ?? '';
controller.mount(this);
}
#unmount() {
if (!this.#mountedAssetId) {
return;
}
this.#controller?.unmount(this);
videoSessionManager.release(this.#mountedAssetId);
this.#controller = undefined;
this.#mountedAssetId = undefined;
}
}
if (globalThis.customElements && !customElements.get('immich-video')) {
customElements.define('immich-video', ImmichVideoElement);
}
export default ImmichVideoElement;
@@ -4,7 +4,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -264,8 +264,7 @@
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
assetId={asset.id}
cacheKey={asset.thumbhash}
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={asset.duration ? asset.duration / 1000 : 0}
@@ -276,8 +275,7 @@
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
@@ -1,16 +1,12 @@
<script lang="ts">
import { cleanClass } from '$lib';
import '$lib/components/asset-viewer/immich-video-element';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
import { Icon, LoadingSpinner } from '@immich/ui';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import { Duration } from 'luxon';
import type { ClassValue } from 'svelte/elements';
interface Props {
assetId: string;
cacheKey: string | null;
url: string;
durationInSeconds?: number;
enablePlayback?: boolean;
playbackOnIconHover?: boolean;
@@ -22,8 +18,7 @@
}
let {
assetId,
cacheKey,
url,
durationInSeconds = 0,
enablePlayback = $bindable(false),
playbackOnIconHover = false,
@@ -34,27 +29,26 @@
class: className,
}: Props = $props();
const useHls = $derived(featureFlagsManager.value.realtimeTranscoding);
let active = $state(false);
const controller = $derived(videoSessionManager.get(assetId));
const remainingSeconds = $derived(controller?.remainingSeconds || durationInSeconds);
let remainingSeconds = $state(durationInSeconds);
let loading = $state(true);
let error = $state(false);
let player: HTMLVideoElement | undefined = $state();
$effect(() => {
if (!enablePlayback) {
active = false;
remainingSeconds = durationInSeconds;
return;
}
if (!useHls) {
active = true;
if (!player) {
return;
}
// Cold-starting a transcode for every thumbnail the pointer brushes over would hammer the server,
// so wait for the hover to settle before opening an HLS session.
const timer = setTimeout(() => (active = true), 200);
return () => clearTimeout(timer);
const video = player;
return () => {
video.pause();
video.removeAttribute('src');
video.load();
};
});
const onMouseEnter = () => {
if (playbackOnIconHover) {
enablePlayback = true;
@@ -68,15 +62,35 @@
};
</script>
{#if active}
<immich-video
asset-id={assetId}
cache-key={cacheKey ?? ''}
{#if enablePlayback}
<video
bind:this={player}
class={cleanClass('h-full w-full object-cover', className)}
class:rounded-xl={curve}
muted
loop
autoplay
class={cleanClass('h-full w-full [--media-object-fit:cover]', className, curve && 'rounded-xl overflow-hidden')}
></immich-video>
loop
src={url}
onplay={() => {
loading = false;
error = false;
}}
onerror={() => {
if (!player?.src) {
// Do not show error when the URL is empty.
return;
}
error = true;
loading = false;
}}
ontimeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min(
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
durationInSeconds,
);
}}
></video>
{/if}
<div
@@ -100,10 +114,10 @@
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
>
{#if active}
{#if !controller || controller.loading}
{#if enablePlayback}
{#if loading}
<LoadingSpinner size="large" />
{:else if controller.error}
{:else if error}
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
{:else}
<Icon icon={pauseIcon} size="24" />
@@ -1,47 +0,0 @@
import { SvelteMap } from 'svelte/reactivity';
import { VideoController, type VideoControllerOptions } from '$lib/utils/video/controller.svelte';
interface Session {
controller: VideoController;
refs: number;
timer?: NodeJS.Timeout;
}
/**
* Registry of controllers keyed by asset, ref-counted with a grace period. `<immich-video>` acquires and
* releases as it connects and disconnects, with controllers kept briefly before being disposed. This enables
* reuse of bandwidth estimation, downloaded segments, HLS session, etc. for seamless handoff.
*/
class VideoSessionManager {
#sessions = new SvelteMap<string, Session>();
acquire(options: VideoControllerOptions): VideoController {
const existing = this.#sessions.get(options.assetId);
if (existing) {
clearTimeout(existing.timer);
existing.timer = undefined;
existing.refs++;
return existing.controller;
}
const controller = new VideoController(options);
this.#sessions.set(options.assetId, { controller, refs: 1, timer: undefined });
return controller;
}
release(assetId: string) {
const session = this.#sessions.get(assetId);
if (!session || --session.refs > 0) {
return;
}
session.timer = setTimeout(() => {
session.controller.release();
this.#sessions.delete(assetId);
}, 1000);
}
get(assetId: string): VideoController | undefined {
return this.#sessions.get(assetId)?.controller;
}
}
export const videoSessionManager = new VideoSessionManager();
@@ -1,219 +0,0 @@
import { AssetMediaSize } from '@immich/sdk';
import Hls, { type ErrorData, type Level } from 'hls.js';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { createHls, filterEfficientLevels, getHlsSessionId, releaseHlsSession } from '$lib/utils/video/hls';
export interface VideoControllerOptions {
assetId: string;
cacheKey: string | null;
playOriginal: boolean;
}
const HLS_MIME = 'application/x-mpegURL';
const MAX_REBUILDS = 1;
/**
* Owns a single, long-lived `<video>` for an asset and all of its playback wiring.
* Because the controller owns the element, hosts can {@link mount} it and hand it off by re-parenting, enabling
* bandwidth estimation, buffer, HLS session, playback position, etc. to survive.
*/
export class VideoController {
readonly element: HTMLVideoElement;
loading = $state(true);
error = $state(false);
currentTime = $state(0);
duration = $state(0);
levels = $state<Level[]>([]);
selectedLevel = $state(-1);
private assetId: string;
private cacheKey: string | null;
private api: Hls | undefined;
private sourceTeardown: (() => void) | undefined;
private started = false;
private wasPlaying = false;
private rebuilds = 0;
#level = $state(-1);
#playOriginal: boolean;
constructor({ assetId, cacheKey, playOriginal }: VideoControllerOptions) {
this.assetId = assetId;
this.cacheKey = cacheKey;
this.#playOriginal = playOriginal;
const element = document.createElement('video');
element.setAttribute('playsinline', '');
element.setAttribute('disablepictureinpicture', '');
element.addEventListener('play', () => {
this.loading = false;
this.error = false;
});
element.addEventListener('error', () => {
this.error = true;
this.loading = false;
});
element.addEventListener('timeupdate', () => {
this.currentTime = element.currentTime;
this.duration = element.duration;
});
this.element = element;
}
mount(container: HTMLElement) {
container.append(this.element);
if (!this.started) {
this.started = true;
const useHls = featureFlagsManager.value.realtimeTranscoding && !this.#playOriginal;
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
} else if (this.wasPlaying) {
void this.element.play().catch(() => {});
}
}
unmount(container: HTMLElement) {
if (this.element.parentElement === container) {
this.wasPlaying = !this.element.paused;
this.element.remove();
}
}
release() {
this.sourceTeardown?.();
this.sourceTeardown = undefined;
this.element.remove();
}
get remainingSeconds() {
return Math.max(0, this.duration - this.currentTime);
}
set playOriginal(playOriginal: boolean) {
if (this.#playOriginal === playOriginal) {
return;
}
this.#playOriginal = playOriginal;
if (!this.started) {
return;
}
this.sourceTeardown?.();
const useHls = featureFlagsManager.value.realtimeTranscoding && !playOriginal;
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
}
get level() {
return this.#level;
}
set level(index: number) {
if (!this.api) {
return;
}
// -1 re-enables ABR without flushing
if (index === -1) {
this.api.loadLevel = -1;
} else {
this.api.currentLevel = index;
}
this.selectedLevel = index;
}
private attachProgressive() {
this.element.src = this.#playOriginal
? getAssetMediaUrl({ id: this.assetId, size: AssetMediaSize.Original, cacheKey: this.cacheKey })
: getAssetPlaybackUrl({ id: this.assetId, cacheKey: this.cacheKey });
return () => this.detachSource();
}
private detachSource() {
this.element.pause();
this.element.removeAttribute('src');
this.element.load();
}
private attachHls(startPosition = -1): () => void {
const video = this.element;
// Old iOS versions don't support Media Source Extensions
if (!Hls.isSupported()) {
return this.attachNativeHls();
}
const hls = createHls({ autoStartLoad: false });
this.api = hls;
let sessionId: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-misused-promises
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
sessionId = getHlsSessionId(hls);
await filterEfficientLevels(hls);
this.levels = hls.levels;
hls.attachMedia(video); // Need to attach after filtering for the auto size cap to work
hls.startLoad(startPosition);
// autoStartLoad defers the first fragment, so the `autoplay` attribute may have already fired and done nothing
if (video.autoplay && video.paused) {
void video.play().catch(() => {});
}
});
hls.on(Hls.Events.LEVELS_UPDATED, (_, data) => (this.levels = data.levels));
hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => (this.#level = data.level));
hls.on(Hls.Events.FRAG_LOADED, () => (this.rebuilds = 0));
hls.on(Hls.Events.ERROR, (_, data) => this.onHlsError(data));
const onPageHide = (event: PageTransitionEvent) => {
if (!event.persisted && sessionId) {
releaseHlsSession(this.assetId, sessionId);
}
};
window.addEventListener('pagehide', onPageHide);
hls.loadSource(getAssetHlsUrl(this.assetId));
return () => {
window.removeEventListener('pagehide', onPageHide);
if (sessionId) {
releaseHlsSession(this.assetId, sessionId);
}
hls.destroy();
this.api = undefined;
this.levels = [];
this.#level = -1;
this.selectedLevel = -1;
};
}
private attachNativeHls() {
if (this.element.canPlayType(HLS_MIME)) {
this.element.src = getAssetHlsUrl(this.assetId);
} else {
this.error = true;
this.loading = false;
}
return () => this.detachSource();
}
private onHlsError(data: ErrorData) {
// A fragment 404 usually means the server session expired (e.g. after a long pause). Rebuild it
// once, resuming where we left off, before giving up.
if (
data.fatal &&
data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR &&
data.response?.code === 404 &&
this.rebuilds < MAX_REBUILDS
) {
this.rebuilds++;
this.loading = true;
this.error = false;
const resume = this.element.currentTime;
this.sourceTeardown?.();
this.sourceTeardown = this.attachHls(resume);
return;
}
if (data.fatal) {
console.error('Fatal HLS error', data.details, data.response?.code);
this.error = true;
this.loading = false;
}
}
}
-157
View File
@@ -1,157 +0,0 @@
import Hls, {
AbrController,
Events,
FetchLoader,
type FragLoadedData,
type FragLoadingData,
type HlsConfig,
} from 'hls.js';
import { debounce } from 'lodash-es';
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
import { getAssetHlsSessionUrl } from '$lib/utils';
const HLS_TARGET_SEGMENT_HEADER = 'x-immich-hls-msn';
const RESIZE_FLUSH_DEBOUNCE_MS = 150;
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
// it emergency switches to a different variant. This extends the delay even further due to
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
//
// It can also emergency switch between fragments: while a switch's first segment is still loading,
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
// This can cause multiple redundant transcoding restarts when it occurs.
// Hold the committed level until its first fragment lands, then resume normal ABR.
export class NoAbandonAbrController extends AbrController {
private switchTarget = -1;
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
if (data.frag.sn === 'initSegment') {
this.switchTarget = data.frag.level;
}
}
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
if (data.frag.sn !== 'initSegment') {
this.switchTarget = -1;
}
super.onFragLoaded(event, data);
}
override get nextAutoLevel(): number {
const level = super.nextAutoLevel;
const target = this.hls.levels[this.switchTarget];
// Hold the committed level, but only while hls.js still considers it healthy.
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
return this.switchTarget;
}
return level;
}
override set nextAutoLevel(level: number) {
super.nextAutoLevel = level;
}
}
// hls.js flushes the forward buffer on a level switch so the new variant surfaces quickly, but requests can happen
// out of order and cause unnecessary transcodes since it leaves the loader running during the flush.
// This version stops the loader until the buffer is flushed so segment requests stay monotonic.
class FlushAheadStreamController extends Hls.DefaultConfig.streamController {
#flushPending = false;
#flushAhead = debounce(() => this.#switchAhead(), RESIZE_FLUSH_DEBOUNCE_MS);
override nextLevelSwitch() {
this.#flushAhead();
}
#switchAhead() {
const { media, hls, levels, playlistType } = this;
if (!media?.readyState || !levels || this.#flushPending) {
return;
}
const bufferInfo = this.getFwdBufferInfo(this.getBufferOutput(), playlistType);
const nextLevel = levels[hls.nextLoadLevel];
if (!bufferInfo || !nextLevel) {
return;
}
const { fetchdelay, okToFlushForwardBuffer } = this.calculateOptimalSwitchPoint(nextLevel, bufferInfo);
const flushFrom = this.playhead + fetchdelay;
if (!okToFlushForwardBuffer || bufferInfo.end <= flushFrom) {
return;
}
this.#flushPending = true;
hls.stopLoad();
hls.once(Events.BUFFER_FLUSHED, () => {
this.#flushPending = false;
hls.startLoad();
});
hls.trigger(Events.BUFFER_FLUSHING, {
startOffset: flushFrom,
endOffset: Number.POSITIVE_INFINITY,
type: null,
});
}
}
export const createHls = (overrides?: Partial<HlsConfig>): Hls => {
const hls = new Hls({
abrController: NoAbandonAbrController,
loader: FetchLoader,
capLevelToPlayerSize: true,
streamController: FlushAheadStreamController,
testBandwidth: false,
highBufferWatchdogPeriod: 10,
detectStallWithCurrentTimeMs: 10_000,
maxBufferHole: 0.5,
maxBufferLength: 30,
maxMaxBufferLength: 60,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 30_000,
maxLoadTimeMs: 60_000,
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
},
},
useMediaCapabilities: false,
...overrides,
});
// init.mp4 carries no segment number, but the server needs to know which segment an init.mp4
// is for so it can start the transcode there. It sometimes can't infer this since segments can
// be loaded from browser cache and the server might not know where the client really is as a result.
// Let the client hint the target segment it's switching to with a custom header.
hls.config.fetchSetup = (context, initParams) => {
const frag = (context as { frag?: { sn: number | 'initSegment' } }).frag;
if (frag?.sn === 'initSegment') {
const sn = hls.inFlightFragments.main.frag?.sn;
if (typeof sn === 'number') {
(initParams.headers as Headers).set(HLS_TARGET_SEGMENT_HEADER, String(sn));
}
}
return new Request(context.url, initParams);
};
return hls;
};
export const getHlsSessionId = (api: Hls): string | undefined => {
return api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
};
export const releaseHlsSession = (assetId: string, sessionId: string) => {
const url = getAssetHlsSessionUrl(assetId, sessionId);
void fetch(url, { method: 'DELETE' }).catch(() =>
console.warn('Failed to release HLS session', { assetId, sessionId }),
);
};
/** Drop every variant the browser can't hardware-decode efficiently, keeping one per resolution. */
export const filterEfficientLevels = async (api: Hls) => {
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
for (let i = api.levels.length - 1; i >= 0; i--) {
if (!keep.has(i)) {
api.removeLevel(i);
}
}
};
@@ -1,9 +1,8 @@
<script lang="ts">
import '$lib/components/asset-viewer/immich-video-element';
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { autoPlayVideo } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import 'media-chrome/media-controller';
import { onMount } from 'svelte';
@@ -11,11 +10,13 @@
interface Props {
asset: TimelineAsset;
videoPlayer: HTMLVideoElement | undefined;
}
let { asset }: Props = $props();
let { asset, videoPlayer = $bindable() }: Props = $props();
let showVideo: boolean = $state(false);
let showVideo = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
@@ -23,17 +24,20 @@
</script>
{#if showVideo}
<div class="size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
<div class="bg-pink-9000 size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
<media-controller id="memory-video" nohotkeys class="size-full rounded-2xl object-contain transition-all">
<immich-video
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoPlayer}
slot="media"
asset-id={asset.id}
cache-key={asset.thumbhash ?? ''}
class="size-full"
draggable="false"
autoplay={$autoPlayVideo}
playsinline
disablepictureinpicture
class="size-full"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
></immich-video>
draggable="false"
></video>
</media-controller>
</div>
{/if}
@@ -21,7 +21,6 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { locale } from '$lib/stores/preferences.store';
@@ -81,7 +80,7 @@
// need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
let progressBarController: Tween<number> | undefined = $state(undefined);
const videoPlayer = $derived(currentAssetId ? videoSessionManager.get(currentAssetId)?.element : undefined);
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
@@ -517,7 +516,7 @@
<div class="relative size-full rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer asset={current.asset} />
<MemoryVideoViewer asset={current.asset} bind:videoPlayer />
{:else}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
{/if}