Feat: vidstack player

This commit is contained in:
Min Idzelis 2025-05-04 19:48:43 +00:00
parent 8f7baf8336
commit 8a8a0e462c
28 changed files with 639 additions and 217 deletions

View File

@ -59,5 +59,6 @@
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"html.customData": [".web/node_modules/vidstack/vscode.html-data.json"]
}

61
web/package-lock.json generated
View File

@ -35,7 +35,8 @@
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.0.0",
"svelte-persisted-store": "^0.12.0",
"thumbhash": "^0.1.1"
"thumbhash": "^0.1.1",
"vidstack": "^1.12.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -2442,6 +2443,12 @@
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
@ -5800,6 +5807,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -6066,6 +6082,15 @@
"node": ">= 0.4"
}
},
"node_modules/media-captions": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/memoizee": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
@ -8987,6 +9012,19 @@
"node": ">= 4.0.0"
}
},
"node_modules/unplugin": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@ -9056,6 +9094,21 @@
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/vidstack": {
"version": "1.12.13",
"resolved": "https://registry.npmjs.org/vidstack/-/vidstack-1.12.13.tgz",
"integrity": "sha512-vuNeyRmWoH/7EoFVDYjp9nkgcqtCMmal518LDeb78dYKgWb+p6+vtY0AzDhrkBv5q1UiCn+xwmjMmwvSlPLuhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"lit-html": "^2.8.0",
"media-captions": "^1.0.4",
"unplugin": "^1.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/vite": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
@ -9725,6 +9778,12 @@
"node": ">=12"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",

View File

@ -51,7 +51,8 @@
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.0.0",
"svelte-persisted-store": "^0.12.0",
"thumbhash": "^0.1.1"
"thumbhash": "^0.1.1",
"vidstack": "^1.12.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",

View File

@ -373,6 +373,10 @@
handlePromiseError(handleGetAllAlbums());
}
});
let controlsVisible = $state(true);
const onControlsChange = ({ controlsVisible: visible }: { controlsVisible: boolean }) => {
controlsVisible = visible;
};
</script>
<svelte:document bind:fullscreenElement />
@ -384,7 +388,12 @@
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<div
class={[
{ 'opacity-100': controlsVisible },
'z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform to-transparent opacity-0 transition-opacity ',
]}
>
<AssetViewerNavBar
{asset}
{album}
@ -413,7 +422,12 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div
class={[
{ 'opacity-100': controlsVisible },
'to-transparent opacity-0 transition-opacity z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start',
]}
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
@ -453,9 +467,9 @@
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{onControlsChange}
/>
{/if}
{/key}
@ -471,6 +485,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
{onControlsChange}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
@ -498,9 +513,9 @@
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{onControlsChange}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
@ -519,7 +534,12 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div
class={[
{ 'opacity-100': controlsVisible },
'to-transparent opacity-0 transition-opacity z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end',
]}
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}

View File

@ -12,7 +12,7 @@
<button
type="button"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-white/20 hover:text-white"
aria-label={label}
onclick={onClick}
>

View File

@ -1,147 +0,0 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.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 { onDestroy, onMount } from 'svelte';
import { swipe } from 'svelte-gestures';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
interface Props {
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
}
let {
assetId,
loopVideo,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
}: Props = $props();
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
onMount(() => {
if (videoPlayer) {
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
forceMuted = false;
videoPlayer.load();
}
});
onDestroy(() => {
if (videoPlayer) {
videoPlayer.src = '';
}
});
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
await video.play();
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
await tryForceMutedPlay(video);
return;
}
handleError(error, $t('errors.unable_to_play_video'));
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
try {
video.muted = true;
await handleCanPlay(video);
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => {
if (isFaceEditMode.value) {
videoPlayer?.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}
>
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay
playsinline
controls
class="h-full object-contain"
use:swipe={() => ({})}
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiCastVariant } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-airplay-button
class="ring-media-focus text-white group relative mr-0.5 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4 aria-hidden:hidden"
>
<Icon class=" w-5 h-5" path={mdiCastVariant} color="currentColor"></Icon>
</media-airplay-button>
{/snippet}
{#snippet content()}
<span>Enter Airplay</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-fullscreen-button
class="ring-media-focus text-white group relative mr-0.5 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4 aria-hidden:hidden"
>
<Icon class="media-pip:hidden w-6 h-6" path={mdiFullscreen} color="currentColor"></Icon>
<Icon class="not-media-pip:hidden" path={mdiFullscreenExit} color="currentColor"></Icon>
</media-fullscreen-button>
{/snippet}
{#snippet content()}
<span class="media-pip:hidden">Enter Fullscreen</span>
<span class="media-pip:block hidden">Exit Fullscreen</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiCastVariant } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-google-cast-button
class="ring-media-focus text-white group relative mr-0.5 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4 aria-hidden:hidden"
>
<Icon class=" w-5 h-5" path={mdiCastVariant} color="currentColor"></Icon>
</media-google-cast-button>
{/snippet}
{#snippet content()}
<span>Google Cast</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiVolumeHigh, mdiVolumeLow, mdiVolumeMute } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-mute-button
class="ring-media-focus group text-white relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4"
>
<Icon class="w-6 h-6 hidden group-data-[state='muted']:block" path={mdiVolumeMute} color="currentColor" />
<Icon class="w-6 h-6 hidden group-data-[state='low']:block" path={mdiVolumeLow} color="currentColor" />
<Icon class="w-6 h-6 hidden group-data-[state='high']:block" path={mdiVolumeHigh} color="currentColor" />
</media-mute-button>
{/snippet}
{#snippet content()}
<span class="media-muted:hidden">Mute</span>
<span class="media-muted:block hidden">Unmute</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiPictureInPictureBottomRight, mdiSquare } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-pip-button
class="ring-media-focus text-white group relative mr-0.5 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4 aria-hidden:hidden"
>
<Icon class="media-pip:hidden w-5 h-5" path={mdiPictureInPictureBottomRight} color="currentColor"></Icon>
<Icon class="not-media-pip:hidden w-5 h-5" path={mdiSquare} color="currentColor"></Icon>
</media-pip-button>
{/snippet}
{#snippet content()}
<span class="media-pip:hidden">Enter PIP</span>
<span class="media-pip:block hidden">Exit PIP</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiPause, mdiPlay } from '@mdi/js';
import type { TooltipPlacement } from 'vidstack';
import Tooltip from '../tooltip.svelte';
type Props = {
tooltipPlacement: TooltipPlacement;
};
let { tooltipPlacement }: Props = $props();
</script>
<Tooltip placement={tooltipPlacement}>
{#snippet trigger()}
<media-play-button
class="ring-media-focus text-white relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4"
>
<Icon class="w-5 h-5 media-playing:hidden" path={mdiPlay} color="currentColor" />
<Icon class="w-5 h-5 media-paused:hidden" path={mdiPause} color="currentColor" />
</media-play-button>
{/snippet}
{#snippet content()}
<span class="media-playing:hidden">Play</span>
<span class="media-paused:hidden">Pause</span>
{/snippet}
</Tooltip>

View File

@ -0,0 +1,11 @@
<div class="ml-1.5 flex items-center text-sm font-medium">
<media-time class="time text-white" type="current"></media-time>
<div class="mx-1 text-white/80">/</div>
<media-time class="time text-white" type="duration"></media-time>
</div>
<style>
.time {
contain: content;
}
</style>

View File

@ -0,0 +1,22 @@
<media-slider-chapters class="relative flex h-full w-full items-center rounded-[1px]">
<template>
<!-- Chapter -->
<div class="chapter last-child:mr-0 relative mr-0.5 flex h-full w-full items-center rounded-[1px]">
<!-- Track -->
<div class="ring-media-focus relative z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]">
<div class="bg-media-brand absolute h-full w-[var(--chapter-fill)] rounded-sm will-change-[width]"></div>
<div class="absolute z-10 h-full w-[var(--chapter-progress)] rounded-sm bg-white/50 will-change-[width]"></div>
</div>
<!-- Progress -->
<div
class="absolute left-0 top-1/2 z-10 h-[5px] w-[var(--chapter-progress)] -translate-y-1/2 rounded-sm bg-white/50 will-change-[width]"
></div>
</div>
</template>
</media-slider-chapters>
<style scoped>
.chapter {
contain: layout style;
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
thumbnails?: string;
noClamp?: boolean;
children?: Snippet;
};
const { thumbnails, noClamp, children }: Props = $props();
</script>
<media-slider-preview
class="flex flex-col items-center opacity-0 transition-opacity duration-200 data-[visible]:opacity-100 pointer-events-none"
{noClamp}
>
{#if thumbnails}
<media-slider-thumbnail
class="block h-[var(--thumbnail-height)] max-h-[160px] min-h-[80px] w-[var(--thumbnail-width)] min-w-[120px] max-w-[180px] overflow-hidden border border-white bg-black"
src={thumbnails}
></media-slider-thumbnail>
{/if}
{@render children?.()}
</media-slider-preview>

View File

@ -0,0 +1,3 @@
<div
class="absolute left-[var(--slider-fill)] top-1/2 z-20 h-[15px] w-[15px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#cacaca] bg-white opacity-0 ring-white/40 transition-opacity group-data-[active]:opacity-100 group-data-[dragging]:ring-4 will-change-[left]"
></div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import SliderChapters from '$lib/components/asset-viewer/video-viewer/sliders/slider-chapters.svelte';
import SliderPreview from '$lib/components/asset-viewer/video-viewer/sliders/slider-preview.svelte';
import SliderThumb from '$lib/components/asset-viewer/video-viewer/sliders/slider-thumb.svelte';
type Props = {
thumbnails?: string;
};
const { thumbnails }: Props = $props();
</script>
<media-time-slider
class="group text-white relative mx-[7.5px] -mt-1.5 inline-flex h-10 w-full cursor-pointer touch-none select-none items-center outline-none"
>
<SliderChapters />
<SliderThumb />
<SliderPreview {thumbnails}>
<div class="mt-2 text-sm" data-part="chapter-title"></div>
<media-slider-value class="relative top-3 text-[13px]"></media-slider-value>
</SliderPreview>
</media-time-slider>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import SliderThumb from '$lib/components/asset-viewer/video-viewer/sliders/slider-thumb.svelte';
import SliderPreview from './slider-preview.svelte';
</script>
<media-volume-slider
class="group relative mx-[7.5px] inline-flex h-10 w-full max-w-[80px] cursor-pointer touch-none select-none items-center outline-none aria-hidden:hidden"
>
<!-- Track -->
<div class="ring-media-focus relative z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]">
<div class="bg-white absolute h-full w-[var(--slider-fill)] rounded-sm will-change-[width]"></div>
</div>
<SliderThumb></SliderThumb>
<SliderPreview noClamp>
<media-slider-value class="rounded-sm bg-black px-2 py-px text-[13px] font-medium"></media-slider-value>
</SliderPreview>
</media-volume-slider>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { TooltipPlacement } from 'vidstack';
type Props = {
placement: TooltipPlacement;
content?: Snippet;
trigger?: Snippet;
};
let { placement, content, trigger }: Props = $props();
</script>
<media-tooltip>
<media-tooltip-trigger>
{@render trigger?.()}
</media-tooltip-trigger>
<media-tooltip-content
class="tooltip animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white"
{placement}
>
{@render content?.()}
</media-tooltip-content>
</media-tooltip>
<style>
:global(media-menu[data-open]) .tooltip {
display: none !important;
}
</style>

View File

@ -0,0 +1,54 @@
<script lang="ts">
import AirplayButton from '$lib/components/asset-viewer/video-viewer/buttons/airplay-button.svelte';
import FullscreenButton from '$lib/components/asset-viewer/video-viewer/buttons/fullscreen-button.svelte';
import GoogleCastButton from '$lib/components/asset-viewer/video-viewer/buttons/google-cast-button.svelte';
import MuteButton from '$lib/components/asset-viewer/video-viewer/buttons/mute-button.svelte';
import PIPButton from '$lib/components/asset-viewer/video-viewer/buttons/pip-button.svelte';
import PlayButton from '$lib/components/asset-viewer/video-viewer/buttons/play-button.svelte';
import TimeGroup from './groups/time-group.svelte';
import TimeSlider from './sliders/time-slider.svelte';
import VolumeSlider from './sliders/volume-slider.svelte';
</script>
<media-gesture class="absolute inset-0 z-0 block h-full w-full" event="pointerup" action="toggle:paused"
></media-gesture>
<media-gesture class="absolute inset-0 z-0 block h-full w-full" event="dblpointerup" action="toggle:fullscreen"
></media-gesture>
<media-gesture class="absolute left-0 top-0 z-10 block h-full w-1/5" event="dblpointerup" action="seek:-10"
></media-gesture>
<media-gesture class="absolute right-0 top-0 z-10 block h-full w-1/5" event="dblpointerup" action="seek:10"
></media-gesture>
<div>
<media-controls
class="media-controls:opacity-100 absolute inset-0 h-full w-full flex flex-col bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity"
>
<div class="flex-1"></div>
<media-controls-group class="flex w-full items-center px-2 -mb-2">
<TimeSlider></TimeSlider>
</media-controls-group>
<media-controls-group class="pl-1 pr-1 pb-2 flex w-full items-center">
<PlayButton tooltipPlacement="top start" />
<MuteButton tooltipPlacement="top start" />
<VolumeSlider />
<TimeGroup />
<div class="flex-1"></div>
<AirplayButton tooltipPlacement="top" />
<GoogleCastButton tooltipPlacement="top" />
<PIPButton tooltipPlacement="top"></PIPButton>
<FullscreenButton tooltipPlacement="top end" />
</media-controls-group>
</media-controls>
</div>
<style scoped>
media-controls {
/* These CSS variables apply offsets to all tooltips/menus. */
--media-tooltip-y-offset: 30px;
--media-menu-y-offset: 30px;
}
media-controls :global(media-volume-slider) {
--media-slider-preview-offset: 30px;
}
</style>

View File

@ -0,0 +1,150 @@
<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>

View File

@ -1,18 +1,18 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import VideoNativeViewer from '$lib/components/asset-viewer/video-viewer/video-native-viewer.svelte';
import { ProjectionType } from '$lib/constants';
interface Props {
assetId: string;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onControlsChange?: ({ controlsVisible }: { controlsVisible: boolean }) => void;
}
let {
@ -21,10 +21,10 @@
cacheKey,
loopVideo,
onPreviousAsset,
onClose,
onNextAsset,
onVideoEnded,
onVideoStarted,
onControlsChange,
}: Props = $props();
</script>
@ -39,6 +39,6 @@
{onNextAsset}
{onVideoEnded}
{onVideoStarted}
{onClose}
{onControlsChange}
/>
{/if}

View File

@ -1,32 +1,32 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte';
import Portal from '../shared-components/portal/portal.svelte';
import type { UpdatePayload } from 'vite';
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
interface Props {
isSelectionMode?: boolean;
@ -802,26 +802,22 @@
</section>
</section>
<Portal target="body">
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
{/if}
</Portal>
{#if $showAssetViewer}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/if}
<style>
#asset-grid {

View File

@ -1,28 +1,27 @@
<script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store';
import { focusNext } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { debounce } from 'lodash-es';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
import ShowShortcuts from '../show-shortcuts.svelte';
interface Props {
assets: AssetResponseDto[];
@ -492,17 +491,15 @@
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
</Portal>
<AssetViewer
asset={$viewingAsset}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
{/if}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
@ -26,7 +26,7 @@
});
</script>
<div class:display-none={$showAssetViewer}>
<div>
{@render children?.()}
</div>
<UploadCover />
@ -35,7 +35,4 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>

View File

@ -1,4 +1,5 @@
import plugin from 'tailwindcss/plugin';
import vidstackPlugin from 'vidstack/tailwind.cjs';
/** @type {import('tailwindcss').Config} */
export default {
@ -60,6 +61,10 @@ export default {
},
},
plugins: [
vidstackPlugin({
prefix: 'media',
webComponents: true,
}),
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{

View File

@ -11,7 +11,7 @@
"sourceMap": true,
"strict": true,
"target": "es2022",
"types": ["vitest/globals"]
"types": ["vitest/globals", "vidstack/svelte"]
},
"extends": "./.svelte-kit/tsconfig.json"
}

View File

@ -3,6 +3,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
import { vite as vidstack } from 'vidstack/plugins';
import { defineConfig } from 'vite';
const upstream = {
@ -35,6 +36,7 @@ export default defineConfig({
allowedHosts: true,
},
plugins: [
vidstack(),
sveltekit(),
process.env.BUILD_STATS === 'true'
? visualizer({