mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
rewrite Image component
- remove react-image dependency - use manual blob load - abort load when exiting viewport
This commit is contained in:
@@ -127,7 +127,6 @@
|
|||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-i18next": "^16.3.3",
|
"react-i18next": "^16.3.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image": "^4.1.0",
|
|
||||||
"react-player": "^2.16.0",
|
"react-player": "^2.16.0",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"react-split-pane": "^3.0.4",
|
"react-split-pane": "^3.0.4",
|
||||||
|
|||||||
Generated
-16
@@ -182,9 +182,6 @@ importers:
|
|||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0(react@19.1.0)
|
version: 5.5.0(react@19.1.0)
|
||||||
react-image:
|
|
||||||
specifier: ^4.1.0
|
|
||||||
version: 4.1.0(@babel/runtime@7.28.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
|
||||||
react-player:
|
react-player:
|
||||||
specifier: ^2.16.0
|
specifier: ^2.16.0
|
||||||
version: 2.16.0(react@19.1.0)
|
version: 2.16.0(react@19.1.0)
|
||||||
@@ -4565,13 +4562,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '*'
|
react: '*'
|
||||||
|
|
||||||
react-image@4.1.0:
|
|
||||||
resolution: {integrity: sha512-qwPNlelQe9Zy14K2pGWSwoL+vHsAwmJKS6gkotekDgRpcnRuzXNap00GfibD3eEPYu3WCPlyIUUNzcyHOrLHjw==}
|
|
||||||
peerDependencies:
|
|
||||||
'@babel/runtime': '>=7'
|
|
||||||
react: '>=16.8'
|
|
||||||
react-dom: '>=16.8'
|
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -10588,12 +10578,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
react-image@4.1.0(@babel/runtime@7.28.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
|
||||||
dependencies:
|
|
||||||
'@babel/runtime': 7.28.4
|
|
||||||
react: 19.1.0
|
|
||||||
react-dom: 19.1.0(react@19.1.0)
|
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
react-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
|
|||||||
@@ -442,6 +442,25 @@ export const controller: GeneralController = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getImageRequest(args) {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getImageRequest',
|
||||||
|
server.type,
|
||||||
|
)?.(
|
||||||
|
addContext({
|
||||||
|
...args,
|
||||||
|
apiClientProps: { ...args.apiClientProps, server },
|
||||||
|
}),
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
getImageUrl(args) {
|
getImageUrl(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
|
|||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { createAuthHeader, jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
|
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
|
||||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
Folder,
|
Folder,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
|
ImageArgs,
|
||||||
|
ImageRequest,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Played,
|
Played,
|
||||||
playlistListSortMap,
|
playlistListSortMap,
|
||||||
|
ReplaceApiClientProps,
|
||||||
ServerType,
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
@@ -29,6 +32,33 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
|
const getJellyfinImageRequest = ({
|
||||||
|
apiClientProps: { server },
|
||||||
|
baseUrl,
|
||||||
|
query,
|
||||||
|
}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {
|
||||||
|
const { id, size } = query;
|
||||||
|
const imageSize = size;
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheKey: ['jellyfin', server.id, baseUrl || '', id, imageSize || ''].join(':'),
|
||||||
|
headers: server.credential
|
||||||
|
? { Authorization: createAuthHeader().concat(`, Token="${server.credential}"`) }
|
||||||
|
: { Authorization: createAuthHeader() },
|
||||||
|
url: `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatCommaDelimitedString = (value: string[]) => {
|
const formatCommaDelimitedString = (value: string[]) => {
|
||||||
return value.join(',');
|
return value.join(',');
|
||||||
};
|
};
|
||||||
@@ -789,23 +819,8 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.body?.TotalRecordCount || 0,
|
totalRecordCount: res.body?.TotalRecordCount || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
getImageRequest: getJellyfinImageRequest,
|
||||||
const { id, size } = query;
|
getImageUrl: (args) => getJellyfinImageRequest(args)?.url || null,
|
||||||
const imageSize = size;
|
|
||||||
const url = baseUrl || getServerUrl(server);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Jellyfin, we construct the URL pattern
|
|
||||||
// The server will return a 404 or placeholder if no image exists
|
|
||||||
const imageUrl = `${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 imageUrl;
|
|
||||||
},
|
|
||||||
getInternetRadioStations: async (args) => {
|
getInternetRadioStations: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getImageRequest: SubsonicController.getImageRequest,
|
||||||
getImageUrl: SubsonicController.getImageUrl,
|
getImageUrl: SubsonicController.getImageUrl,
|
||||||
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
||||||
getLyrics: SubsonicController.getLyrics,
|
getLyrics: SubsonicController.getLyrics,
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@
|
|||||||
import {
|
import {
|
||||||
AlbumListSort,
|
AlbumListSort,
|
||||||
GenreListSort,
|
GenreListSort,
|
||||||
|
ImageArgs,
|
||||||
|
ImageRequest,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
PlaylistListSort,
|
PlaylistListSort,
|
||||||
|
ReplaceApiClientProps,
|
||||||
ServerType,
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
@@ -30,6 +33,36 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
|
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
|
const getSubsonicImageRequest = ({
|
||||||
|
apiClientProps: { server },
|
||||||
|
baseUrl,
|
||||||
|
query,
|
||||||
|
}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {
|
||||||
|
const { id, size } = query;
|
||||||
|
const imageSize = size;
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
|
if (!url || !server?.credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for default placeholder image ID
|
||||||
|
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheKey: ['subsonic', server.id, baseUrl || '', id, imageSize || ''].join(':'),
|
||||||
|
url:
|
||||||
|
`${url}/rest/getCoverArt.view` +
|
||||||
|
`?id=${id}` +
|
||||||
|
`&${server.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=Feishin' +
|
||||||
|
(imageSize ? `&size=${imageSize}` : ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
||||||
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
||||||
[AlbumListSort.ARTIST]: undefined,
|
[AlbumListSort.ARTIST]: undefined,
|
||||||
@@ -952,29 +985,8 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
startIndex: query.startIndex,
|
startIndex: query.startIndex,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
getImageRequest: getSubsonicImageRequest,
|
||||||
const { id, size } = query;
|
getImageUrl: (args) => getSubsonicImageRequest(args)?.url || null,
|
||||||
const imageSize = size;
|
|
||||||
const url = baseUrl || getServerUrl(server);
|
|
||||||
|
|
||||||
if (!url || !server?.credential) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for default placeholder image ID
|
|
||||||
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
`${url}/rest/getCoverArt.view` +
|
|
||||||
`?id=${id}` +
|
|
||||||
`&${server.credential}` +
|
|
||||||
'&v=1.13.0' +
|
|
||||||
'&c=Feishin' +
|
|
||||||
(imageSize ? `&size=${imageSize}` : '')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getInternetRadioStations: async (args) => {
|
getInternetRadioStations: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
||||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, ImageRequest, LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const getUnloaderIcon = (itemType: LibraryItem) => {
|
const getUnloaderIcon = (itemType: LibraryItem) => {
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
@@ -54,10 +54,19 @@ const BaseItemImage = (
|
|||||||
type: props.type,
|
type: props.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageRequest = useItemImageRequest({
|
||||||
|
id: props.id,
|
||||||
|
imageUrl: src,
|
||||||
|
itemType: props.itemType,
|
||||||
|
serverId: serverId || undefined,
|
||||||
|
type: props.type,
|
||||||
|
});
|
||||||
|
|
||||||
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
|
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseImage
|
<BaseImage
|
||||||
|
imageRequest={imageRequest}
|
||||||
isExplicit={isExplicit}
|
isExplicit={isExplicit}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
unloaderIcon={getUnloaderIcon(props.itemType)}
|
unloaderIcon={getUnloaderIcon(props.itemType)}
|
||||||
@@ -113,6 +122,79 @@ export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
|||||||
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useItemImageRequest = (args: UseItemImageUrlProps) => {
|
||||||
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const imageRes = useImageRes();
|
||||||
|
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (imageUrl) {
|
||||||
|
return {
|
||||||
|
cacheKey: imageUrl,
|
||||||
|
url: imageUrl,
|
||||||
|
} satisfies ImageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetServerId = args.serverId || serverId;
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(targetServerId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
api.controller.getImageRequest({
|
||||||
|
apiClientProps: { serverId: targetServerId },
|
||||||
|
baseUrl,
|
||||||
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
|
}) || undefined
|
||||||
|
);
|
||||||
|
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getItemImageRequest(args: UseItemImageUrlProps) {
|
||||||
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
|
const authStore = useAuthStore.getState();
|
||||||
|
const currentServerId = authStore.currentServer?.id;
|
||||||
|
const serverId = (args.serverId || currentServerId) as string;
|
||||||
|
|
||||||
|
const imageRes = useSettingsStore.getState().general.imageRes;
|
||||||
|
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
return {
|
||||||
|
cacheKey: imageUrl,
|
||||||
|
url: imageUrl,
|
||||||
|
} satisfies ImageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
api.controller.getImageRequest({
|
||||||
|
apiClientProps: { serverId },
|
||||||
|
baseUrl,
|
||||||
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
|
}) || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getItemImageUrl(args: UseItemImageUrlProps) {
|
export function getItemImageUrl(args: UseItemImageUrlProps) {
|
||||||
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
const authStore = useAuthStore.getState();
|
const authStore = useAuthStore.getState();
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import {
|
|||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
memo,
|
memo,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useRef,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Img } from 'react-image';
|
|
||||||
|
|
||||||
import styles from './image.module.css';
|
import styles from './image.module.css';
|
||||||
|
import { useNativeImage } from './use-native-image';
|
||||||
|
|
||||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
||||||
|
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
@@ -24,11 +27,11 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 's
|
|||||||
enableViewport?: boolean;
|
enableViewport?: boolean;
|
||||||
fetchPriority?: 'auto' | 'high' | 'low';
|
fetchPriority?: 'auto' | 'high' | 'low';
|
||||||
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
||||||
|
imageRequest?: ImageRequest;
|
||||||
includeLoader?: boolean;
|
includeLoader?: boolean;
|
||||||
includeUnloader?: boolean;
|
includeUnloader?: boolean;
|
||||||
isExplicit?: boolean;
|
isExplicit?: boolean;
|
||||||
src: string | undefined;
|
src: string | undefined;
|
||||||
thumbHash?: string;
|
|
||||||
unloaderIcon?: keyof typeof AppIcon;
|
unloaderIcon?: keyof typeof AppIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,230 +56,62 @@ export function BaseImage({
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
enableAnimation = false,
|
enableAnimation = false,
|
||||||
enableDebounce = true,
|
enableDebounce = false,
|
||||||
enableViewport = true,
|
enableViewport = true,
|
||||||
fetchPriority,
|
fetchPriority,
|
||||||
imageContainerProps,
|
imageContainerProps,
|
||||||
|
imageRequest,
|
||||||
includeLoader = true,
|
includeLoader = true,
|
||||||
includeUnloader = true,
|
includeUnloader = true,
|
||||||
isExplicit = false,
|
isExplicit = false,
|
||||||
|
onError,
|
||||||
|
onLoad,
|
||||||
src,
|
src,
|
||||||
unloaderIcon = 'emptyImage',
|
unloaderIcon = 'emptyImage',
|
||||||
...props
|
...props
|
||||||
}: ImageProps) {
|
}: ImageProps) {
|
||||||
if (enableDebounce) {
|
|
||||||
return (
|
|
||||||
<ImageWithDebounce
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
enableAnimation={enableAnimation}
|
|
||||||
enableViewport={enableViewport}
|
|
||||||
imageContainerProps={imageContainerProps}
|
|
||||||
includeLoader={includeLoader}
|
|
||||||
includeUnloader={includeUnloader}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
src={src}
|
|
||||||
unloaderIcon={unloaderIcon}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableViewport) {
|
|
||||||
return (
|
|
||||||
<ImageWithViewport
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
enableAnimation={enableAnimation}
|
|
||||||
imageContainerProps={imageContainerProps}
|
|
||||||
includeLoader={includeLoader}
|
|
||||||
includeUnloader={includeUnloader}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
src={src}
|
|
||||||
unloaderIcon={unloaderIcon}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImageContainer
|
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={src}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageWithDebounce({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
enableAnimation,
|
|
||||||
enableViewport,
|
|
||||||
fetchPriority,
|
|
||||||
imageContainerProps,
|
|
||||||
includeLoader,
|
|
||||||
includeUnloader,
|
|
||||||
isExplicit = false,
|
|
||||||
src,
|
|
||||||
unloaderIcon,
|
|
||||||
...props
|
|
||||||
}: ImageProps) {
|
|
||||||
const [debouncedSrc] = useDebouncedValue(src, 100, { waitForInitial: true });
|
|
||||||
const viewport = useInViewport();
|
const viewport = useInViewport();
|
||||||
const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };
|
const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
||||||
|
|
||||||
const hasBeenInViewportRef = useRef(false);
|
const rawImageRequest = useMemo(
|
||||||
const prevDebouncedSrcRef = useRef(debouncedSrc);
|
() => imageRequest ?? (src ? { cacheKey: src, url: src } : undefined),
|
||||||
|
[imageRequest, src],
|
||||||
|
);
|
||||||
|
const [debouncedImageRequest] = useDebouncedValue(rawImageRequest, 100, {
|
||||||
|
waitForInitial: true,
|
||||||
|
});
|
||||||
|
const effectiveImageRequest = enableDebounce ? debouncedImageRequest : rawImageRequest;
|
||||||
|
|
||||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
|
||||||
|
|
||||||
if (srcInDisplayedCache) {
|
useEffect(() => {
|
||||||
hasBeenInViewportRef.current = true;
|
setHasLoadedInInstance(false);
|
||||||
}
|
}, [effectiveImageRequest?.cacheKey]);
|
||||||
|
|
||||||
if (prevDebouncedSrcRef.current !== debouncedSrc) {
|
const shouldLoadImage = Boolean(
|
||||||
prevDebouncedSrcRef.current = debouncedSrc;
|
effectiveImageRequest && (!enableViewport || inViewport || hasLoadedInInstance),
|
||||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (inViewport && debouncedSrc) {
|
const nativeImage = useNativeImage({
|
||||||
hasBeenInViewportRef.current = true;
|
enabled: shouldLoadImage,
|
||||||
}
|
fetchPriority,
|
||||||
|
onFetchError: src
|
||||||
|
? () => {
|
||||||
|
(onError as ((event: undefined) => void) | undefined)?.(undefined);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
request: effectiveImageRequest,
|
||||||
|
});
|
||||||
|
|
||||||
const effectiveSrc = debouncedSrc ?? (srcInDisplayedCache ? src : undefined);
|
useEffect(() => {
|
||||||
const shouldShowImage = enableViewport
|
if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
|
||||||
? (inViewport || hasBeenInViewportRef.current) && effectiveSrc
|
return;
|
||||||
: effectiveSrc;
|
|
||||||
|
|
||||||
if (enableViewport) {
|
|
||||||
if (shouldShowImage && effectiveSrc) {
|
|
||||||
addToDisplayedCache(effectiveSrc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
setHasLoadedInInstance(true);
|
||||||
<ImageContainer
|
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
ref={ref}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{shouldShowImage && effectiveSrc ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={effectiveSrc}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : !src ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : (
|
|
||||||
<ImageLoader className={className} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveSrc) addToDisplayedCache(effectiveSrc);
|
|
||||||
return (
|
|
||||||
<ImageContainer
|
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{effectiveSrc ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={effectiveSrc}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : !src ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : (
|
|
||||||
<ImageLoader className={className} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageWithViewport({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
enableAnimation,
|
|
||||||
fetchPriority,
|
|
||||||
imageContainerProps,
|
|
||||||
includeLoader,
|
|
||||||
includeUnloader,
|
|
||||||
isExplicit = false,
|
|
||||||
src,
|
|
||||||
unloaderIcon,
|
|
||||||
...props
|
|
||||||
}: ImageProps) {
|
|
||||||
const { inViewport, ref } = useInViewport();
|
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
|
||||||
|
|
||||||
const hasBeenInViewportRef = useRef(false);
|
|
||||||
const prevSrcRef = useRef(src);
|
|
||||||
|
|
||||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
|
||||||
if (srcInDisplayedCache) {
|
|
||||||
hasBeenInViewportRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevSrcRef.current !== src) {
|
|
||||||
prevSrcRef.current = src;
|
|
||||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inViewport && src) {
|
|
||||||
hasBeenInViewportRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldShowImage = (inViewport || hasBeenInViewportRef.current) && src;
|
|
||||||
|
|
||||||
if (shouldShowImage && src) addToDisplayedCache(src);
|
|
||||||
return (
|
return (
|
||||||
<ImageContainer
|
<ImageContainer
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
className={clsx(containerClassName, containerPropsClassName)}
|
||||||
@@ -284,71 +119,31 @@ function ImageWithViewport({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
{...restContainerProps}
|
{...restContainerProps}
|
||||||
>
|
>
|
||||||
{shouldShowImage ? (
|
{nativeImage.displaySrc ? (
|
||||||
<Img
|
<img
|
||||||
className={clsx(styles.image, className, {
|
className={clsx(styles.image, className, {
|
||||||
[styles.animated]: enableAnimation,
|
[styles.animated]: enableAnimation,
|
||||||
})}
|
})}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchPriority={fetchPriority}
|
fetchPriority={fetchPriority}
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
onError={onError}
|
||||||
src={src}
|
onLoad={onLoad}
|
||||||
unloader={
|
src={nativeImage.displaySrc}
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : !src ? (
|
) : !src ? (
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||||
) : (
|
) : nativeImage.isError ? (
|
||||||
|
includeUnloader ? (
|
||||||
|
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||||
|
) : null
|
||||||
|
) : includeLoader ? (
|
||||||
<ImageLoader className={className} />
|
<ImageLoader className={className} />
|
||||||
)}
|
) : null}
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAYED_SRC_CACHE_KEY = 'feishin-displayed-src-cache';
|
|
||||||
const MAX_DISPLAYED_SRC_CACHE = 500;
|
|
||||||
|
|
||||||
function addToDisplayedCache(src: string | undefined) {
|
|
||||||
if (!src) return;
|
|
||||||
try {
|
|
||||||
const cache = getDisplayedSrcCache();
|
|
||||||
if (cache.includes(src)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (cache.length >= MAX_DISPLAYED_SRC_CACHE) {
|
|
||||||
cache.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.push(src);
|
|
||||||
sessionStorage.setItem(DISPLAYED_SRC_CACHE_KEY, JSON.stringify(cache));
|
|
||||||
} catch {
|
|
||||||
// ignore error if sessionStorage is unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayedSrcCache(): string[] {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(DISPLAYED_SRC_CACHE_KEY);
|
|
||||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInDisplayedCache(src: string | undefined): boolean {
|
|
||||||
if (!src) return false;
|
|
||||||
try {
|
|
||||||
return getDisplayedSrcCache().includes(src);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Image = memo(BaseImage);
|
export const Image = memo(BaseImage);
|
||||||
|
|
||||||
const ImageContainer = forwardRef(
|
const ImageContainer = forwardRef(
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
type FetchPriority = 'auto' | 'high' | 'low';
|
||||||
|
|
||||||
|
interface NativeImageState {
|
||||||
|
displaySrc?: string;
|
||||||
|
status: 'error' | 'idle' | 'loaded' | 'loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNativeImageArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
fetchPriority?: FetchPriority;
|
||||||
|
onFetchError?: () => void;
|
||||||
|
request?: ImageRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeImage({
|
||||||
|
enabled,
|
||||||
|
fetchPriority,
|
||||||
|
onFetchError,
|
||||||
|
request,
|
||||||
|
}: UseNativeImageArgs) {
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const loadedRequestSignatureRef = useRef<null | string>(null);
|
||||||
|
const objectUrlRef = useRef<null | string>(null);
|
||||||
|
const onFetchErrorRef = useRef(onFetchError);
|
||||||
|
const [state, setState] = useState<NativeImageState>({ status: 'idle' });
|
||||||
|
|
||||||
|
const requestSignature = useMemo(() => {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
cacheKey: request.cacheKey,
|
||||||
|
credentials: request.credentials,
|
||||||
|
headers: request.headers,
|
||||||
|
url: request.url,
|
||||||
|
});
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
onFetchErrorRef.current = onFetchError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortCurrentRequest = () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeObjectUrl = () => {
|
||||||
|
if (!objectUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
loadedRequestSignatureRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request || !requestSignature) {
|
||||||
|
abortCurrentRequest();
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
abortCurrentRequest();
|
||||||
|
setState((currentState) =>
|
||||||
|
currentState.displaySrc
|
||||||
|
? { ...currentState, status: 'loaded' }
|
||||||
|
: { status: 'idle' },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
|
||||||
|
setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortCurrentRequest();
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const init = {
|
||||||
|
credentials: request.credentials,
|
||||||
|
headers: request.headers,
|
||||||
|
signal: abortController.signal,
|
||||||
|
} as RequestInit & { priority?: FetchPriority };
|
||||||
|
|
||||||
|
if (fetchPriority) {
|
||||||
|
init.priority = fetchPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(request.url, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load image: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
objectUrlRef.current = objectUrl;
|
||||||
|
loadedRequestSignatureRef.current = requestSignature;
|
||||||
|
setState({ displaySrc: objectUrl, status: 'loaded' });
|
||||||
|
} catch {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'error' });
|
||||||
|
onFetchErrorRef.current?.();
|
||||||
|
} finally {
|
||||||
|
if (abortControllerRef.current === abortController) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
if (abortControllerRef.current === abortController) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, fetchPriority, request, requestSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displaySrc: state.displaySrc,
|
||||||
|
isError: state.status === 'error',
|
||||||
|
isLoaded: state.status === 'loaded',
|
||||||
|
isLoading: state.status === 'loading',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1397,6 +1397,7 @@ export type ControllerEndpoint = {
|
|||||||
getDownloadUrl: (args: DownloadArgs) => string;
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
|
getImageRequest: (args: ImageArgs) => ImageRequest | null;
|
||||||
getImageUrl: (args: ImageArgs) => null | string;
|
getImageUrl: (args: ImageArgs) => null | string;
|
||||||
getInternetRadioStations: (
|
getInternetRadioStations: (
|
||||||
args: GetInternetRadioStationsArgs,
|
args: GetInternetRadioStationsArgs,
|
||||||
@@ -1471,6 +1472,13 @@ export type ImageArgs = BaseEndpointArgs & {
|
|||||||
query: ImageQuery;
|
query: ImageQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImageRequest = {
|
||||||
|
cacheKey: string;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImageQuery = {
|
export type ImageQuery = {
|
||||||
id: string;
|
id: string;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
@@ -1523,6 +1531,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||||
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||||
|
getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;
|
||||||
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
||||||
getInternetRadioStations: (
|
getInternetRadioStations: (
|
||||||
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
||||||
|
|||||||
Reference in New Issue
Block a user