mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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>
This commit is contained in:
@@ -1761,6 +1761,7 @@
|
|||||||
"play_original_video": "Play original video",
|
"play_original_video": "Play original video",
|
||||||
"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.",
|
"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.",
|
||||||
"play_transcoded_video": "Play transcoded video",
|
"play_transcoded_video": "Play transcoded video",
|
||||||
|
"playback_speed": "Playback speed",
|
||||||
"please_auth_to_access": "Please authenticate to access",
|
"please_auth_to_access": "Please authenticate to access",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||||
@@ -2436,6 +2437,7 @@
|
|||||||
"workflows": "Workflows",
|
"workflows": "Workflows",
|
||||||
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
||||||
"wrong_pin_code": "Wrong PIN code",
|
"wrong_pin_code": "Wrong PIN code",
|
||||||
|
"x_of_total": "{x}/{total}",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
|||||||
Generated
+21
@@ -804,6 +804,9 @@ importers:
|
|||||||
maplibre-gl:
|
maplibre-gl:
|
||||||
specifier: ^5.6.2
|
specifier: ^5.6.2
|
||||||
version: 5.24.0
|
version: 5.24.0
|
||||||
|
media-chrome:
|
||||||
|
specifier: ^4.19.0
|
||||||
|
version: 4.19.0(react@19.2.5)
|
||||||
pmtiles:
|
pmtiles:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.4.1
|
version: 4.4.1
|
||||||
@@ -6175,6 +6178,11 @@ packages:
|
|||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
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:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -9080,6 +9088,9 @@ packages:
|
|||||||
mdn-data@2.27.1:
|
mdn-data@2.27.1:
|
||||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
|
media-chrome@4.19.0:
|
||||||
|
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
|
||||||
|
|
||||||
media-typer@0.3.0:
|
media-typer@0.3.0:
|
||||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -18763,6 +18774,10 @@ snapshots:
|
|||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
|
ce-la-react@0.3.2(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
@@ -22167,6 +22182,12 @@ snapshots:
|
|||||||
|
|
||||||
mdn-data@2.27.1: {}
|
mdn-data@2.27.1: {}
|
||||||
|
|
||||||
|
media-chrome@4.19.0(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
ce-la-react: 0.3.2(react@19.2.5)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"maplibre-gl": "^5.6.2",
|
"maplibre-gl": "^5.6.2",
|
||||||
|
"media-chrome": "^4.19.0",
|
||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"simple-icons": "^16.0.0",
|
"simple-icons": "^16.0.0",
|
||||||
|
|||||||
@@ -540,6 +540,7 @@
|
|||||||
cacheKey={asset.thumbhash}
|
cacheKey={asset.thumbhash}
|
||||||
projectionType={asset.exifInfo?.projectionType}
|
projectionType={asset.exifInfo?.projectionType}
|
||||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||||
|
extendedControls
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onPreviousAsset={() => navigateAsset('previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
onNextAsset={() => navigateAsset('next')}
|
||||||
onClose={closeViewer}
|
onClose={closeViewer}
|
||||||
|
|||||||
@@ -2,26 +2,50 @@
|
|||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
|
||||||
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
|
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import {
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
autoPlayVideo,
|
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||||
loopVideo as loopVideoPreference,
|
|
||||||
videoViewerMuted,
|
|
||||||
videoViewerVolume,
|
|
||||||
} from '$lib/stores/preferences.store';
|
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
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 { onDestroy, onMount } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
asset: AssetResponseDto;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
|
extendedControls?: boolean;
|
||||||
onPreviousAsset?: () => void;
|
onPreviousAsset?: () => void;
|
||||||
onNextAsset?: () => void;
|
onNextAsset?: () => void;
|
||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
@@ -30,10 +54,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
asset,
|
||||||
assetId,
|
assetId,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
|
extendedControls = false,
|
||||||
onPreviousAsset = () => {},
|
onPreviousAsset = () => {},
|
||||||
onNextAsset = () => {},
|
onNextAsset = () => {},
|
||||||
onVideoEnded = () => {},
|
onVideoEnded = () => {},
|
||||||
@@ -48,12 +74,11 @@
|
|||||||
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
||||||
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
||||||
);
|
);
|
||||||
let isScrubbing = $state(false);
|
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
|
||||||
let showVideo = $state(false);
|
let showVideo = $state(false);
|
||||||
let hasFocused = $state(false);
|
let hasFocused = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Show video after mount to ensure fading in.
|
|
||||||
showVideo = true;
|
showVideo = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +98,7 @@
|
|||||||
|
|
||||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||||
try {
|
try {
|
||||||
if (!video.paused && !isScrubbing) {
|
if (!video.paused) {
|
||||||
await video.play();
|
await video.play();
|
||||||
onVideoStarted();
|
onVideoStarted();
|
||||||
}
|
}
|
||||||
@@ -138,33 +163,83 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<video
|
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
|
||||||
bind:this={videoPlayer}
|
<media-controller
|
||||||
loop={$loopVideoPreference && loopVideo}
|
dir="ltr"
|
||||||
autoplay={$autoPlayVideo}
|
nohotkeys
|
||||||
playsinline
|
class="dark h-full max-w-full"
|
||||||
controls
|
style:aspect-ratio={aspectRatio}
|
||||||
disablePictureInPicture
|
defaultduration={asset.duration! / 1000}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
</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="w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||||
|
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
|
||||||
|
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
|
||||||
|
{$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 h-32 w-full flex-col justify-end bg-linear-to-b to-black/80 px-4">
|
||||||
|
<media-control-bar part="bottom" class="flex h-10 w-full gap-2">
|
||||||
|
<media-play-button class="shrink-0 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="grow"></span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="volume-wrapper shrink-0 rounded-full bg-light-100/0 transition-colors duration-400 hover:bg-light-100"
|
||||||
|
>
|
||||||
|
<media-volume-range class="h-full bg-none outline-none"></media-volume-range>
|
||||||
|
<media-mute-button class="bg-none 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>
|
||||||
|
|
||||||
|
{#if extendedControls}
|
||||||
|
<media-fullscreen-button class="shrink-0 rounded-full p-2 outline-none">
|
||||||
|
<Icon slot="enter" icon={mdiFullscreen} />
|
||||||
|
<Icon slot="exit" icon={mdiFullscreenExit} />
|
||||||
|
</media-fullscreen-button>
|
||||||
|
<media-settings-menu-button class="shrink-0 rounded-full p-2 outline-none"></media-settings-menu-button>
|
||||||
|
{/if}
|
||||||
|
</media-control-bar>
|
||||||
|
<media-time-range class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></media-time-range>
|
||||||
|
</div>
|
||||||
|
</media-controller>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="absolute flex place-content-center place-items-center">
|
<div class="absolute flex place-content-center place-items-center">
|
||||||
@@ -178,3 +253,85 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
media-time-display {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
|
extendedControls?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onPreviousAsset?: () => void;
|
onPreviousAsset?: () => void;
|
||||||
onNextAsset?: () => void;
|
onNextAsset?: () => void;
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
cacheKey,
|
cacheKey,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
|
extendedControls = false,
|
||||||
onPreviousAsset,
|
onPreviousAsset,
|
||||||
onClose,
|
onClose,
|
||||||
onNextAsset,
|
onNextAsset,
|
||||||
@@ -41,8 +43,10 @@
|
|||||||
<VideoNativeViewer
|
<VideoNativeViewer
|
||||||
{loopVideo}
|
{loopVideo}
|
||||||
{cacheKey}
|
{cacheKey}
|
||||||
|
{asset}
|
||||||
assetId={effectiveAssetId}
|
assetId={effectiveAssetId}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
|
{extendedControls}
|
||||||
{onPreviousAsset}
|
{onPreviousAsset}
|
||||||
{onNextAsset}
|
{onNextAsset}
|
||||||
{onVideoEnded}
|
{onVideoEnded}
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ const persistedObject = <T>(key: string, defaults: T) =>
|
|||||||
|
|
||||||
export const mapSettings = persistedObject<MapSettings>('map-settings', defaultMapSettings);
|
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 {
|
export interface AlbumViewSettings {
|
||||||
view: string;
|
view: string;
|
||||||
filter: string;
|
filter: string;
|
||||||
|
|||||||
+16
-14
@@ -4,17 +4,16 @@
|
|||||||
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
|
import 'media-chrome/media-controller';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: TimelineAsset;
|
||||||
videoPlayer: HTMLVideoElement | undefined;
|
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);
|
let showVideo: boolean = $state(false);
|
||||||
|
|
||||||
@@ -26,16 +25,19 @@
|
|||||||
|
|
||||||
{#if showVideo}
|
{#if showVideo}
|
||||||
<div class="bg-pink-9000 size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
<div class="bg-pink-9000 size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||||
<video
|
<media-controller id="memory-video" nohotkeys class="size-full rounded-2xl object-contain transition-all">
|
||||||
bind:this={videoPlayer}
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
autoplay={$autoPlayVideo}
|
<video
|
||||||
playsinline
|
bind:this={videoPlayer}
|
||||||
class="size-full rounded-2xl object-contain transition-all"
|
slot="media"
|
||||||
src={getAssetPlaybackUrl({ id: asset.id })}
|
autoplay={$autoPlayVideo}
|
||||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
playsinline
|
||||||
draggable="false"
|
disablepictureinpicture
|
||||||
muted={videoViewerMuted}
|
class="size-full"
|
||||||
volume={videoViewerVolume}
|
src={getAssetPlaybackUrl({ id: asset.id })}
|
||||||
></video>
|
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||||
|
draggable="false"
|
||||||
|
></video>
|
||||||
|
</media-controller>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
import { ActionButton, IconButton, Text, toastManager } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCardsOutline,
|
mdiCardsOutline,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import 'media-chrome/media-mute-button';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
@@ -308,7 +309,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (videoPlayer) {
|
if (videoPlayer) {
|
||||||
videoPlayer.muted = $videoViewerMuted;
|
|
||||||
initPlayer();
|
initPlayer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -380,42 +380,62 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
|
<div class="dark flex place-content-center place-items-center gap-2">
|
||||||
<div class="dark w-12.5">
|
<IconButton
|
||||||
<IconButton
|
shape="round"
|
||||||
shape="round"
|
variant="ghost"
|
||||||
variant="ghost"
|
color="secondary"
|
||||||
color="secondary"
|
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
||||||
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
icon={paused ? mdiPlay : mdiPause}
|
||||||
icon={paused ? mdiPlay : mdiPause}
|
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
||||||
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each current.memory.assets as asset, index (asset.id)}
|
{#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 inset-s-0 h-0.5 w-full bg-gray-500"></span>
|
<span class="absolute inset-s-0 h-0.5 w-full bg-gray-500"></span>
|
||||||
<span class="absolute inset-s-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
|
<span class="absolute inset-s-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div>
|
<Text size="small">
|
||||||
<p class="text-small">
|
{$t('x_of_total', {
|
||||||
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
|
values: {
|
||||||
</p>
|
x: (current.assetIndex + 1).toLocaleString($locale),
|
||||||
</div>
|
total: current.memory.assets.length.toLocaleString($locale),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
|
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
|
||||||
<div class="dark w-12.5">
|
<media-mute-button
|
||||||
|
mediacontroller={videoPlayer ? 'memory-video' : ''}
|
||||||
|
disabled={!videoPlayer}
|
||||||
|
class="rounded-full bg-transparent outline-offset-2 outline-dark focus-visible:outline-2"
|
||||||
|
style="--media-focus-box-shadow: none;"
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
slot="off"
|
||||||
|
disabled={!videoPlayer}
|
||||||
|
tabindex={-1}
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
|
aria-label={$t('unmute_memories')}
|
||||||
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
|
icon={mdiVolumeOff}
|
||||||
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
@@ -487,12 +507,7 @@
|
|||||||
<div class="relative size-full rounded-2xl bg-black">
|
<div class="relative size-full rounded-2xl bg-black">
|
||||||
{#key current.asset.id}
|
{#key current.asset.id}
|
||||||
{#if current.asset.isVideo}
|
{#if current.asset.isVideo}
|
||||||
<MemoryVideoViewer
|
<MemoryVideoViewer asset={current.asset} bind:videoPlayer />
|
||||||
asset={current.asset}
|
|
||||||
bind:videoPlayer
|
|
||||||
videoViewerMuted={$videoViewerMuted}
|
|
||||||
videoViewerVolume={$videoViewerVolume}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -511,7 +526,6 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||||
onclick={() => handleSaveMemory()}
|
onclick={() => handleSaveMemory()}
|
||||||
class="size-12"
|
|
||||||
/>
|
/>
|
||||||
<!-- <IconButton
|
<!-- <IconButton
|
||||||
icon={mdiShareVariantOutline}
|
icon={mdiShareVariantOutline}
|
||||||
|
|||||||
Reference in New Issue
Block a user