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
@@ -4,6 +4,7 @@ import { generatePath, Link, useParams } from 'react-router';
import styles from './album-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -82,10 +83,16 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
const releaseYear = detailQuery?.data?.releaseYear;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.id,
itemType: LibraryItem.ALBUM,
type: 'header',
});
return (
<Stack ref={ref}>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
>
@@ -6,6 +6,7 @@ import { Fragment, Suspense, useCallback, useRef } from 'react';
import styles from './expanded-album-list-item.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -197,10 +198,16 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.id,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
src: data?.imageUrl,
src: imageUrl,
srcLoaded: true,
});
@@ -300,7 +307,7 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${data?.imageUrl})`,
backgroundImage: `url(${imageUrl})`,
}}
/>
{data?.songs && data.songs.length > 0 && (
@@ -7,6 +7,7 @@ import styles from './dummy-album-detail-route.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
@@ -113,12 +114,18 @@ const DummyAlbumDetailRoute = () => {
},
];
const imageUrl = useItemImageUrl({
id: albumId,
itemType: LibraryItem.ALBUM,
type: 'header',
});
return (
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
<LibraryContainer>
<Stack>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
loading={!background || colorId !== albumId}
title={detailQuery?.data?.name || ''}
@@ -5,6 +5,7 @@ import { useParams } from 'react-router';
import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -116,9 +117,15 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
return (
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
ref={ref}
title={detailQuery?.data?.name || ''}
@@ -2,13 +2,15 @@ import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
import isElectron from 'is-electron';
import { useCallback, useEffect, useRef, useState } from 'react';
import { controller } from '/@/renderer/api/controller';
import { api } from '/@/renderer/api';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import {
DiscordDisplayType,
DiscordLinkType,
useAppStore,
useDiscordSettings,
useGeneralSettings,
usePlayerSong,
usePlayerStore,
useTimestampStoreBase,
} from '/@/renderer/store';
@@ -16,7 +18,7 @@ import { sentenceCase } from '/@/renderer/utils';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
const discordRpc = isElectron() ? window.api.discordRpc : null;
@@ -33,10 +35,24 @@ export const useDiscordRpc = () => {
const privateMode = useAppStore((state) => state.privateMode);
const [lastUniqueId, setlastUniqueId] = useState('');
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'table',
});
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const previousActivityStateRef = useRef<ActivityState | null>(null);
// Update imageUrl ref when it changes
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
const setActivity = useCallback(
async (current: ActivityState, previous: ActivityState) => {
// Check if track changed by comparing with previous state
@@ -178,20 +194,26 @@ export const useDiscordRpc = () => {
}
if (discordSettings.showServerImage && song) {
if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
activity.largeImageKey = song.imageUrl;
} else if (song._serverType === ServerType.NAVIDROME) {
try {
const info = await controller.getAlbumInfo({
apiClientProps: { serverId: song._serverId },
query: { id: song.albumId },
});
// Use imageUrl from useItemImageUrl hook if available and song matches current song
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
activity.largeImageKey = imageUrlRef.current;
} else {
// Fallback to old logic if song doesn't match (shouldn't happen in normal flow)
if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
activity.largeImageKey = song.imageUrl;
} else if (song._serverType === ServerType.NAVIDROME) {
try {
const info = await api.controller.getAlbumInfo({
apiClientProps: { serverId: song._serverId },
query: { id: song.albumId },
});
if (info.imageUrl) {
activity.largeImageKey = info.imageUrl;
if (info.imageUrl) {
activity.largeImageKey = info.imageUrl;
}
} catch {
/* empty */
}
} catch {
/* empty */
}
}
}
@@ -275,6 +297,7 @@ export const useDiscordRpc = () => {
discordSettings.displayType,
discordSettings.linkType,
lastUniqueId,
currentSong?._uniqueId,
],
);
@@ -52,7 +52,7 @@ export const AlbumInfiniteFeatureCarousel = ({
// Filter for albums with images and remove duplicates by ID
const uniqueAlbums = new Map<string, Album>();
for (const album of allAlbums) {
if (album.imageUrl && !uniqueAlbums.has(album.id)) {
if (album.imageId && !uniqueAlbums.has(album.id)) {
uniqueAlbums.set(album.id, album);
}
}
@@ -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>
@@ -1,9 +1,16 @@
import isElectron from 'is-electron';
import { useEffect, useMemo } from 'react';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaybackSettings, useSettingsStore, useTimestampStoreBase } from '/@/renderer/store';
import {
usePlaybackSettings,
usePlayerSong,
useSettingsStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const mediaSession = navigator.mediaSession;
@@ -13,6 +20,14 @@ export const useMediaSession = () => {
const player = usePlayer();
const skip = useSettingsStore((state) => state.general.skipButtons);
const playbackType = useSettingsStore((state) => state.playback.type);
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web
@@ -94,7 +109,7 @@ export const useMediaSession = () => {
mediaSession.metadata = new MediaMetadata({
album: song?.album ?? '',
artist: song?.artistName ?? '',
artwork: song?.imageUrl ? [{ src: song.imageUrl, type: 'image/png' }] : [],
artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],
title: song?.name ?? '',
});
},
@@ -107,6 +122,6 @@ export const useMediaSession = () => {
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
},
},
[isMediaSessionEnabled, mediaSession],
[isMediaSessionEnabled, mediaSession, imageUrl],
);
};
+18 -21
View File
@@ -1,8 +1,10 @@
import isElectron from 'is-electron';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayerStore } from '/@/renderer/store';
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
@@ -11,6 +13,14 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export const useMPRIS = () => {
const player = usePlayerStore();
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
useEffect(() => {
if (!mpris) {
@@ -41,32 +51,19 @@ export const useMPRIS = () => {
};
}, [player]);
const isInitializedRef = useRef(false);
// Update MPRIS when song or imageUrl changes
useEffect(() => {
if (isInitializedRef.current) {
if (!mpris) {
return;
}
isInitializedRef.current = true;
const currentSong = player.getCurrentSong();
if (!currentSong) {
return;
}
mpris?.updateSong(currentSong);
}, [player]);
mpris?.updateSong(currentSong, imageUrl);
}, [currentSong, imageUrl]);
usePlayerEvents(
{
onCurrentSongChange: (properties) => {
if (!mpris) {
return;
}
mpris?.updateSong(properties.song);
onCurrentSongChange: () => {
// The effect above will handle the update when currentSong changes
},
onPlayerProgress: (properties) => {
if (!mpris) {
@@ -1,11 +1,12 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
import { useAppStore, usePlaybackSettings, usePlayerSong, usePlayerStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
/*
@@ -59,7 +60,16 @@ export const useScrobble = () => {
const isScrobbleEnabled = scrobbleSettings?.enabled;
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
@@ -68,6 +78,10 @@ export const useScrobble = () => {
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
const handleScrobbleFromProgress = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
@@ -198,7 +212,7 @@ export const useScrobble = () => {
new Notification(`${currentSong.name}`, {
body: `${artists}\n${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
icon: imageUrlRef.current || undefined,
silent: true,
});
}
@@ -5,20 +5,15 @@ import { QueueSong } from '/@/shared/types/domain-types';
const remote = isElectron() ? window.api.remote : null;
const mediaSession = navigator.mediaSession;
export const updateSong = (song: QueueSong | undefined) => {
export const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
if (mediaSession) {
let metadata: MediaMetadata;
if (song?.id) {
let artwork: MediaImage[];
if (song.imageUrl) {
const image300 = song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300');
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
if (imageUrl) {
artwork = [{ sizes: '300x300', src: imageUrl, type: 'image/png' }];
} else {
artwork = [];
}
@@ -7,6 +7,7 @@ import styles from './add-to-playlist-context-modal.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import {
getAlbumSongsById,
getArtistSongsById,
@@ -26,7 +27,6 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Pill } from '/@/shared/components/pill/pill';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
@@ -38,7 +38,7 @@ import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
export const AddToPlaylistContextModal = ({
id,
@@ -555,14 +555,13 @@ const PlaylistTableItem = memo(
<Grid align="center" gutter="xs" w="100%">
<Grid.Col span="content">
<Flex align="center" justify="center" px="sm">
{item.imageUrl && (
<Image
imageContainerProps={{
className: styles.imageContainer,
}}
src={item.imageUrl}
/>
)}
<ItemImage
id={item.id}
imageContainerProps={{
className: styles.imageContainer,
}}
itemType={LibraryItem.PLAYLIST}
/>
</Flex>
</Grid.Col>
<Grid.Col className={styles.gridCol} span="auto">
@@ -2,6 +2,7 @@ import { CSSProperties, useCallback, useState } from 'react';
import styles from './library-command-item.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
LONG_PRESS_PLAY_BEHAVIOR,
@@ -11,7 +12,6 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -95,11 +95,13 @@ export const LibraryCommandItem = ({
>
<div className={styles.itemGrid} style={{ '--item-height': '40px' } as CSSProperties}>
<div className={styles.imageWrapper}>
<Image
<ItemImage
alt="cover"
className={styles.image}
height={40}
src={imageUrl || ''}
id={id}
itemType={itemType}
src={imageUrl}
width={40}
/>
</div>
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n, { languages } from '/@/i18n/i18n';
import { ImageResolutionSettings } from '/@/renderer/features/settings/components/general/art-resolution-settings';
import { ArtistSettings } from '/@/renderer/features/settings/components/general/artist-settings';
import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings';
import {
@@ -575,37 +576,13 @@ export const ApplicationSettings = () => {
isHidden: false,
title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }),
},
{
control: (
<NumberInput
defaultValue={settings.albumArtRes || undefined}
hideControls={false}
max={2500}
onBlur={(e) => {
const newVal =
e.currentTarget.value !== '0'
? Math.min(Math.max(Number(e.currentTarget.value), 175), 2500)
: null;
setSettings({ general: { ...settings, albumArtRes: newVal } });
}}
placeholder="0"
value={settings.albumArtRes ?? 0}
width={75}
/>
),
description: t('setting.playerAlbumArtResolution', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerAlbumArtResolution', { postProcess: 'sentenceCase' }),
},
];
return (
<SettingsSection
extra={
<>
<ImageResolutionSettings />
<HomeSettings />
<ArtistSettings />
</>
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Table } from '/@/shared/components/table/table';
import { Text } from '/@/shared/components/text/text';
const options = [
{
label: i18n.t('setting.imageResolution_optionTable', { postProcess: 'sentenceCase' }),
value: 'table',
},
{
label: i18n.t('setting.imageResolution_optionItemCard', { postProcess: 'sentenceCase' }),
value: 'itemCard',
},
{
label: i18n.t('setting.imageResolution_optionSidebar', { postProcess: 'sentenceCase' }),
value: 'sidebar',
},
{
label: i18n.t('setting.imageResolution_optionHeader', { postProcess: 'sentenceCase' }),
value: 'header',
},
{
label: i18n.t('setting.imageResolution_optionFullScreenPlayer', {
postProcess: 'sentenceCase',
}),
value: 'fullScreenPlayer',
},
];
export const ImageResolutionSettings = () => {
const { t } = useTranslation();
const { setSettings } = useSettingsStoreActions();
const settings = useGeneralSettings();
const [open, setOpen] = useState(false);
const descriptionText = t('setting.imageResolution', {
context: 'description',
postProcess: 'sentenceCase',
});
const titleText = t('setting.imageResolution', { postProcess: 'sentenceCase' });
return (
<>
<SettingsOptions
control={
<>
<Button
onClick={() => setOpen(!open)}
size="compact-md"
variant={open ? 'subtle' : 'filled'}
>
{t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })}
</Button>
</>
}
description={descriptionText}
title={titleText}
/>
{open && (
<Table withRowBorders={false}>
<Table.Tbody>
{options.map((option) => (
<Table.Tr key={option.value}>
<Table.Th key={option.value}>
<Text>{option.label}</Text>
</Table.Th>
<Table.Td align="right" key={option.value}>
<NumberInput
max={2000}
min={0}
onChange={(e) => {
if (!e) return;
if (typeof e === 'string') return;
setSettings({
general: {
...settings,
imageRes: {
...settings.imageRes,
[option.value]: e,
},
},
});
}}
rightSection={
<Text isMuted isNoSelect pr="lg" size="sm">
px
</Text>
}
value={settings.imageRes[option.value]}
width={90}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
};
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import styles from './sidebar.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
@@ -151,10 +152,11 @@ const SidebarImage = () => {
const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong();
const upsizedImageUrl = currentSong?.imageUrl
?.replace(/size=\d+/, 'size=450')
.replace(/width=\d+/, 'width=450')
.replace(/height=\d+/, 'height=450');
const imageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
type: 'sidebar',
});
const isSongDefined = Boolean(currentSong?.id);
@@ -202,8 +204,8 @@ const SidebarImage = () => {
postProcess: 'sentenceCase',
})}
>
{upsizedImageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={upsizedImageUrl} />
{imageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={imageUrl} />
) : (
<ImageUnloader />
)}