|
2 | 2 | import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte'; |
3 | 3 | import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte'; |
4 | 4 | import { assetViewerFadeDuration } from '$lib/constants'; |
5 | | - import { castManager } from '$lib/managers/cast-manager.svelte'; |
6 | 5 | 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'; |
13 | 8 | 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'; |
16 | 37 | import { onDestroy, onMount } from 'svelte'; |
17 | 38 | import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; |
| 39 | + import { t } from 'svelte-i18n'; |
18 | 40 | import { fade } from 'svelte/transition'; |
19 | 41 |
|
20 | 42 | interface Props { |
| 43 | + asset: AssetResponseDto; |
21 | 44 | assetId: string; |
22 | 45 | loopVideo: boolean; |
23 | 46 | cacheKey: string | null; |
24 | 47 | playOriginalVideo: boolean; |
| 48 | + extendedControls?: boolean; |
25 | 49 | onPreviousAsset?: () => void; |
26 | 50 | onNextAsset?: () => void; |
27 | 51 | onVideoEnded?: () => void; |
|
30 | 54 | } |
31 | 55 |
|
32 | 56 | let { |
| 57 | + asset, |
33 | 58 | assetId, |
34 | 59 | loopVideo, |
35 | 60 | cacheKey, |
36 | 61 | playOriginalVideo, |
| 62 | + extendedControls = false, |
37 | 63 | onPreviousAsset = () => {}, |
38 | 64 | onNextAsset = () => {}, |
39 | 65 | onVideoEnded = () => {}, |
|
48 | 74 | ? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey }) |
49 | 75 | : getAssetPlaybackUrl({ id: assetId, cacheKey }), |
50 | 76 | ); |
51 | | - let isScrubbing = $state(false); |
| 77 | + const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined); |
52 | 78 | let showVideo = $state(false); |
53 | 79 | let hasFocused = $state(false); |
54 | 80 |
|
55 | 81 | onMount(() => { |
56 | | - // Show video after mount to ensure fading in. |
57 | 82 | showVideo = true; |
58 | 83 | }); |
59 | 84 |
|
|
73 | 98 |
|
74 | 99 | const handleCanPlay = async (video: HTMLVideoElement) => { |
75 | 100 | try { |
76 | | - if (!video.paused && !isScrubbing) { |
| 101 | + if (!video.paused) { |
77 | 102 | await video.play(); |
78 | 103 | onVideoStarted(); |
79 | 104 | } |
|
138 | 163 | /> |
139 | 164 | </div> |
140 | 165 | {: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} |
166 | 173 | > |
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> |
168 | 243 |
|
169 | 244 | {#if isLoading} |
170 | 245 | <div class="absolute flex place-content-center place-items-center"> |
|
178 | 253 | {/if} |
179 | 254 | </div> |
180 | 255 | {/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