Add image URL generation at runtime to allow for dynamic image sizes (#1439)

* add getImageUrl to domain endpoints

* add new ItemImage component and hooks to generate image url

* add configuration for image resolution based on types
This commit is contained in:
Jeff
2025-12-23 20:18:52 -08:00
committed by GitHub
parent 96f38e597c
commit 25bfb65b6d
39 changed files with 823 additions and 670 deletions
@@ -1,11 +1,11 @@
import clsx from 'clsx';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { generatePath } from 'react-router';
import { Link } from 'react-router';
import { Fragment, useEffect, useRef } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './full-screen-player-image.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { AppRoute } from '/@/renderer/router/routes';
import { usePlayerData, usePlayerSong } from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
@@ -17,6 +17,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useSetState } from '/@/shared/hooks/use-set-state';
import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -41,13 +42,6 @@ const imageVariants: Variants = {
},
};
const scaleImageUrl = (imageSize: number, url?: null | string) => {
return url
?.replace(/&size=\d+/, `&size=${imageSize}`)
.replace(/\?width=\d+/, `?width=${imageSize}`)
.replace(/&height=\d+/, `&height=${imageSize}`);
};
const MotionImage = motion.img;
const ImageWithPlaceholder = ({
@@ -85,44 +79,27 @@ const ImageWithPlaceholder = ({
export const FullScreenPlayerImage = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
const currentImageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
type: 'fullScreenPlayer',
});
const updateImageSize = useCallback(() => {
if (mainImageRef.current) {
setMainImageDimensions({
idealSize:
albumArtRes ||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.id,
itemType: LibraryItem.SONG,
type: 'fullScreenPlayer',
});
setImageState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
});
}
}, [
mainImageDimensions.idealSize,
setImageState,
albumArtRes,
currentSong?.imageUrl,
nextSong?.imageUrl,
]);
useLayoutEffect(() => {
updateImageSize();
}, [updateImageSize]);
const [imageState, setImageState] = useSetState({
bottomImage: nextImageUrl,
current: 0,
topImage: currentImageUrl,
});
// Track previous song to detect changes
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
@@ -133,15 +110,13 @@ export const FullScreenPlayerImage = () => {
imageStateRef.current = imageState;
}, [imageState]);
// Update images when song changes
// Update images when song or size changes
useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) {
return;
}
const isTop = imageStateRef.current.current === 0;
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -150,13 +125,7 @@ export const FullScreenPlayerImage = () => {
});
previousSongRef.current = currentSong?._uniqueId;
}, [
currentSong?._uniqueId,
currentSong?.imageUrl,
nextSong?.imageUrl,
mainImageDimensions.idealSize,
setImageState,
]);
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
return (
<Flex
@@ -13,6 +13,7 @@ import { useLocation } from 'react-router';
import styles from './full-screen-player.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
@@ -38,6 +39,7 @@ import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types';
const mainBackground = 'var(--theme-colors-background)';
@@ -74,14 +76,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const currentImageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.id,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const [imageState, setImageState] = useState({
bottomImage: nextSong?.imageUrl
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
bottomImage: nextImageUrl,
current: 0,
topImage: currentSong?.imageUrl
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
topImage: currentImageUrl,
});
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
@@ -99,12 +109,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}
const isTop = imageStateRef.current.current === 0;
const currentImageUrl = currentSong?.imageUrl
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined;
const nextImageUrl = nextSong?.imageUrl
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined;
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -113,7 +117,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
});
previousSongRef.current = currentSong?._uniqueId;
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) {
return null;
@@ -610,9 +614,15 @@ const PlayerContainer = memo(
windowBarStyle,
}: PlayerContainerProps) => {
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const { background } = useFastAverageColor({
algorithm: 'dominant',
src: currentSong?.imageUrl,
src: imageUrl,
srcLoaded: true,
});
@@ -7,6 +7,7 @@ import { shallow } from 'zustand/shallow';
import styles from './left-controls.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
@@ -21,7 +22,6 @@ import {
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -116,13 +116,14 @@ export const LeftControls = () => {
})}
openDelay={0}
>
<Image
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
id={currentSong?.id}
itemType={LibraryItem.SONG}
loading="eager"
src={isRadioMode ? '' : (currentSong?.imageUrl ?? '')}
/>
</Tooltip>
{!collapsed && (
@@ -4,16 +4,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import styles from './mobile-fullscreen-player.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import {
useFullScreenPlayerStore,
useGeneralSettings,
usePlayerData,
usePlayerSong,
useSettingsStore,
} from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center';
import { Icon } from '/@/shared/components/icon/icon';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { useSetState } from '/@/shared/hooks/use-set-state';
import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -38,13 +40,6 @@ const imageVariants: Variants = {
},
};
const scaleImageUrl = (imageSize: number, url?: null | string) => {
return url
?.replace(/&size=\d+/, `&size=${imageSize}`)
.replace(/\?width=\d+/, `?width=${imageSize}`)
.replace(/&height=\d+/, `&height=${imageSize}`);
};
const MotionImage = motion.img;
const ImageWithPlaceholder = ({
@@ -83,15 +78,29 @@ export const MobileFullscreenPlayerAlbumArt = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 });
const { albumArtRes } = useGeneralSettings();
const albumArtRes = useSettingsStore((store) => store.general.imageRes.fullScreenPlayer);
const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const currentImageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
size: mainImageDimensions.idealSize,
type: 'fullScreenPlayer',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.id,
itemType: LibraryItem.SONG,
size: mainImageDimensions.idealSize,
type: 'fullScreenPlayer',
});
const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
bottomImage: nextImageUrl,
current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
topImage: currentImageUrl,
});
const updateImageSize = useCallback(() => {
@@ -101,14 +110,8 @@ export const MobileFullscreenPlayerAlbumArt = () => {
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100;
setMainImageDimensions({ idealSize });
setImageState({
bottomImage: scaleImageUrl(idealSize, nextSong?.imageUrl),
current: 0,
topImage: scaleImageUrl(idealSize, currentSong?.imageUrl),
});
}
}, [albumArtRes, currentSong?.imageUrl, nextSong?.imageUrl, setImageState]);
}, [albumArtRes]);
useLayoutEffect(() => {
updateImageSize();
@@ -123,15 +126,13 @@ export const MobileFullscreenPlayerAlbumArt = () => {
imageStateRef.current = imageState;
}, [imageState]);
// Update images when song changes
// Update images when song or size changes
useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) {
return;
}
const isTop = imageStateRef.current.current === 0;
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -140,13 +141,7 @@ export const MobileFullscreenPlayerAlbumArt = () => {
});
previousSongRef.current = currentSong?._uniqueId;
}, [
currentSong?._uniqueId,
currentSong?.imageUrl,
nextSong?.imageUrl,
mainImageDimensions.idealSize,
setImageState,
]);
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
return (
<div className={styles.imageContainer} ref={mainImageRef}>
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
@@ -74,14 +75,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const currentImageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.id,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const [imageState, setImageState] = useState({
bottomImage: nextSong?.imageUrl
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
bottomImage: nextImageUrl,
current: 0,
topImage: currentSong?.imageUrl
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
topImage: currentImageUrl,
});
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
@@ -98,12 +107,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}
const isTop = imageStateRef.current.current === 0;
const currentImageUrl = currentSong?.imageUrl
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined;
const nextImageUrl = nextSong?.imageUrl
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined;
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -112,7 +115,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
});
previousSongRef.current = currentSong?._uniqueId;
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) {
return null;
@@ -299,9 +302,15 @@ interface MobilePlayerContainerProps {
const MobilePlayerContainer = memo(
({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => {
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const { background } = useFastAverageColor({
algorithm: 'dominant',
src: currentSong?.imageUrl,
src: imageUrl,
srcLoaded: true,
});
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
import styles from './mobile-playerbar.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -20,7 +21,6 @@ import {
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -68,7 +68,7 @@ export const MobilePlayerbar = () => {
<div className={styles.contentWrapper}>
<LayoutGroup>
<AnimatePresence initial={false} mode="popLayout">
{currentSong?.imageUrl && (
{currentSong?.id && (
<div className={styles.imageWrapper}>
<motion.div
animate={{ opacity: 1, scale: 1 }}
@@ -87,13 +87,14 @@ export const MobilePlayerbar = () => {
})}
openDelay={0}
>
<Image
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
id={currentSong.id}
itemType={LibraryItem.SONG}
loading="eager"
src={currentSong.imageUrl}
/>
</Tooltip>
</motion.div>