From 25bfb65b6ded239c9d1711db23e242ed2a3b1b60 Mon Sep 17 00:00:00 2001
From: Jeff <42182408+jeffvli@users.noreply.github.com>
Date: Tue, 23 Dec 2025 20:18:52 -0800
Subject: [PATCH] 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
---
src/i18n/locales/en.json | 9 +-
src/main/features/linux/mpris.ts | 84 +++---
src/preload/mpris.ts | 4 +-
src/renderer/api/controller.ts | 14 +
.../api/jellyfin/jellyfin-controller.ts | 20 +-
.../api/navidrome/navidrome-controller.ts | 5 +-
.../api/subsonic/subsonic-controller.ts | 22 ++
.../feature-carousel/feature-carousel.tsx | 16 +-
.../components/item-card/item-card.tsx | 23 +-
.../components/item-detail/item-detail.tsx | 260 +++++++++---------
.../components/item-image/item-image.tsx | 57 ++++
.../item-table-list/columns/image-column.tsx | 14 +-
.../columns/title-combined-column.tsx | 18 +-
.../albums/components/album-detail-header.tsx | 9 +-
.../components/expanded-album-list-item.tsx | 11 +-
.../routes/dummy-album-detail-route.tsx | 9 +-
.../components/album-artist-detail-header.tsx | 9 +-
.../features/discord-rpc/use-discord-rpc.ts | 51 +++-
.../album-infinite-feature-carousel.tsx | 2 +-
.../components/full-screen-player-image.tsx | 71 ++---
.../player/components/full-screen-player.tsx | 38 ++-
.../player/components/left-controls.tsx | 7 +-
.../mobile-fullscreen-player-album-art.tsx | 51 ++--
.../components/mobile-fullscreen-player.tsx | 37 ++-
.../player/components/mobile-playerbar.tsx | 9 +-
.../player/hooks/use-media-session.ts | 21 +-
.../features/player/hooks/use-mpris.ts | 39 ++-
.../features/player/hooks/use-scrobble.ts | 22 +-
.../features/player/update-remote-song.tsx | 11 +-
.../add-to-playlist-context-modal.tsx | 19 +-
.../components/library-command-item.tsx | 8 +-
.../general/application-settings.tsx | 27 +-
.../general/art-resolution-settings.tsx | 111 ++++++++
.../features/sidebar/components/sidebar.tsx | 14 +-
src/renderer/store/settings.store.ts | 18 +-
src/shared/api/jellyfin/jellyfin-normalize.ts | 210 ++++----------
.../api/navidrome/navidrome-normalize.ts | 100 ++-----
src/shared/components/image/image.tsx | 20 +-
src/shared/types/domain-types.ts | 23 +-
39 files changed, 823 insertions(+), 670 deletions(-)
create mode 100644 src/renderer/components/item-image/item-image.tsx
create mode 100644 src/renderer/features/settings/components/general/art-resolution-settings.tsx
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index eb4f42d58..029749a95 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -863,8 +863,13 @@
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"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",
- "playerAlbumArtResolution": "player album art resolution",
+ "imageResolution": "image 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": "playerbar fullscreen toggle",
"playerbarSlider": "playerbar slider",
diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts
index 85a0fd4ea..e58f597bd 100644
--- a/src/main/features/linux/mpris.ts
+++ b/src/main/features/linux/mpris.ts
@@ -141,48 +141,48 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle;
});
-ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
- try {
- if (!song?.id) {
- mprisPlayer.metadata = {};
- return;
+ipcMain.on(
+ 'update-song',
+ (_event, song: QueueSong | undefined, imageUrl: null | string | undefined) => {
+ try {
+ if (!song?.id) {
+ mprisPlayer.metadata = {};
+ return;
+ }
+
+ mprisPlayer.metadata = {
+ 'mpris:artUrl': imageUrl || null,
+ 'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
+ 'mpris:trackid': song.id
+ ? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
+ : '',
+ 'xesam:album': song.album || null,
+ 'xesam:albumArtist': song.albumArtists?.length
+ ? song.albumArtists.map((artist) => artist.name)
+ : null,
+ 'xesam:artist': song.artists?.length
+ ? song.artists.map((artist) => artist.name)
+ : null,
+ 'xesam:audioBpm': song.bpm,
+ // Comment is a `list of strings` type
+ 'xesam:comment': song.comment ? [song.comment] : null,
+ 'xesam:contentCreated': song.releaseDate,
+ 'xesam:discNumber': song.discNumber ? song.discNumber : null,
+ 'xesam:genre': song.genres?.length
+ ? song.genres.map((genre: any) => genre.name)
+ : null,
+ 'xesam:lastUsed': song.lastPlayedAt,
+ 'xesam:title': song.name || null,
+ 'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
+ 'xesam:useCount':
+ song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
+ // User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
+ 'xesam:userRating': song.userRating ? song.userRating / 5 : null,
+ };
+ } catch (err) {
+ console.error(err);
}
-
- const upsizedImageUrl = song.imageUrl
- ? song.imageUrl
- ?.replace(/&size=\d+/, '&size=300')
- .replace(/\?width=\d+/, '?width=300')
- .replace(/&height=\d+/, '&height=300')
- : null;
-
- mprisPlayer.metadata = {
- 'mpris:artUrl': upsizedImageUrl,
- 'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
- 'mpris:trackid': song.id
- ? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
- : '',
- 'xesam:album': song.album || null,
- 'xesam:albumArtist': song.albumArtists?.length
- ? song.albumArtists.map((artist) => artist.name)
- : null,
- 'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
- 'xesam:audioBpm': song.bpm,
- // Comment is a `list of strings` type
- 'xesam:comment': song.comment ? [song.comment] : null,
- 'xesam:contentCreated': song.releaseDate,
- 'xesam:discNumber': song.discNumber ? song.discNumber : null,
- 'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
- 'xesam:lastUsed': song.lastPlayedAt,
- 'xesam:title': song.name || null,
- 'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
- 'xesam:useCount':
- song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
- // User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
- 'xesam:userRating': song.userRating ? song.userRating / 5 : null,
- };
- } catch (err) {
- console.error(err);
- }
-});
+ },
+);
export { mprisPlayer };
diff --git a/src/preload/mpris.ts b/src/preload/mpris.ts
index 9cb98e040..8f962d1e1 100644
--- a/src/preload/mpris.ts
+++ b/src/preload/mpris.ts
@@ -27,8 +27,8 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
-const updateSong = (song: QueueSong | undefined) => {
- ipcRenderer.send('update-song', song);
+const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
+ ipcRenderer.send('update-song', song, imageUrl);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts
index 4573caad0..d64fb8754 100644
--- a/src/renderer/api/controller.ts
+++ b/src/renderer/api/controller.ts
@@ -370,6 +370,20 @@ export const controller: GeneralController = {
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) {
const server = getServerById(args.apiClientProps.serverId);
diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts
index 5a8941c4e..cc924508d 100644
--- a/src/renderer/api/jellyfin/jellyfin-controller.ts
+++ b/src/renderer/api/jellyfin/jellyfin-controller.ts
@@ -670,6 +670,22 @@ export const JellyfinController: InternalControllerEndpoint = {
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) => {
const { apiClientProps } = args;
@@ -1077,9 +1093,7 @@ export const JellyfinController: InternalControllerEndpoint = {
}
return {
- items: items.map((item) =>
- jfNormalize.song(item, apiClientProps.server, query.imageSize),
- ),
+ items: items.map((item) => jfNormalize.song(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount,
};
diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts
index 207280692..30dc0fa96 100644
--- a/src/renderer/api/navidrome/navidrome-controller.ts
+++ b/src/renderer/api/navidrome/navidrome-controller.ts
@@ -461,6 +461,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
+ getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations,
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
@@ -664,9 +665,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return {
- items: res.body.data.map((song) =>
- ndNormalize.song(song, apiClientProps.server, query.imageSize),
- ),
+ items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts
index 2866b1f4b..c7ff20843 100644
--- a/src/renderer/api/subsonic/subsonic-controller.ts
+++ b/src/renderer/api/subsonic/subsonic-controller.ts
@@ -827,6 +827,28 @@ export const SubsonicController: InternalControllerEndpoint = {
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) => {
const { apiClientProps } = args;
diff --git a/src/renderer/components/feature-carousel/feature-carousel.tsx b/src/renderer/components/feature-carousel/feature-carousel.tsx
index 8ee228fee..026fb0b99 100644
--- a/src/renderer/components/feature-carousel/feature-carousel.tsx
+++ b/src/renderer/components/feature-carousel/feature-carousel.tsx
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
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 { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
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 { Badge } from '/@/shared/components/badge/badge';
import { Group } from '/@/shared/components/group/group';
-import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem } from '/@/shared/types/domain-types';
@@ -78,9 +78,15 @@ interface CarouselItemProps {
}
const CarouselItem = ({ album }: CarouselItemProps) => {
+ const imageUrl = useItemImageUrl({
+ id: album.id,
+ itemType: LibraryItem.ALBUM,
+ type: 'itemCard',
+ });
+
const { background: backgroundColor } = useFastAverageColor({
algorithm: 'dominant',
- src: album.imageUrl || null,
+ src: imageUrl || null,
srcLoaded: true,
});
@@ -110,10 +116,12 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
-
diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx
index 8afee3fe6..74c5e97cd 100644
--- a/src/renderer/components/item-card/item-card.tsx
+++ b/src/renderer/components/item-card/item-card.tsx
@@ -7,6 +7,7 @@ import { generatePath, Link } from 'react-router';
import styles from './item-card.module.css';
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 { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import {
@@ -18,7 +19,6 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
-import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
@@ -137,7 +137,6 @@ const CompactItemCard = ({
data,
enableExpansion,
enableNavigation,
- imageUrl,
internalState,
isRound,
itemType,
@@ -247,11 +246,13 @@ const CompactItemCard = ({
const imageContainerContent = (
<>
-
{isFavorite &&
}
{hasRating &&
{userRating}
}
@@ -351,7 +352,6 @@ const DefaultItemCard = ({
data,
enableExpansion,
enableNavigation,
- imageUrl,
internalState,
isRound,
itemType,
@@ -461,9 +461,11 @@ const DefaultItemCard = ({
const imageContainerContent = (
<>
-
{isFavorite &&
}
{hasRating &&
{userRating}
}
@@ -563,7 +565,6 @@ const PosterItemCard = ({
enableDrag,
enableExpansion,
enableNavigation,
- imageUrl,
internalState,
isRound,
itemType,
@@ -720,9 +721,11 @@ const PosterItemCard = ({
const imageContainerContent = (
<>
-
{isFavorite &&
}
{hasRating &&
{userRating}
}
diff --git a/src/renderer/components/item-detail/item-detail.tsx b/src/renderer/components/item-detail/item-detail.tsx
index 50a1a1f5f..c7b12ee32 100644
--- a/src/renderer/components/item-detail/item-detail.tsx
+++ b/src/renderer/components/item-detail/item-detail.tsx
@@ -1,146 +1,146 @@
-import { AnimatePresence } from 'motion/react';
-import { MouseEvent, useMemo, useState } from 'react';
-import { Link } from 'react-router';
+// import { AnimatePresence } from 'motion/react';
+// import { MouseEvent, useMemo, useState } from 'react';
+// 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 { useFastAverageColor } from '/@/renderer/hooks';
-import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
-import { Badge } from '/@/shared/components/badge/badge';
-import { Divider } from '/@/shared/components/divider/divider';
-import { Group } from '/@/shared/components/group/group';
-import { Image } from '/@/shared/components/image/image';
-import { Rating } from '/@/shared/components/rating/rating';
-import { Text } from '/@/shared/components/text/text';
-import {
- Album,
- AlbumArtist,
- Artist,
- LibraryItem,
- Playlist,
- Song,
-} from '/@/shared/types/domain-types';
-import { stringToColor } from '/@/shared/utils/string-to-color';
+// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
+// import { useFastAverageColor } from '/@/renderer/hooks';
+// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
+// import { Badge } from '/@/shared/components/badge/badge';
+// import { Divider } from '/@/shared/components/divider/divider';
+// import { Group } from '/@/shared/components/group/group';
+// import { Image } from '/@/shared/components/image/image';
+// import { Rating } from '/@/shared/components/rating/rating';
+// import { Text } from '/@/shared/components/text/text';
+// import {
+// Album,
+// AlbumArtist,
+// Artist,
+// LibraryItem,
+// Playlist,
+// Song,
+// } from '/@/shared/types/domain-types';
+// import { stringToColor } from '/@/shared/utils/string-to-color';
-interface ItemDetailProps {
- data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
- itemHeight: number;
- itemType: LibraryItem;
- onClick?: (e: MouseEvent
, item: unknown, itemType: LibraryItem) => void;
- withControls?: boolean;
-}
+// interface ItemDetailProps {
+// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
+// itemHeight: number;
+// itemType: LibraryItem;
+// onClick?: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void;
+// withControls?: boolean;
+// }
-export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
- const imageUrl = getImageUrl(data);
+// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
+// const imageUrl = getImageUrl(data);
- const [showControls, setShowControls] = useState(false);
+// const [showControls, setShowControls] = useState(false);
- const { background } = useFastAverageColor({
- algorithm: 'simple',
- src: imageUrl,
- srcLoaded: false,
- });
+// const { background } = useFastAverageColor({
+// algorithm: 'simple',
+// src: imageUrl,
+// srcLoaded: false,
+// });
- // const tags = [...(data?.genres ?? [])];
+// // const tags = [...(data?.genres ?? [])];
- const tags = useMemo(() => {
- if (!data) {
- return [];
- }
+// const tags = useMemo(() => {
+// if (!data) {
+// return [];
+// }
- const items: {
- color?: string;
- id: string;
- isLight?: boolean;
- itemType: LibraryItem;
- name: string;
- }[] = [];
+// const items: {
+// color?: string;
+// id: string;
+// isLight?: boolean;
+// itemType: LibraryItem;
+// name: string;
+// }[] = [];
- if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
- data.albumArtists?.forEach((tag: { id: string; name: string }) => {
- items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
- });
- }
+// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
+// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
+// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
+// });
+// }
- if ('genres' in data && Array.isArray(data.genres)) {
- data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
- const { color, isLight } = stringToColor(tag.name);
- items.push({ ...tag, color, isLight });
- });
- }
+// if ('genres' in data && Array.isArray(data.genres)) {
+// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
+// const { color, isLight } = stringToColor(tag.name);
+// items.push({ ...tag, color, isLight });
+// });
+// }
- // if ('tags' in data && typeof data.tags === 'object') {
- // console.log('data.tags :>> ', data.tags);
- // Object.entries(data.tags).forEach(([key, value]) => {
- // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
- // });
- // }
+// // if ('tags' in data && typeof data.tags === 'object') {
+// // console.log('data.tags :>> ', data.tags);
+// // Object.entries(data.tags).forEach(([key, value]) => {
+// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
+// // });
+// // }
- return items;
- }, [data]);
+// return items;
+// }, [data]);
- return (
- onClick?.(e, data, itemType)}
- style={{ backgroundColor: background }}
- >
-
withControls && setShowControls(true)}
- onMouseLeave={() => withControls && setShowControls(false)}
- >
-
-
- {withControls && showControls && }
-
-
-
-
-
- {data?.name}
-
-
- {data && 'userRating' in data && (
-
- )}
- {data && 'userFavorite' in data && (
-
- )}
-
-
-
-
-
- {tags.map((tag) => (
-
- {tag.name}
-
- ))}
-
-
-
-
- );
-};
+// return (
+// onClick?.(e, data, itemType)}
+// style={{ backgroundColor: background }}
+// >
+//
withControls && setShowControls(true)}
+// onMouseLeave={() => withControls && setShowControls(false)}
+// >
+//
+//
+// {withControls && showControls && }
+//
+//
+//
+//
+//
+// {data?.name}
+//
+//
+// {data && 'userRating' in data && (
+//
+// )}
+// {data && 'userFavorite' in data && (
+//
+// )}
+//
+//
+//
+//
+//
+// {tags.map((tag) => (
+//
+// {tag.name}
+//
+// ))}
+//
+//
+//
+//
+// );
+// };
-const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
- if (data && 'imageUrl' in data) {
- return data.imageUrl || undefined;
- }
+// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
+// if (data && 'imageUrl' in data) {
+// return data.imageUrl || undefined;
+// }
- return undefined;
-};
+// return undefined;
+// };
diff --git a/src/renderer/components/item-image/item-image.tsx b/src/renderer/components/item-image/item-image.tsx
new file mode 100644
index 000000000..dd3898b93
--- /dev/null
+++ b/src/renderer/components/item-image/item-image.tsx
@@ -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 & {
+ 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 ;
+};
+
+export const ItemImage = memo(BaseItemImage);
+
+interface UseItemImageUrlProps {
+ id?: string;
+ imageUrl?: null | string;
+ itemType: LibraryItem;
+ size?: number;
+ type?: keyof z.infer['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]);
+};
diff --git a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx
index 93a40bf5d..8a300e948 100644
--- a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx
+++ b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react';
import styles from './image-column.module.css';
+import { ItemImage } from '/@/renderer/components/item-image/item-image';
import {
ItemTableListInnerColumn,
TableColumnContainer,
@@ -14,17 +15,14 @@ import {
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
-import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => {
- const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
- props.columns[props.columnIndex].id
- ];
- const playButtonBehavior = usePlayButtonBehavior();
+ const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any;
+ const playButtonBehavior = usePlayButtonBehavior();
const internalState = (props as any).internalState;
const [isHovered, setIsHovered] = useState(false);
@@ -80,12 +78,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
-
{isHovered && (
{
- 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 internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior();
@@ -110,7 +110,12 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
-
+
{isHovered && (
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
-
+
{isHovered && (
((_props, ref) => {
const firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
const releaseYear = detailQuery?.data?.releaseYear;
+ const imageUrl = useItemImageUrl({
+ id: detailQuery?.data?.id,
+ itemType: LibraryItem.ALBUM,
+ type: 'header',
+ });
+
return (
diff --git a/src/renderer/features/albums/components/expanded-album-list-item.tsx b/src/renderer/features/albums/components/expanded-album-list-item.tsx
index 1b427f8e0..324212f50 100644
--- a/src/renderer/features/albums/components/expanded-album-list-item.tsx
+++ b/src/renderer/features/albums/components/expanded-album-list-item.tsx
@@ -6,6 +6,7 @@ import { Fragment, Suspense, useCallback, useRef } from 'react';
import styles from './expanded-album-list-item.module.css';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -197,10 +198,16 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
const player = usePlayer();
+ const imageUrl = useItemImageUrl({
+ id: item.id,
+ itemType: LibraryItem.ALBUM,
+ type: 'itemCard',
+ });
+
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
- src: data?.imageUrl,
+ src: imageUrl,
srcLoaded: true,
});
@@ -300,7 +307,7 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
- backgroundImage: `url(${data?.imageUrl})`,
+ backgroundImage: `url(${imageUrl})`,
}}
/>
{data?.songs && data.songs.length > 0 && (
diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx
index 8603a80c8..1d1b866ac 100644
--- a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx
+++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx
@@ -7,6 +7,7 @@ import styles from './dummy-album-detail-route.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
@@ -113,12 +114,18 @@ const DummyAlbumDetailRoute = () => {
},
];
+ const imageUrl = useItemImageUrl({
+ id: albumId,
+ itemType: LibraryItem.ALBUM,
+ type: 'header',
+ });
+
return (
{
const privateMode = useAppStore((state) => state.privateMode);
const [lastUniqueId, setlastUniqueId] = useState('');
+ const currentSong = usePlayerSong();
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'table',
+ });
+
+ const imageUrlRef = useRef(imageUrl);
const previousEnabledRef = useRef(discordSettings.enabled);
const intervalRef = useRef(null);
const previousActivityStateRef = useRef(null);
+ // Update imageUrl ref when it changes
+ useEffect(() => {
+ imageUrlRef.current = imageUrl;
+ }, [imageUrl]);
+
const setActivity = useCallback(
async (current: ActivityState, previous: ActivityState) => {
// Check if track changed by comparing with previous state
@@ -178,20 +194,26 @@ export const useDiscordRpc = () => {
}
if (discordSettings.showServerImage && song) {
- if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
- activity.largeImageKey = song.imageUrl;
- } else if (song._serverType === ServerType.NAVIDROME) {
- try {
- const info = await controller.getAlbumInfo({
- apiClientProps: { serverId: song._serverId },
- query: { id: song.albumId },
- });
+ // Use imageUrl from useItemImageUrl hook if available and song matches current song
+ if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
+ activity.largeImageKey = imageUrlRef.current;
+ } else {
+ // Fallback to old logic if song doesn't match (shouldn't happen in normal flow)
+ if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
+ activity.largeImageKey = song.imageUrl;
+ } else if (song._serverType === ServerType.NAVIDROME) {
+ try {
+ const info = await api.controller.getAlbumInfo({
+ apiClientProps: { serverId: song._serverId },
+ query: { id: song.albumId },
+ });
- if (info.imageUrl) {
- activity.largeImageKey = info.imageUrl;
+ if (info.imageUrl) {
+ activity.largeImageKey = info.imageUrl;
+ }
+ } catch {
+ /* empty */
}
- } catch {
- /* empty */
}
}
}
@@ -275,6 +297,7 @@ export const useDiscordRpc = () => {
discordSettings.displayType,
discordSettings.linkType,
lastUniqueId,
+ currentSong?._uniqueId,
],
);
diff --git a/src/renderer/features/home/components/album-infinite-feature-carousel.tsx b/src/renderer/features/home/components/album-infinite-feature-carousel.tsx
index 853980765..0295d8c48 100644
--- a/src/renderer/features/home/components/album-infinite-feature-carousel.tsx
+++ b/src/renderer/features/home/components/album-infinite-feature-carousel.tsx
@@ -52,7 +52,7 @@ export const AlbumInfiniteFeatureCarousel = ({
// Filter for albums with images and remove duplicates by ID
const uniqueAlbums = new Map();
for (const album of allAlbums) {
- if (album.imageUrl && !uniqueAlbums.has(album.id)) {
+ if (album.imageId && !uniqueAlbums.has(album.id)) {
uniqueAlbums.set(album.id, album);
}
}
diff --git a/src/renderer/features/player/components/full-screen-player-image.tsx b/src/renderer/features/player/components/full-screen-player-image.tsx
index b09be9661..cd5ccc7a4 100644
--- a/src/renderer/features/player/components/full-screen-player-image.tsx
+++ b/src/renderer/features/player/components/full-screen-player-image.tsx
@@ -1,11 +1,11 @@
import clsx from 'clsx';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
-import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { generatePath } from 'react-router';
-import { Link } from 'react-router';
+import { Fragment, useEffect, useRef } from 'react';
+import { generatePath, Link } from 'react-router';
import styles from './full-screen-player-image.module.css';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { AppRoute } from '/@/renderer/router/routes';
import { usePlayerData, usePlayerSong } from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
@@ -17,6 +17,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useSetState } from '/@/shared/hooks/use-set-state';
+import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -41,13 +42,6 @@ const imageVariants: Variants = {
},
};
-const scaleImageUrl = (imageSize: number, url?: null | string) => {
- return url
- ?.replace(/&size=\d+/, `&size=${imageSize}`)
- .replace(/\?width=\d+/, `?width=${imageSize}`)
- .replace(/&height=\d+/, `&height=${imageSize}`);
-};
-
const MotionImage = motion.img;
const ImageWithPlaceholder = ({
@@ -85,44 +79,27 @@ const ImageWithPlaceholder = ({
export const FullScreenPlayerImage = () => {
const mainImageRef = useRef(null);
- const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
-
- const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
- const [imageState, setImageState] = useSetState({
- bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
- current: 0,
- topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
+ const currentImageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ itemType: LibraryItem.SONG,
+ type: 'fullScreenPlayer',
});
- const updateImageSize = useCallback(() => {
- if (mainImageRef.current) {
- setMainImageDimensions({
- idealSize:
- albumArtRes ||
- Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
- });
+ const nextImageUrl = useItemImageUrl({
+ id: nextSong?.id,
+ itemType: LibraryItem.SONG,
+ type: 'fullScreenPlayer',
+ });
- setImageState({
- bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
- current: 0,
- topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
- });
- }
- }, [
- mainImageDimensions.idealSize,
- setImageState,
- albumArtRes,
- currentSong?.imageUrl,
- nextSong?.imageUrl,
- ]);
-
- useLayoutEffect(() => {
- updateImageSize();
- }, [updateImageSize]);
+ const [imageState, setImageState] = useSetState({
+ bottomImage: nextImageUrl,
+ current: 0,
+ topImage: currentImageUrl,
+ });
// Track previous song to detect changes
const previousSongRef = useRef(currentSong?._uniqueId);
@@ -133,15 +110,13 @@ export const FullScreenPlayerImage = () => {
imageStateRef.current = imageState;
}, [imageState]);
- // Update images when song changes
+ // Update images when song or size changes
useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) {
return;
}
const isTop = imageStateRef.current.current === 0;
- const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
- const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -150,13 +125,7 @@ export const FullScreenPlayerImage = () => {
});
previousSongRef.current = currentSong?._uniqueId;
- }, [
- currentSong?._uniqueId,
- currentSong?.imageUrl,
- nextSong?.imageUrl,
- mainImageDimensions.idealSize,
- setImageState,
- ]);
+ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
return (
(currentSong?._uniqueId);
@@ -99,12 +109,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}
const isTop = imageStateRef.current.current === 0;
- const currentImageUrl = currentSong?.imageUrl
- ? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined;
- const nextImageUrl = nextSong?.imageUrl
- ? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined;
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -113,7 +117,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
});
previousSongRef.current = currentSong?._uniqueId;
- }, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
+ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) {
return null;
@@ -610,9 +614,15 @@ const PlayerContainer = memo(
windowBarStyle,
}: PlayerContainerProps) => {
const currentSong = usePlayerSong();
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
const { background } = useFastAverageColor({
algorithm: 'dominant',
- src: currentSong?.imageUrl,
+ src: imageUrl,
srcLoaded: true,
});
diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx
index 1fbf8cb49..497171dc0 100644
--- a/src/renderer/features/player/components/left-controls.tsx
+++ b/src/renderer/features/player/components/left-controls.tsx
@@ -7,6 +7,7 @@ import { shallow } from 'zustand/shallow';
import styles from './left-controls.module.css';
+import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
@@ -21,7 +22,6 @@ import {
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
-import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -116,13 +116,14 @@ export const LeftControls = () => {
})}
openDelay={0}
>
-
{!collapsed && (
diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx
index 025d061ba..dfdba6625 100644
--- a/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx
+++ b/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx
@@ -4,16 +4,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import styles from './mobile-fullscreen-player.module.css';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import {
useFullScreenPlayerStore,
- useGeneralSettings,
usePlayerData,
usePlayerSong,
+ useSettingsStore,
} from '/@/renderer/store';
import { Center } from '/@/shared/components/center/center';
import { Icon } from '/@/shared/components/icon/icon';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { useSetState } from '/@/shared/hooks/use-set-state';
+import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -38,13 +40,6 @@ const imageVariants: Variants = {
},
};
-const scaleImageUrl = (imageSize: number, url?: null | string) => {
- return url
- ?.replace(/&size=\d+/, `&size=${imageSize}`)
- .replace(/\?width=\d+/, `?width=${imageSize}`)
- .replace(/&height=\d+/, `&height=${imageSize}`);
-};
-
const MotionImage = motion.img;
const ImageWithPlaceholder = ({
@@ -83,15 +78,29 @@ export const MobileFullscreenPlayerAlbumArt = () => {
const mainImageRef = useRef(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 });
- const { albumArtRes } = useGeneralSettings();
+ const albumArtRes = useSettingsStore((store) => store.general.imageRes.fullScreenPlayer);
const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
+ const currentImageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ itemType: LibraryItem.SONG,
+ size: mainImageDimensions.idealSize,
+ type: 'fullScreenPlayer',
+ });
+
+ const nextImageUrl = useItemImageUrl({
+ id: nextSong?.id,
+ itemType: LibraryItem.SONG,
+ size: mainImageDimensions.idealSize,
+ type: 'fullScreenPlayer',
+ });
+
const [imageState, setImageState] = useSetState({
- bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
+ bottomImage: nextImageUrl,
current: 0,
- topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
+ topImage: currentImageUrl,
});
const updateImageSize = useCallback(() => {
@@ -101,14 +110,8 @@ export const MobileFullscreenPlayerAlbumArt = () => {
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100;
setMainImageDimensions({ idealSize });
-
- setImageState({
- bottomImage: scaleImageUrl(idealSize, nextSong?.imageUrl),
- current: 0,
- topImage: scaleImageUrl(idealSize, currentSong?.imageUrl),
- });
}
- }, [albumArtRes, currentSong?.imageUrl, nextSong?.imageUrl, setImageState]);
+ }, [albumArtRes]);
useLayoutEffect(() => {
updateImageSize();
@@ -123,15 +126,13 @@ export const MobileFullscreenPlayerAlbumArt = () => {
imageStateRef.current = imageState;
}, [imageState]);
- // Update images when song changes
+ // Update images when song or size changes
useEffect(() => {
if (currentSong?._uniqueId === previousSongRef.current) {
return;
}
const isTop = imageStateRef.current.current === 0;
- const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
- const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -140,13 +141,7 @@ export const MobileFullscreenPlayerAlbumArt = () => {
});
previousSongRef.current = currentSong?._uniqueId;
- }, [
- currentSong?._uniqueId,
- currentSong?.imageUrl,
- nextSong?.imageUrl,
- mainImageDimensions.idealSize,
- setImageState,
- ]);
+ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
return (
diff --git a/src/renderer/features/player/components/mobile-fullscreen-player.tsx b/src/renderer/features/player/components/mobile-fullscreen-player.tsx
index fdd0da4b2..083e62b1f 100644
--- a/src/renderer/features/player/components/mobile-fullscreen-player.tsx
+++ b/src/renderer/features/player/components/mobile-fullscreen-player.tsx
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
@@ -74,14 +75,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
+ const currentImageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
+
+ const nextImageUrl = useItemImageUrl({
+ id: nextSong?.id,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
+
const [imageState, setImageState] = useState({
- bottomImage: nextSong?.imageUrl
- ? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined,
+ bottomImage: nextImageUrl,
current: 0,
- topImage: currentSong?.imageUrl
- ? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined,
+ topImage: currentImageUrl,
});
const previousSongRef = useRef
(currentSong?._uniqueId);
@@ -98,12 +107,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
}
const isTop = imageStateRef.current.current === 0;
- const currentImageUrl = currentSong?.imageUrl
- ? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined;
- const nextImageUrl = nextSong?.imageUrl
- ? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
- : undefined;
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
@@ -112,7 +115,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
});
previousSongRef.current = currentSong?._uniqueId;
- }, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
+ }, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
if (!dynamicBackground || !dynamicIsImage) {
return null;
@@ -299,9 +302,15 @@ interface MobilePlayerContainerProps {
const MobilePlayerContainer = memo(
({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => {
const currentSong = usePlayerSong();
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
const { background } = useFastAverageColor({
algorithm: 'dominant',
- src: currentSong?.imageUrl,
+ src: imageUrl,
srcLoaded: true,
});
diff --git a/src/renderer/features/player/components/mobile-playerbar.tsx b/src/renderer/features/player/components/mobile-playerbar.tsx
index bd68f61a1..eb0e5f34e 100644
--- a/src/renderer/features/player/components/mobile-playerbar.tsx
+++ b/src/renderer/features/player/components/mobile-playerbar.tsx
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
import styles from './mobile-playerbar.module.css';
+import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -20,7 +21,6 @@ import {
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
-import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
@@ -68,7 +68,7 @@ export const MobilePlayerbar = () => {
- {currentSong?.imageUrl && (
+ {currentSong?.id && (
{
})}
openDelay={0}
>
-
diff --git a/src/renderer/features/player/hooks/use-media-session.ts b/src/renderer/features/player/hooks/use-media-session.ts
index 303c7bbf3..b963a9b5d 100644
--- a/src/renderer/features/player/hooks/use-media-session.ts
+++ b/src/renderer/features/player/hooks/use-media-session.ts
@@ -1,9 +1,16 @@
import isElectron from 'is-electron';
import { useEffect, useMemo } from 'react';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
-import { usePlaybackSettings, useSettingsStore, useTimestampStoreBase } from '/@/renderer/store';
+import {
+ usePlaybackSettings,
+ usePlayerSong,
+ useSettingsStore,
+ useTimestampStoreBase,
+} from '/@/renderer/store';
+import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const mediaSession = navigator.mediaSession;
@@ -13,6 +20,14 @@ export const useMediaSession = () => {
const player = usePlayer();
const skip = useSettingsStore((state) => state.general.skipButtons);
const playbackType = useSettingsStore((state) => state.playback.type);
+ const currentSong = usePlayerSong();
+
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
const isMediaSessionEnabled = useMemo(() => {
// Always enable media session on web
@@ -94,7 +109,7 @@ export const useMediaSession = () => {
mediaSession.metadata = new MediaMetadata({
album: song?.album ?? '',
artist: song?.artistName ?? '',
- artwork: song?.imageUrl ? [{ src: song.imageUrl, type: 'image/png' }] : [],
+ artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],
title: song?.name ?? '',
});
},
@@ -107,6 +122,6 @@ export const useMediaSession = () => {
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
},
},
- [isMediaSessionEnabled, mediaSession],
+ [isMediaSessionEnabled, mediaSession, imageUrl],
);
};
diff --git a/src/renderer/features/player/hooks/use-mpris.ts b/src/renderer/features/player/hooks/use-mpris.ts
index 11b2f6f97..b7447e9b8 100644
--- a/src/renderer/features/player/hooks/use-mpris.ts
+++ b/src/renderer/features/player/hooks/use-mpris.ts
@@ -1,8 +1,10 @@
import isElectron from 'is-electron';
-import { useEffect, useRef } from 'react';
+import { useEffect } from 'react';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
-import { usePlayerStore } from '/@/renderer/store';
+import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
+import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
@@ -11,6 +13,14 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export const useMPRIS = () => {
const player = usePlayerStore();
+ const currentSong = usePlayerSong();
+
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
useEffect(() => {
if (!mpris) {
@@ -41,32 +51,19 @@ export const useMPRIS = () => {
};
}, [player]);
- const isInitializedRef = useRef(false);
-
+ // Update MPRIS when song or imageUrl changes
useEffect(() => {
- if (isInitializedRef.current) {
+ if (!mpris) {
return;
}
- isInitializedRef.current = true;
-
- const currentSong = player.getCurrentSong();
-
- if (!currentSong) {
- return;
- }
-
- mpris?.updateSong(currentSong);
- }, [player]);
+ mpris?.updateSong(currentSong, imageUrl);
+ }, [currentSong, imageUrl]);
usePlayerEvents(
{
- onCurrentSongChange: (properties) => {
- if (!mpris) {
- return;
- }
-
- mpris?.updateSong(properties.song);
+ onCurrentSongChange: () => {
+ // The effect above will handle the update when currentSong changes
},
onPlayerProgress: (properties) => {
if (!mpris) {
diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts
index b64abdb01..dd40be57a 100644
--- a/src/renderer/features/player/hooks/use-scrobble.ts
+++ b/src/renderer/features/player/hooks/use-scrobble.ts
@@ -1,11 +1,12 @@
-import { useCallback, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
-import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
+import { useAppStore, usePlaybackSettings, usePlayerSong, usePlayerStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
-import { QueueSong, ServerType } from '/@/shared/types/domain-types';
+import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
/*
@@ -59,7 +60,16 @@ export const useScrobble = () => {
const isScrobbleEnabled = scrobbleSettings?.enabled;
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble();
+ const currentSong = usePlayerSong();
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ imageUrl: currentSong?.imageUrl,
+ itemType: LibraryItem.SONG,
+ type: 'itemCard',
+ });
+
+ const imageUrlRef = useRef
(imageUrl);
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const previousSongRef = useRef(undefined);
const previousTimestampRef = useRef(0);
@@ -68,6 +78,10 @@ export const useScrobble = () => {
const songChangeTimeoutRef = useRef | undefined>(undefined);
const notifyTimeoutRef = useRef | undefined>(undefined);
+ useEffect(() => {
+ imageUrlRef.current = imageUrl;
+ }, [imageUrl]);
+
const handleScrobbleFromProgress = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
@@ -198,7 +212,7 @@ export const useScrobble = () => {
new Notification(`${currentSong.name}`, {
body: `${artists}\n${currentSong.album}`,
- icon: currentSong.imageUrl || undefined,
+ icon: imageUrlRef.current || undefined,
silent: true,
});
}
diff --git a/src/renderer/features/player/update-remote-song.tsx b/src/renderer/features/player/update-remote-song.tsx
index 8e7146a14..461822ed9 100644
--- a/src/renderer/features/player/update-remote-song.tsx
+++ b/src/renderer/features/player/update-remote-song.tsx
@@ -5,20 +5,15 @@ import { QueueSong } from '/@/shared/types/domain-types';
const remote = isElectron() ? window.api.remote : null;
const mediaSession = navigator.mediaSession;
-export const updateSong = (song: QueueSong | undefined) => {
+export const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
if (mediaSession) {
let metadata: MediaMetadata;
if (song?.id) {
let artwork: MediaImage[];
- if (song.imageUrl) {
- const image300 = song.imageUrl
- ?.replace(/&size=\d+/, '&size=300')
- .replace(/\?width=\d+/, '?width=300')
- .replace(/&height=\d+/, '&height=300');
-
- artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
+ if (imageUrl) {
+ artwork = [{ sizes: '300x300', src: imageUrl, type: 'image/png' }];
} else {
artwork = [];
}
diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx
index a8f6fd986..dded44a02 100644
--- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx
+++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx
@@ -7,6 +7,7 @@ import styles from './add-to-playlist-context-modal.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
+import { ItemImage } from '/@/renderer/components/item-image/item-image';
import {
getAlbumSongsById,
getArtistSongsById,
@@ -26,7 +27,6 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
-import { Image } from '/@/shared/components/image/image';
import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Pill } from '/@/shared/components/pill/pill';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
@@ -38,7 +38,7 @@ import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
-import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
+import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
export const AddToPlaylistContextModal = ({
id,
@@ -555,14 +555,13 @@ const PlaylistTableItem = memo(
- {item.imageUrl && (
-
- )}
+
diff --git a/src/renderer/features/search/components/library-command-item.tsx b/src/renderer/features/search/components/library-command-item.tsx
index 917cd8d3c..83c8d4db7 100644
--- a/src/renderer/features/search/components/library-command-item.tsx
+++ b/src/renderer/features/search/components/library-command-item.tsx
@@ -2,6 +2,7 @@ import { CSSProperties, useCallback, useState } from 'react';
import styles from './library-command-item.module.css';
+import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
LONG_PRESS_PLAY_BEHAVIOR,
@@ -11,7 +12,6 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
-import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -95,11 +95,13 @@ export const LibraryCommandItem = ({
>
-
diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx
index 3cbc330d8..6f4c3cb58 100644
--- a/src/renderer/features/settings/components/general/application-settings.tsx
+++ b/src/renderer/features/settings/components/general/application-settings.tsx
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n, { languages } from '/@/i18n/i18n';
+import { ImageResolutionSettings } from '/@/renderer/features/settings/components/general/art-resolution-settings';
import { ArtistSettings } from '/@/renderer/features/settings/components/general/artist-settings';
import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings';
import {
@@ -575,37 +576,13 @@ export const ApplicationSettings = () => {
isHidden: false,
title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }),
},
- {
- control: (
-
{
- 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 (
+
>
diff --git a/src/renderer/features/settings/components/general/art-resolution-settings.tsx b/src/renderer/features/settings/components/general/art-resolution-settings.tsx
new file mode 100644
index 000000000..12cbaa3e9
--- /dev/null
+++ b/src/renderer/features/settings/components/general/art-resolution-settings.tsx
@@ -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 (
+ <>
+
+
+ >
+ }
+ description={descriptionText}
+ title={titleText}
+ />
+ {open && (
+
+
+ {options.map((option) => (
+
+
+ {option.label}
+
+
+ {
+ if (!e) return;
+
+ if (typeof e === 'string') return;
+
+ setSettings({
+ general: {
+ ...settings,
+ imageRes: {
+ ...settings.imageRes,
+ [option.value]: e,
+ },
+ },
+ });
+ }}
+ rightSection={
+
+ px
+
+ }
+ value={settings.imageRes[option.value]}
+ width={90}
+ />
+
+
+ ))}
+
+
+ )}
+ >
+ );
+};
diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx
index 1304f3c52..eced639b1 100644
--- a/src/renderer/features/sidebar/components/sidebar.tsx
+++ b/src/renderer/features/sidebar/components/sidebar.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import styles from './sidebar.module.css';
+import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
@@ -151,10 +152,11 @@ const SidebarImage = () => {
const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong();
- const upsizedImageUrl = currentSong?.imageUrl
- ?.replace(/size=\d+/, 'size=450')
- .replace(/width=\d+/, 'width=450')
- .replace(/height=\d+/, 'height=450');
+ const imageUrl = useItemImageUrl({
+ id: currentSong?.id,
+ itemType: LibraryItem.SONG,
+ type: 'sidebar',
+ });
const isSongDefined = Boolean(currentSong?.id);
@@ -202,8 +204,8 @@ const SidebarImage = () => {
postProcess: 'sentenceCase',
})}
>
- {upsizedImageUrl ? (
-
+ {imageUrl ? (
+
) : (
)}
diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts
index 183f59f47..f1d321505 100644
--- a/src/renderer/store/settings.store.ts
+++ b/src/renderer/store/settings.store.ts
@@ -215,7 +215,7 @@ const PlayerbarSliderSchema = z.object({
type: PlayerbarSliderTypeSchema,
});
-const GeneralSettingsSchema = z.object({
+export const GeneralSettingsSchema = z.object({
accent: z
.string()
.refine(
@@ -224,7 +224,6 @@ const GeneralSettingsSchema = z.object({
message: 'Accent must be a valid rgb() color string',
},
),
- albumArtRes: z.number().nullable().optional(),
albumBackground: z.boolean(),
albumBackgroundBlur: z.number(),
artistBackground: z.boolean(),
@@ -238,6 +237,13 @@ const GeneralSettingsSchema = z.object({
genreTarget: GenreTargetSchema,
homeFeature: z.boolean(),
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(),
lastFM: z.boolean(),
lastfmApiKey: z.string(),
@@ -712,7 +718,6 @@ const initialState: SettingsState = {
},
general: {
accent: 'rgb(53, 116, 252)',
- albumArtRes: undefined,
albumBackground: false,
albumBackgroundBlur: 3,
artistBackground: false,
@@ -726,6 +731,13 @@ const initialState: SettingsState = {
genreTarget: GenreTarget.TRACK,
homeFeature: true,
homeItems,
+ imageRes: {
+ fullScreenPlayer: 0,
+ header: 300,
+ itemCard: 300,
+ sidebar: 300,
+ table: 30,
+ },
language: 'en',
lastFM: true,
lastfmApiKey: '',
diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts
index 57c79343f..71598e23c 100644
--- a/src/shared/api/jellyfin/jellyfin-normalize.ts
+++ b/src/shared/api/jellyfin/jellyfin-normalize.ts
@@ -16,100 +16,6 @@ import { ServerListItem, ServerType } from '/@/shared/types/types';
const TICKS_PER_MS = 10000;
-const getAlbumArtistCoverArtUrl = (args: {
- baseUrl: string;
- item: z.infer;
- 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;
- 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;
- 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;
- 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 | z.infer;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
@@ -128,6 +34,7 @@ const getPeople = (item: AlbumOrSong): null | Record =>
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
id: '',
+ imageId: null,
imageUrl: null,
name: person.Name,
};
@@ -158,10 +65,47 @@ const getTags = (item: AlbumOrSong): null | Record => {
return null;
};
+const getSongImageId = (item: z.infer): null | string => {
+ if (item.ImageTags?.Primary) {
+ return item.Id;
+ }
+
+ if (item.AlbumPrimaryImageTag && item.AlbumId) {
+ return item.AlbumId;
+ }
+
+ return null;
+};
+
+const getAlbumImageId = (item: z.infer): null | string => {
+ if (item.ImageTags?.Primary) {
+ return item.Id;
+ }
+
+ return null;
+};
+
+const getAlbumArtistImageId = (
+ item: z.infer,
+): null | string => {
+ if (item.ImageTags?.Primary) {
+ return item.Id;
+ }
+
+ return null;
+};
+
+const getPlaylistImageId = (item: z.infer): null | string => {
+ if (item.ImageTags?.Primary) {
+ return item.Id;
+ }
+
+ return null;
+};
+
const normalizeSong = (
item: z.infer,
server: null | ServerListItem,
- imageSize?: number,
): Song => {
let bitRate = 0;
let channels: null | number = null;
@@ -201,6 +145,7 @@ const normalizeSong = (
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
+ imageId: entry.Id,
imageUrl: null,
name: entry.Name,
})),
@@ -209,6 +154,7 @@ const normalizeSong = (
artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
+ imageId: entry.Id,
imageUrl: null,
name: entry.Name,
}),
@@ -241,13 +187,14 @@ const normalizeSong = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
+ imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
- imagePlaceholderUrl: null,
- imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
+ imageId: getSongImageId(item),
+ imageUrl: null,
lastPlayedAt: null,
lyrics: null,
mbzRecordingId: null,
@@ -273,7 +220,6 @@ const normalizeSong = (
const normalizeAlbum = (
item: z.infer,
server: null | ServerListItem,
- imageSize?: number,
): Album => {
return {
_itemType: LibraryItem.ALBUM,
@@ -283,17 +229,18 @@ const normalizeAlbum = (
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
+ imageId: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: (item.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
+ imageId: entry.Id,
imageUrl: null,
name: entry.Name,
}),
),
- backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / TICKS_PER_MS,
@@ -305,17 +252,14 @@ const normalizeAlbum = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
+ imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})) || [],
id: item.Id,
- imagePlaceholderUrl: null,
- imageUrl: getAlbumCoverArtUrl({
- baseUrl: server?.url || '',
- item,
- size: imageSize || 300,
- }),
+ imageId: getAlbumImageId(item),
+ imageUrl: null,
isCompilation: null,
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
@@ -329,7 +273,7 @@ const normalizeAlbum = (
releaseYear: item.ProductionYear || null,
size: 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),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
@@ -343,17 +287,13 @@ const normalizeAlbumArtist = (
similarArtists?: z.infer;
},
server: null | ServerListItem,
- imageSize?: number,
): AlbumArtist => {
const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({
id: entry.Id,
- imageUrl: getAlbumArtistCoverArtUrl({
- baseUrl: server?.url || '',
- item: entry,
- size: imageSize || 300,
- }),
+ imageId: entry.Id,
+ imageUrl: null,
name: entry.Name,
}),
) || [];
@@ -363,7 +303,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
albumCount: item.AlbumCount ?? null,
- backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
@@ -372,16 +311,14 @@ const normalizeAlbumArtist = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
+ imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
- imageUrl: getAlbumArtistCoverArtUrl({
- baseUrl: server?.url || '',
- item,
- size: imageSize || 300,
- }),
+ imageId: getAlbumArtistImageId(item),
+ imageUrl: null,
lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
@@ -396,16 +333,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer,
server: null | ServerListItem,
- imageSize?: number,
): Playlist => {
- const imageUrl = getPlaylistCoverArtUrl({
- baseUrl: server?.url || '',
- item,
- size: imageSize || 300,
- });
-
- const imagePlaceholderUrl = null;
-
return {
_itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || '',
@@ -418,13 +346,14 @@ const normalizePlaylist = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
+ imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
- imagePlaceholderUrl,
- imageUrl: imageUrl || null,
+ imageId: getPlaylistImageId(item),
+ imageUrl: null,
name: item.Name,
owner: null,
ownerId: null,
@@ -463,26 +392,6 @@ const normalizeMusicFolder = (item: z.infer
// };
// };
-const getGenreCoverArtUrl = (args: {
- baseUrl: string;
- item: z.infer;
- 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 = (
item: z.infer,
server: null | ServerListItem,
@@ -493,7 +402,8 @@ const normalizeGenre = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: item.Id,
- imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
+ imageId: item.Id,
+ imageUrl: null,
name: item.Name,
songCount: null,
};
diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts
index d0f1a1f6c..37d5cd447 100644
--- a/src/shared/api/navidrome/navidrome-normalize.ts
+++ b/src/shared/api/navidrome/navidrome-normalize.ts
@@ -24,32 +24,6 @@ const getImageUrl = (args: { url: null | string }) => {
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 {
playDate?: string;
}
@@ -74,6 +48,7 @@ const getArtists = (
if (role === 'albumartist' || role === 'artist') {
const roleList = list.map((item) => ({
id: item.id,
+ imageId: null,
imageUrl: null,
name: item.name,
}));
@@ -89,6 +64,7 @@ const getArtists = (
for (const artist of list) {
const item: RelatedArtist = {
id: artist.id,
+ imageId: null,
imageUrl: null,
name: artist.name,
};
@@ -112,11 +88,13 @@ const getArtists = (
}
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) {
- artists = [{ id: item.artistId, imageUrl: null, name: item.artist }];
+ artists = [{ id: item.artistId, imageId: null, imageUrl: null, name: item.artist }];
}
return { albumArtists, artists, participants };
@@ -125,7 +103,6 @@ const getArtists = (
const normalizeSong = (
item: z.infer | z.infer,
server?: null | ServerListItem,
- imageSize?: number,
): Song => {
let id;
let playlistItemId;
@@ -138,15 +115,6 @@ const normalizeSong = (
id = item.id;
}
- const imageUrl = getCoverArtUrl({
- baseUrl: server?.url,
- coverArtId: id,
- credential: server?.credential,
- size: imageSize || 100,
- updated: item.updatedAt,
- });
-
- const imagePlaceholderUrl = null;
return {
album: item.album,
albumId: item.albumId,
@@ -182,13 +150,14 @@ const normalizeSong = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
+ imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id,
- imagePlaceholderUrl,
- imageUrl,
+ imageId: item.id,
+ imageUrl: null,
lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null,
mbzRecordingId: item.mbzReleaseTrackId || null,
@@ -261,20 +230,7 @@ const normalizeAlbum = (
songs?: z.infer;
},
server?: null | ServerListItem,
- imageSize?: number,
): 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 {
...parseAlbumTags(item),
...getArtists(item),
@@ -282,7 +238,6 @@ const normalizeAlbum = (
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumArtist: item.albumArtist,
- backdropImageUrl: imageBackdropUrl,
comment: item.comment || null,
createdAt: item.createdAt,
duration: item.duration !== undefined ? item.duration * 1000 : null,
@@ -298,13 +253,14 @@ const normalizeAlbum = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
+ imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id: item.id,
- imagePlaceholderUrl,
- imageUrl,
+ imageId: item.coverArtId || item.id,
+ imageUrl: null,
isCompilation: item.compilation,
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
@@ -329,17 +285,7 @@ const normalizeAlbumArtist = (
},
server?: null | ServerListItem,
): AlbumArtist => {
- let 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 || '',
- });
- }
+ const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
let albumCount: number;
let songCount: number;
@@ -363,7 +309,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumCount,
- backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: (item.genres || []).map((genre) => ({
@@ -372,11 +317,13 @@ const normalizeAlbumArtist = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
+ imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id: item.id,
+ imageId: item.id,
imageUrl: imageUrl || null,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
@@ -385,6 +332,7 @@ const normalizeAlbumArtist = (
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
+ imageId: null,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
@@ -397,18 +345,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer,
server?: null | ServerListItem,
- imageSize?: number,
): Playlist => {
- const imageUrl = getCoverArtUrl({
- baseUrl: server?.url,
- coverArtId: item.id,
- credential: server?.credential,
- size: imageSize || 300,
- updated: item.updatedAt,
- });
-
- const imagePlaceholderUrl = null;
-
return {
_itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || 'unknown',
@@ -417,8 +354,8 @@ const normalizePlaylist = (
duration: item.duration * 1000,
genres: [],
id: item.id,
- imagePlaceholderUrl,
- imageUrl,
+ imageId: item.id,
+ imageUrl: null,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
@@ -440,6 +377,7 @@ const normalizeGenre = (
_serverType: ServerType.NAVIDROME,
albumCount: item.albumCount ?? null,
id: item.id,
+ imageId: null,
imageUrl: null,
name: item.name,
songCount: item.songCount ?? null,
diff --git a/src/shared/components/image/image.tsx b/src/shared/components/image/image.tsx
index 703622351..235e6171e 100644
--- a/src/shared/components/image/image.tsx
+++ b/src/shared/components/image/image.tsx
@@ -15,16 +15,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
-interface ImageContainerProps extends HTMLAttributes {
- children: ReactNode;
- enableAnimation?: boolean;
-}
-
-interface ImageLoaderProps {
- className?: string;
-}
-
-interface ImageProps extends Omit, 'src'> {
+export interface ImageProps extends Omit, 'src'> {
containerClassName?: string;
enableAnimation?: boolean;
imageContainerProps?: Omit;
@@ -34,6 +25,15 @@ interface ImageProps extends Omit, 'src'> {
thumbHash?: string;
}
+interface ImageContainerProps extends HTMLAttributes {
+ children: ReactNode;
+ enableAnimation?: boolean;
+}
+
+interface ImageLoaderProps {
+ className?: string;
+}
+
interface ImageUnloaderProps {
className?: string;
}
diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts
index 7c3fd0045..d99b8db7d 100644
--- a/src/shared/types/domain-types.ts
+++ b/src/shared/types/domain-types.ts
@@ -174,14 +174,13 @@ export type Album = {
albumArtist: string;
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
- backdropImageUrl: null | string;
comment: null | string;
createdAt: string;
duration: null | number;
explicitStatus: ExplicitStatus | null;
genres: Genre[];
id: string;
- imagePlaceholderUrl: null | string;
+ imageId: null | string;
imageUrl: null | string;
isCompilation: boolean | null;
lastPlayedAt: null | string;
@@ -209,11 +208,11 @@ export type AlbumArtist = {
_serverId: string;
_serverType: ServerType;
albumCount: null | number;
- backgroundImageUrl: null | string;
biography: null | string;
duration: null | number;
genres: Genre[];
id: string;
+ imageId: null | string;
imageUrl: null | string;
lastPlayedAt: null | string;
mbz: null | string;
@@ -294,6 +293,7 @@ export type Genre = {
_serverType: ServerType;
albumCount: null | number;
id: string;
+ imageId: null | string;
imageUrl: null | string;
name: string;
songCount: null | number;
@@ -334,7 +334,7 @@ export type Playlist = {
duration: null | number;
genres: Genre[];
id: string;
- imagePlaceholderUrl: null | string;
+ imageId: null | string;
imageUrl: null | string;
name: string;
owner: null | string;
@@ -353,6 +353,7 @@ export type RelatedAlbumArtist = {
export type RelatedArtist = {
id: string;
+ imageId: null | string;
imageUrl: null | string;
name: string;
};
@@ -381,7 +382,7 @@ export type Song = {
gain: GainInfo | null;
genres: Genre[];
id: string;
- imagePlaceholderUrl: null | string;
+ imageId: null | string;
imageUrl: null | string;
lastPlayedAt: null | string;
lyrics: null | string;
@@ -1340,6 +1341,7 @@ export type ControllerEndpoint = {
getDownloadUrl: (args: DownloadArgs) => string;
getFolder: (args: FolderArgs) => Promise;
getGenreList: (args: GenreListArgs) => Promise;
+ getImageUrl: (args: ImageArgs) => null | string;
getInternetRadioStations: (
args: GetInternetRadioStationsArgs,
) => Promise;
@@ -1408,6 +1410,16 @@ export type GetQueueResponse = {
username: string;
};
+export type ImageArgs = BaseEndpointArgs & {
+ query: ImageQuery;
+};
+
+export type ImageQuery = {
+ id: string;
+ itemType: LibraryItem;
+ size?: number;
+};
+
export type InternalControllerEndpoint = {
addToPlaylist: (
args: ReplaceApiClientProps,
@@ -1449,6 +1461,7 @@ export type InternalControllerEndpoint = {
getDownloadUrl: (args: ReplaceApiClientProps) => string;
getFolder: (args: ReplaceApiClientProps) => Promise;
getGenreList: (args: ReplaceApiClientProps) => Promise;
+ getImageUrl: (args: ReplaceApiClientProps) => null | string;
getInternetRadioStations: (
args: ReplaceApiClientProps,
) => Promise;