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:
Mees Frensel
2026-05-01 19:26:30 +02:00
committed by GitHub
parent f1d8ab8aae
commit affe0ac5ee
9 changed files with 286 additions and 87 deletions
+2
View File
@@ -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",
+21
View File
@@ -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: {}
+1
View File
@@ -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}
-3
View File
@@ -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;
@@ -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}