diff --git a/i18n/en.json b/i18n/en.json index add755c05d..cc30d9e350 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1761,6 +1761,7 @@ "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_transcoded_video": "Play transcoded video", + "playback_speed": "Playback speed", "please_auth_to_access": "Please authenticate to access", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", @@ -2436,6 +2437,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7aa8f33f9..c3f970ca51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: maplibre-gl: specifier: ^5.6.2 version: 5.24.0 + media-chrome: + specifier: ^4.19.0 + version: 4.19.0(react@19.2.5) pmtiles: specifier: ^4.3.0 version: 4.4.1 @@ -6175,6 +6178,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'} @@ -9080,6 +9088,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-chrome@4.19.0: + resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -18763,6 +18774,10 @@ snapshots: ccount@2.0.1: {} + ce-la-react@0.3.2(react@19.2.5): + dependencies: + react: 19.2.5 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -22167,6 +22182,12 @@ snapshots: 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@1.1.0: {} diff --git a/web/package.json b/web/package.json index 1b4f1c46ed..daaa74d9e9 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "maplibre-gl": "^5.6.2", + "media-chrome": "^4.19.0", "pmtiles": "^4.3.0", "qrcode": "^1.5.4", "simple-icons": "^16.0.0", diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index 656d67dea8..cb49828f0d 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -540,6 +540,7 @@ cacheKey={asset.thumbhash} projectionType={asset.exifInfo?.projectionType} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} + extendedControls onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} onClose={closeViewer} diff --git a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte index 5ae768d4d4..58e6fdd1e1 100644 --- a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte @@ -2,26 +2,50 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte'; import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; - import { castManager } from '$lib/managers/cast-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; - import { - autoPlayVideo, - loopVideo as loopVideoPreference, - videoViewerMuted, - videoViewerVolume, - } from '$lib/stores/preferences.store'; + import { castManager } from '$lib/managers/cast-manager.svelte'; + 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 { 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 +54,12 @@ } let { + asset, assetId, loopVideo, cacheKey, playOriginalVideo, + extendedControls = false, onPreviousAsset = () => {}, onNextAsset = () => {}, onVideoEnded = () => {}, @@ -48,12 +74,11 @@ ? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, 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 hasFocused = $state(false); onMount(() => { - // Show video after mount to ensure fading in. showVideo = true; }); @@ -73,7 +98,7 @@ const handleCanPlay = async (video: HTMLVideoElement) => { try { - if (!video.paused && !isScrubbing) { + if (!video.paused) { await video.play(); onVideoStarted(); } @@ -138,33 +163,83 @@ /> {:else} - + + + {#if extendedControls} + + {/if} + +
+ + + + + + + + + +
+ + + + + + + +
+ + {#if extendedControls} + + + + + + {/if} +
+ +
+ {#if isLoading}
@@ -178,3 +253,85 @@ {/if}
{/if} + + diff --git a/web/src/lib/components/asset-viewer/VideoWrapperViewer.svelte b/web/src/lib/components/asset-viewer/VideoWrapperViewer.svelte index a26a60ee86..ddcf16c3dd 100644 --- a/web/src/lib/components/asset-viewer/VideoWrapperViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoWrapperViewer.svelte @@ -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 @@ (key: string, defaults: T) => export const mapSettings = persistedObject('map-settings', defaultMapSettings); -export const videoViewerVolume = persisted('video-viewer-volume', 1, {}); -export const videoViewerMuted = persisted('video-viewer-muted', false, {}); - export interface AlbumViewSettings { view: string; filter: string; diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte index 9aefa4f9f0..02f95dd13c 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte @@ -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}
- + + + +
{/if} diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index 63539a3e68..6b373de8cb 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -26,11 +26,11 @@ import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; 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 { 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, @@ -50,6 +50,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'; @@ -308,7 +309,6 @@ $effect(() => { if (videoPlayer) { - videoPlayer.muted = $videoViewerMuted; initPlayer(); } }); @@ -380,42 +380,62 @@ {/if} {/snippet} -
-
- handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))} - /> -
+
+ handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))} + /> {#each current.memory.assets as asset, index (asset.id)} - + {/each} -
-

- {(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)} -

-
+ + {$t('x_of_total', { + values: { + x: (current.assetIndex + 1).toLocaleString($locale), + total: current.memory.assets.length.toLocaleString($locale), + }, + })} + {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)} -
+ ($videoViewerMuted = !$videoViewerMuted)} + aria-label={$t('unmute_memories')} + icon={mdiVolumeOff} + onclick={() => {}} /> -
+ {}} + /> + {/if}
@@ -487,12 +507,7 @@
{#key current.asset.id} {#if current.asset.isVideo} - + {:else} {/if} @@ -511,7 +526,6 @@ color="secondary" aria-label={isSaved ? $t('unfavorite') : $t('favorite')} onclick={() => handleSaveMemory()} - class="size-12" />