Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
788123eca0 refactor(web): user sidebar 2026-01-15 21:04:36 -05:00
8 changed files with 40 additions and 239 deletions

View File

@@ -258,7 +258,6 @@ export class MediaRepository {
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
displayAspectRatio: stream.display_aspect_ratio,
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')

View File

@@ -599,21 +599,6 @@ describe(MetadataService.name, () => {
);
});
it('should apply Display Aspect Ratio (DAR) for anamorphic video', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
mocks.media.probe.mockResolvedValue(probeStub.videoStreamAnamorphic);
mockReadTags({});
await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
// Anamorphic video: 1440x1080 with DAR 16:9 should display as 1920x1080
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ exifImageWidth: 1920, exifImageHeight: 1080 }),
{ lockedPropertiesBehavior: 'skip' },
);
});
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.livePhotoWithOriginalFileName,

View File

@@ -989,15 +989,12 @@ export class MetadataService extends BaseService {
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
if (videoStreams[0]) {
// Set video dimensions, considering Display Aspect Ratio (DAR) for anamorphic videos
const { width, height, displayAspectRatio } = videoStreams[0];
const displayDimensions = this.applyDisplayAspectRatio(width, height, displayAspectRatio);
if (displayDimensions.width) {
tags.ImageWidth = displayDimensions.width;
// Set video dimensions
if (videoStreams[0].width) {
tags.ImageWidth = videoStreams[0].width;
}
if (displayDimensions.height) {
tags.ImageHeight = displayDimensions.height;
if (videoStreams[0].height) {
tags.ImageHeight = videoStreams[0].height;
}
switch (videoStreams[0].rotation) {
@@ -1026,44 +1023,4 @@ export class MetadataService extends BaseService {
return tags;
}
/**
* Calculates the display dimensions of a video based on its Display Aspect Ratio (DAR).
* DAR accounts for anamorphic videos where the stored pixel dimensions differ from the intended display dimensions.
* For example, a 1440x1080 video with DAR 16:9 should display as 1920x1080 (1440 * 16/9 / (1440/1080) = 1920).
*/
private applyDisplayAspectRatio(
width: number | undefined,
height: number | undefined,
displayAspectRatio: string | undefined,
): { width?: number; height?: number } {
if (!width || !height || !displayAspectRatio) {
return { width, height };
}
// Parse DAR string (e.g., "16:9" or "4:3")
const darMatch = displayAspectRatio.match(/^(\d+):(\d+)$/);
if (!darMatch) {
return { width, height };
}
const darWidth = Number.parseInt(darMatch[1], 10);
const darHeight = Number.parseInt(darMatch[2], 10);
if (!darWidth || !darHeight) {
return { width, height };
}
const dar = darWidth / darHeight;
const storedAspectRatio = width / height;
// If DAR is effectively the same as stored aspect ratio (within a small tolerance), no adjustment needed
if (Math.abs(dar - storedAspectRatio) < 0.01) {
return { width, height };
}
// Apply DAR by adjusting width while keeping height constant
// This matches how video players typically handle anamorphic content
const displayWidth = Math.round(height * dar);
return { width: displayWidth, height };
}
}

View File

@@ -85,7 +85,6 @@ export interface VideoStreamInfo {
colorPrimaries?: string;
colorSpace?: string;
colorTransfer?: string;
displayAspectRatio?: string;
}
export interface AudioStreamInfo {

View File

@@ -272,21 +272,4 @@ export const probeStub = {
},
],
}),
videoStreamAnamorphic: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
index: 0,
height: 1080,
width: 1440,
codecName: 'h264',
frameCount: 100,
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p',
displayAspectRatio: '16:9',
},
],
}),
};

View File

@@ -1,76 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Icon } from '@immich/ui';
import { mdiChevronDown, mdiChevronLeft } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
title: string;
href: string;
icon: string;
flippedLogo?: boolean;
isSelected?: boolean;
preloadData?: boolean;
dropDownContent?: Snippet;
dropdownOpen?: boolean;
}
let {
title,
href,
icon,
flippedLogo = false,
isSelected = $bindable(false),
preloadData = true,
dropDownContent: hasDropdown,
dropdownOpen = $bindable(false),
}: Props = $props();
$effect(() => {
isSelected = page.url.pathname.startsWith(href);
});
</script>
<div class="relative">
{#if hasDropdown}
<span class="hidden md:block absolute start-1 h-full">
<button
type="button"
aria-label={$t('recent-albums')}
class="relative flex cursor-default pt-4 pb-4 select-none justify-center hover:cursor-pointer hover:bg-subtle hover:fill-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary rounded h-fill"
onclick={() => (dropdownOpen = !dropdownOpen)}
>
<Icon
icon={dropdownOpen ? mdiChevronDown : mdiChevronLeft}
size="1em"
class="shrink-0 delay-100 duration-100 "
flipped={flippedLogo}
aria-hidden
/>
</button>
</span>
{/if}
<!-- safari still needs a tabIndex=0 -->
<a
tabindex="0"
{href}
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false"
aria-current={isSelected ? 'page' : undefined}
class="flex w-full place-items-center gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 dark:text-primary text-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10'
: ''}"
>
<div class="flex w-full place-items-center gap-4 ps-5 overflow-hidden truncate">
<Icon {icon} size="1.5em" class="shrink-0" flipped={flippedLogo} aria-hidden />
<span class="text-sm font-medium">{title}</span>
</div>
<div></div>
</a>
</div>
{#if hasDropdown && dropdownOpen}
{@render hasDropdown?.()}
{/if}

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
// import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
// import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { NavbarGroup, NavbarItem } from '@immich/ui';
import {
mdiAccount,
mdiAccountMultiple,
@@ -32,120 +33,74 @@
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import SideBarLink from './side-bar-link.svelte';
let isArchiveSelected: boolean = $state(false);
let isFavoritesSelected: boolean = $state(false);
let isMapSelected: boolean = $state(false);
let isPeopleSelected: boolean = $state(false);
let isPhotosSelected: boolean = $state(false);
let isSharingSelected: boolean = $state(false);
let isTrashSelected: boolean = $state(false);
let isUtilitiesSelected: boolean = $state(false);
let isLockedFolderSelected: boolean = $state(false);
// import { fly } from 'svelte/transition';
</script>
<Sidebar ariaLabel={$t('primary')}>
<SideBarLink
<NavbarItem
title={$t('photos')}
href={resolve('/(user)/photos')}
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
></SideBarLink>
href={AppRoute.PHOTOS}
icon={mdiImageMultipleOutline}
activeIcon={mdiImageMultiple}
/>
{#if featureFlagsManager.value.search}
<SideBarLink title={$t('explore')} href={resolve('/(user)/explore')} icon={mdiMagnify} />
<NavbarItem title={$t('explore')} href={AppRoute.EXPLORE} icon={mdiMagnify} />
{/if}
{#if featureFlagsManager.value.map}
<SideBarLink
title={$t('map')}
href={resolve('/(user)/map')}
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
<NavbarItem title={$t('map')} href={AppRoute.MAP} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<SideBarLink
title={$t('people')}
href={resolve('/(user)/people')}
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
<NavbarItem title={$t('people')} href={AppRoute.PEOPLE} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
<SideBarLink title={$t('shared_links')} href={resolve('/(user)/shared-links')} icon={mdiLink} />
<NavbarItem title={$t('shared_links')} href={AppRoute.SHARED_LINKS} icon={mdiLink} />
{/if}
<SideBarLink
<NavbarItem
title={$t('sharing')}
href={resolve('/(user)/sharing')}
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
></SideBarLink>
href={AppRoute.SHARING}
icon={mdiAccountMultipleOutline}
activeIcon={mdiAccountMultiple}
/>
<p class="text-xs py-5 ps-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
<NavbarGroup title={$t('library')} />
<SideBarLink
title={$t('favorites')}
href={resolve('/(user)/favorites')}
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
></SideBarLink>
<NavbarItem title={$t('favorites')} href={AppRoute.FAVORITES} icon={mdiHeartOutline} activeIcon={mdiHeart} />
<SideBarLink
title={$t('albums')}
href={resolve('/(user)/albums')}
icon={mdiImageAlbum}
flippedLogo
bind:dropdownOpen={$recentAlbumsDropdown}
>
{#snippet dropDownContent()}
<NavbarItem title={$t('albums')} href={AppRoute.ALBUMS} icon={{ icon: mdiImageAlbum, flipped: true }} />
<!-- bind:expanded={$recentAlbumsDropdown}
{#snippet items()}
<span in:fly={{ y: -20 }} class="hidden md:block">
<RecentAlbums />
</span>
{/snippet}
</SideBarLink>
</NavbarItem> -->
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} href={resolve('/(user)/tags')} icon={mdiTagMultipleOutline} flippedLogo />
<NavbarItem title={$t('tags')} href={AppRoute.TAGS} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<SideBarLink title={$t('folders')} href={resolve('/(user)/folders')} icon={mdiFolderOutline} flippedLogo />
<NavbarItem title={$t('folders')} href={AppRoute.FOLDERS} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
<SideBarLink
title={$t('utilities')}
href={resolve('/(user)/utilities')}
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<NavbarItem title={$t('utilities')} href={AppRoute.UTILITIES} icon={mdiToolboxOutline} activeIcon={mdiToolbox} />
<SideBarLink
<NavbarItem
title={$t('archive')}
href={resolve('/(user)/archive')}
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
></SideBarLink>
href={AppRoute.ARCHIVE}
icon={mdiArchiveArrowDownOutline}
activeIcon={mdiArchiveArrowDown}
/>
<SideBarLink
title={$t('locked_folder')}
href={resolve('/(user)/locked')}
bind:isSelected={isLockedFolderSelected}
icon={isLockedFolderSelected ? mdiLock : mdiLockOutline}
></SideBarLink>
<NavbarItem title={$t('locked_folder')} href={AppRoute.LOCKED} icon={mdiLockOutline} activeIcon={mdiLock} />
{#if featureFlagsManager.value.trash}
<SideBarLink
title={$t('trash')}
href={resolve('/(user)/trash')}
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
></SideBarLink>
<NavbarItem title={$t('trash')} href={AppRoute.TRASH} icon={mdiTrashCanOutline} activeIcon={mdiTrashCan} />
{/if}
<BottomInfo />

View File

@@ -30,7 +30,6 @@ export enum AppRoute {
ADMIN_REPAIR = '/admin/repair',
ALBUMS = '/albums',
LIBRARIES = '/libraries',
ARCHIVE = '/archive',
FAVORITES = '/favorites',
PEOPLE = '/people',