mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
Feat: vidstack player
This commit is contained in:
parent
8f7baf8336
commit
8a8a0e462c
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -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
61
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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}
|
||||
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -11,7 +11,7 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"types": ["vitest/globals"]
|
||||
"types": ["vitest/globals", "vidstack/svelte"]
|
||||
},
|
||||
"extends": "./.svelte-kit/tsconfig.json"
|
||||
}
|
||||
|
@ -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({
|
||||
|
Loading…
x
Reference in New Issue
Block a user