Compare commits

...

17 Commits

Author SHA1 Message Date
timonrieger
86aef3ecc9 enhance video player layout by ensuring full width and maintaining aspect ratio 2026-03-17 18:08:56 +01:00
timonrieger
96fbc97032 fix full width on video player on safari 2026-03-17 12:44:37 +01:00
Mees Frensel
d608fde175 update memory viewer 2026-03-16 15:49:40 +01:00
Mees Frensel
d1e0552b9d change ui 2026-03-13 00:37:23 +01:00
Mees Frensel
d6cfb2b98e Merge branch 'main' into feat/video-player 2026-03-12 16:36:29 +01:00
Mees Frensel
a733584f49 remove seek buttons and center controls, and put time range above controls 2026-03-11 13:53:03 +01:00
Mees Frensel
5baf860289 Merge branch 'main' into feat/video-player 2026-03-11 13:14:00 +01:00
Mees Frensel
b67ff2c19a always display time range 2026-02-23 13:09:43 +01:00
timonrieger
f45eb1e7e4 fix black screen issue 2026-02-23 11:23:28 +01:00
Mees Frensel
3396180d62 re-add playsinline for safari iphone playback 2026-02-21 00:38:36 +01:00
Mees Frensel
3359c971d4 disable video shortcut keys 2026-02-21 00:32:24 +01:00
Mees Frensel
2e17f1af16 fix memories 2026-02-21 00:27:02 +01:00
Mees Frensel
e408cd3601 Merge branch 'main' into feat/video-player 2026-02-20 20:58:45 +01:00
Mees Frensel
7f5ba33ab5 wrap memory viewer in media-controller for muted/volume store 2026-02-16 13:06:47 +01:00
Mees Frensel
21b539be5d Merge branch 'main' into feat/video-player 2026-02-16 12:26:13 +01:00
Mees Frensel
0a347d84b2 add seek & rate buttons 2026-02-16 11:54:43 +01:00
Mees Frensel
a99631b12f feat(web): custom video player controls 2026-02-13 12:27:03 +01:00
9 changed files with 285 additions and 86 deletions

View File

@@ -1743,6 +1743,7 @@
"places": "Places",
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
"play": "Play",
"playback_speed": "Playback speed",
"play_memories": "Play memories",
"play_motion_photo": "Play Motion Photo",
"play_or_pause_video": "Play or pause video",
@@ -2418,6 +2419,7 @@
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
"x_of_total": "{x}/{total}",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",

21
pnpm-lock.yaml generated
View File

@@ -815,6 +815,9 @@ importers:
maplibre-gl:
specifier: ^5.6.2
version: 5.19.0
media-chrome:
specifier: ^4.17.2
version: 4.17.2(react@19.2.4)
pmtiles:
specifier: ^4.3.0
version: 4.4.0
@@ -5879,6 +5882,11 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
ce-la-react@0.3.2:
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
peerDependencies:
react: '>=17.0.0'
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -8777,6 +8785,9 @@ packages:
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
media-chrome@4.17.2:
resolution: {integrity: sha512-o/IgiHx0tdSVwRxxqF5H12FK31A/A8T71sv3KdAvh7b6XeBS9dXwqvIFwlR9kdEuqg3n7xpmRIuL83rmYq8FTg==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -18138,6 +18149,10 @@ snapshots:
ccount@2.0.1: {}
ce-la-react@0.3.2(react@19.2.4):
dependencies:
react: 19.2.4
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -21545,6 +21560,12 @@ snapshots:
mdn-data@2.0.30: {}
media-chrome@4.17.2(react@19.2.4):
dependencies:
ce-la-react: 0.3.2(react@19.2.4)
transitivePeerDependencies:
- react
media-typer@0.3.0: {}
media-typer@1.1.0: {}

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

@@ -542,6 +542,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
extendedControls
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}

View File

@@ -4,24 +4,49 @@
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 {
mdiCheck,
mdiChevronLeft,
mdiChevronRight,
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 'media-chrome/menu/media-playback-rate-menu';
import 'media-chrome/menu/media-settings-menu';
import 'media-chrome/menu/media-settings-menu-button';
import 'media-chrome/menu/media-settings-menu-item';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
extendedControls?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,10 +55,12 @@
}
let {
asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
extendedControls = false,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -48,12 +75,11 @@
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let duration = $derived(timeToSeconds(asset.duration));
let showVideo = $state(false);
let hasFocused = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
@@ -73,7 +99,7 @@
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
if (!video.paused) {
await video.play();
onVideoStarted();
}
@@ -138,33 +164,81 @@
/>
</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) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
<media-controller
dir="ltr"
nohotkeys
class="h-full max-w-full dark"
style={asset.width != null && asset.height != null ? `aspect-ratio: ${asset.width} / ${asset.height};` : undefined}
defaultduration={duration}
>
</video>
<video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
></video>
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="border-light-300 rounded-xl border shadow-sm w-3xs">
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
<media-settings-menu-item class="rounded-lg p-1 ps-2 mx-1">
{$t('playback_speed')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('playback_speed')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
</media-settings-menu>
{/if}
<div class="flex flex-col justify-end w-full h-32 px-4 bg-linear-to-b to-black/80">
<media-control-bar part="bottom" class="flex w-full h-10 gap-2">
<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-display showduration class="rounded-lg p-2 outline-none"></media-time-display>
<span class="flex-grow"></span>
<div class="volume-wrapper rounded-full bg-light-100/0 hover:bg-light-100 transition-colors duration-400">
<media-volume-range class="h-full bg-none outline-none"></media-volume-range>
<media-mute-button class="p-2 bg-none 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>
{#if extendedControls}
<media-fullscreen-button class="rounded-full p-2 outline-none">
<Icon slot="enter" icon={mdiFullscreen} />
<Icon slot="exit" icon={mdiFullscreenExit} />
</media-fullscreen-button>
<media-settings-menu-button class="rounded-full p-2 outline-none"></media-settings-menu-button>
{/if}
</media-control-bar>
<media-time-range class="w-full h-8 px-2 pb-3 rounded-lg outline-none"></media-time-range>
</div>
</media-controller>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
@@ -178,3 +252,86 @@
{/if}
</div>
{/if}
<style>
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-family: var(--font-sans);
--media-font-size: var(--text-base);
--media-font-weight: var(--font-weight-medium);
--media-menu-border-radius: var(--radius-xl);
--media-menu-gap: var(--spacing);
--media-menu-item-hover-background: var(--immich-ui-light-200);
--media-menu-item-icon-height: 1em;
--media-menu-item-indicator-height: 1em;
--media-primary-color: var(--immich-ui-dark);
--media-time-range-buffered-color: var(--immich-ui-dark-400);
--media-time-range-hover-bottom: 0;
--media-time-range-hover-height: 100%;
--media-range-thumb-box-shadow: none;
--media-range-thumb-opacity: 0;
--media-range-thumb-transition: opacity 0.15s ease;
--media-range-track-border-radius: 2px;
--media-range-track-height: 3.5px;
--media-range-padding: 0;
--media-settings-menu-background: var(--immich-ui-light-100);
--media-text-content-height: var(--text-base--line-height);
--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) * 2) calc(var(--spacing) * 3.5);
}
/* Needs special handling for some reason */
media-time-display:focus-visible {
box-shadow: var(--media-focus-box-shadow);
}
media-time-range,
media-volume-range {
--media-control-hover-background: none;
}
media-time-range:hover,
media-volume-range:hover {
--media-range-thumb-opacity: 1;
}
*::part(tooltip) {
--media-font-size: var(--text-xs);
--media-text-content-height: var(--text-xs--line-height);
color: white;
}
*[mediavolumeunavailable] {
--media-volume-range-display: none;
}
.volume-wrapper {
--media-control-hover-background: none;
}
media-volume-range:has(+ media-mute-button) {
padding: 0;
margin: 0;
width: 0;
overflow: hidden;
transition: width 0.4s ease-out;
}
/* Expand volume control in all relevant states */
.volume-wrapper:hover > media-volume-range,
media-volume-range:has(+ media-mute-button:hover),
media-volume-range:has(+ media-mute-button:focus),
media-volume-range:has(+ media-mute-button:focus-within),
media-volume-range:hover,
media-volume-range:focus,
media-volume-range:focus-within {
padding: 0 calc(var(--spacing) * 2);
margin-left: calc(var(--spacing) * 2);
width: 70px;
}
</style>

View File

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

View File

@@ -4,17 +4,16 @@
import { autoPlayVideo } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import 'media-chrome/media-controller';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
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);
@@ -26,16 +25,19 @@
{#if showVideo}
<div class="h-full w-full bg-pink-9000" transition:fade={{ duration: assetViewerFadeDuration }}>
<video
bind:this={videoPlayer}
autoplay={$autoPlayVideo}
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}
></video>
<media-controller id="memory-video" nohotkeys class="h-full w-full rounded-2xl object-contain transition-all">
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoPlayer}
slot="media"
autoplay={$autoPlayVideo}
playsinline
disablepictureinpicture
class="h-full w-full"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
></video>
</media-controller>
</div>
{/if}

View File

@@ -26,13 +26,13 @@
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';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
import { ActionButton, IconButton, Text, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
@@ -52,6 +52,7 @@
} from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import 'media-chrome/media-mute-button';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
@@ -318,7 +319,6 @@
$effect(() => {
if (videoPlayer) {
videoPlayer.muted = $videoViewerMuted;
initPlayer();
}
});
@@ -390,42 +390,62 @@
{/if}
{/snippet}
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
<div class="w-12.5 dark">
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
icon={paused ? mdiPlay : mdiPause}
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
/>
</div>
<div class="flex place-content-center place-items-center gap-2 dark">
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
icon={paused ? mdiPlay : mdiPause}
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
/>
{#each current.memory.assets as asset, index (asset.id)}
<a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
<a class="relative grow py-2" href={asHref(asset)} aria-label={$t('view')}>
<span class="absolute start-0 h-0.5 w-full bg-gray-500"></span>
<span class="absolute start-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
</a>
{/each}
<div>
<p class="text-small">
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
</p>
</div>
<Text size="small">
{$t('x_of_total', {
values: {
x: (current.assetIndex + 1).toLocaleString($locale),
total: current.memory.assets.length.toLocaleString($locale),
},
})}
</Text>
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
<div class="w-12.5 dark">
<media-mute-button
mediacontroller={videoPlayer ? 'memory-video' : ''}
disabled={!videoPlayer}
class="bg-transparent rounded-full focus-visible:outline-2 outline-offset-2 outline-dark"
style="--media-focus-box-shadow: none;"
>
<IconButton
slot="off"
disabled={!videoPlayer}
tabindex={-1}
shape="round"
variant="ghost"
color="secondary"
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
aria-label={$t('unmute_memories')}
icon={mdiVolumeOff}
onclick={() => {}}
/>
</div>
<IconButton
slot="high"
disabled={!videoPlayer}
tabindex={-1}
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('mute_memories')}
icon={mdiVolumeHigh}
onclick={() => {}}
/>
</media-mute-button>
{/if}
</div>
</ControlAppBar>
@@ -497,12 +517,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}
@@ -521,7 +536,6 @@
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
icon={mdiShareVariantOutline}

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;