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.
pull/24627/head
timonrieger 2025-12-16 21:02:04 +01:00
parent f0b069adb9
commit f96cd758cd
No known key found for this signature in database
5 changed files with 202 additions and 2 deletions

View File

@ -1984,6 +1984,7 @@
"show_progress_bar": "Show Progress Bar",
"show_search_options": "Show search options",
"show_shared_links": "Show shared links",
"show_slideshow_metadata_overlay": "Show image info overlay",
"show_slideshow_transition": "Show slideshow transition",
"show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge",
@ -1999,6 +2000,9 @@
"skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags",
"slideshow": "Slideshow",
"slideshow_metadata_overlay_mode": "Overlay content",
"slideshow_metadata_overlay_mode_description_only": "Description only",
"slideshow_metadata_overlay_mode_full": "Full",
"slideshow_settings": "Slideshow settings",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",

View File

@ -48,6 +48,7 @@
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import SlideshowMetadataOverlay from './slideshow-metadata-overlay.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
@ -560,6 +561,10 @@
<OcrButton />
</div>
{/if}
{#if $slideshowState !== SlideshowState.None}
<SlideshowMetadataOverlay {asset} />
{/if}
{/key}
{/if}
</div>

View File

@ -0,0 +1,145 @@
<script lang="ts">
import { SlideshowMetadataOverlayMode, slideshowStore } from '$lib/stores/slideshow.store';
import { decodeBase64 } from '$lib/utils';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { thumbHashToRGBA } from 'thumbhash';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
const { slideshowShowMetadataOverlay, slideshowMetadataOverlayMode } = slideshowStore;
let scrimOpacity = $state(0.7);
// Compute description
const description = $derived(asset.exifInfo?.description?.trim() || '');
// Compute date taken
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));
// Compute location
const locationParts = $derived(
(() => {
const parts: string[] = [];
if (asset.exifInfo?.city) {
parts.push(asset.exifInfo.city);
}
if (asset.exifInfo?.state) {
parts.push(asset.exifInfo.state);
}
if (asset.exifInfo?.country) {
parts.push(asset.exifInfo.country);
}
return parts;
})(),
);
const locationString = $derived(locationParts.join(', '));
// Compute visibility
const shouldShow = $derived(() => {
if (!$slideshowShowMetadataOverlay) {
return false;
}
if ($slideshowMetadataOverlayMode === SlideshowMetadataOverlayMode.DescriptionOnly) {
return !!description;
}
// Full mode: show if any field is available
return !!description || !!dateString || !!locationString;
});
// Compute adaptive scrim opacity from thumbhash
const computeScrimOpacity = (thumbhash: string | null | undefined): number => {
if (!thumbhash) {
return 0.7; // Safe fallback
}
try {
const decoded = decodeBase64(thumbhash);
const { h, w, rgba } = thumbHashToRGBA(decoded);
// Sample the bottom ~30% region where overlay sits
const bottomStart = Math.floor(h * 0.7);
let totalLuminance = 0;
let sampleCount = 0;
for (let y = bottomStart; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4;
const r = rgba[idx];
const g = rgba[idx + 1];
const b = rgba[idx + 2];
// Calculate relative luminance (perceived brightness)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
totalLuminance += luminance;
sampleCount++;
}
}
if (sampleCount === 0) {
return 0.7;
}
const avgLuminance = totalLuminance / sampleCount;
// Map luminance to opacity: bright images get darker scrim
// opacity = clamp(0.45 + luminance * 0.40, 0.45, 0.85)
const opacity = Math.max(0.45, Math.min(0.85, 0.45 + avgLuminance * 0.4));
return opacity;
} catch (error) {
console.warn('Failed to compute scrim opacity from thumbhash:', error);
return 0.7; // Safe fallback
}
};
$effect(() => {
scrimOpacity = asset.thumbhash ? computeScrimOpacity(asset.thumbhash) : 0.7;
console.log('scrimOpacity', scrimOpacity);
});
</script>
{#if shouldShow()}
<div class="absolute bottom-0 left-0 right-0 z-10">
<!-- Dark scrim with adaptive opacity -->
<div
class="w-full px-6 py-4"
style="background: linear-gradient(to top, rgba(0, 0, 0, {scrimOpacity}) 0%, rgba(0, 0, 0, {scrimOpacity *
0.8}) 100%);"
>
<div class="flex flex-col gap-2 text-white">
{#if $slideshowMetadataOverlayMode === SlideshowMetadataOverlayMode.DescriptionOnly}
{#if description}
<p class="text-base font-medium leading-relaxed whitespace-pre-wrap wrap-break-word">
{description}
</p>
{/if}
{:else}
<!-- Full mode: show all available metadata -->
{#if description}
<p class="text-base font-medium leading-relaxed whitespace-pre-wrap wrap-break-word">
{description}
</p>
{/if}
<div class="flex flex-col gap-1 text-sm opacity-90">
{#if dateString}
<p>{dateString}</p>
{/if}
{#if locationString}
<p>{locationString}</p>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}

View File

@ -14,7 +14,12 @@
} from '@mdi/js';
import { t } from 'svelte-i18n';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
import {
SlideshowLook,
SlideshowMetadataOverlayMode,
SlideshowNavigation,
slideshowStore,
} from '../stores/slideshow.store';
const {
slideshowDelay,
@ -23,6 +28,8 @@
slideshowLook,
slideshowTransition,
slideshowAutoplay,
slideshowShowMetadataOverlay,
slideshowMetadataOverlayMode,
} = slideshowStore;
interface Props {
@ -38,6 +45,8 @@
let tempSlideshowLook = $state($slideshowLook);
let tempSlideshowTransition = $state($slideshowTransition);
let tempSlideshowAutoplay = $state($slideshowAutoplay);
let tempSlideshowShowMetadataOverlay = $state($slideshowShowMetadataOverlay);
let tempSlideshowMetadataOverlayMode = $state($slideshowMetadataOverlayMode);
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@ -51,7 +60,16 @@
[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,
options: Record<Type, RenderedOption>,
): undefined | Type => {
@ -69,6 +87,8 @@
$slideshowLook = tempSlideshowLook;
$slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay;
$slideshowShowMetadataOverlay = tempSlideshowShowMetadataOverlay;
$slideshowMetadataOverlayMode = tempSlideshowMetadataOverlayMode;
onClose();
};
</script>
@ -95,6 +115,20 @@
<SettingSwitch title={$t('autoplay_slideshow')} bind:checked={tempSlideshowAutoplay} />
<SettingSwitch title={$t('show_progress_bar')} bind:checked={tempShowProgressBar} />
<SettingSwitch title={$t('show_slideshow_transition')} bind:checked={tempSlideshowTransition} />
<SettingSwitch title={$t('show_slideshow_metadata_overlay')} bind:checked={tempSlideshowShowMetadataOverlay} />
<div class={tempSlideshowShowMetadataOverlay ? '' : 'opacity-50 pointer-events-none'}>
<SettingDropdown
title={$t('slideshow_metadata_overlay_mode')}
options={Object.values(metadataOverlayModeOptions)}
selectedOption={metadataOverlayModeOptions[tempSlideshowMetadataOverlayMode]}
onToggle={(option) => {
if (tempSlideshowShowMetadataOverlay) {
tempSlideshowMetadataOverlayMode =
handleToggle(option, metadataOverlayModeOptions) || tempSlideshowMetadataOverlayMode;
}
}}
/>
</div>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('duration')}

View File

@ -19,6 +19,11 @@ export enum SlideshowLook {
BlurredBackground = 'blurred-background',
}
export enum SlideshowMetadataOverlayMode {
DescriptionOnly = 'description-only',
Full = 'full',
}
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
[SlideshowLook.Contain]: 'object-contain',
[SlideshowLook.Cover]: 'object-cover',
@ -40,6 +45,11 @@ function createSlideshowStore() {
const slideshowDelay = persisted<number>('slideshow-delay', 5, {});
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
const slideshowShowMetadataOverlay = persisted<boolean>('slideshow-show-metadata-overlay', false);
const slideshowMetadataOverlayMode = persisted<SlideshowMetadataOverlayMode>(
'slideshow-metadata-overlay-mode',
SlideshowMetadataOverlayMode.Full,
);
return {
restartProgress: {
@ -71,6 +81,8 @@ function createSlideshowStore() {
showProgressBar,
slideshowTransition,
slideshowAutoplay,
slideshowShowMetadataOverlay,
slideshowMetadataOverlayMode,
};
}