From 31a201ca321b2c6adb648fb950394c25a72bff42 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 9 Mar 2026 20:47:52 -0700 Subject: [PATCH] rewrite Image component - remove react-image dependency - use manual blob load - abort load when exiting viewport --- package.json | 1 - pnpm-lock.yaml | 16 - src/renderer/api/controller.ts | 19 ++ .../api/jellyfin/jellyfin-controller.ts | 51 ++- .../api/navidrome/navidrome-controller.ts | 1 + .../api/subsonic/subsonic-controller.ts | 58 ++-- .../components/item-image/item-image.tsx | 84 ++++- src/shared/components/image/image.tsx | 307 +++--------------- .../components/image/use-native-image.ts | 159 +++++++++ src/shared/types/domain-types.ts | 9 + 10 files changed, 390 insertions(+), 315 deletions(-) create mode 100644 src/shared/components/image/use-native-image.ts diff --git a/package.json b/package.json index 6b40d5ecf..a8d10477b 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,6 @@ "react-error-boundary": "^5.0.0", "react-i18next": "^16.3.3", "react-icons": "^5.5.0", - "react-image": "^4.1.0", "react-player": "^2.16.0", "react-router": "^7.13.1", "react-split-pane": "^3.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5db45210e..36cb5d103 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,9 +182,6 @@ importers: react-icons: specifier: ^5.5.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: specifier: ^2.16.0 version: 2.16.0(react@19.1.0) @@ -4565,13 +4562,6 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -10588,12 +10578,6 @@ snapshots: dependencies: 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-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 434919666..31061418b 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 2514d3063..13052e9d0 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -4,7 +4,7 @@ import filter from 'lodash/filter'; import orderBy from 'lodash/orderBy'; 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 { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize'; @@ -15,10 +15,13 @@ import { albumListSortMap, Folder, genreListSortMap, + ImageArgs, + ImageRequest, InternalControllerEndpoint, LibraryItem, Played, playlistListSortMap, + ReplaceApiClientProps, ServerType, Song, SongListSort, @@ -29,6 +32,33 @@ import { } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; +const getJellyfinImageRequest = ({ + apiClientProps: { server }, + baseUrl, + query, +}: ReplaceApiClientProps): 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[]) => { return value.join(','); }; @@ -789,23 +819,8 @@ export const JellyfinController: InternalControllerEndpoint = { totalRecordCount: res.body?.TotalRecordCount || 0, }; }, - getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => { - const { id, size } = query; - 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; - }, + getImageRequest: getJellyfinImageRequest, + getImageUrl: (args) => getJellyfinImageRequest(args)?.url || null, getInternetRadioStations: async (args) => { const { apiClientProps } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index d669a3e83..9221068cd 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -545,6 +545,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, + getImageRequest: SubsonicController.getImageRequest, getImageUrl: SubsonicController.getImageUrl, getInternetRadioStations: SubsonicController.getInternetRadioStations, getLyrics: SubsonicController.getLyrics, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 19fe17346..c4811c24a 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -20,9 +20,12 @@ import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@ import { AlbumListSort, GenreListSort, + ImageArgs, + ImageRequest, InternalControllerEndpoint, LibraryItem, PlaylistListSort, + ReplaceApiClientProps, ServerType, Song, SongListSort, @@ -30,6 +33,36 @@ import { } from '/@/shared/types/domain-types'; import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types'; +const getSubsonicImageRequest = ({ + apiClientProps: { server }, + baseUrl, + query, +}: ReplaceApiClientProps): 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.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST, [AlbumListSort.ARTIST]: undefined, @@ -952,29 +985,8 @@ export const SubsonicController: InternalControllerEndpoint = { startIndex: query.startIndex, }); }, - getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => { - 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 ( - `${url}/rest/getCoverArt.view` + - `?id=${id}` + - `&${server.credential}` + - '&v=1.13.0' + - '&c=Feishin' + - (imageSize ? `&size=${imageSize}` : '') - ); - }, + getImageRequest: getSubsonicImageRequest, + getImageUrl: (args) => getSubsonicImageRequest(args)?.url || null, getInternetRadioStations: async (args) => { const { apiClientProps } = args; diff --git a/src/renderer/components/item-image/item-image.tsx b/src/renderer/components/item-image/item-image.tsx index fbb4668d4..4058f2983 100644 --- a/src/renderer/components/item-image/item-image.tsx +++ b/src/renderer/components/item-image/item-image.tsx @@ -12,7 +12,7 @@ import { useSettingsStore, } from '/@/renderer/store'; 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) => { switch (itemType) { @@ -54,10 +54,19 @@ const BaseItemImage = ( 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; return ( { }, [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) { const { id, imageUrl, itemType, size, type, useRemoteUrl } = args; const authStore = useAuthStore.getState(); diff --git a/src/shared/components/image/image.tsx b/src/shared/components/image/image.tsx index b0109b986..f9c0ea54b 100644 --- a/src/shared/components/image/image.tsx +++ b/src/shared/components/image/image.tsx @@ -6,16 +6,19 @@ import { type ImgHTMLAttributes, memo, ReactNode, - useRef, + useEffect, + useMemo, + useState, } from 'react'; -import { Img } from 'react-image'; import styles from './image.module.css'; +import { useNativeImage } from './use-native-image'; import { AppIcon, Icon } from '/@/shared/components/icon/icon'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useInViewport } from '/@/shared/hooks/use-in-viewport'; +import { ImageRequest } from '/@/shared/types/domain-types'; export interface ImageProps extends Omit, 'src'> { containerClassName?: string; @@ -24,11 +27,11 @@ export interface ImageProps extends Omit, 's enableViewport?: boolean; fetchPriority?: 'auto' | 'high' | 'low'; imageContainerProps?: Omit; + imageRequest?: ImageRequest; includeLoader?: boolean; includeUnloader?: boolean; isExplicit?: boolean; src: string | undefined; - thumbHash?: string; unloaderIcon?: keyof typeof AppIcon; } @@ -53,230 +56,62 @@ export function BaseImage({ className, containerClassName, enableAnimation = false, - enableDebounce = true, + enableDebounce = false, enableViewport = true, fetchPriority, imageContainerProps, + imageRequest, includeLoader = true, includeUnloader = true, isExplicit = false, + onError, + onLoad, src, unloaderIcon = 'emptyImage', ...props }: ImageProps) { - if (enableDebounce) { - return ( - - ); - } - - if (enableViewport) { - return ( - - ); - } - - const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {}; - - return ( - - {src ? ( - : null} - src={src} - unloader={ - includeUnloader ? ( - - ) : null - } - {...props} - /> - ) : ( - - )} - - ); -} - -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 { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined }; const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {}; - const hasBeenInViewportRef = useRef(false); - const prevDebouncedSrcRef = useRef(debouncedSrc); + const rawImageRequest = useMemo( + () => 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) { - hasBeenInViewportRef.current = true; - } + useEffect(() => { + setHasLoadedInInstance(false); + }, [effectiveImageRequest?.cacheKey]); - if (prevDebouncedSrcRef.current !== debouncedSrc) { - prevDebouncedSrcRef.current = debouncedSrc; - if (!srcInDisplayedCache) hasBeenInViewportRef.current = false; - } + const shouldLoadImage = Boolean( + effectiveImageRequest && (!enableViewport || inViewport || hasLoadedInInstance), + ); - if (inViewport && debouncedSrc) { - hasBeenInViewportRef.current = true; - } + const nativeImage = useNativeImage({ + enabled: shouldLoadImage, + fetchPriority, + onFetchError: src + ? () => { + (onError as ((event: undefined) => void) | undefined)?.(undefined); + } + : undefined, + request: effectiveImageRequest, + }); - const effectiveSrc = debouncedSrc ?? (srcInDisplayedCache ? src : undefined); - const shouldShowImage = enableViewport - ? (inViewport || hasBeenInViewportRef.current) && effectiveSrc - : effectiveSrc; - - if (enableViewport) { - if (shouldShowImage && effectiveSrc) { - addToDisplayedCache(effectiveSrc); + useEffect(() => { + if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) { + return; } - return ( - - {shouldShowImage && effectiveSrc ? ( - : null} - src={effectiveSrc} - unloader={ - includeUnloader ? ( - - ) : null - } - {...props} - /> - ) : !src ? ( - - ) : ( - - )} - - ); - } + setHasLoadedInInstance(true); + }, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]); - if (effectiveSrc) addToDisplayedCache(effectiveSrc); - return ( - - {effectiveSrc ? ( - : null} - src={effectiveSrc} - unloader={ - includeUnloader ? ( - - ) : null - } - {...props} - /> - ) : !src ? ( - - ) : ( - - )} - - ); -} - -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 ( - {shouldShowImage ? ( - : null} - src={src} - unloader={ - includeUnloader ? ( - - ) : null - } + onError={onError} + onLoad={onLoad} + src={nativeImage.displaySrc} {...props} /> ) : !src ? ( - ) : ( + ) : nativeImage.isError ? ( + includeUnloader ? ( + + ) : null + ) : includeLoader ? ( - )} + ) : null} ); } -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); const ImageContainer = forwardRef( diff --git a/src/shared/components/image/use-native-image.ts b/src/shared/components/image/use-native-image.ts new file mode 100644 index 000000000..6b1a5d171 --- /dev/null +++ b/src/shared/components/image/use-native-image.ts @@ -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(null); + const loadedRequestSignatureRef = useRef(null); + const objectUrlRef = useRef(null); + const onFetchErrorRef = useRef(onFetchError); + const [state, setState] = useState({ 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', + }; +} diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index c661b55ba..2d1c52758 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1397,6 +1397,7 @@ export type ControllerEndpoint = { getDownloadUrl: (args: DownloadArgs) => string; getFolder: (args: FolderArgs) => Promise; getGenreList: (args: GenreListArgs) => Promise; + getImageRequest: (args: ImageArgs) => ImageRequest | null; getImageUrl: (args: ImageArgs) => null | string; getInternetRadioStations: ( args: GetInternetRadioStationsArgs, @@ -1471,6 +1472,13 @@ export type ImageArgs = BaseEndpointArgs & { query: ImageQuery; }; +export type ImageRequest = { + cacheKey: string; + credentials?: RequestCredentials; + headers?: Record; + url: string; +}; + export type ImageQuery = { id: string; itemType: LibraryItem; @@ -1523,6 +1531,7 @@ export type InternalControllerEndpoint = { getDownloadUrl: (args: ReplaceApiClientProps) => string; getFolder: (args: ReplaceApiClientProps) => Promise; getGenreList: (args: ReplaceApiClientProps) => Promise; + getImageRequest: (args: ReplaceApiClientProps) => ImageRequest | null; getImageUrl: (args: ReplaceApiClientProps) => null | string; getInternetRadioStations: ( args: ReplaceApiClientProps,