feat(web): custom video player controls

This commit is contained in:
Mees Frensel
2026-02-12 18:00:05 +01:00
parent 71fe9192fd
commit a99631b12f
8 changed files with 213 additions and 70 deletions

View File

@@ -50,6 +50,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
"media-chrome": "^4.17.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^15.15.0",

View File

@@ -513,6 +513,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
showFullscreen={false}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}

View File

@@ -4,24 +4,41 @@
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
loopVideo as loopVideoPreference,
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { timeToSeconds } from '$lib/utils/date-time';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner } from '@immich/ui';
import {
mdiFullscreen,
mdiFullscreenExit,
mdiPause,
mdiPlay,
mdiVolumeHigh,
mdiVolumeLow,
mdiVolumeMedium,
mdiVolumeMute,
} from '@mdi/js';
import 'media-chrome/media-control-bar';
import 'media-chrome/media-controller';
import 'media-chrome/media-fullscreen-button';
import 'media-chrome/media-mute-button';
import 'media-chrome/media-play-button';
import 'media-chrome/media-playback-rate-button';
import 'media-chrome/media-time-display';
import 'media-chrome/media-time-range';
import 'media-chrome/media-volume-range';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
showFullscreen?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,10 +47,12 @@
}
let {
asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
showFullscreen = true,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -48,7 +67,6 @@
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
onMount(() => {
@@ -71,7 +89,7 @@
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
if (!video.paused) {
await video.play();
onVideoStarted();
}
@@ -136,30 +154,56 @@
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
<media-controller class="h-full dark" defaultduration={timeToSeconds(asset.duration)}>
<video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => e.currentTarget.focus()}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
></video>
<div part="center" slot="centered-chrome">
<media-play-button class="rounded-full h-12 p-3 outline-none bg-light-100/60 hover:bg-light-100"
></media-play-button>
</div>
<div class="flex h-16 w-full place-items-center bg-linear-to-b to-black/40 px-3">
<media-control-bar part="bottom" class="flex justify-end gap-2 h-10 w-full">
<media-play-button class="rounded-full p-2 outline-none">
<Icon slot="play" icon={mdiPlay} />
<Icon slot="pause" icon={mdiPause} />
</media-play-button>
<media-time-range class="rounded-lg p-2 outline-none"></media-time-range>
<media-time-display showduration class="rounded-lg p-2 outline-none"></media-time-display>
<div class="media-volume-wrapper" style:position="relative">
<media-mute-button class="rounded-full p-2 outline-none">
<Icon slot="off" icon={mdiVolumeMute} />
<Icon slot="low" icon={mdiVolumeLow} />
<Icon slot="medium" icon={mdiVolumeMedium} />
<Icon slot="high" icon={mdiVolumeHigh} />
</media-mute-button>
<div class="media-volume-range-wrapper">
<media-volume-range class="rounded-lg h-10 p-2 outline-none bg-light-100"></media-volume-range>
</div>
</div>
{#if showFullscreen}
<media-fullscreen-button class="rounded-full p-2 outline-none">
<Icon slot="enter" icon={mdiFullscreen} />
<Icon slot="exit" icon={mdiFullscreenExit} />
</media-fullscreen-button>
{/if}
</media-control-bar>
</div>
</media-controller>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
@@ -168,8 +212,86 @@
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
<FaceEditor htmlElement={videoPlayer!} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
</div>
{/if}
<style>
/* Always */
media-controller {
--media-control-background: none;
--media-control-hover-background: var(--immich-ui-light-100);
--media-focus-box-shadow: 0 0 0 2px var(--immich-ui-dark);
--media-font: none;
--media-primary-color: var(--immich-ui-dark);
--media-time-range-buffered-color: var(--immich-ui-dark-400);
--media-range-track-border-radius: 2px;
--media-range-padding: calc(var(--spacing) * 1);
--media-tooltip-arrow-display: none;
--media-tooltip-border-radius: var(--radius-lg);
--media-tooltip-background-color: var(--immich-ui-light-200);
--media-tooltip-distance: 8px;
--media-tooltip-padding: calc(var(--spacing) * 4) calc(var(--spacing) * 3.5);
}
/* Needs special handling for some reason */
media-time-display:focus-visible {
box-shadow: var(--media-focus-box-shadow);
}
/* Small screens */
media-controller {
--bottom-play-button-display: none;
--center-play-button-display: inline-flex;
--media-time-range-display: none;
}
/* Larger screens */
*[breakpointsm] {
--bottom-play-button-display: flex;
--center-play-button-display: none;
--media-time-range-display: flex;
}
*::part(bottom) {
--media-play-button-display: var(--bottom-play-button-display);
}
*::part(center) {
--media-play-button-display: var(--center-play-button-display);
}
*::part(tooltip) {
font-size: var(--text-xs);
color: white;
}
/* should be media-volume-range[mediavolumeunavailable] */
*[mediavolumeunavailable] {
display: none;
}
.media-volume-wrapper {
--media-tooltip-display: none;
}
.media-volume-range-wrapper {
transform: rotate(-90deg);
position: absolute;
top: -70px;
left: -30px;
opacity: 0;
--media-control-background: var(--media-control-hover-background);
}
media-mute-button:hover + .media-volume-range-wrapper,
media-mute-button:focus + .media-volume-range-wrapper,
media-mute-button:focus-within + .media-volume-range-wrapper,
.media-volume-range-wrapper:hover,
.media-volume-range-wrapper:focus,
.media-volume-range-wrapper:focus-within {
opacity: 1;
}
</style>

View File

@@ -11,6 +11,7 @@
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
showFullscreen?: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
@@ -25,6 +26,7 @@
cacheKey,
loopVideo,
playOriginalVideo,
showFullscreen = true,
onPreviousAsset,
onClose,
onNextAsset,
@@ -41,8 +43,10 @@
<VideoNativeViewer
{loopVideo}
{cacheKey}
{asset}
assetId={effectiveAssetId}
{playOriginalVideo}
{showFullscreen}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}

View File

@@ -10,11 +10,9 @@
interface Props {
asset: TimelineAsset;
videoPlayer: HTMLVideoElement | undefined;
videoViewerMuted?: boolean;
videoViewerVolume?: number;
}
let { asset, videoPlayer = $bindable(), videoViewerVolume, videoViewerMuted }: Props = $props();
let { asset, videoPlayer = $bindable() }: Props = $props();
let showVideo: boolean = $state(false);
@@ -34,8 +32,6 @@
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}
></video>
</div>
{/if}

View File

@@ -28,7 +28,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -303,7 +303,6 @@
$effect(() => {
if (videoPlayer) {
videoPlayer.muted = $videoViewerMuted;
initPlayer();
}
});
@@ -407,9 +406,9 @@
shape="round"
variant="ghost"
color="secondary"
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
aria-label={videoPlayer?.muted ? $t('unmute_memories') : $t('mute_memories')}
icon={videoPlayer?.muted ? mdiVolumeOff : mdiVolumeHigh}
onclick={() => (videoPlayer ? (videoPlayer.muted = !videoPlayer.muted) : {})}
/>
</div>
{/if}
@@ -483,12 +482,7 @@
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
<MemoryVideoViewer asset={current.asset} bind:videoPlayer />
{:else}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
{/if}

View File

@@ -56,9 +56,6 @@ const persistedObject = <T>(key: string, defaults: T) =>
export const mapSettings = persistedObject<MapSettings>('map-settings', defaultMapSettings);
export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
export const videoViewerMuted = persisted<boolean>('video-viewer-muted', false, {});
export interface AlbumViewSettings {
view: string;
filter: string;