Skip to content

Commit affe0ac

Browse files
feat(web): custom video player controls (#26183)
* feat(web): custom video player controls * add seek & rate buttons * wrap memory viewer in media-controller for muted/volume store * fix memories * disable video shortcut keys * re-add playsinline for safari iphone playback * fix black screen issue * always display time range * remove seek buttons and center controls, and put time range above controls * change ui * update memory viewer * fix full width on video player on safari * enhance video player layout by ensuring full width and maintaining aspect ratio * layout: don't shrink buttons, tabular time text --------- Co-authored-by: timonrieger <mail@timonrieger.de>
1 parent f1d8ab8 commit affe0ac

9 files changed

Lines changed: 286 additions & 87 deletions

File tree

i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,6 +1761,7 @@
17611761
"play_original_video": "Play original video",
17621762
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
17631763
"play_transcoded_video": "Play transcoded video",
1764+
"playback_speed": "Playback speed",
17641765
"please_auth_to_access": "Please authenticate to access",
17651766
"port": "Port",
17661767
"preferences_settings_subtitle": "Manage the app's preferences",
@@ -2436,6 +2437,7 @@
24362437
"workflows": "Workflows",
24372438
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
24382439
"wrong_pin_code": "Wrong PIN code",
2440+
"x_of_total": "{x}/{total}",
24392441
"year": "Year",
24402442
"years_ago": "{years, plural, one {# year} other {# years}} ago",
24412443
"yes": "Yes",

pnpm-lock.yaml

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"lodash-es": "^4.17.21",
5252
"luxon": "^3.4.4",
5353
"maplibre-gl": "^5.6.2",
54+
"media-chrome": "^4.19.0",
5455
"pmtiles": "^4.3.0",
5556
"qrcode": "^1.5.4",
5657
"simple-icons": "^16.0.0",

web/src/lib/components/asset-viewer/AssetViewer.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@
540540
cacheKey={asset.thumbhash}
541541
projectionType={asset.exifInfo?.projectionType}
542542
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
543+
extendedControls
543544
onPreviousAsset={() => navigateAsset('previous')}
544545
onNextAsset={() => navigateAsset('next')}
545546
onClose={closeViewer}

web/src/lib/components/asset-viewer/VideoNativeViewer.svelte

Lines changed: 195 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,50 @@
22
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
33
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
44
import { assetViewerFadeDuration } from '$lib/constants';
5-
import { castManager } from '$lib/managers/cast-manager.svelte';
65
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
7-
import {
8-
autoPlayVideo,
9-
loopVideo as loopVideoPreference,
10-
videoViewerMuted,
11-
videoViewerVolume,
12-
} from '$lib/stores/preferences.store';
6+
import { castManager } from '$lib/managers/cast-manager.svelte';
7+
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
138
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
14-
import { AssetMediaSize } from '@immich/sdk';
15-
import { LoadingSpinner } from '@immich/ui';
9+
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
10+
import { Icon, LoadingSpinner } from '@immich/ui';
11+
import {
12+
mdiCheck,
13+
mdiChevronLeft,
14+
mdiChevronRight,
15+
mdiFullscreen,
16+
mdiFullscreenExit,
17+
mdiPause,
18+
mdiPlay,
19+
mdiVolumeHigh,
20+
mdiVolumeLow,
21+
mdiVolumeMedium,
22+
mdiVolumeMute,
23+
} from '@mdi/js';
24+
import 'media-chrome/media-control-bar';
25+
import 'media-chrome/media-controller';
26+
import 'media-chrome/media-fullscreen-button';
27+
import 'media-chrome/media-mute-button';
28+
import 'media-chrome/media-play-button';
29+
import 'media-chrome/media-playback-rate-button';
30+
import 'media-chrome/media-time-display';
31+
import 'media-chrome/media-time-range';
32+
import 'media-chrome/media-volume-range';
33+
import 'media-chrome/menu/media-playback-rate-menu';
34+
import 'media-chrome/menu/media-settings-menu';
35+
import 'media-chrome/menu/media-settings-menu-button';
36+
import 'media-chrome/menu/media-settings-menu-item';
1637
import { onDestroy, onMount } from 'svelte';
1738
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
39+
import { t } from 'svelte-i18n';
1840
import { fade } from 'svelte/transition';
1941
2042
interface Props {
43+
asset: AssetResponseDto;
2144
assetId: string;
2245
loopVideo: boolean;
2346
cacheKey: string | null;
2447
playOriginalVideo: boolean;
48+
extendedControls?: boolean;
2549
onPreviousAsset?: () => void;
2650
onNextAsset?: () => void;
2751
onVideoEnded?: () => void;
@@ -30,10 +54,12 @@
3054
}
3155
3256
let {
57+
asset,
3358
assetId,
3459
loopVideo,
3560
cacheKey,
3661
playOriginalVideo,
62+
extendedControls = false,
3763
onPreviousAsset = () => {},
3864
onNextAsset = () => {},
3965
onVideoEnded = () => {},
@@ -48,12 +74,11 @@
4874
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
4975
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
5076
);
51-
let isScrubbing = $state(false);
77+
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
5278
let showVideo = $state(false);
5379
let hasFocused = $state(false);
5480
5581
onMount(() => {
56-
// Show video after mount to ensure fading in.
5782
showVideo = true;
5883
});
5984
@@ -73,7 +98,7 @@
7398
7499
const handleCanPlay = async (video: HTMLVideoElement) => {
75100
try {
76-
if (!video.paused && !isScrubbing) {
101+
if (!video.paused) {
77102
await video.play();
78103
onVideoStarted();
79104
}
@@ -138,33 +163,83 @@
138163
/>
139164
</div>
140165
{:else}
141-
<video
142-
bind:this={videoPlayer}
143-
loop={$loopVideoPreference && loopVideo}
144-
autoplay={$autoPlayVideo}
145-
playsinline
146-
controls
147-
disablePictureInPicture
148-
class="h-full object-contain"
149-
{...useSwipe(onSwipe)}
150-
oncanplay={(e) => handleCanPlay(e.currentTarget)}
151-
onended={onVideoEnded}
152-
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
153-
onseeking={() => (isScrubbing = true)}
154-
onseeked={() => (isScrubbing = false)}
155-
onplaying={(e) => {
156-
if (!hasFocused) {
157-
e.currentTarget.focus();
158-
hasFocused = true;
159-
}
160-
}}
161-
onclose={() => onClose()}
162-
muted={$videoViewerMuted}
163-
bind:volume={$videoViewerVolume}
164-
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
165-
src={assetFileUrl}
166+
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
167+
<media-controller
168+
dir="ltr"
169+
nohotkeys
170+
class="dark h-full max-w-full"
171+
style:aspect-ratio={aspectRatio}
172+
defaultduration={asset.duration! / 1000}
166173
>
167-
</video>
174+
<video
175+
bind:this={videoPlayer}
176+
slot="media"
177+
loop={$loopVideoPreference && loopVideo}
178+
autoplay={$autoPlayVideo}
179+
disablePictureInPicture
180+
playsinline
181+
{...useSwipe(onSwipe)}
182+
class="h-full object-contain"
183+
oncanplay={(e) => handleCanPlay(e.currentTarget)}
184+
onended={onVideoEnded}
185+
onplaying={(e) => {
186+
if (!hasFocused) {
187+
e.currentTarget.focus();
188+
hasFocused = true;
189+
}
190+
}}
191+
onclose={onClose}
192+
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
193+
src={assetFileUrl}
194+
></video>
195+
196+
{#if extendedControls}
197+
<media-settings-menu hidden anchor="auto" class="w-3xs rounded-xl border border-light-300 shadow-sm">
198+
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
199+
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
200+
{$t('playback_speed')}
201+
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
202+
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
203+
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
204+
<span slot="title">{$t('playback_speed')}</span>
205+
</media-playback-rate-menu>
206+
</media-settings-menu-item>
207+
</media-settings-menu>
208+
{/if}
209+
210+
<div class="flex h-32 w-full flex-col justify-end bg-linear-to-b to-black/80 px-4">
211+
<media-control-bar part="bottom" class="flex h-10 w-full gap-2">
212+
<media-play-button class="shrink-0 rounded-full p-2 outline-none">
213+
<Icon slot="play" icon={mdiPlay} />
214+
<Icon slot="pause" icon={mdiPause} />
215+
</media-play-button>
216+
<media-time-display showduration class="rounded-lg p-2 outline-none"></media-time-display>
217+
218+
<span class="grow"></span>
219+
220+
<div
221+
class="volume-wrapper shrink-0 rounded-full bg-light-100/0 transition-colors duration-400 hover:bg-light-100"
222+
>
223+
<media-volume-range class="h-full bg-none outline-none"></media-volume-range>
224+
<media-mute-button class="bg-none p-2 outline-none">
225+
<Icon slot="off" icon={mdiVolumeMute} />
226+
<Icon slot="low" icon={mdiVolumeLow} />
227+
<Icon slot="medium" icon={mdiVolumeMedium} />
228+
<Icon slot="high" icon={mdiVolumeHigh} />
229+
</media-mute-button>
230+
</div>
231+
232+
{#if extendedControls}
233+
<media-fullscreen-button class="shrink-0 rounded-full p-2 outline-none">
234+
<Icon slot="enter" icon={mdiFullscreen} />
235+
<Icon slot="exit" icon={mdiFullscreenExit} />
236+
</media-fullscreen-button>
237+
<media-settings-menu-button class="shrink-0 rounded-full p-2 outline-none"></media-settings-menu-button>
238+
{/if}
239+
</media-control-bar>
240+
<media-time-range class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></media-time-range>
241+
</div>
242+
</media-controller>
168243

169244
{#if isLoading}
170245
<div class="absolute flex place-content-center place-items-center">
@@ -178,3 +253,85 @@
178253
{/if}
179254
</div>
180255
{/if}
256+
257+
<style>
258+
media-controller {
259+
--media-control-background: none;
260+
--media-control-hover-background: var(--immich-ui-light-100);
261+
--media-focus-box-shadow: 0 0 0 2px var(--immich-ui-dark);
262+
--media-font-family: var(--font-sans);
263+
--media-font-size: var(--text-base);
264+
--media-font-weight: var(--font-weight-medium);
265+
--media-menu-border-radius: var(--radius-xl);
266+
--media-menu-gap: var(--spacing);
267+
--media-menu-item-hover-background: var(--immich-ui-light-200);
268+
--media-menu-item-icon-height: 1em;
269+
--media-menu-item-indicator-height: 1em;
270+
--media-primary-color: var(--immich-ui-dark);
271+
--media-time-range-buffered-color: var(--immich-ui-dark-400);
272+
--media-time-range-hover-bottom: 0;
273+
--media-time-range-hover-height: 100%;
274+
--media-range-thumb-box-shadow: none;
275+
--media-range-thumb-opacity: 0;
276+
--media-range-thumb-transition: opacity 0.15s ease;
277+
--media-range-track-border-radius: 2px;
278+
--media-range-track-height: 3.5px;
279+
--media-range-padding: 0;
280+
--media-settings-menu-background: var(--immich-ui-light-100);
281+
--media-text-content-height: var(--text-base--line-height);
282+
--media-tooltip-arrow-display: none;
283+
--media-tooltip-border-radius: var(--radius-lg);
284+
--media-tooltip-background-color: var(--immich-ui-light-200);
285+
--media-tooltip-distance: 8px;
286+
--media-tooltip-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3.5);
287+
}
288+
289+
media-time-display {
290+
font-variant-numeric: tabular-nums;
291+
}
292+
293+
media-time-range,
294+
media-volume-range {
295+
--media-control-hover-background: none;
296+
}
297+
298+
media-time-range:hover,
299+
media-volume-range:hover {
300+
--media-range-thumb-opacity: 1;
301+
}
302+
303+
*::part(tooltip) {
304+
--media-font-size: var(--text-xs);
305+
--media-text-content-height: var(--text-xs--line-height);
306+
color: white;
307+
}
308+
309+
*[mediavolumeunavailable] {
310+
--media-volume-range-display: none;
311+
}
312+
313+
.volume-wrapper {
314+
--media-control-hover-background: none;
315+
}
316+
317+
media-volume-range:has(+ media-mute-button) {
318+
padding: 0;
319+
margin: 0;
320+
width: 0;
321+
overflow: hidden;
322+
transition: width 0.4s ease-out;
323+
}
324+
325+
/* Expand volume control in all relevant states */
326+
.volume-wrapper:hover > media-volume-range,
327+
media-volume-range:has(+ media-mute-button:hover),
328+
media-volume-range:has(+ media-mute-button:focus),
329+
media-volume-range:has(+ media-mute-button:focus-within),
330+
media-volume-range:hover,
331+
media-volume-range:focus,
332+
media-volume-range:focus-within {
333+
padding: 0 calc(var(--spacing) * 2);
334+
margin-left: calc(var(--spacing) * 2);
335+
width: 70px;
336+
}
337+
</style>

0 commit comments

Comments
 (0)