immich/web/src/lib/components/asset-viewer/video-viewer/video-native-viewer.svelte
2025-05-04 19:48:43 +00:00

151 lines
4.6 KiB
Svelte

<script lang="ts">
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoLayout from '$lib/components/asset-viewer/video-viewer/video-layout.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk';
import { tick } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { MediaAutoPlayFailEvent, MediaVolumeChangeEvent } from 'vidstack';
import 'vidstack/player';
import 'vidstack/player/styles/base.css';
import 'vidstack/player/ui';
import type { MediaPlayerElement } from 'vidstack/elements';
interface Props {
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onControlsChange?: ({ controlsVisible }: { controlsVisible: boolean }) => void;
}
let {
assetId,
loopVideo,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
onVideoStarted = () => {},
onControlsChange = () => {},
}: Props = $props();
let player: MediaPlayerElement | undefined = $state();
let assetFileUrl = $derived(getAssetPlaybackUrl({ id: assetId, cacheKey }));
let videoElement = $derived(player?.querySelector('video') as HTMLVideoElement);
let forceMuted = $state(false);
let containerWidth = $state(0);
let containerHeight = $state(0);
const streamType = 'on-demand';
const logLevel: 'silent' | 'error' | 'warn' | 'info' | 'debug' = 'error';
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
$effect(() => {
if (isFaceEditMode.value) {
void player?.pause();
}
});
</script>
<div
transition:fade={{ duration: 150 }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
use:swipe={() => ({})}
onswipe={onSwipe}
>
{// vidstack is missing some types for svelte5 event syntax: onauto-play-fail
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */ undefined}
<media-player
class="h-full w-full ring-media-focus data-[focus]:ring-4"
bind:this={player}
src={assetFileUrl}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{logLevel}
{streamType}
loop={$loopVideoPreference && loopVideo}
playsInline
autoPlay
load="idle"
viewType="video"
oncontrols-change={(e: CustomEvent) => {
onControlsChange?.({ controlsVisible: e.detail as boolean });
}}
muted={forceMuted || $videoViewerMuted}
onauto-play-fail={async (e: MediaAutoPlayFailEvent) => {
if (e.detail.error.name === 'NotAllowedError') {
forceMuted = true;
try {
await tick();
await player?.play();
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
}
}
}}
onvolume-change={(e: MediaVolumeChangeEvent) => {
if (forceMuted && !e.detail.muted && e.detail.volume > 0) {
forceMuted = false;
}
if (!forceMuted) {
$videoViewerVolume = e.detail.volume;
$videoViewerMuted = e.detail.muted;
}
}}
onended={onVideoEnded}
onstarted={() => {
if (!forceMuted) {
player!.volume = $videoViewerVolume;
player!.muted = $videoViewerMuted;
}
onVideoStarted();
}}
>
<media-provider>
<media-poster
class="absolute inset-0 block h-full w-full rounded-md opacity-0 transition-opacity data-[visible]:opacity-100"
src={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
></media-poster>
</media-provider>
<VideoLayout />
</media-player>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoElement} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
<style>
:global {
media-player video {
height: 100%;
}
media-poster img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
</style>