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
parent
f0b069adb9
commit
f96cd758cd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue