mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(web): Add metadata overlay to slideshow (#24627)
* feat: add slideshow metadata overlay and settings * Introduced a new SlideshowMetadataOverlay component to display image information during slideshows. * Updated slideshow settings modal to include options for showing the metadata overlay and selecting its display mode (Description Only or Full). * Added corresponding translations and store management for the new overlay features. * remove noisy log * constant opacity * 2nd pass * more * use text components * lint
This commit is contained in:
@@ -2192,6 +2192,7 @@
|
|||||||
"show_schema": "Show schema",
|
"show_schema": "Show schema",
|
||||||
"show_search_options": "Show search options",
|
"show_search_options": "Show search options",
|
||||||
"show_shared_links": "Show shared links",
|
"show_shared_links": "Show shared links",
|
||||||
|
"show_slideshow_metadata_overlay": "Show image info overlay",
|
||||||
"show_slideshow_transition": "Show slideshow transition",
|
"show_slideshow_transition": "Show slideshow transition",
|
||||||
"show_supporter_badge": "Supporter badge",
|
"show_supporter_badge": "Supporter badge",
|
||||||
"show_supporter_badge_description": "Show a supporter badge",
|
"show_supporter_badge_description": "Show a supporter badge",
|
||||||
@@ -2207,6 +2208,9 @@
|
|||||||
"skip_to_folders": "Skip to folders",
|
"skip_to_folders": "Skip to folders",
|
||||||
"skip_to_tags": "Skip to tags",
|
"skip_to_tags": "Skip to tags",
|
||||||
"slideshow": "Slideshow",
|
"slideshow": "Slideshow",
|
||||||
|
"slideshow_metadata_overlay_mode": "Overlay content",
|
||||||
|
"slideshow_metadata_overlay_mode_description_only": "Description only",
|
||||||
|
"slideshow_metadata_overlay_mode_full": "Full",
|
||||||
"slideshow_repeat": "Repeat slideshow",
|
"slideshow_repeat": "Repeat slideshow",
|
||||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||||
"slideshow_settings": "Slideshow settings",
|
"slideshow_settings": "Slideshow settings",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
import OcrButton from './OcrButton.svelte';
|
import OcrButton from './OcrButton.svelte';
|
||||||
import PhotoViewer from './PhotoViewer.svelte';
|
import PhotoViewer from './PhotoViewer.svelte';
|
||||||
import SlideshowBar from './SlideshowBar.svelte';
|
import SlideshowBar from './SlideshowBar.svelte';
|
||||||
|
import SlideshowMetadataOverlay from './SlideshowMetadataOverlay.svelte';
|
||||||
import VideoViewer from './VideoWrapperViewer.svelte';
|
import VideoViewer from './VideoWrapperViewer.svelte';
|
||||||
|
|
||||||
export type AssetCursor = {
|
export type AssetCursor = {
|
||||||
@@ -588,6 +589,10 @@
|
|||||||
<OcrButton />
|
<OcrButton />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if $slideshowState !== SlideshowState.None}
|
||||||
|
<SlideshowMetadataOverlay {asset} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SlideshowMetadataOverlayMode, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
|
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { Text } from '@immich/ui';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
asset: AssetResponseDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { asset }: Props = $props();
|
||||||
|
|
||||||
|
const { slideshowShowMetadataOverlay, slideshowMetadataOverlayMode } = slideshowStore;
|
||||||
|
|
||||||
|
const opacity = 0.7;
|
||||||
|
|
||||||
|
const description = $derived(asset.exifInfo?.description?.trim() || '');
|
||||||
|
|
||||||
|
const dateTime = $derived(
|
||||||
|
asset.exifInfo?.timeZone && asset.exifInfo?.dateTimeOriginal
|
||||||
|
? fromISODateTime(asset.exifInfo.dateTimeOriginal, asset.exifInfo.timeZone)
|
||||||
|
: fromISODateTimeUTC(asset.localDateTime),
|
||||||
|
);
|
||||||
|
const dateString = $derived(dateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY));
|
||||||
|
|
||||||
|
const locationString = $derived(
|
||||||
|
[asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean).join(', '),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldShow = $derived.by(() => {
|
||||||
|
if (!$slideshowShowMetadataOverlay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($slideshowMetadataOverlayMode === SlideshowMetadataOverlayMode.DescriptionOnly) {
|
||||||
|
return !!description;
|
||||||
|
}
|
||||||
|
return !!description || !!dateString || !!locationString;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if shouldShow}
|
||||||
|
<div class="absolute inset-x-0 bottom-0 z-10">
|
||||||
|
<div
|
||||||
|
class="w-full px-6 py-4"
|
||||||
|
style="background: linear-gradient(to top, rgba(0, 0, 0, {opacity}) 0%, rgba(0, 0, 0, {opacity * 0.8}) 100%);"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 text-white">
|
||||||
|
{#if description}
|
||||||
|
<Text fontWeight="medium" class="leading-relaxed wrap-break-word whitespace-pre-wrap">{description}</Text>
|
||||||
|
{/if}
|
||||||
|
{#if $slideshowMetadataOverlayMode !== SlideshowMetadataOverlayMode.DescriptionOnly}
|
||||||
|
<div class="flex flex-col gap-1 text-sm opacity-90">
|
||||||
|
{#if dateString}
|
||||||
|
<Text>{dateString}</Text>
|
||||||
|
{/if}
|
||||||
|
{#if locationString}
|
||||||
|
<Text>{locationString}</Text>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
options: RenderedOption[];
|
options: RenderedOption[];
|
||||||
selectedOption: RenderedOption;
|
selectedOption: RenderedOption;
|
||||||
isEdited?: boolean;
|
isEdited?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onToggle: (option: RenderedOption) => void;
|
onToggle: (option: RenderedOption) => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
@@ -21,12 +22,13 @@
|
|||||||
options,
|
options,
|
||||||
selectedOption = $bindable(),
|
selectedOption = $bindable(),
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
disabled = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
children,
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex place-items-center justify-between">
|
<div class="flex place-items-center justify-between" class:pointer-events-none={disabled} class:opacity-50={disabled}>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-6.5 place-items-center gap-1">
|
<div class="flex h-6.5 place-items-center gap-1">
|
||||||
<label class="text-sm font-medium" for={title}>
|
<label class="text-sm font-medium" for={title}>
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import SettingDropdown from '../components/shared-components/settings/SettingDropdown.svelte';
|
import SettingDropdown from '../components/shared-components/settings/SettingDropdown.svelte';
|
||||||
import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
|
import {
|
||||||
|
SlideshowLook,
|
||||||
|
SlideshowMetadataOverlayMode,
|
||||||
|
SlideshowNavigation,
|
||||||
|
SlideshowState,
|
||||||
|
slideshowStore,
|
||||||
|
} from '../stores/slideshow.store';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
slideshowDelay,
|
slideshowDelay,
|
||||||
@@ -22,6 +28,8 @@
|
|||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
|
slideshowShowMetadataOverlay,
|
||||||
|
slideshowMetadataOverlayMode,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -38,6 +46,8 @@
|
|||||||
let tempSlideshowTransition = $state($slideshowTransition);
|
let tempSlideshowTransition = $state($slideshowTransition);
|
||||||
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
||||||
let tempSlideshowRepeat = $state($slideshowRepeat);
|
let tempSlideshowRepeat = $state($slideshowRepeat);
|
||||||
|
let tempSlideshowShowMetadataOverlay = $state($slideshowShowMetadataOverlay);
|
||||||
|
let tempSlideshowMetadataOverlayMode = $state($slideshowMetadataOverlayMode);
|
||||||
|
|
||||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||||
@@ -51,7 +61,16 @@
|
|||||||
[SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: $t('blurred_background') },
|
[SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: $t('blurred_background') },
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>(
|
const metadataOverlayModeOptions: Record<SlideshowMetadataOverlayMode, RenderedOption> = {
|
||||||
|
[SlideshowMetadataOverlayMode.DescriptionOnly]: {
|
||||||
|
title: $t('slideshow_metadata_overlay_mode_description_only'),
|
||||||
|
},
|
||||||
|
[SlideshowMetadataOverlayMode.Full]: {
|
||||||
|
title: $t('slideshow_metadata_overlay_mode_full'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook | SlideshowMetadataOverlayMode>(
|
||||||
record: RenderedOption,
|
record: RenderedOption,
|
||||||
options: Record<Type, RenderedOption>,
|
options: Record<Type, RenderedOption>,
|
||||||
): undefined | Type => {
|
): undefined | Type => {
|
||||||
@@ -71,6 +90,8 @@
|
|||||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||||
$slideshowRepeat = tempSlideshowRepeat;
|
$slideshowRepeat = tempSlideshowRepeat;
|
||||||
$slideshowState = SlideshowState.PlaySlideshow;
|
$slideshowState = SlideshowState.PlaySlideshow;
|
||||||
|
$slideshowShowMetadataOverlay = tempSlideshowShowMetadataOverlay;
|
||||||
|
$slideshowMetadataOverlayMode = tempSlideshowMetadataOverlayMode;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -111,6 +132,21 @@
|
|||||||
<Switch bind:checked={tempSlideshowRepeat} />
|
<Switch bind:checked={tempSlideshowRepeat} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={$t('show_slideshow_metadata_overlay')}>
|
||||||
|
<Switch bind:checked={tempSlideshowShowMetadataOverlay} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SettingDropdown
|
||||||
|
title={$t('slideshow_metadata_overlay_mode')}
|
||||||
|
options={Object.values(metadataOverlayModeOptions)}
|
||||||
|
selectedOption={metadataOverlayModeOptions[tempSlideshowMetadataOverlayMode]}
|
||||||
|
disabled={!tempSlideshowShowMetadataOverlay}
|
||||||
|
onToggle={(option) => {
|
||||||
|
tempSlideshowMetadataOverlayMode =
|
||||||
|
handleToggle(option, metadataOverlayModeOptions) || tempSlideshowMetadataOverlayMode;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Field label={$t('duration')}>
|
<Field label={$t('duration')}>
|
||||||
<NumberInput min={1} bind:value={tempSlideshowDelay} />
|
<NumberInput min={1} bind:value={tempSlideshowDelay} />
|
||||||
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
|
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export enum SlideshowLook {
|
|||||||
BlurredBackground = 'blurred-background',
|
BlurredBackground = 'blurred-background',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SlideshowMetadataOverlayMode {
|
||||||
|
DescriptionOnly = 'description-only',
|
||||||
|
Full = 'full',
|
||||||
|
}
|
||||||
|
|
||||||
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
|
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
|
||||||
[SlideshowLook.Contain]: 'object-contain',
|
[SlideshowLook.Contain]: 'object-contain',
|
||||||
[SlideshowLook.Cover]: 'object-cover',
|
[SlideshowLook.Cover]: 'object-cover',
|
||||||
@@ -41,6 +46,11 @@ function createSlideshowStore() {
|
|||||||
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
||||||
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
||||||
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
||||||
|
const slideshowShowMetadataOverlay = persisted<boolean>('slideshow-show-metadata-overlay', false);
|
||||||
|
const slideshowMetadataOverlayMode = persisted<SlideshowMetadataOverlayMode>(
|
||||||
|
'slideshow-metadata-overlay-mode',
|
||||||
|
SlideshowMetadataOverlayMode.Full,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
restartProgress: {
|
restartProgress: {
|
||||||
@@ -73,6 +83,8 @@ function createSlideshowStore() {
|
|||||||
slideshowTransition,
|
slideshowTransition,
|
||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
slideshowShowMetadataOverlay,
|
||||||
|
slideshowMetadataOverlayMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user