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
+7 -2
View File
@@ -863,8 +863,13 @@
"playButtonBehavior_optionPlay": "$t(player.play)", "playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)", "playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior": "play button behavior", "playButtonBehavior": "play button behavior",
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto", "imageResolution": "image resolution",
"playerAlbumArtResolution": "player album art resolution", "imageResolution_description": "the resolution for the images used around the app. using a value of 0 will default to the native image resolution",
"imageResolution_optionTable": "table",
"imageResolution_optionItemCard": "item card",
"imageResolution_optionSidebar": "sidebar",
"imageResolution_optionHeader": "header",
"imageResolution_optionFullScreenPlayer": "fullscreen player",
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player", "playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
"playerbarOpenDrawer": "playerbar fullscreen toggle", "playerbarOpenDrawer": "playerbar fullscreen toggle",
"playerbarSlider": "playerbar slider", "playerbarSlider": "playerbar slider",
+12 -12
View File
@@ -141,22 +141,17 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle; mprisPlayer.shuffle = shuffle;
}); });
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => { ipcMain.on(
'update-song',
(_event, song: QueueSong | undefined, imageUrl: null | string | undefined) => {
try { try {
if (!song?.id) { if (!song?.id) {
mprisPlayer.metadata = {}; mprisPlayer.metadata = {};
return; return;
} }
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = { mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl, 'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null, 'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song.id 'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`) ? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
@@ -165,13 +160,17 @@ ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
'xesam:albumArtist': song.albumArtists?.length 'xesam:albumArtist': song.albumArtists?.length
? song.albumArtists.map((artist) => artist.name) ? song.albumArtists.map((artist) => artist.name)
: null, : null,
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null, 'xesam:artist': song.artists?.length
? song.artists.map((artist) => artist.name)
: null,
'xesam:audioBpm': song.bpm, 'xesam:audioBpm': song.bpm,
// Comment is a `list of strings` type // Comment is a `list of strings` type
'xesam:comment': song.comment ? [song.comment] : null, 'xesam:comment': song.comment ? [song.comment] : null,
'xesam:contentCreated': song.releaseDate, 'xesam:contentCreated': song.releaseDate,
'xesam:discNumber': song.discNumber ? song.discNumber : null, 'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null, 'xesam:genre': song.genres?.length
? song.genres.map((genre: any) => genre.name)
: null,
'xesam:lastUsed': song.lastPlayedAt, 'xesam:lastUsed': song.lastPlayedAt,
'xesam:title': song.name || null, 'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null, 'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
@@ -183,6 +182,7 @@ ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}); },
);
export { mprisPlayer }; export { mprisPlayer };
+2 -2
View File
@@ -27,8 +27,8 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle); ipcRenderer.send('update-shuffle', shuffle);
}; };
const updateSong = (song: QueueSong | undefined) => { const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
ipcRenderer.send('update-song', song); ipcRenderer.send('update-song', song, imageUrl);
}; };
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => { const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
+14
View File
@@ -370,6 +370,20 @@ export const controller: GeneralController = {
query: mergeMusicFolderId(args.query, server), query: mergeMusicFolderId(args.query, server),
}); });
}, },
getImageUrl(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
return null;
}
return (
apiController(
'getImageUrl',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }) || null
);
},
getInternetRadioStations(args) { getInternetRadioStations(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -670,6 +670,22 @@ export const JellyfinController: InternalControllerEndpoint = {
totalRecordCount: res.body?.TotalRecordCount || 0, totalRecordCount: res.body?.TotalRecordCount || 0,
}; };
}, },
getImageUrl: ({ apiClientProps: { server }, query }) => {
const { id, size } = query;
const imageSize = size;
if (!server?.url) {
return null;
}
// For Jellyfin, we construct the URL pattern
// The server will return a 404 or placeholder if no image exists
const baseUrl = `${server.url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`;
// For songs, we might want to fall back to album art, but we don't have albumId here
// The caller can handle this if needed
return baseUrl;
},
getInternetRadioStations: async (args) => { getInternetRadioStations: async (args) => {
const { apiClientProps } = args; const { apiClientProps } = args;
@@ -1077,9 +1093,7 @@ export const JellyfinController: InternalControllerEndpoint = {
} }
return { return {
items: items.map((item) => items: items.map((item) => jfNormalize.song(item, apiClientProps.server)),
jfNormalize.song(item, apiClientProps.server, query.imageSize),
),
startIndex: query.startIndex, startIndex: query.startIndex,
totalRecordCount, totalRecordCount,
}; };
@@ -461,6 +461,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}, },
getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations, getInternetRadioStations: SubsonicController.getInternetRadioStations,
getLyrics: SubsonicController.getLyrics, getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList, getMusicFolderList: SubsonicController.getMusicFolderList,
@@ -664,9 +665,7 @@ export const NavidromeController: InternalControllerEndpoint = {
} }
return { return {
items: res.body.data.map((song) => items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server)),
ndNormalize.song(song, apiClientProps.server, query.imageSize),
),
startIndex: query?.startIndex || 0, startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
@@ -827,6 +827,28 @@ export const SubsonicController: InternalControllerEndpoint = {
startIndex: query.startIndex, startIndex: query.startIndex,
}); });
}, },
getImageUrl: ({ apiClientProps: { server }, query }) => {
const { id, size } = query;
const imageSize = size;
if (!server?.url || !server?.credential) {
return null;
}
// Check for default placeholder image ID
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${server.url}/rest/getCoverArt.view` +
`?id=${id}` +
`&${server.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
(imageSize ? `&size=${imageSize}` : '')
);
},
getInternetRadioStations: async (args) => { getInternetRadioStations: async (args) => {
const { apiClientProps } = args; const { apiClientProps } = args;
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
import styles from './feature-carousel.module.css'; import styles from './feature-carousel.module.css';
import { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay'; import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group'; import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -15,7 +16,6 @@ import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge'; import { Badge } from '/@/shared/components/badge/badge';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem } from '/@/shared/types/domain-types'; import { Album, LibraryItem } from '/@/shared/types/domain-types';
@@ -78,9 +78,15 @@ interface CarouselItemProps {
} }
const CarouselItem = ({ album }: CarouselItemProps) => { const CarouselItem = ({ album }: CarouselItemProps) => {
const imageUrl = useItemImageUrl({
id: album.id,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const { background: backgroundColor } = useFastAverageColor({ const { background: backgroundColor } = useFastAverageColor({
algorithm: 'dominant', algorithm: 'dominant',
src: album.imageUrl || null, src: imageUrl || null,
srcLoaded: true, srcLoaded: true,
}); });
@@ -110,10 +116,12 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
</div> </div>
<div className={styles.imageSection}> <div className={styles.imageSection}>
<Image <ItemImage
className={styles.albumImage} className={styles.albumImage}
containerClassName={styles.albumImageContainer} containerClassName={styles.albumImageContainer}
src={album.imageUrl || undefined} id={album.id}
itemType={LibraryItem.ALBUM}
src={imageUrl}
/> />
<div className={styles.playButtonOverlay}> <div className={styles.playButtonOverlay}>
<PlayButtonGroup onPlay={handlePlay} /> <PlayButtonGroup onPlay={handlePlay} />
+13 -10
View File
@@ -7,6 +7,7 @@ import { generatePath, Link } from 'react-router';
import styles from './item-card.module.css'; import styles from './item-card.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items'; import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import { import {
@@ -18,7 +19,6 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format'; import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
@@ -137,7 +137,6 @@ const CompactItemCard = ({
data, data,
enableExpansion, enableExpansion,
enableNavigation, enableNavigation,
imageUrl,
internalState, internalState,
isRound, isRound,
itemType, itemType,
@@ -247,11 +246,13 @@ const CompactItemCard = ({
const imageContainerContent = ( const imageContainerContent = (
<> <>
<Image <ItemImage
className={clsx(styles.image, { className={clsx(styles.image, {
[styles.isRound]: isRound, [styles.isRound]: isRound,
})} })}
src={imageUrl} id={data?.id}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
/> />
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
@@ -351,7 +352,6 @@ const DefaultItemCard = ({
data, data,
enableExpansion, enableExpansion,
enableNavigation, enableNavigation,
imageUrl,
internalState, internalState,
isRound, isRound,
itemType, itemType,
@@ -461,9 +461,11 @@ const DefaultItemCard = ({
const imageContainerContent = ( const imageContainerContent = (
<> <>
<Image <ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })} className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl} id={data?.id}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
/> />
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
@@ -563,7 +565,6 @@ const PosterItemCard = ({
enableDrag, enableDrag,
enableExpansion, enableExpansion,
enableNavigation, enableNavigation,
imageUrl,
internalState, internalState,
isRound, isRound,
itemType, itemType,
@@ -720,9 +721,11 @@ const PosterItemCard = ({
const imageContainerContent = ( const imageContainerContent = (
<> <>
<Image <ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })} className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl} id={data?.id}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
/> />
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
@@ -1,146 +1,146 @@
import { AnimatePresence } from 'motion/react'; // import { AnimatePresence } from 'motion/react';
import { MouseEvent, useMemo, useState } from 'react'; // import { MouseEvent, useMemo, useState } from 'react';
import { Link } from 'react-router'; // import { Link } from 'react-router';
import styles from './item-detail.module.css'; // import styles from './item-detail.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; // import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { useFastAverageColor } from '/@/renderer/hooks'; // import { useFastAverageColor } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; // import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge'; // import { Badge } from '/@/shared/components/badge/badge';
import { Divider } from '/@/shared/components/divider/divider'; // import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; // import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image'; // import { Image } from '/@/shared/components/image/image';
import { Rating } from '/@/shared/components/rating/rating'; // import { Rating } from '/@/shared/components/rating/rating';
import { Text } from '/@/shared/components/text/text'; // import { Text } from '/@/shared/components/text/text';
import { // import {
Album, // Album,
AlbumArtist, // AlbumArtist,
Artist, // Artist,
LibraryItem, // LibraryItem,
Playlist, // Playlist,
Song, // Song,
} from '/@/shared/types/domain-types'; // } from '/@/shared/types/domain-types';
import { stringToColor } from '/@/shared/utils/string-to-color'; // import { stringToColor } from '/@/shared/utils/string-to-color';
interface ItemDetailProps { // interface ItemDetailProps {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined; // data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
itemHeight: number; // itemHeight: number;
itemType: LibraryItem; // itemType: LibraryItem;
onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void; // onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
withControls?: boolean; // withControls?: boolean;
} // }
export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => { // export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
const imageUrl = getImageUrl(data); // const imageUrl = getImageUrl(data);
const [showControls, setShowControls] = useState(false); // const [showControls, setShowControls] = useState(false);
const { background } = useFastAverageColor({ // const { background } = useFastAverageColor({
algorithm: 'simple', // algorithm: 'simple',
src: imageUrl, // src: imageUrl,
srcLoaded: false, // srcLoaded: false,
}); // });
// const tags = [...(data?.genres ?? [])]; // // const tags = [...(data?.genres ?? [])];
const tags = useMemo(() => { // const tags = useMemo(() => {
if (!data) { // if (!data) {
return []; // return [];
} // }
const items: { // const items: {
color?: string; // color?: string;
id: string; // id: string;
isLight?: boolean; // isLight?: boolean;
itemType: LibraryItem; // itemType: LibraryItem;
name: string; // name: string;
}[] = []; // }[] = [];
if ('albumArtists' in data && Array.isArray(data.albumArtists)) { // if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
data.albumArtists?.forEach((tag: { id: string; name: string }) => { // data.albumArtists?.forEach((tag: { id: string; name: string }) => {
items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name }); // items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
}); // });
} // }
if ('genres' in data && Array.isArray(data.genres)) { // if ('genres' in data && Array.isArray(data.genres)) {
data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => { // data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
const { color, isLight } = stringToColor(tag.name); // const { color, isLight } = stringToColor(tag.name);
items.push({ ...tag, color, isLight }); // items.push({ ...tag, color, isLight });
}); // });
} // }
// if ('tags' in data && typeof data.tags === 'object') { // // if ('tags' in data && typeof data.tags === 'object') {
// console.log('data.tags :>> ', data.tags); // // console.log('data.tags :>> ', data.tags);
// Object.entries(data.tags).forEach(([key, value]) => { // // Object.entries(data.tags).forEach(([key, value]) => {
// items.push({ id: key, itemType: LibraryItem.TAG, name: value }); // // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
// }); // // });
// } // // }
return items; // return items;
}, [data]); // }, [data]);
return ( // return (
<div // <div
className={styles.container} // className={styles.container}
onClick={(e) => onClick?.(e, data, itemType)} // onClick={(e) => onClick?.(e, data, itemType)}
style={{ backgroundColor: background }} // style={{ backgroundColor: background }}
> // >
<div // <div
className={styles.imageContainer} // className={styles.imageContainer}
onMouseEnter={() => withControls && setShowControls(true)} // onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)} // onMouseLeave={() => withControls && setShowControls(false)}
> // >
<Image alt={data?.name} src={imageUrl} /> // <Image alt={data?.name} src={imageUrl} />
<AnimatePresence> // <AnimatePresence>
{withControls && showControls && <ItemCardControls type="compact" />} // {withControls && showControls && <ItemCardControls type="compact" />}
</AnimatePresence> // </AnimatePresence>
</div> // </div>
<div className={styles.metadataContainer}> // <div className={styles.metadataContainer}>
<div className={styles.header}> // <div className={styles.header}>
<Text className={styles.title} component={Link} isLink size="lg" weight={500}> // <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
{data?.name} // {data?.name}
</Text> // </Text>
<Group> // <Group>
{data && 'userRating' in data && ( // {data && 'userRating' in data && (
<Rating size="xs" value={data?.userRating ?? 0} /> // <Rating size="xs" value={data?.userRating ?? 0} />
)} // )}
{data && 'userFavorite' in data && ( // {data && 'userFavorite' in data && (
<ActionIcon // <ActionIcon
icon="favorite" // icon="favorite"
iconProps={{ // iconProps={{
fill: data?.userFavorite ? 'primary' : 'default', // fill: data?.userFavorite ? 'primary' : 'default',
}} // }}
size="xs" // size="xs"
/> // />
)} // )}
</Group> // </Group>
</div> // </div>
<Divider /> // <Divider />
<div className={styles.content}> // <div className={styles.content}>
<Group className={styles.tags} gap="xs"> // <Group className={styles.tags} gap="xs">
{tags.map((tag) => ( // {tags.map((tag) => (
<Badge // <Badge
key={tag.id} // key={tag.id}
style={{ // style={{
backgroundColor: tag.color, // backgroundColor: tag.color,
color: tag.isLight ? 'black' : 'white', // color: tag.isLight ? 'black' : 'white',
}} // }}
> // >
{tag.name} // {tag.name}
</Badge> // </Badge>
))} // ))}
</Group> // </Group>
</div> // </div>
</div> // </div>
</div> // </div>
); // );
}; // };
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { // const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) { // if (data && 'imageUrl' in data) {
return data.imageUrl || undefined; // return data.imageUrl || undefined;
} // }
return undefined; // return undefined;
}; // };
@@ -0,0 +1,57 @@
import { memo, useMemo } from 'react';
import z from 'zod';
import { api } from '/@/renderer/api';
import { GeneralSettingsSchema, useCurrentServerId, useSettingsStore } from '/@/renderer/store';
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
import { LibraryItem } from '/@/shared/types/domain-types';
const BaseItemImage = (
props: Omit<ImageProps, 'src'> & {
id?: null | string;
itemType: LibraryItem;
src?: null | string;
},
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { src, ...rest } = props;
const imageUrl = useItemImageUrl({ id: props.id, itemType: props.itemType, size: 300 });
return <BaseImage src={imageUrl} {...rest} />;
};
export const ItemImage = memo(BaseItemImage);
interface UseItemImageUrlProps {
id?: string;
imageUrl?: null | string;
itemType: LibraryItem;
size?: number;
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
}
export const useItemImageUrl = (args: UseItemImageUrlProps) => {
const { id, imageUrl, itemType, size, type } = args;
const serverId = useCurrentServerId();
const imageRes = useSettingsStore((store) => store.general.imageRes);
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
return useMemo(() => {
if (imageUrl) {
return imageUrl;
}
if (!id) {
return undefined;
}
return (
api.controller.getImageUrl({
apiClientProps: { serverId },
query: { id, itemType, size: size ?? sizeByType },
}) || undefined
);
}, [id, imageUrl, itemType, serverId, size, sizeByType]);
};
@@ -3,6 +3,7 @@ import { useState } from 'react';
import styles from './image-column.module.css'; import styles from './image-column.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { import {
ItemTableListInnerColumn, ItemTableListInnerColumn,
TableColumnContainer, TableColumnContainer,
@@ -14,17 +15,14 @@ import {
} from '/@/renderer/features/shared/components/play-button-group'; } from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Folder, LibraryItem } from '/@/shared/types/domain-types'; import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => { export const ImageColumn = (props: ItemTableListInnerColumn) => {
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
props.columns[props.columnIndex].id
];
const playButtonBehavior = usePlayButtonBehavior();
const item = props.data[props.rowIndex] as any; const item = props.data[props.rowIndex] as any;
const playButtonBehavior = usePlayButtonBehavior();
const internalState = (props as any).internalState; const internalState = (props as any).internalState;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -80,12 +78,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<Image <ItemImage
containerClassName={clsx({ containerClassName={clsx({
[styles.imageContainerWithAspectRatio]: [styles.imageContainerWithAspectRatio]:
props.size === 'default' || props.size === 'large', props.size === 'default' || props.size === 'large',
})} })}
src={row} id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/> />
{isHovered && ( {isHovered && (
<div <div
@@ -4,6 +4,7 @@ import { generatePath, Link } from 'react-router';
import styles from './title-combined-column.module.css'; import styles from './title-combined-column.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import { import {
ColumnNullFallback, ColumnNullFallback,
@@ -19,13 +20,12 @@ import {
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { usePlayButtonBehavior } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types'; import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]; const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any; const item = props.data[props.rowIndex] as any;
const internalState = (props as any).internalState; const internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
@@ -110,7 +110,12 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<Image containerClassName={styles.image} src={row.imageUrl as string} /> <ItemImage
containerClassName={styles.image}
id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/>
{isHovered && ( {isHovered && (
<div <div
className={clsx(styles.playButtonOverlay, { className={clsx(styles.playButtonOverlay, {
@@ -263,7 +268,12 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<Image containerClassName={styles.image} src={row.imageUrl as string} /> <ItemImage
containerClassName={styles.image}
id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/>
{isHovered && ( {isHovered && (
<div <div
className={clsx(styles.playButtonOverlay, { className={clsx(styles.playButtonOverlay, {
@@ -4,6 +4,7 @@ import { generatePath, Link, useParams } from 'react-router';
import styles from './album-detail-header.module.css'; 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 { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; 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 firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
const releaseYear = detailQuery?.data?.releaseYear; const releaseYear = detailQuery?.data?.releaseYear;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.id,
itemType: LibraryItem.ALBUM,
type: 'header',
});
return ( return (
<Stack ref={ref}> <Stack ref={ref}>
<LibraryHeader <LibraryHeader
imageUrl={detailQuery?.data?.imageUrl} imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }} item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''} 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 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 { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
@@ -197,10 +198,16 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
const player = usePlayer(); const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.id,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({ const color = useFastAverageColor({
algorithm: 'sqrt', algorithm: 'sqrt',
id: item.id, id: item.id,
src: data?.imageUrl, src: imageUrl,
srcLoaded: true, srcLoaded: true,
}); });
@@ -300,7 +307,7 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
className={styles.backgroundImage} className={styles.backgroundImage}
style={{ style={{
['--bg-color' as string]: color?.background, ['--bg-color' as string]: color?.background,
backgroundImage: `url(${data?.imageUrl})`, backgroundImage: `url(${imageUrl})`,
}} }}
/> />
{data?.songs && data.songs.length > 0 && ( {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 { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; 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 { usePlayer } from '/@/renderer/features/player/context/player-context';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; 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 ( return (
<AnimatedPage key={`dummy-album-detail-${albumId}`}> <AnimatedPage key={`dummy-album-detail-${albumId}`}>
<LibraryContainer> <LibraryContainer>
<Stack> <Stack>
<LibraryHeader <LibraryHeader
imageUrl={detailQuery?.data?.imageUrl} imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }} item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
loading={!background || colorId !== albumId} loading={!background || colorId !== albumId}
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
@@ -5,6 +5,7 @@ import { useParams } from 'react-router';
import styles from './album-artist-detail-header.module.css'; 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 { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; 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 showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
return ( return (
<LibraryHeader <LibraryHeader
imageUrl={detailQuery?.data?.imageUrl} imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }} item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
ref={ref} ref={ref}
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
@@ -2,13 +2,15 @@ import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { import {
DiscordDisplayType, DiscordDisplayType,
DiscordLinkType, DiscordLinkType,
useAppStore, useAppStore,
useDiscordSettings, useDiscordSettings,
useGeneralSettings, useGeneralSettings,
usePlayerSong,
usePlayerStore, usePlayerStore,
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
@@ -16,7 +18,7 @@ import { sentenceCase } from '/@/renderer/utils';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; import { logMsg } from '/@/renderer/utils/logger-message';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; 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'; import { PlayerStatus } from '/@/shared/types/types';
const discordRpc = isElectron() ? window.api.discordRpc : null; const discordRpc = isElectron() ? window.api.discordRpc : null;
@@ -33,10 +35,24 @@ export const useDiscordRpc = () => {
const privateMode = useAppStore((state) => state.privateMode); const privateMode = useAppStore((state) => state.privateMode);
const [lastUniqueId, setlastUniqueId] = useState(''); 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 previousEnabledRef = useRef<boolean>(discordSettings.enabled);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const previousActivityStateRef = useRef<ActivityState | null>(null); const previousActivityStateRef = useRef<ActivityState | null>(null);
// Update imageUrl ref when it changes
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
const setActivity = useCallback( const setActivity = useCallback(
async (current: ActivityState, previous: ActivityState) => { async (current: ActivityState, previous: ActivityState) => {
// Check if track changed by comparing with previous state // Check if track changed by comparing with previous state
@@ -178,11 +194,16 @@ export const useDiscordRpc = () => {
} }
if (discordSettings.showServerImage && song) { if (discordSettings.showServerImage && song) {
// 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) { if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
activity.largeImageKey = song.imageUrl; activity.largeImageKey = song.imageUrl;
} else if (song._serverType === ServerType.NAVIDROME) { } else if (song._serverType === ServerType.NAVIDROME) {
try { try {
const info = await controller.getAlbumInfo({ const info = await api.controller.getAlbumInfo({
apiClientProps: { serverId: song._serverId }, apiClientProps: { serverId: song._serverId },
query: { id: song.albumId }, query: { id: song.albumId },
}); });
@@ -195,6 +216,7 @@ export const useDiscordRpc = () => {
} }
} }
} }
}
if ( if (
activity.largeImageKey === undefined && activity.largeImageKey === undefined &&
@@ -275,6 +297,7 @@ export const useDiscordRpc = () => {
discordSettings.displayType, discordSettings.displayType,
discordSettings.linkType, discordSettings.linkType,
lastUniqueId, lastUniqueId,
currentSong?._uniqueId,
], ],
); );
@@ -52,7 +52,7 @@ export const AlbumInfiniteFeatureCarousel = ({
// Filter for albums with images and remove duplicates by ID // Filter for albums with images and remove duplicates by ID
const uniqueAlbums = new Map<string, Album>(); const uniqueAlbums = new Map<string, Album>();
for (const album of allAlbums) { for (const album of allAlbums) {
if (album.imageUrl && !uniqueAlbums.has(album.id)) { if (album.imageId && !uniqueAlbums.has(album.id)) {
uniqueAlbums.set(album.id, album); uniqueAlbums.set(album.id, album);
} }
} }
@@ -1,11 +1,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react'; import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef } from 'react';
import { generatePath } from 'react-router'; import { generatePath, Link } from 'react-router';
import { Link } from 'react-router';
import styles from './full-screen-player-image.module.css'; 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 { AppRoute } from '/@/renderer/router/routes';
import { usePlayerData, usePlayerSong } from '/@/renderer/store'; import { usePlayerData, usePlayerSong } from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.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 { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useSetState } from '/@/shared/hooks/use-set-state'; import { useSetState } from '/@/shared/hooks/use-set-state';
import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = { const imageVariants: Variants = {
closed: { 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 MotionImage = motion.img;
const ImageWithPlaceholder = ({ const ImageWithPlaceholder = ({
@@ -85,45 +79,28 @@ const ImageWithPlaceholder = ({
export const FullScreenPlayerImage = () => { export const FullScreenPlayerImage = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null); const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { nextSong } = usePlayerData(); const { nextSong } = usePlayerData();
const currentImageUrl = useItemImageUrl({
id: currentSong?.id,
itemType: LibraryItem.SONG,
type: 'fullScreenPlayer',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.id,
itemType: LibraryItem.SONG,
type: 'fullScreenPlayer',
});
const [imageState, setImageState] = useSetState({ const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl), bottomImage: nextImageUrl,
current: 0, current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl), topImage: currentImageUrl,
}); });
const updateImageSize = useCallback(() => {
if (mainImageRef.current) {
setMainImageDimensions({
idealSize:
albumArtRes ||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
});
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]);
// Track previous song to detect changes // Track previous song to detect changes
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId); const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
const imageStateRef = useRef(imageState); const imageStateRef = useRef(imageState);
@@ -133,15 +110,13 @@ export const FullScreenPlayerImage = () => {
imageStateRef.current = imageState; imageStateRef.current = imageState;
}, [imageState]); }, [imageState]);
// Update images when song changes // Update images when song or size changes
useEffect(() => { useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) { if (currentSong?._uniqueId === previousSongRef.current) {
return; return;
} }
const isTop = imageStateRef.current.current === 0; const isTop = imageStateRef.current.current === 0;
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({ setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl, bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -150,13 +125,7 @@ export const FullScreenPlayerImage = () => {
}); });
previousSongRef.current = currentSong?._uniqueId; previousSongRef.current = currentSong?._uniqueId;
}, [ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
currentSong?._uniqueId,
currentSong?.imageUrl,
nextSong?.imageUrl,
mainImageDimensions.idealSize,
setImageState,
]);
return ( return (
<Flex <Flex
@@ -13,6 +13,7 @@ import { useLocation } from 'react-router';
import styles from './full-screen-player.module.css'; 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 { 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 { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue'; 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 { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types';
const mainBackground = 'var(--theme-colors-background)'; const mainBackground = 'var(--theme-colors-background)';
@@ -74,14 +76,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { nextSong } = usePlayerData(); 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({ const [imageState, setImageState] = useState({
bottomImage: nextSong?.imageUrl bottomImage: nextImageUrl,
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
current: 0, current: 0,
topImage: currentSong?.imageUrl topImage: currentImageUrl,
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
}); });
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId); const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
@@ -99,12 +109,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
} }
const isTop = imageStateRef.current.current === 0; 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({ setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl, bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -113,7 +117,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}); });
previousSongRef.current = currentSong?._uniqueId; previousSongRef.current = currentSong?._uniqueId;
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]); }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) { if (!dynamicBackground || !dynamicIsImage) {
return null; return null;
@@ -610,9 +614,15 @@ const PlayerContainer = memo(
windowBarStyle, windowBarStyle,
}: PlayerContainerProps) => { }: PlayerContainerProps) => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const { background } = useFastAverageColor({ const { background } = useFastAverageColor({
algorithm: 'dominant', algorithm: 'dominant',
src: currentSong?.imageUrl, src: imageUrl,
srcLoaded: true, srcLoaded: true,
}); });
@@ -7,6 +7,7 @@ import { shallow } from 'zustand/shallow';
import styles from './left-controls.module.css'; 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 { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display'; import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player'; import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
@@ -21,7 +22,6 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -116,13 +116,14 @@ export const LeftControls = () => {
})} })}
openDelay={0} openDelay={0}
> >
<Image <ItemImage
className={clsx( className={clsx(
styles.playerbarImage, styles.playerbarImage,
PlaybackSelectors.playerCoverArt, PlaybackSelectors.playerCoverArt,
)} )}
id={currentSong?.id}
itemType={LibraryItem.SONG}
loading="eager" loading="eager"
src={isRadioMode ? '' : (currentSong?.imageUrl ?? '')}
/> />
</Tooltip> </Tooltip>
{!collapsed && ( {!collapsed && (
@@ -4,16 +4,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import styles from './mobile-fullscreen-player.module.css'; import styles from './mobile-fullscreen-player.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { import {
useFullScreenPlayerStore, useFullScreenPlayerStore,
useGeneralSettings,
usePlayerData, usePlayerData,
usePlayerSong, usePlayerSong,
useSettingsStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors'; import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { useSetState } from '/@/shared/hooks/use-set-state'; import { useSetState } from '/@/shared/hooks/use-set-state';
import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = { const imageVariants: Variants = {
closed: { 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 MotionImage = motion.img;
const ImageWithPlaceholder = ({ const ImageWithPlaceholder = ({
@@ -83,15 +78,29 @@ export const MobileFullscreenPlayerAlbumArt = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null); const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 }); const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 });
const { albumArtRes } = useGeneralSettings(); const albumArtRes = useSettingsStore((store) => store.general.imageRes.fullScreenPlayer);
const { useImageAspectRatio } = useFullScreenPlayerStore(); const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { nextSong } = usePlayerData(); 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({ const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl), bottomImage: nextImageUrl,
current: 0, current: 0,
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl), topImage: currentImageUrl,
}); });
const updateImageSize = useCallback(() => { const updateImageSize = useCallback(() => {
@@ -101,14 +110,8 @@ export const MobileFullscreenPlayerAlbumArt = () => {
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100; Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100;
setMainImageDimensions({ idealSize }); setMainImageDimensions({ idealSize });
setImageState({
bottomImage: scaleImageUrl(idealSize, nextSong?.imageUrl),
current: 0,
topImage: scaleImageUrl(idealSize, currentSong?.imageUrl),
});
} }
}, [albumArtRes, currentSong?.imageUrl, nextSong?.imageUrl, setImageState]); }, [albumArtRes]);
useLayoutEffect(() => { useLayoutEffect(() => {
updateImageSize(); updateImageSize();
@@ -123,15 +126,13 @@ export const MobileFullscreenPlayerAlbumArt = () => {
imageStateRef.current = imageState; imageStateRef.current = imageState;
}, [imageState]); }, [imageState]);
// Update images when song changes // Update images when song or size changes
useEffect(() => { useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) { if (currentSong?._uniqueId === previousSongRef.current) {
return; return;
} }
const isTop = imageStateRef.current.current === 0; const isTop = imageStateRef.current.current === 0;
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({ setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl, bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -140,13 +141,7 @@ export const MobileFullscreenPlayerAlbumArt = () => {
}); });
previousSongRef.current = currentSong?._uniqueId; previousSongRef.current = currentSong?._uniqueId;
}, [ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
currentSong?._uniqueId,
currentSong?.imageUrl,
nextSong?.imageUrl,
mainImageDimensions.idealSize,
setImageState,
]);
return ( return (
<div className={styles.imageContainer} ref={mainImageRef}> <div className={styles.imageContainer} ref={mainImageRef}>
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css'; 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 { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics'; import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
@@ -74,14 +75,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { nextSong } = usePlayerData(); 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({ const [imageState, setImageState] = useState({
bottomImage: nextSong?.imageUrl bottomImage: nextImageUrl,
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
current: 0, current: 0,
topImage: currentSong?.imageUrl topImage: currentImageUrl,
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
: undefined,
}); });
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId); const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
@@ -98,12 +107,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
} }
const isTop = imageStateRef.current.current === 0; 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({ setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl, bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -112,7 +115,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}); });
previousSongRef.current = currentSong?._uniqueId; previousSongRef.current = currentSong?._uniqueId;
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]); }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) { if (!dynamicBackground || !dynamicIsImage) {
return null; return null;
@@ -299,9 +302,15 @@ interface MobilePlayerContainerProps {
const MobilePlayerContainer = memo( const MobilePlayerContainer = memo(
({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => { ({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
const { background } = useFastAverageColor({ const { background } = useFastAverageColor({
algorithm: 'dominant', algorithm: 'dominant',
src: currentSong?.imageUrl, src: imageUrl,
srcLoaded: true, srcLoaded: true,
}); });
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
import styles from './mobile-playerbar.module.css'; 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 { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button'; import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -20,7 +21,6 @@ import {
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -68,7 +68,7 @@ export const MobilePlayerbar = () => {
<div className={styles.contentWrapper}> <div className={styles.contentWrapper}>
<LayoutGroup> <LayoutGroup>
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{currentSong?.imageUrl && ( {currentSong?.id && (
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<motion.div <motion.div
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
@@ -87,13 +87,14 @@ export const MobilePlayerbar = () => {
})} })}
openDelay={0} openDelay={0}
> >
<Image <ItemImage
className={clsx( className={clsx(
styles.playerbarImage, styles.playerbarImage,
PlaybackSelectors.playerCoverArt, PlaybackSelectors.playerCoverArt,
)} )}
id={currentSong.id}
itemType={LibraryItem.SONG}
loading="eager" loading="eager"
src={currentSong.imageUrl}
/> />
</Tooltip> </Tooltip>
</motion.div> </motion.div>
@@ -1,9 +1,16 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect, useMemo } from 'react'; 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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; 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'; import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const mediaSession = navigator.mediaSession; const mediaSession = navigator.mediaSession;
@@ -13,6 +20,14 @@ export const useMediaSession = () => {
const player = usePlayer(); const player = usePlayer();
const skip = useSettingsStore((state) => state.general.skipButtons); const skip = useSettingsStore((state) => state.general.skipButtons);
const playbackType = useSettingsStore((state) => state.playback.type); 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(() => { const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web // Always enable media session on web
@@ -94,7 +109,7 @@ export const useMediaSession = () => {
mediaSession.metadata = new MediaMetadata({ mediaSession.metadata = new MediaMetadata({
album: song?.album ?? '', album: song?.album ?? '',
artist: song?.artistName ?? '', artist: song?.artistName ?? '',
artwork: song?.imageUrl ? [{ src: song.imageUrl, type: 'image/png' }] : [], artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],
title: song?.name ?? '', title: song?.name ?? '',
}); });
}, },
@@ -107,6 +122,6 @@ export const useMediaSession = () => {
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused'; mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
}, },
}, },
[isMediaSessionEnabled, mediaSession], [isMediaSessionEnabled, mediaSession, imageUrl],
); );
}; };
+20 -23
View File
@@ -1,8 +1,10 @@
import isElectron from 'is-electron'; 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 { 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'; import { PlayerShuffle } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
@@ -11,6 +13,14 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export const useMPRIS = () => { export const useMPRIS = () => {
const player = usePlayerStore(); const player = usePlayerStore();
const currentSong = usePlayerSong();
const imageUrl = useItemImageUrl({
id: currentSong?.id,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'itemCard',
});
useEffect(() => { useEffect(() => {
if (!mpris) { if (!mpris) {
@@ -41,32 +51,19 @@ export const useMPRIS = () => {
}; };
}, [player]); }, [player]);
const isInitializedRef = useRef(false); // Update MPRIS when song or imageUrl changes
useEffect(() => { useEffect(() => {
if (isInitializedRef.current) {
return;
}
isInitializedRef.current = true;
const currentSong = player.getCurrentSong();
if (!currentSong) {
return;
}
mpris?.updateSong(currentSong);
}, [player]);
usePlayerEvents(
{
onCurrentSongChange: (properties) => {
if (!mpris) { if (!mpris) {
return; return;
} }
mpris?.updateSong(properties.song); mpris?.updateSong(currentSong, imageUrl);
}, [currentSong, imageUrl]);
usePlayerEvents(
{
onCurrentSongChange: () => {
// The effect above will handle the update when currentSong changes
}, },
onPlayerProgress: (properties) => { onPlayerProgress: (properties) => {
if (!mpris) { 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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; 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 { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; 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'; import { PlayerStatus } from '/@/shared/types/types';
/* /*
@@ -59,7 +60,16 @@ export const useScrobble = () => {
const isScrobbleEnabled = scrobbleSettings?.enabled; const isScrobbleEnabled = scrobbleSettings?.enabled;
const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble(); 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 [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const previousSongRef = useRef<QueueSong | undefined>(undefined); const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0); const previousTimestampRef = useRef<number>(0);
@@ -68,6 +78,10 @@ export const useScrobble = () => {
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
const handleScrobbleFromProgress = useCallback( const handleScrobbleFromProgress = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => { (properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
@@ -198,7 +212,7 @@ export const useScrobble = () => {
new Notification(`${currentSong.name}`, { new Notification(`${currentSong.name}`, {
body: `${artists}\n${currentSong.album}`, body: `${artists}\n${currentSong.album}`,
icon: currentSong.imageUrl || undefined, icon: imageUrlRef.current || undefined,
silent: true, silent: true,
}); });
} }
@@ -5,20 +5,15 @@ import { QueueSong } from '/@/shared/types/domain-types';
const remote = isElectron() ? window.api.remote : null; const remote = isElectron() ? window.api.remote : null;
const mediaSession = navigator.mediaSession; const mediaSession = navigator.mediaSession;
export const updateSong = (song: QueueSong | undefined) => { export const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
if (mediaSession) { if (mediaSession) {
let metadata: MediaMetadata; let metadata: MediaMetadata;
if (song?.id) { if (song?.id) {
let artwork: MediaImage[]; let artwork: MediaImage[];
if (song.imageUrl) { if (imageUrl) {
const image300 = song.imageUrl artwork = [{ sizes: '300x300', src: imageUrl, type: 'image/png' }];
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300');
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
} else { } else {
artwork = []; artwork = [];
} }
@@ -7,6 +7,7 @@ import styles from './add-to-playlist-context-modal.module.css';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { import {
getAlbumSongsById, getAlbumSongsById,
getArtistSongsById, getArtistSongsById,
@@ -26,7 +27,6 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Grid } from '/@/shared/components/grid/grid'; import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { ModalButton } from '/@/shared/components/modal/model-shared'; import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Pill } from '/@/shared/components/pill/pill'; import { Pill } from '/@/shared/components/pill/pill';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; 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 { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; 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 = ({ export const AddToPlaylistContextModal = ({
id, id,
@@ -555,14 +555,13 @@ const PlaylistTableItem = memo(
<Grid align="center" gutter="xs" w="100%"> <Grid align="center" gutter="xs" w="100%">
<Grid.Col span="content"> <Grid.Col span="content">
<Flex align="center" justify="center" px="sm"> <Flex align="center" justify="center" px="sm">
{item.imageUrl && ( <ItemImage
<Image id={item.id}
imageContainerProps={{ imageContainerProps={{
className: styles.imageContainer, className: styles.imageContainer,
}} }}
src={item.imageUrl} itemType={LibraryItem.PLAYLIST}
/> />
)}
</Flex> </Flex>
</Grid.Col> </Grid.Col>
<Grid.Col className={styles.gridCol} span="auto"> <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 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 { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
LONG_PRESS_PLAY_BEHAVIOR, LONG_PRESS_PLAY_BEHAVIOR,
@@ -11,7 +12,6 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/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.itemGrid} style={{ '--item-height': '40px' } as CSSProperties}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<Image <ItemImage
alt="cover" alt="cover"
className={styles.image} className={styles.image}
height={40} height={40}
src={imageUrl || ''} id={id}
itemType={itemType}
src={imageUrl}
width={40} width={40}
/> />
</div> </div>
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import i18n, { languages } from '/@/i18n/i18n'; 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 { ArtistSettings } from '/@/renderer/features/settings/components/general/artist-settings';
import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings'; import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings';
import { import {
@@ -575,37 +576,13 @@ export const ApplicationSettings = () => {
isHidden: false, isHidden: false,
title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }), 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 ( return (
<SettingsSection <SettingsSection
extra={ extra={
<> <>
<ImageResolutionSettings />
<HomeSettings /> <HomeSettings />
<ArtistSettings /> <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 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 { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player'; import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
@@ -151,10 +152,11 @@ const SidebarImage = () => {
const { setSideBar } = useAppStoreActions(); const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const upsizedImageUrl = currentSong?.imageUrl const imageUrl = useItemImageUrl({
?.replace(/size=\d+/, 'size=450') id: currentSong?.id,
.replace(/width=\d+/, 'width=450') itemType: LibraryItem.SONG,
.replace(/height=\d+/, 'height=450'); type: 'sidebar',
});
const isSongDefined = Boolean(currentSong?.id); const isSongDefined = Boolean(currentSong?.id);
@@ -202,8 +204,8 @@ const SidebarImage = () => {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
})} })}
> >
{upsizedImageUrl ? ( {imageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={upsizedImageUrl} /> <img className={styles.sidebarImage} loading="eager" src={imageUrl} />
) : ( ) : (
<ImageUnloader /> <ImageUnloader />
)} )}
+15 -3
View File
@@ -215,7 +215,7 @@ const PlayerbarSliderSchema = z.object({
type: PlayerbarSliderTypeSchema, type: PlayerbarSliderTypeSchema,
}); });
const GeneralSettingsSchema = z.object({ export const GeneralSettingsSchema = z.object({
accent: z accent: z
.string() .string()
.refine( .refine(
@@ -224,7 +224,6 @@ const GeneralSettingsSchema = z.object({
message: 'Accent must be a valid rgb() color string', message: 'Accent must be a valid rgb() color string',
}, },
), ),
albumArtRes: z.number().nullable().optional(),
albumBackground: z.boolean(), albumBackground: z.boolean(),
albumBackgroundBlur: z.number(), albumBackgroundBlur: z.number(),
artistBackground: z.boolean(), artistBackground: z.boolean(),
@@ -238,6 +237,13 @@ const GeneralSettingsSchema = z.object({
genreTarget: GenreTargetSchema, genreTarget: GenreTargetSchema,
homeFeature: z.boolean(), homeFeature: z.boolean(),
homeItems: z.array(SortableItemSchema(HomeItemSchema)), homeItems: z.array(SortableItemSchema(HomeItemSchema)),
imageRes: z.object({
fullScreenPlayer: z.number(),
header: z.number(),
itemCard: z.number(),
sidebar: z.number(),
table: z.number(),
}),
language: z.string(), language: z.string(),
lastFM: z.boolean(), lastFM: z.boolean(),
lastfmApiKey: z.string(), lastfmApiKey: z.string(),
@@ -712,7 +718,6 @@ const initialState: SettingsState = {
}, },
general: { general: {
accent: 'rgb(53, 116, 252)', accent: 'rgb(53, 116, 252)',
albumArtRes: undefined,
albumBackground: false, albumBackground: false,
albumBackgroundBlur: 3, albumBackgroundBlur: 3,
artistBackground: false, artistBackground: false,
@@ -726,6 +731,13 @@ const initialState: SettingsState = {
genreTarget: GenreTarget.TRACK, genreTarget: GenreTarget.TRACK,
homeFeature: true, homeFeature: true,
homeItems, homeItems,
imageRes: {
fullScreenPlayer: 0,
header: 300,
itemCard: 300,
sidebar: 300,
table: 30,
},
language: 'en', language: 'en',
lastFM: true, lastFM: true,
lastfmApiKey: '', lastfmApiKey: '',
+60 -150
View File
@@ -16,100 +16,6 @@ import { ServerListItem, ServerType } from '/@/shared/types/types';
const TICKS_PER_MS = 10000; const TICKS_PER_MS = 10000;
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getAlbumCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.album>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getSongCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
}) => {
const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96' +
// Invalidate the cache if the image chances. This appears to be
// how Jellyfin Web does it as well
`&tag=${args.item.ImageTags.Primary}`
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
}
return null;
};
const getPlaylistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.playlist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>; type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']); const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
@@ -128,6 +34,7 @@ const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> =>
// for other roles, we just want to display this and not filter. // for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds // filtering (and links) would require a separate field, PersonIds
id: '', id: '',
imageId: null,
imageUrl: null, imageUrl: null,
name: person.Name, name: person.Name,
}; };
@@ -158,10 +65,47 @@ const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
return null; return null;
}; };
const getSongImageId = (item: z.infer<typeof jfType._response.song>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
if (item.AlbumPrimaryImageTag && item.AlbumId) {
return item.AlbumId;
}
return null;
};
const getAlbumImageId = (item: z.infer<typeof jfType._response.album>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const getAlbumArtistImageId = (
item: z.infer<typeof jfType._response.albumArtist>,
): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof jfType._response.song>, item: z.infer<typeof jfType._response.song>,
server: null | ServerListItem, server: null | ServerListItem,
imageSize?: number,
): Song => { ): Song => {
let bitRate = 0; let bitRate = 0;
let channels: null | number = null; let channels: null | number = null;
@@ -201,6 +145,7 @@ const normalizeSong = (
album: item.Album, album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id, id: entry.Id,
imageId: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})), })),
@@ -209,6 +154,7 @@ const normalizeSong = (
artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map( artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({ (entry) => ({
id: entry.Id, id: entry.Id,
imageId: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
}), }),
@@ -241,13 +187,14 @@ const normalizeSong = (
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: null, albumCount: null,
id: entry.Id, id: entry.Id,
imageId: null,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
songCount: null, songCount: null,
})), })),
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imageId: getSongImageId(item),
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), imageUrl: null,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null, lyrics: null,
mbzRecordingId: null, mbzRecordingId: null,
@@ -273,7 +220,6 @@ const normalizeSong = (
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>, item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem, server: null | ServerListItem,
imageSize?: number,
): Album => { ): Album => {
return { return {
_itemType: LibraryItem.ALBUM, _itemType: LibraryItem.ALBUM,
@@ -283,17 +229,18 @@ const normalizeAlbum = (
albumArtists: albumArtists:
item.AlbumArtists.map((entry) => ({ item.AlbumArtists.map((entry) => ({
id: entry.Id, id: entry.Id,
imageId: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})) || [], })) || [],
artists: (item.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map( artists: (item.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({ (entry) => ({
id: entry.Id, id: entry.Id,
imageId: entry.Id,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
}), }),
), ),
backdropImageUrl: null,
comment: null, comment: null,
createdAt: item.DateCreated, createdAt: item.DateCreated,
duration: item.RunTimeTicks / TICKS_PER_MS, duration: item.RunTimeTicks / TICKS_PER_MS,
@@ -305,17 +252,14 @@ const normalizeAlbum = (
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: null, albumCount: null,
id: entry.Id, id: entry.Id,
imageId: null,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
songCount: null, songCount: null,
})) || [], })) || [],
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imageId: getAlbumImageId(item),
imageUrl: getAlbumCoverArtUrl({ imageUrl: null,
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
isCompilation: null, isCompilation: null,
lastPlayedAt: null, lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null, mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
@@ -329,7 +273,7 @@ const normalizeAlbum = (
releaseYear: item.ProductionYear || null, releaseYear: item.ProductionYear || null,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, imageSize)), songs: item.Songs?.map((song) => normalizeSong(song, server)),
tags: getTags(item), tags: getTags(item),
updatedAt: item?.DateLastMediaAdded || item.DateCreated, updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
@@ -343,17 +287,13 @@ const normalizeAlbumArtist = (
similarArtists?: z.infer<typeof jfType._response.albumArtistList>; similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
}, },
server: null | ServerListItem, server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => { ): AlbumArtist => {
const similarArtists = const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map( item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({ (entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({ imageId: entry.Id,
baseUrl: server?.url || '', imageUrl: null,
item: entry,
size: imageSize || 300,
}),
name: entry.Name, name: entry.Name,
}), }),
) || []; ) || [];
@@ -363,7 +303,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || '', _serverId: server?.id || '',
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: item.AlbumCount ?? null, albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null, biography: item.Overview || null,
duration: item.RunTimeTicks / TICKS_PER_MS, duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({ genres: item.GenreItems?.map((entry) => ({
@@ -372,16 +311,14 @@ const normalizeAlbumArtist = (
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: null, albumCount: null,
id: entry.Id, id: entry.Id,
imageId: null,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
songCount: null, songCount: null,
})), })),
id: item.Id, id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({ imageId: getAlbumArtistImageId(item),
baseUrl: server?.url || '', imageUrl: null,
item,
size: imageSize || 300,
}),
lastPlayedAt: null, lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null, mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name, name: item.Name,
@@ -396,16 +333,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = ( const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>, item: z.infer<typeof jfType._response.playlist>,
server: null | ServerListItem, server: null | ServerListItem,
imageSize?: number,
): Playlist => { ): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return { return {
_itemType: LibraryItem.PLAYLIST, _itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || '', _serverId: server?.id || '',
@@ -418,13 +346,14 @@ const normalizePlaylist = (
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: null, albumCount: null,
id: entry.Id, id: entry.Id,
imageId: null,
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
songCount: null, songCount: null,
})), })),
id: item.Id, id: item.Id,
imagePlaceholderUrl, imageId: getPlaylistImageId(item),
imageUrl: imageUrl || null, imageUrl: null,
name: item.Name, name: item.Name,
owner: null, owner: null,
ownerId: null, ownerId: null,
@@ -463,26 +392,6 @@ const normalizeMusicFolder = (item: z.infer<typeof jfType._response.musicFolder>
// }; // };
// }; // };
const getGenreCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.genre>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const normalizeGenre = ( const normalizeGenre = (
item: z.infer<typeof jfType._response.genre>, item: z.infer<typeof jfType._response.genre>,
server: null | ServerListItem, server: null | ServerListItem,
@@ -493,7 +402,8 @@ const normalizeGenre = (
_serverType: ServerType.JELLYFIN, _serverType: ServerType.JELLYFIN,
albumCount: null, albumCount: null,
id: item.Id, id: item.Id,
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }), imageId: item.Id,
imageUrl: null,
name: item.Name, name: item.Name,
songCount: null, songCount: null,
}; };
+19 -81
View File
@@ -24,32 +24,6 @@ const getImageUrl = (args: { url: null | string }) => {
return url; return url;
}; };
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
updated: string;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
`&size=${size}` +
// A dummy variable to invalidate the cached image if the item is updated
// This is adapted from how Navidrome web does it
`&_=${args.updated}`
);
};
interface WithDate { interface WithDate {
playDate?: string; playDate?: string;
} }
@@ -74,6 +48,7 @@ const getArtists = (
if (role === 'albumartist' || role === 'artist') { if (role === 'albumartist' || role === 'artist') {
const roleList = list.map((item) => ({ const roleList = list.map((item) => ({
id: item.id, id: item.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: item.name, name: item.name,
})); }));
@@ -89,6 +64,7 @@ const getArtists = (
for (const artist of list) { for (const artist of list) {
const item: RelatedArtist = { const item: RelatedArtist = {
id: artist.id, id: artist.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: artist.name, name: artist.name,
}; };
@@ -112,11 +88,13 @@ const getArtists = (
} }
if (albumArtists === undefined) { if (albumArtists === undefined) {
albumArtists = [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }]; albumArtists = [
{ id: item.albumArtistId, imageId: null, imageUrl: null, name: item.albumArtist },
];
} }
if (artists === undefined) { if (artists === undefined) {
artists = [{ id: item.artistId, imageUrl: null, name: item.artist }]; artists = [{ id: item.artistId, imageId: null, imageUrl: null, name: item.artist }];
} }
return { albumArtists, artists, participants }; return { albumArtists, artists, participants };
@@ -125,7 +103,6 @@ const getArtists = (
const normalizeSong = ( const normalizeSong = (
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>, item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
server?: null | ServerListItem, server?: null | ServerListItem,
imageSize?: number,
): Song => { ): Song => {
let id; let id;
let playlistItemId; let playlistItemId;
@@ -138,15 +115,6 @@ const normalizeSong = (
id = item.id; id = item.id;
} }
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
return { return {
album: item.album, album: item.album,
albumId: item.albumId, albumId: item.albumId,
@@ -182,13 +150,14 @@ const normalizeSong = (
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount: null, albumCount: null,
id: genre.id, id: genre.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: genre.name, name: genre.name,
songCount: null, songCount: null,
})), })),
id, id,
imagePlaceholderUrl, imageId: item.id,
imageUrl, imageUrl: null,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null, lyrics: item.lyrics ? item.lyrics : null,
mbzRecordingId: item.mbzReleaseTrackId || null, mbzRecordingId: item.mbzReleaseTrackId || null,
@@ -261,20 +230,7 @@ const normalizeAlbum = (
songs?: z.infer<typeof ndType._response.songList>; songs?: z.infer<typeof ndType._response.songList>;
}, },
server?: null | ServerListItem, server?: null | ServerListItem,
imageSize?: number,
): Album => { ): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return { return {
...parseAlbumTags(item), ...parseAlbumTags(item),
...getArtists(item), ...getArtists(item),
@@ -282,7 +238,6 @@ const normalizeAlbum = (
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumArtist: item.albumArtist, albumArtist: item.albumArtist,
backdropImageUrl: imageBackdropUrl,
comment: item.comment || null, comment: item.comment || null,
createdAt: item.createdAt, createdAt: item.createdAt,
duration: item.duration !== undefined ? item.duration * 1000 : null, duration: item.duration !== undefined ? item.duration * 1000 : null,
@@ -298,13 +253,14 @@ const normalizeAlbum = (
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount: null, albumCount: null,
id: genre.id, id: genre.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: genre.name, name: genre.name,
songCount: null, songCount: null,
})), })),
id: item.id, id: item.id,
imagePlaceholderUrl, imageId: item.coverArtId || item.id,
imageUrl, imageUrl: null,
isCompilation: item.compilation, isCompilation: item.compilation,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null, mbzId: item.mbzAlbumId || null,
@@ -329,17 +285,7 @@ const normalizeAlbumArtist = (
}, },
server?: null | ServerListItem, server?: null | ServerListItem,
): AlbumArtist => { ): AlbumArtist => {
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null }); const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
if (!imageUrl) {
imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 300,
updated: item.updatedAt || '',
});
}
let albumCount: number; let albumCount: number;
let songCount: number; let songCount: number;
@@ -363,7 +309,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount, albumCount,
backgroundImageUrl: null,
biography: item.biography || null, biography: item.biography || null,
duration: null, duration: null,
genres: (item.genres || []).map((genre) => ({ genres: (item.genres || []).map((genre) => ({
@@ -372,11 +317,13 @@ const normalizeAlbumArtist = (
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount: null, albumCount: null,
id: genre.id, id: genre.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: genre.name, name: genre.name,
songCount: null, songCount: null,
})), })),
id: item.id, id: item.id,
imageId: item.id,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null, mbz: item.mbzArtistId || null,
@@ -385,6 +332,7 @@ const normalizeAlbumArtist = (
similarArtists: similarArtists:
item.similarArtists?.map((artist) => ({ item.similarArtists?.map((artist) => ({
id: artist.id, id: artist.id,
imageId: null,
imageUrl: artist?.artistImageUrl || null, imageUrl: artist?.artistImageUrl || null,
name: artist.name, name: artist.name,
})) || null, })) || null,
@@ -397,18 +345,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = ( const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>, item: z.infer<typeof ndType._response.playlist>,
server?: null | ServerListItem, server?: null | ServerListItem,
imageSize?: number,
): Playlist => { ): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
return { return {
_itemType: LibraryItem.PLAYLIST, _itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
@@ -417,8 +354,8 @@ const normalizePlaylist = (
duration: item.duration * 1000, duration: item.duration * 1000,
genres: [], genres: [],
id: item.id, id: item.id,
imagePlaceholderUrl, imageId: item.id,
imageUrl, imageUrl: null,
name: item.name, name: item.name,
owner: item.ownerName, owner: item.ownerName,
ownerId: item.ownerId, ownerId: item.ownerId,
@@ -440,6 +377,7 @@ const normalizeGenre = (
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount: item.albumCount ?? null, albumCount: item.albumCount ?? null,
id: item.id, id: item.id,
imageId: null,
imageUrl: null, imageUrl: null,
name: item.name, name: item.name,
songCount: item.songCount ?? null, songCount: item.songCount ?? null,
+10 -10
View File
@@ -15,16 +15,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { useInViewport } from '/@/shared/hooks/use-in-viewport'; import { useInViewport } from '/@/shared/hooks/use-in-viewport';
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> { export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
children: ReactNode;
enableAnimation?: boolean;
}
interface ImageLoaderProps {
className?: string;
}
interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
containerClassName?: string; containerClassName?: string;
enableAnimation?: boolean; enableAnimation?: boolean;
imageContainerProps?: Omit<ImageContainerProps, 'children'>; imageContainerProps?: Omit<ImageContainerProps, 'children'>;
@@ -34,6 +25,15 @@ interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
thumbHash?: string; thumbHash?: string;
} }
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
enableAnimation?: boolean;
}
interface ImageLoaderProps {
className?: string;
}
interface ImageUnloaderProps { interface ImageUnloaderProps {
className?: string; className?: string;
} }
+18 -5
View File
@@ -174,14 +174,13 @@ export type Album = {
albumArtist: string; albumArtist: string;
albumArtists: RelatedArtist[]; albumArtists: RelatedArtist[];
artists: RelatedArtist[]; artists: RelatedArtist[];
backdropImageUrl: null | string;
comment: null | string; comment: null | string;
createdAt: string; createdAt: string;
duration: null | number; duration: null | number;
explicitStatus: ExplicitStatus | null; explicitStatus: ExplicitStatus | null;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imagePlaceholderUrl: null | string; imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
isCompilation: boolean | null; isCompilation: boolean | null;
lastPlayedAt: null | string; lastPlayedAt: null | string;
@@ -209,11 +208,11 @@ export type AlbumArtist = {
_serverId: string; _serverId: string;
_serverType: ServerType; _serverType: ServerType;
albumCount: null | number; albumCount: null | number;
backgroundImageUrl: null | string;
biography: null | string; biography: null | string;
duration: null | number; duration: null | number;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
lastPlayedAt: null | string; lastPlayedAt: null | string;
mbz: null | string; mbz: null | string;
@@ -294,6 +293,7 @@ export type Genre = {
_serverType: ServerType; _serverType: ServerType;
albumCount: null | number; albumCount: null | number;
id: string; id: string;
imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
name: string; name: string;
songCount: null | number; songCount: null | number;
@@ -334,7 +334,7 @@ export type Playlist = {
duration: null | number; duration: null | number;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imagePlaceholderUrl: null | string; imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
name: string; name: string;
owner: null | string; owner: null | string;
@@ -353,6 +353,7 @@ export type RelatedAlbumArtist = {
export type RelatedArtist = { export type RelatedArtist = {
id: string; id: string;
imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
name: string; name: string;
}; };
@@ -381,7 +382,7 @@ export type Song = {
gain: GainInfo | null; gain: GainInfo | null;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imagePlaceholderUrl: null | string; imageId: null | string;
imageUrl: null | string; imageUrl: null | string;
lastPlayedAt: null | string; lastPlayedAt: null | string;
lyrics: null | string; lyrics: null | string;
@@ -1340,6 +1341,7 @@ export type ControllerEndpoint = {
getDownloadUrl: (args: DownloadArgs) => string; getDownloadUrl: (args: DownloadArgs) => string;
getFolder: (args: FolderArgs) => Promise<FolderResponse>; getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getImageUrl: (args: ImageArgs) => null | string;
getInternetRadioStations: ( getInternetRadioStations: (
args: GetInternetRadioStationsArgs, args: GetInternetRadioStationsArgs,
) => Promise<GetInternetRadioStationsResponse>; ) => Promise<GetInternetRadioStationsResponse>;
@@ -1408,6 +1410,16 @@ export type GetQueueResponse = {
username: string; username: string;
}; };
export type ImageArgs = BaseEndpointArgs & {
query: ImageQuery;
};
export type ImageQuery = {
id: string;
itemType: LibraryItem;
size?: number;
};
export type InternalControllerEndpoint = { export type InternalControllerEndpoint = {
addToPlaylist: ( addToPlaylist: (
args: ReplaceApiClientProps<AddToPlaylistArgs>, args: ReplaceApiClientProps<AddToPlaylistArgs>,
@@ -1449,6 +1461,7 @@ export type InternalControllerEndpoint = {
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string; getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>; getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>; getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
getInternetRadioStations: ( getInternetRadioStations: (
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>, args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
) => Promise<GetInternetRadioStationsResponse>; ) => Promise<GetInternetRadioStationsResponse>;