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)} - > - {data?.name} - - {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)} +// > +// {data?.name} +// +// {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 = ({ >
- cover
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;