mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Add image URL generation at runtime to allow for dynamic image sizes (#1439)
* add getImageUrl to domain endpoints * add new ItemImage component and hooks to generate image url * add configuration for image resolution based on types
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
</div>
|
||||
|
||||
<div className={styles.imageSection}>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={styles.albumImage}
|
||||
containerClassName={styles.albumImageContainer}
|
||||
src={album.imageUrl || undefined}
|
||||
id={album.id}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
src={imageUrl}
|
||||
/>
|
||||
<div className={styles.playButtonOverlay}>
|
||||
<PlayButtonGroup onPlay={handlePlay} />
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={clsx(styles.image, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
src={imageUrl}
|
||||
id={data?.id}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
/>
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
@@ -351,7 +352,6 @@ const DefaultItemCard = ({
|
||||
data,
|
||||
enableExpansion,
|
||||
enableNavigation,
|
||||
imageUrl,
|
||||
internalState,
|
||||
isRound,
|
||||
itemType,
|
||||
@@ -461,9 +461,11 @@ const DefaultItemCard = ({
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
src={imageUrl}
|
||||
id={data?.id}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
/>
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
@@ -563,7 +565,6 @@ const PosterItemCard = ({
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableNavigation,
|
||||
imageUrl,
|
||||
internalState,
|
||||
isRound,
|
||||
itemType,
|
||||
@@ -720,9 +721,11 @@ const PosterItemCard = ({
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
src={imageUrl}
|
||||
id={data?.id}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
/>
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
|
||||
@@ -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<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
|
||||
withControls?: boolean;
|
||||
}
|
||||
// interface ItemDetailProps {
|
||||
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
||||
// itemHeight: number;
|
||||
// itemType: LibraryItem;
|
||||
// onClick?: (e: MouseEvent<HTMLDivElement>, 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 (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={(e) => onClick?.(e, data, itemType)}
|
||||
style={{ backgroundColor: background }}
|
||||
>
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
onMouseEnter={() => withControls && setShowControls(true)}
|
||||
onMouseLeave={() => withControls && setShowControls(false)}
|
||||
>
|
||||
<Image alt={data?.name} src={imageUrl} />
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && <ItemCardControls type="compact" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className={styles.metadataContainer}>
|
||||
<div className={styles.header}>
|
||||
<Text className={styles.title} component={Link} isLink size="lg" weight={500}>
|
||||
{data?.name}
|
||||
</Text>
|
||||
<Group>
|
||||
{data && 'userRating' in data && (
|
||||
<Rating size="xs" value={data?.userRating ?? 0} />
|
||||
)}
|
||||
{data && 'userFavorite' in data && (
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: data?.userFavorite ? 'primary' : 'default',
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={styles.content}>
|
||||
<Group className={styles.tags} gap="xs">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color: tag.isLight ? 'black' : 'white',
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// return (
|
||||
// <div
|
||||
// className={styles.container}
|
||||
// onClick={(e) => onClick?.(e, data, itemType)}
|
||||
// style={{ backgroundColor: background }}
|
||||
// >
|
||||
// <div
|
||||
// className={styles.imageContainer}
|
||||
// onMouseEnter={() => withControls && setShowControls(true)}
|
||||
// onMouseLeave={() => withControls && setShowControls(false)}
|
||||
// >
|
||||
// <Image alt={data?.name} src={imageUrl} />
|
||||
// <AnimatePresence>
|
||||
// {withControls && showControls && <ItemCardControls type="compact" />}
|
||||
// </AnimatePresence>
|
||||
// </div>
|
||||
// <div className={styles.metadataContainer}>
|
||||
// <div className={styles.header}>
|
||||
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
|
||||
// {data?.name}
|
||||
// </Text>
|
||||
// <Group>
|
||||
// {data && 'userRating' in data && (
|
||||
// <Rating size="xs" value={data?.userRating ?? 0} />
|
||||
// )}
|
||||
// {data && 'userFavorite' in data && (
|
||||
// <ActionIcon
|
||||
// icon="favorite"
|
||||
// iconProps={{
|
||||
// fill: data?.userFavorite ? 'primary' : 'default',
|
||||
// }}
|
||||
// size="xs"
|
||||
// />
|
||||
// )}
|
||||
// </Group>
|
||||
// </div>
|
||||
// <Divider />
|
||||
// <div className={styles.content}>
|
||||
// <Group className={styles.tags} gap="xs">
|
||||
// {tags.map((tag) => (
|
||||
// <Badge
|
||||
// key={tag.id}
|
||||
// style={{
|
||||
// backgroundColor: tag.color,
|
||||
// color: tag.isLight ? 'black' : 'white',
|
||||
// }}
|
||||
// >
|
||||
// {tag.name}
|
||||
// </Badge>
|
||||
// ))}
|
||||
// </Group>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
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;
|
||||
// };
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { GeneralSettingsSchema, useCurrentServerId, useSettingsStore } from '/@/renderer/store';
|
||||
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const BaseItemImage = (
|
||||
props: Omit<ImageProps, 'src'> & {
|
||||
id?: null | string;
|
||||
itemType: LibraryItem;
|
||||
src?: null | string;
|
||||
},
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { src, ...rest } = props;
|
||||
|
||||
const imageUrl = useItemImageUrl({ id: props.id, itemType: props.itemType, size: 300 });
|
||||
|
||||
return <BaseImage src={imageUrl} {...rest} />;
|
||||
};
|
||||
|
||||
export const ItemImage = memo(BaseItemImage);
|
||||
|
||||
interface UseItemImageUrlProps {
|
||||
id?: string;
|
||||
imageUrl?: null | string;
|
||||
itemType: LibraryItem;
|
||||
size?: number;
|
||||
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
|
||||
}
|
||||
|
||||
export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
||||
const { id, imageUrl, itemType, size, type } = args;
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const imageRes = useSettingsStore((store) => store.general.imageRes);
|
||||
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
|
||||
|
||||
return useMemo(() => {
|
||||
if (imageUrl) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
api.controller.getImageUrl({
|
||||
apiClientProps: { serverId },
|
||||
query: { id, itemType, size: size ?? sizeByType },
|
||||
}) || undefined
|
||||
);
|
||||
}, [id, imageUrl, itemType, serverId, size, sizeByType]);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
|
||||
import styles from './image-column.module.css';
|
||||
|
||||
import { 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)}
|
||||
>
|
||||
<Image
|
||||
<ItemImage
|
||||
containerClassName={clsx({
|
||||
[styles.imageContainerWithAspectRatio]:
|
||||
props.size === 'default' || props.size === 'large',
|
||||
})}
|
||||
src={row}
|
||||
id={item?.id}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
/>
|
||||
{isHovered && (
|
||||
<div
|
||||
|
||||
+14
-4
@@ -4,6 +4,7 @@ import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './title-combined-column.module.css';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
|
||||
import {
|
||||
ColumnNullFallback,
|
||||
@@ -19,13 +20,12 @@ import {
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
|
||||
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
|
||||
const item = props.data[props.rowIndex] as any;
|
||||
const internalState = (props as any).internalState;
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
@@ -110,7 +110,12 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Image containerClassName={styles.image} src={row.imageUrl as string} />
|
||||
<ItemImage
|
||||
containerClassName={styles.image}
|
||||
id={item?.id}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
/>
|
||||
{isHovered && (
|
||||
<div
|
||||
className={clsx(styles.playButtonOverlay, {
|
||||
@@ -263,7 +268,12 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Image containerClassName={styles.image} src={row.imageUrl as string} />
|
||||
<ItemImage
|
||||
containerClassName={styles.image}
|
||||
id={item?.id}
|
||||
itemType={item?._itemType}
|
||||
src={item?.imageUrl}
|
||||
/>
|
||||
{isHovered && (
|
||||
<div
|
||||
className={clsx(styles.playButtonOverlay, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generatePath, Link, useParams } from 'react-router';
|
||||
|
||||
import styles from './album-detail-header.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
@@ -82,10 +83,16 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
const firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
|
||||
const releaseYear = detailQuery?.data?.releaseYear;
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: detailQuery?.data?.id,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'header',
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack ref={ref}>
|
||||
<LibraryHeader
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Fragment, Suspense, useCallback, useRef } from 'react';
|
||||
|
||||
import styles from './expanded-album-list-item.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
@@ -197,10 +198,16 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
|
||||
|
||||
const player = usePlayer();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: item.id,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const color = useFastAverageColor({
|
||||
algorithm: 'sqrt',
|
||||
id: item.id,
|
||||
src: data?.imageUrl,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
@@ -300,7 +307,7 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
|
||||
className={styles.backgroundImage}
|
||||
style={{
|
||||
['--bg-color' as string]: color?.background,
|
||||
backgroundImage: `url(${data?.imageUrl})`,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
}}
|
||||
/>
|
||||
{data?.songs && data.songs.length > 0 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import styles from './dummy-album-detail-route.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||
@@ -113,12 +114,18 @@ const DummyAlbumDetailRoute = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: albumId,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'header',
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
|
||||
<LibraryContainer>
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
|
||||
loading={!background || colorId !== albumId}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'react-router';
|
||||
|
||||
import styles from './album-artist-detail-header.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
@@ -116,9 +117,15 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
||||
|
||||
const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: detailQuery?.data?.id,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
return (
|
||||
<LibraryHeader
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
|
||||
ref={ref}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
|
||||
@@ -2,13 +2,15 @@ import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
DiscordDisplayType,
|
||||
DiscordLinkType,
|
||||
useAppStore,
|
||||
useDiscordSettings,
|
||||
useGeneralSettings,
|
||||
usePlayerSong,
|
||||
usePlayerStore,
|
||||
useTimestampStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
@@ -16,7 +18,7 @@ import { sentenceCase } from '/@/renderer/utils';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const discordRpc = isElectron() ? window.api.discordRpc : null;
|
||||
@@ -33,10 +35,24 @@ export const useDiscordRpc = () => {
|
||||
const privateMode = useAppStore((state) => state.privateMode);
|
||||
const [lastUniqueId, setlastUniqueId] = useState('');
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'table',
|
||||
});
|
||||
|
||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const previousActivityStateRef = useRef<ActivityState | null>(null);
|
||||
|
||||
// Update imageUrl ref when it changes
|
||||
useEffect(() => {
|
||||
imageUrlRef.current = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const setActivity = useCallback(
|
||||
async (current: ActivityState, previous: ActivityState) => {
|
||||
// Check if track changed by comparing with previous state
|
||||
@@ -178,20 +194,26 @@ export const useDiscordRpc = () => {
|
||||
}
|
||||
|
||||
if (discordSettings.showServerImage && song) {
|
||||
if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
|
||||
activity.largeImageKey = song.imageUrl;
|
||||
} else if (song._serverType === ServerType.NAVIDROME) {
|
||||
try {
|
||||
const info = await controller.getAlbumInfo({
|
||||
apiClientProps: { serverId: song._serverId },
|
||||
query: { id: song.albumId },
|
||||
});
|
||||
// Use imageUrl from useItemImageUrl hook if available and song matches current song
|
||||
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
||||
activity.largeImageKey = imageUrlRef.current;
|
||||
} else {
|
||||
// Fallback to old logic if song doesn't match (shouldn't happen in normal flow)
|
||||
if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
|
||||
activity.largeImageKey = song.imageUrl;
|
||||
} else if (song._serverType === ServerType.NAVIDROME) {
|
||||
try {
|
||||
const info = await api.controller.getAlbumInfo({
|
||||
apiClientProps: { serverId: song._serverId },
|
||||
query: { id: song.albumId },
|
||||
});
|
||||
|
||||
if (info.imageUrl) {
|
||||
activity.largeImageKey = info.imageUrl;
|
||||
if (info.imageUrl) {
|
||||
activity.largeImageKey = info.imageUrl;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +297,7 @@ export const useDiscordRpc = () => {
|
||||
discordSettings.displayType,
|
||||
discordSettings.linkType,
|
||||
lastUniqueId,
|
||||
currentSong?._uniqueId,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export const AlbumInfiniteFeatureCarousel = ({
|
||||
// Filter for albums with images and remove duplicates by ID
|
||||
const uniqueAlbums = new Map<string, Album>();
|
||||
for (const album of allAlbums) {
|
||||
if (album.imageUrl && !uniqueAlbums.has(album.id)) {
|
||||
if (album.imageId && !uniqueAlbums.has(album.id)) {
|
||||
uniqueAlbums.set(album.id, album);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { Fragment, useEffect, useRef } from 'react';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './full-screen-player-image.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayerData, usePlayerSong } from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
@@ -17,6 +17,7 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useSetState } from '/@/shared/hooks/use-set-state';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
@@ -41,13 +42,6 @@ const imageVariants: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (imageSize: number, url?: null | string) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, `&size=${imageSize}`)
|
||||
.replace(/\?width=\d+/, `?width=${imageSize}`)
|
||||
.replace(/&height=\d+/, `&height=${imageSize}`);
|
||||
};
|
||||
|
||||
const MotionImage = motion.img;
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
@@ -85,44 +79,27 @@ const ImageWithPlaceholder = ({
|
||||
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const mainImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
|
||||
|
||||
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
|
||||
const updateImageSize = useCallback(() => {
|
||||
if (mainImageRef.current) {
|
||||
setMainImageDimensions({
|
||||
idealSize:
|
||||
albumArtRes ||
|
||||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
|
||||
});
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
|
||||
setImageState({
|
||||
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
mainImageDimensions.idealSize,
|
||||
setImageState,
|
||||
albumArtRes,
|
||||
currentSong?.imageUrl,
|
||||
nextSong?.imageUrl,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateImageSize();
|
||||
}, [updateImageSize]);
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: nextImageUrl,
|
||||
current: 0,
|
||||
topImage: currentImageUrl,
|
||||
});
|
||||
|
||||
// Track previous song to detect changes
|
||||
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
|
||||
@@ -133,15 +110,13 @@ export const FullScreenPlayerImage = () => {
|
||||
imageStateRef.current = imageState;
|
||||
}, [imageState]);
|
||||
|
||||
// Update images when song changes
|
||||
// Update images when song or size changes
|
||||
useEffect(() => {
|
||||
if (currentSong?._uniqueId === previousSongRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTop = imageStateRef.current.current === 0;
|
||||
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
@@ -150,13 +125,7 @@ export const FullScreenPlayerImage = () => {
|
||||
});
|
||||
|
||||
previousSongRef.current = currentSong?._uniqueId;
|
||||
}, [
|
||||
currentSong?._uniqueId,
|
||||
currentSong?.imageUrl,
|
||||
nextSong?.imageUrl,
|
||||
mainImageDimensions.idealSize,
|
||||
setImageState,
|
||||
]);
|
||||
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocation } from 'react-router';
|
||||
|
||||
import styles from './full-screen-player.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
@@ -38,6 +39,7 @@ import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types';
|
||||
|
||||
const mainBackground = 'var(--theme-colors-background)';
|
||||
@@ -74,14 +76,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const [imageState, setImageState] = useState({
|
||||
bottomImage: nextSong?.imageUrl
|
||||
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined,
|
||||
bottomImage: nextImageUrl,
|
||||
current: 0,
|
||||
topImage: currentSong?.imageUrl
|
||||
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined,
|
||||
topImage: currentImageUrl,
|
||||
});
|
||||
|
||||
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
|
||||
@@ -99,12 +109,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
}
|
||||
|
||||
const isTop = imageStateRef.current.current === 0;
|
||||
const currentImageUrl = currentSong?.imageUrl
|
||||
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined;
|
||||
const nextImageUrl = nextSong?.imageUrl
|
||||
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined;
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
@@ -113,7 +117,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
});
|
||||
|
||||
previousSongRef.current = currentSong?._uniqueId;
|
||||
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
|
||||
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
|
||||
|
||||
if (!dynamicBackground || !dynamicIsImage) {
|
||||
return null;
|
||||
@@ -610,9 +614,15 @@ const PlayerContainer = memo(
|
||||
windowBarStyle,
|
||||
}: PlayerContainerProps) => {
|
||||
const currentSong = usePlayerSong();
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
const { background } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
src: currentSong?.imageUrl,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
import styles from './left-controls.module.css';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
|
||||
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
@@ -116,13 +116,14 @@ export const LeftControls = () => {
|
||||
})}
|
||||
openDelay={0}
|
||||
>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
id={currentSong?.id}
|
||||
itemType={LibraryItem.SONG}
|
||||
loading="eager"
|
||||
src={isRadioMode ? '' : (currentSong?.imageUrl ?? '')}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!collapsed && (
|
||||
|
||||
@@ -4,16 +4,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useGeneralSettings,
|
||||
usePlayerData,
|
||||
usePlayerSong,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { useSetState } from '/@/shared/hooks/use-set-state';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
@@ -38,13 +40,6 @@ const imageVariants: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (imageSize: number, url?: null | string) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, `&size=${imageSize}`)
|
||||
.replace(/\?width=\d+/, `?width=${imageSize}`)
|
||||
.replace(/&height=\d+/, `&height=${imageSize}`);
|
||||
};
|
||||
|
||||
const MotionImage = motion.img;
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
@@ -83,15 +78,29 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
const mainImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1000 });
|
||||
|
||||
const { albumArtRes } = useGeneralSettings();
|
||||
const albumArtRes = useSettingsStore((store) => store.general.imageRes.fullScreenPlayer);
|
||||
const { useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
size: mainImageDimensions.idealSize,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
size: mainImageDimensions.idealSize,
|
||||
type: 'fullScreenPlayer',
|
||||
});
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl),
|
||||
bottomImage: nextImageUrl,
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl),
|
||||
topImage: currentImageUrl,
|
||||
});
|
||||
|
||||
const updateImageSize = useCallback(() => {
|
||||
@@ -101,14 +110,8 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100;
|
||||
|
||||
setMainImageDimensions({ idealSize });
|
||||
|
||||
setImageState({
|
||||
bottomImage: scaleImageUrl(idealSize, nextSong?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(idealSize, currentSong?.imageUrl),
|
||||
});
|
||||
}
|
||||
}, [albumArtRes, currentSong?.imageUrl, nextSong?.imageUrl, setImageState]);
|
||||
}, [albumArtRes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateImageSize();
|
||||
@@ -123,15 +126,13 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
imageStateRef.current = imageState;
|
||||
}, [imageState]);
|
||||
|
||||
// Update images when song changes
|
||||
// Update images when song or size changes
|
||||
useEffect(() => {
|
||||
if (currentSong?._uniqueId === previousSongRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTop = imageStateRef.current.current === 0;
|
||||
const currentImageUrl = scaleImageUrl(mainImageDimensions.idealSize, currentSong?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(mainImageDimensions.idealSize, nextSong?.imageUrl);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
@@ -140,13 +141,7 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
||||
});
|
||||
|
||||
previousSongRef.current = currentSong?._uniqueId;
|
||||
}, [
|
||||
currentSong?._uniqueId,
|
||||
currentSong?.imageUrl,
|
||||
nextSong?.imageUrl,
|
||||
mainImageDimensions.idealSize,
|
||||
setImageState,
|
||||
]);
|
||||
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl, setImageState]);
|
||||
|
||||
return (
|
||||
<div className={styles.imageContainer} ref={mainImageRef}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
@@ -74,14 +75,22 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
|
||||
const currentImageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const nextImageUrl = useItemImageUrl({
|
||||
id: nextSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const [imageState, setImageState] = useState({
|
||||
bottomImage: nextSong?.imageUrl
|
||||
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined,
|
||||
bottomImage: nextImageUrl,
|
||||
current: 0,
|
||||
topImage: currentSong?.imageUrl
|
||||
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined,
|
||||
topImage: currentImageUrl,
|
||||
});
|
||||
|
||||
const previousSongRef = useRef<string | undefined>(currentSong?._uniqueId);
|
||||
@@ -98,12 +107,6 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
}
|
||||
|
||||
const isTop = imageStateRef.current.current === 0;
|
||||
const currentImageUrl = currentSong?.imageUrl
|
||||
? currentSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined;
|
||||
const nextImageUrl = nextSong?.imageUrl
|
||||
? nextSong.imageUrl.replace(/size=\d+/g, 'size=500')
|
||||
: undefined;
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
@@ -112,7 +115,7 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
||||
});
|
||||
|
||||
previousSongRef.current = currentSong?._uniqueId;
|
||||
}, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]);
|
||||
}, [currentSong?._uniqueId, currentImageUrl, nextSong?._uniqueId, nextImageUrl]);
|
||||
|
||||
if (!dynamicBackground || !dynamicIsImage) {
|
||||
return null;
|
||||
@@ -299,9 +302,15 @@ interface MobilePlayerContainerProps {
|
||||
const MobilePlayerContainer = memo(
|
||||
({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => {
|
||||
const currentSong = usePlayerSong();
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
const { background } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
src: currentSong?.imageUrl,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './mobile-playerbar.module.css';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
@@ -68,7 +68,7 @@ export const MobilePlayerbar = () => {
|
||||
<div className={styles.contentWrapper}>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{currentSong?.imageUrl && (
|
||||
{currentSong?.id && (
|
||||
<div className={styles.imageWrapper}>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -87,13 +87,14 @@ export const MobilePlayerbar = () => {
|
||||
})}
|
||||
openDelay={0}
|
||||
>
|
||||
<Image
|
||||
<ItemImage
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
id={currentSong.id}
|
||||
itemType={LibraryItem.SONG}
|
||||
loading="eager"
|
||||
src={currentSong.imageUrl}
|
||||
/>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlaybackSettings, useSettingsStore, useTimestampStoreBase } from '/@/renderer/store';
|
||||
import {
|
||||
usePlaybackSettings,
|
||||
usePlayerSong,
|
||||
useSettingsStore,
|
||||
useTimestampStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
|
||||
|
||||
const mediaSession = navigator.mediaSession;
|
||||
@@ -13,6 +20,14 @@ export const useMediaSession = () => {
|
||||
const player = usePlayer();
|
||||
const skip = useSettingsStore((state) => state.general.skipButtons);
|
||||
const playbackType = useSettingsStore((state) => state.playback.type);
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const isMediaSessionEnabled = useMemo(() => {
|
||||
// Always enable media session on web
|
||||
@@ -94,7 +109,7 @@ export const useMediaSession = () => {
|
||||
mediaSession.metadata = new MediaMetadata({
|
||||
album: song?.album ?? '',
|
||||
artist: song?.artistName ?? '',
|
||||
artwork: song?.imageUrl ? [{ src: song.imageUrl, type: 'image/png' }] : [],
|
||||
artwork: imageUrl ? [{ src: imageUrl, type: 'image/png' }] : [],
|
||||
title: song?.name ?? '',
|
||||
});
|
||||
},
|
||||
@@ -107,6 +122,6 @@ export const useMediaSession = () => {
|
||||
mediaSession.playbackState = status === PlayerStatus.PLAYING ? 'playing' : 'paused';
|
||||
},
|
||||
},
|
||||
[isMediaSessionEnabled, mediaSession],
|
||||
[isMediaSessionEnabled, mediaSession, imageUrl],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { PlayerShuffle } from '/@/shared/types/types';
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
@@ -11,6 +13,14 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
|
||||
|
||||
export const useMPRIS = () => {
|
||||
const player = usePlayerStore();
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!mpris) {
|
||||
@@ -41,32 +51,19 @@ export const useMPRIS = () => {
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Update MPRIS when song or imageUrl changes
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current) {
|
||||
if (!mpris) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializedRef.current = true;
|
||||
|
||||
const currentSong = player.getCurrentSong();
|
||||
|
||||
if (!currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
mpris?.updateSong(currentSong);
|
||||
}, [player]);
|
||||
mpris?.updateSong(currentSong, imageUrl);
|
||||
}, [currentSong, imageUrl]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: (properties) => {
|
||||
if (!mpris) {
|
||||
return;
|
||||
}
|
||||
|
||||
mpris?.updateSong(properties.song);
|
||||
onCurrentSongChange: () => {
|
||||
// The effect above will handle the update when currentSong changes
|
||||
},
|
||||
onPlayerProgress: (properties) => {
|
||||
if (!mpris) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
|
||||
import { useAppStore, usePlaybackSettings, usePlayerSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
/*
|
||||
@@ -59,7 +60,16 @@ export const useScrobble = () => {
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
||||
const sendScrobble = useSendScrobble();
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
imageUrl: currentSong?.imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||
const previousTimestampRef = useRef<number>(0);
|
||||
@@ -68,6 +78,10 @@ export const useScrobble = () => {
|
||||
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
imageUrlRef.current = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const handleScrobbleFromProgress = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||
@@ -198,7 +212,7 @@ export const useScrobble = () => {
|
||||
|
||||
new Notification(`${currentSong.name}`, {
|
||||
body: `${artists}\n${currentSong.album}`,
|
||||
icon: currentSong.imageUrl || undefined,
|
||||
icon: imageUrlRef.current || undefined,
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,20 +5,15 @@ import { QueueSong } from '/@/shared/types/domain-types';
|
||||
const remote = isElectron() ? window.api.remote : null;
|
||||
const mediaSession = navigator.mediaSession;
|
||||
|
||||
export const updateSong = (song: QueueSong | undefined) => {
|
||||
export const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
|
||||
if (mediaSession) {
|
||||
let metadata: MediaMetadata;
|
||||
|
||||
if (song?.id) {
|
||||
let artwork: MediaImage[];
|
||||
|
||||
if (song.imageUrl) {
|
||||
const image300 = song.imageUrl
|
||||
?.replace(/&size=\d+/, '&size=300')
|
||||
.replace(/\?width=\d+/, '?width=300')
|
||||
.replace(/&height=\d+/, '&height=300');
|
||||
|
||||
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
|
||||
if (imageUrl) {
|
||||
artwork = [{ sizes: '300x300', src: imageUrl, type: 'image/png' }];
|
||||
} else {
|
||||
artwork = [];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import styles from './add-to-playlist-context-modal.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import {
|
||||
getAlbumSongsById,
|
||||
getArtistSongsById,
|
||||
@@ -26,7 +27,6 @@ import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Grid } from '/@/shared/components/grid/grid';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||
import { Pill } from '/@/shared/components/pill/pill';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
@@ -38,7 +38,7 @@ import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { useForm } from '/@/shared/hooks/use-form';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
export const AddToPlaylistContextModal = ({
|
||||
id,
|
||||
@@ -555,14 +555,13 @@ const PlaylistTableItem = memo(
|
||||
<Grid align="center" gutter="xs" w="100%">
|
||||
<Grid.Col span="content">
|
||||
<Flex align="center" justify="center" px="sm">
|
||||
{item.imageUrl && (
|
||||
<Image
|
||||
imageContainerProps={{
|
||||
className: styles.imageContainer,
|
||||
}}
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
)}
|
||||
<ItemImage
|
||||
id={item.id}
|
||||
imageContainerProps={{
|
||||
className: styles.imageContainer,
|
||||
}}
|
||||
itemType={LibraryItem.PLAYLIST}
|
||||
/>
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
<Grid.Col className={styles.gridCol} span="auto">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CSSProperties, useCallback, useState } from 'react';
|
||||
|
||||
import styles from './library-command-item.module.css';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
LONG_PRESS_PLAY_BEHAVIOR,
|
||||
@@ -11,7 +12,6 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
@@ -95,11 +95,13 @@ export const LibraryCommandItem = ({
|
||||
>
|
||||
<div className={styles.itemGrid} style={{ '--item-height': '40px' } as CSSProperties}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<Image
|
||||
<ItemImage
|
||||
alt="cover"
|
||||
className={styles.image}
|
||||
height={40}
|
||||
src={imageUrl || ''}
|
||||
id={id}
|
||||
itemType={itemType}
|
||||
src={imageUrl}
|
||||
width={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import i18n, { languages } from '/@/i18n/i18n';
|
||||
import { ImageResolutionSettings } from '/@/renderer/features/settings/components/general/art-resolution-settings';
|
||||
import { ArtistSettings } from '/@/renderer/features/settings/components/general/artist-settings';
|
||||
import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings';
|
||||
import {
|
||||
@@ -575,37 +576,13 @@ export const ApplicationSettings = () => {
|
||||
isHidden: false,
|
||||
title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
defaultValue={settings.albumArtRes || undefined}
|
||||
hideControls={false}
|
||||
max={2500}
|
||||
onBlur={(e) => {
|
||||
const newVal =
|
||||
e.currentTarget.value !== '0'
|
||||
? Math.min(Math.max(Number(e.currentTarget.value), 175), 2500)
|
||||
: null;
|
||||
setSettings({ general: { ...settings, albumArtRes: newVal } });
|
||||
}}
|
||||
placeholder="0"
|
||||
value={settings.albumArtRes ?? 0}
|
||||
width={75}
|
||||
/>
|
||||
),
|
||||
description: t('setting.playerAlbumArtResolution', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: false,
|
||||
title: t('setting.playerAlbumArtResolution', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
extra={
|
||||
<>
|
||||
<ImageResolutionSettings />
|
||||
<HomeSettings />
|
||||
<ArtistSettings />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
|
||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: i18n.t('setting.imageResolution_optionTable', { postProcess: 'sentenceCase' }),
|
||||
value: 'table',
|
||||
},
|
||||
{
|
||||
label: i18n.t('setting.imageResolution_optionItemCard', { postProcess: 'sentenceCase' }),
|
||||
value: 'itemCard',
|
||||
},
|
||||
{
|
||||
label: i18n.t('setting.imageResolution_optionSidebar', { postProcess: 'sentenceCase' }),
|
||||
value: 'sidebar',
|
||||
},
|
||||
{
|
||||
label: i18n.t('setting.imageResolution_optionHeader', { postProcess: 'sentenceCase' }),
|
||||
value: 'header',
|
||||
},
|
||||
{
|
||||
label: i18n.t('setting.imageResolution_optionFullScreenPlayer', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'fullScreenPlayer',
|
||||
},
|
||||
];
|
||||
|
||||
export const ImageResolutionSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const settings = useGeneralSettings();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const descriptionText = t('setting.imageResolution', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
});
|
||||
|
||||
const titleText = t('setting.imageResolution', { postProcess: 'sentenceCase' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsOptions
|
||||
control={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(!open)}
|
||||
size="compact-md"
|
||||
variant={open ? 'subtle' : 'filled'}
|
||||
>
|
||||
{t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={descriptionText}
|
||||
title={titleText}
|
||||
/>
|
||||
{open && (
|
||||
<Table withRowBorders={false}>
|
||||
<Table.Tbody>
|
||||
{options.map((option) => (
|
||||
<Table.Tr key={option.value}>
|
||||
<Table.Th key={option.value}>
|
||||
<Text>{option.label}</Text>
|
||||
</Table.Th>
|
||||
<Table.Td align="right" key={option.value}>
|
||||
<NumberInput
|
||||
max={2000}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
|
||||
if (typeof e === 'string') return;
|
||||
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
imageRes: {
|
||||
...settings.imageRes,
|
||||
[option.value]: e,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
<Text isMuted isNoSelect pr="lg" size="sm">
|
||||
px
|
||||
</Text>
|
||||
}
|
||||
value={settings.imageRes[option.value]}
|
||||
width={90}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './sidebar.module.css';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
@@ -151,10 +152,11 @@ const SidebarImage = () => {
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const upsizedImageUrl = currentSong?.imageUrl
|
||||
?.replace(/size=\d+/, 'size=450')
|
||||
.replace(/width=\d+/, 'width=450')
|
||||
.replace(/height=\d+/, 'height=450');
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.id,
|
||||
itemType: LibraryItem.SONG,
|
||||
type: 'sidebar',
|
||||
});
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
|
||||
@@ -202,8 +204,8 @@ const SidebarImage = () => {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
>
|
||||
{upsizedImageUrl ? (
|
||||
<img className={styles.sidebarImage} loading="eager" src={upsizedImageUrl} />
|
||||
{imageUrl ? (
|
||||
<img className={styles.sidebarImage} loading="eager" src={imageUrl} />
|
||||
) : (
|
||||
<ImageUnloader />
|
||||
)}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -16,100 +16,6 @@ import { ServerListItem, ServerType } from '/@/shared/types/types';
|
||||
|
||||
const TICKS_PER_MS = 10000;
|
||||
|
||||
const getAlbumArtistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.albumArtist>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.album>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getSongCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.song>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 100;
|
||||
|
||||
if (args.item.ImageTags.Primary) {
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96' +
|
||||
// Invalidate the cache if the image chances. This appears to be
|
||||
// how Jellyfin Web does it as well
|
||||
`&tag=${args.item.ImageTags.Primary}`
|
||||
);
|
||||
}
|
||||
|
||||
if (args.item?.AlbumPrimaryImageTag) {
|
||||
// Fall back to album art if no image embedded
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item?.AlbumId}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.playlist>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
|
||||
|
||||
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
|
||||
@@ -128,6 +34,7 @@ const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> =>
|
||||
// for other roles, we just want to display this and not filter.
|
||||
// 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<string, string[]> => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSongImageId = (item: z.infer<typeof jfType._response.song>): null | string => {
|
||||
if (item.ImageTags?.Primary) {
|
||||
return item.Id;
|
||||
}
|
||||
|
||||
if (item.AlbumPrimaryImageTag && item.AlbumId) {
|
||||
return item.AlbumId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getAlbumImageId = (item: z.infer<typeof jfType._response.album>): null | string => {
|
||||
if (item.ImageTags?.Primary) {
|
||||
return item.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getAlbumArtistImageId = (
|
||||
item: z.infer<typeof jfType._response.albumArtist>,
|
||||
): null | string => {
|
||||
if (item.ImageTags?.Primary) {
|
||||
return item.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): null | string => {
|
||||
if (item.ImageTags?.Primary) {
|
||||
return item.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
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<typeof jfType._response.album>,
|
||||
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<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
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<typeof jfType._response.playlist>,
|
||||
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<typeof jfType._response.musicFolder>
|
||||
// };
|
||||
// };
|
||||
|
||||
const getGenreCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.genre>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeGenre = (
|
||||
item: z.infer<typeof jfType._response.genre>,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
|
||||
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<typeof ndType._response.songList>;
|
||||
},
|
||||
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<typeof ndType._response.playlist>,
|
||||
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,
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
enableAnimation?: boolean;
|
||||
}
|
||||
|
||||
interface ImageLoaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
containerClassName?: string;
|
||||
enableAnimation?: boolean;
|
||||
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
||||
@@ -34,6 +25,15 @@ interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
thumbHash?: string;
|
||||
}
|
||||
|
||||
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
enableAnimation?: boolean;
|
||||
}
|
||||
|
||||
interface ImageLoaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ImageUnloaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -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<FolderResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getImageUrl: (args: ImageArgs) => null | string;
|
||||
getInternetRadioStations: (
|
||||
args: GetInternetRadioStationsArgs,
|
||||
) => Promise<GetInternetRadioStationsResponse>;
|
||||
@@ -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<AddToPlaylistArgs>,
|
||||
@@ -1449,6 +1461,7 @@ export type InternalControllerEndpoint = {
|
||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
||||
getInternetRadioStations: (
|
||||
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
||||
) => Promise<GetInternetRadioStationsResponse>;
|
||||
|
||||
Reference in New Issue
Block a user