mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-31 02:27:11 -04:00 
			
		
		
		
	Rework images to support lazy loading and blurhash (web only)
This commit is contained in:
		
							parent
							
								
									22e136d9fd
								
							
						
					
					
						commit
						607b973dbd
					
				| @ -12,6 +12,7 @@ | ||||
| 		"@gorhom/portal": "*", | ||||
| 		"@material-symbols/svg-400": "*", | ||||
| 		"@radix-ui/react-dropdown-menu": "*", | ||||
| 		"blurhash": "*", | ||||
| 		"expo-linear-gradient": "*", | ||||
| 		"moti": "*", | ||||
| 		"react": "*", | ||||
| @ -28,6 +29,9 @@ | ||||
| 		"@radix-ui/react-dropdown-menu": { | ||||
| 			"optional": true | ||||
| 		}, | ||||
| 		"blurhash": { | ||||
| 			"optional": true | ||||
| 		}, | ||||
| 		"react-native-blurhash": { | ||||
| 			"optional": true | ||||
| 		}, | ||||
| @ -39,5 +43,8 @@ | ||||
| 		"@expo/html-elements": "^0.5.1", | ||||
| 		"@tanstack/react-query": "^4.32.6", | ||||
| 		"solito": "^4.0.1" | ||||
| 	}, | ||||
| 	"optionalDependencies": { | ||||
| 		"blurhash": "^2.0.5" | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -1,221 +0,0 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { KyooImage } from "@kyoo/models"; | ||||
| import { ComponentType, ReactNode, useState } from "react"; | ||||
| import { | ||||
| 	View, | ||||
| 	ImageSourcePropType, | ||||
| 	ImageStyle, | ||||
| 	Platform, | ||||
| 	ImageProps, | ||||
| 	ViewProps, | ||||
| 	ViewStyle, | ||||
| } from "react-native"; | ||||
| import {Image as Img} from "expo-image" | ||||
| import { percent, useYoshiki } from "yoshiki/native"; | ||||
| import { YoshikiStyle } from "yoshiki/dist/type"; | ||||
| import { Skeleton } from "./skeleton"; | ||||
| import { LinearGradient, LinearGradientProps } from "expo-linear-gradient"; | ||||
| import { alpha, ContrastArea } from "./themes"; | ||||
| 
 | ||||
| type YoshikiEnhanced<Style> = Style extends any | ||||
| 	? { | ||||
| 			[key in keyof Style]: YoshikiStyle<Style[key]>; | ||||
| 	  } | ||||
| 	: never; | ||||
| 
 | ||||
| type WithLoading<T> = (T & { isLoading?: boolean }) | (Partial<T> & { isLoading: true }); | ||||
| 
 | ||||
| type Props = WithLoading<{ | ||||
| 	src?: KyooImage | null; | ||||
| 	alt?: string; | ||||
| 	quality: "low" | "medium" | "high" | ||||
| }>; | ||||
| 
 | ||||
| type ImageLayout = YoshikiEnhanced< | ||||
| 	| { width: ImageStyle["width"]; height: ImageStyle["height"] } | ||||
| 	| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] } | ||||
| 	| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] } | ||||
| >; | ||||
| 
 | ||||
| export const Image = ({ | ||||
| 	src, | ||||
| 	quality, | ||||
| 	alt, | ||||
| 	isLoading: forcedLoading = false, | ||||
| 	layout, | ||||
| 	...props | ||||
| }: Props & { style?: ViewStyle } & { layout: ImageLayout }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<Img | ||||
| 			source={src?.[quality ?? "high"]} | ||||
| 			placeholder={src?.blurhash} | ||||
| 			accessibilityLabel={alt} | ||||
| 			{...css([ | ||||
| 				layout, | ||||
| 			// 	{
 | ||||
| 			// 		// width: percent(100),
 | ||||
| 			// 		// height: percent(100),
 | ||||
| 			// 		// resizeMode: "cover",
 | ||||
| 			// 		borderRadius: 6
 | ||||
| 			// 	},
 | ||||
| 			]) as any} | ||||
| 		/> | ||||
| 	); | ||||
| 	// const [state, setState] = useState<"loading" | "errored" | "finished">(
 | ||||
| 	// 	src ? "loading" : "errored",
 | ||||
| 	// );
 | ||||
| 	//
 | ||||
| 	// // This could be done with a key but this makes the API easier to use.
 | ||||
| 	// // This unsures that the state is resetted when the source change (useful for recycler lists.)
 | ||||
| 	// const [oldSource, setOldSource] = useState(src);
 | ||||
| 	// if (oldSource !== src) {
 | ||||
| 	// 	setState("loading");
 | ||||
| 	// 	setOldSource(src);
 | ||||
| 	// }
 | ||||
| 	//
 | ||||
| 	// const border = { borderRadius: 6 } satisfies ViewStyle;
 | ||||
| 	//
 | ||||
| 	// if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
 | ||||
| 	// if (!src || state === "errored")
 | ||||
| 	// 	return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />;
 | ||||
| 	//
 | ||||
| 	// const nativeProps = Platform.select<Partial<ImageProps>>({
 | ||||
| 	// 	web: {
 | ||||
| 	// 		defaultSource: typeof src === "string" ? { uri: src } : Array.isArray(src) ? src[0] : src,
 | ||||
| 	// 	},
 | ||||
| 	// 	default: {},
 | ||||
| 	// });
 | ||||
| 	//
 | ||||
| 	// return (
 | ||||
| 	// 	<Skeleton variant="custom" show={state === "loading"} {...css([layout, border], props)}>
 | ||||
| 	// 		<Img
 | ||||
| 	// 			source={typeof src === "string" ? { uri: src } : src}
 | ||||
| 	// 			accessibilityLabel={alt}
 | ||||
| 	// 			onLoad={() => setState("finished")}
 | ||||
| 	// 			onError={() => setState("errored")}
 | ||||
| 	// 			{...nativeProps}
 | ||||
| 	// 			{...css([
 | ||||
| 	// 				{
 | ||||
| 	// 					width: percent(100),
 | ||||
| 	// 					height: percent(100),
 | ||||
| 	// 					resizeMode: "cover",
 | ||||
| 	// 				},
 | ||||
| 	// 			])}
 | ||||
| 	// 		/>
 | ||||
| 	// 	</Skeleton>
 | ||||
| 	// );
 | ||||
| }; | ||||
| 
 | ||||
| export const Poster = ({ | ||||
| 	alt, | ||||
| 	isLoading = false, | ||||
| 	layout, | ||||
| 	...props | ||||
| }: Props & { style?: ViewStyle } & { | ||||
| 	layout: YoshikiEnhanced<{ width: ViewStyle["width"] } | { height: ViewStyle["height"] }>; | ||||
| }) => ( | ||||
| 	<Image isLoading={isLoading} alt={alt} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} /> | ||||
| ); | ||||
| 
 | ||||
| export const ImageBackground = <AsProps = ViewProps,>({ | ||||
| 	src, | ||||
| 	alt, | ||||
| 	gradient = true, | ||||
| 	as, | ||||
| 	children, | ||||
| 	containerStyle, | ||||
| 	imageStyle, | ||||
| 	isLoading, | ||||
| 	...asProps | ||||
| }: { | ||||
| 	as?: ComponentType<AsProps>; | ||||
| 	gradient?: Partial<LinearGradientProps> | boolean; | ||||
| 	children: ReactNode; | ||||
| 	containerStyle?: YoshikiEnhanced<ViewStyle>; | ||||
| 	imageStyle?: YoshikiEnhanced<ImageStyle>; | ||||
| } & AsProps & | ||||
| 	Props) => { | ||||
| 	const [isErrored, setErrored] = useState(false); | ||||
| 
 | ||||
| 	const nativeProps = Platform.select<Partial<ImageProps>>({ | ||||
| 		web: { | ||||
| 			defaultSource: typeof src === "string" ? { uri: src! } : Array.isArray(src) ? src[0] : src!, | ||||
| 		}, | ||||
| 		default: {}, | ||||
| 	}); | ||||
| 	const Container = as ?? View; | ||||
| 	return ( | ||||
| 		<ContrastArea contrastText> | ||||
| 			{({ css, theme }) => ( | ||||
| 				<Container {...(asProps as AsProps)}> | ||||
| 					<View | ||||
| 						{...css([ | ||||
| 							{ | ||||
| 								position: "absolute", | ||||
| 								top: 0, | ||||
| 								bottom: 0, | ||||
| 								left: 0, | ||||
| 								right: 0, | ||||
| 								zIndex: -1, | ||||
| 								bg: (theme) => theme.background, | ||||
| 							}, | ||||
| 							containerStyle, | ||||
| 						])} | ||||
| 					> | ||||
| 						{src && !isErrored && ( | ||||
| 							<Img | ||||
| 								source={typeof src === "string" ? { uri: src } : src} | ||||
| 								accessibilityLabel={alt} | ||||
| 								onError={() => setErrored(true)} | ||||
| 								{...nativeProps} | ||||
| 								{...css([ | ||||
| 									{ width: percent(100), height: percent(100), resizeMode: "cover" }, | ||||
| 									imageStyle, | ||||
| 								])} | ||||
| 							/> | ||||
| 						)} | ||||
| 						{gradient && ( | ||||
| 							<LinearGradient | ||||
| 								start={{ x: 0, y: 0.25 }} | ||||
| 								end={{ x: 0, y: 1 }} | ||||
| 								colors={["transparent", alpha(theme.colors.black, 0.6)]} | ||||
| 								{...css( | ||||
| 									{ | ||||
| 										position: "absolute", | ||||
| 										top: 0, | ||||
| 										bottom: 0, | ||||
| 										left: 0, | ||||
| 										right: 0, | ||||
| 									}, | ||||
| 									typeof gradient === "object" ? gradient : undefined, | ||||
| 								)} | ||||
| 							/> | ||||
| 						)} | ||||
| 					</View> | ||||
| 					{children} | ||||
| 				</Container> | ||||
| 			)} | ||||
| 		</ContrastArea> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										46
									
								
								front/packages/primitives/src/image/base-image.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								front/packages/primitives/src/image/base-image.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { KyooImage } from "@kyoo/models"; | ||||
| import { | ||||
| 	Image as Img, | ||||
| 	ImageStyle, | ||||
| } from "react-native"; | ||||
| import { YoshikiStyle } from "yoshiki/src/type"; | ||||
| 
 | ||||
| export type YoshikiEnhanced<Style> = Style extends any | ||||
| 	? { | ||||
| 		[key in keyof Style]: YoshikiStyle<Style[key]>; | ||||
| 	} | ||||
| 	: never; | ||||
| 
 | ||||
| export type WithLoading<T> = (T & { isLoading?: boolean }) | (Partial<T> & { isLoading: true }); | ||||
| 
 | ||||
| export type Props = WithLoading<{ | ||||
| 	src?: KyooImage | null; | ||||
| 	alt: string; | ||||
| 	quality: "low" | "medium" | "high"; | ||||
| }>; | ||||
| 
 | ||||
| export type ImageLayout = YoshikiEnhanced< | ||||
| 	| { width: ImageStyle["width"]; height: ImageStyle["height"] } | ||||
| 	| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] } | ||||
| 	| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] } | ||||
| >; | ||||
							
								
								
									
										188
									
								
								front/packages/primitives/src/image/blurhash-web.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								front/packages/primitives/src/image/blurhash-web.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,188 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { decode } from "blurhash"; | ||||
| 
 | ||||
| // The blurhashToUrl has been stolen from https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff
 | ||||
| export function blurHashToDataURL(hash: string | undefined): string | undefined { | ||||
| 	if (!hash) return undefined; | ||||
| 
 | ||||
| 	const pixels = decode(hash, 32, 32); | ||||
| 	const dataURL = parsePixels(pixels, 32, 32); | ||||
| 	return dataURL; | ||||
| } | ||||
| 
 | ||||
| // thanks to https://github.com/wheany/js-png-encoder
 | ||||
| function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) { | ||||
| 	const pixelsString = Array.from(pixels) | ||||
| 		.map((byte) => String.fromCharCode(byte)) | ||||
| 		.join(""); | ||||
| 	const pngString = generatePng(width, height, pixelsString); | ||||
| 	const dataURL = | ||||
| 		typeof Buffer !== "undefined" | ||||
| 			? Buffer.from(getPngArray(pngString)).toString("base64") | ||||
| 			: btoa(pngString); | ||||
| 	return "data:image/png;base64," + dataURL; | ||||
| } | ||||
| 
 | ||||
| function getPngArray(pngString: string) { | ||||
| 	const pngArray = new Uint8Array(pngString.length); | ||||
| 	for (let i = 0; i < pngString.length; i++) { | ||||
| 		pngArray[i] = pngString.charCodeAt(i); | ||||
| 	} | ||||
| 	return pngArray; | ||||
| } | ||||
| 
 | ||||
| function generatePng(width: number, height: number, rgbaString: string) { | ||||
| 	const DEFLATE_METHOD = String.fromCharCode(0x78, 0x01); | ||||
| 	const CRC_TABLE: number[] = []; | ||||
| 	const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10); | ||||
| 	const NO_FILTER = String.fromCharCode(0); | ||||
| 
 | ||||
| 	let n, c, k; | ||||
| 
 | ||||
| 	// make crc table
 | ||||
| 	for (n = 0; n < 256; n++) { | ||||
| 		c = n; | ||||
| 		for (k = 0; k < 8; k++) { | ||||
| 			if (c & 1) { | ||||
| 				c = 0xedb88320 ^ (c >>> 1); | ||||
| 			} else { | ||||
| 				c = c >>> 1; | ||||
| 			} | ||||
| 		} | ||||
| 		CRC_TABLE[n] = c; | ||||
| 	} | ||||
| 
 | ||||
| 	// Functions
 | ||||
| 	function inflateStore(data: string) { | ||||
| 		const MAX_STORE_LENGTH = 65535; | ||||
| 		let storeBuffer = ""; | ||||
| 		let remaining; | ||||
| 		let blockType; | ||||
| 
 | ||||
| 		for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) { | ||||
| 			remaining = data.length - i; | ||||
| 			blockType = ""; | ||||
| 
 | ||||
| 			if (remaining <= MAX_STORE_LENGTH) { | ||||
| 				blockType = String.fromCharCode(0x01); | ||||
| 			} else { | ||||
| 				remaining = MAX_STORE_LENGTH; | ||||
| 				blockType = String.fromCharCode(0x00); | ||||
| 			} | ||||
| 			// little-endian
 | ||||
| 			storeBuffer += blockType + String.fromCharCode(remaining & 0xff, (remaining & 0xff00) >>> 8); | ||||
| 			storeBuffer += String.fromCharCode(~remaining & 0xff, (~remaining & 0xff00) >>> 8); | ||||
| 
 | ||||
| 			storeBuffer += data.substring(i, i + remaining); | ||||
| 		} | ||||
| 
 | ||||
| 		return storeBuffer; | ||||
| 	} | ||||
| 
 | ||||
| 	function adler32(data: string) { | ||||
| 		let MOD_ADLER = 65521; | ||||
| 		let a = 1; | ||||
| 		let b = 0; | ||||
| 
 | ||||
| 		for (let i = 0; i < data.length; i++) { | ||||
| 			a = (a + data.charCodeAt(i)) % MOD_ADLER; | ||||
| 			b = (b + a) % MOD_ADLER; | ||||
| 		} | ||||
| 
 | ||||
| 		return (b << 16) | a; | ||||
| 	} | ||||
| 
 | ||||
| 	function updateCrc(crc: number, buf: string) { | ||||
| 		let c = crc; | ||||
| 		let b: number; | ||||
| 
 | ||||
| 		for (let n = 0; n < buf.length; n++) { | ||||
| 			b = buf.charCodeAt(n); | ||||
| 			c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); | ||||
| 		} | ||||
| 		return c; | ||||
| 	} | ||||
| 
 | ||||
| 	function crc(buf: string) { | ||||
| 		return updateCrc(0xffffffff, buf) ^ 0xffffffff; | ||||
| 	} | ||||
| 
 | ||||
| 	function dwordAsString(dword: number) { | ||||
| 		return String.fromCharCode( | ||||
| 			(dword & 0xff000000) >>> 24, | ||||
| 			(dword & 0x00ff0000) >>> 16, | ||||
| 			(dword & 0x0000ff00) >>> 8, | ||||
| 			dword & 0x000000ff, | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	function createChunk(length: number, type: string, data: string) { | ||||
| 		const CRC = crc(type + data); | ||||
| 
 | ||||
| 		return dwordAsString(length) + type + data + dwordAsString(CRC); | ||||
| 	} | ||||
| 
 | ||||
| 	function createIHDR(width: number, height: number) { | ||||
| 		const IHDRdata = | ||||
| 			dwordAsString(width) + | ||||
| 			dwordAsString(height) + | ||||
| 			// bit depth
 | ||||
| 			String.fromCharCode(8) + | ||||
| 			// color type: 6=truecolor with alpha
 | ||||
| 			String.fromCharCode(6) + | ||||
| 			// compression method: 0=deflate, only allowed value
 | ||||
| 			String.fromCharCode(0) + | ||||
| 			// filtering: 0=adaptive, only allowed value
 | ||||
| 			String.fromCharCode(0) + | ||||
| 			// interlacing: 0=none
 | ||||
| 			String.fromCharCode(0); | ||||
| 
 | ||||
| 		return createChunk(13, "IHDR", IHDRdata); | ||||
| 	} | ||||
| 
 | ||||
| 	// PNG creations
 | ||||
| 
 | ||||
| 	const IEND = createChunk(0, "IEND", ""); | ||||
| 	const IHDR = createIHDR(width, height); | ||||
| 
 | ||||
| 	let scanlines = ""; | ||||
| 	let scanline; | ||||
| 
 | ||||
| 	for (let y = 0; y < rgbaString.length; y += width * 4) { | ||||
| 		scanline = NO_FILTER; | ||||
| 		if (Array.isArray(rgbaString)) { | ||||
| 			for (let x = 0; x < width * 4; x++) { | ||||
| 				scanline += String.fromCharCode(rgbaString[y + x] & 0xff); | ||||
| 			} | ||||
| 		} else { | ||||
| 			scanline += rgbaString.substr(y, width * 4); | ||||
| 		} | ||||
| 		scanlines += scanline; | ||||
| 	} | ||||
| 
 | ||||
| 	const compressedScanlines = | ||||
| 		DEFLATE_METHOD + inflateStore(scanlines) + dwordAsString(adler32(scanlines)); | ||||
| 	const IDAT = createChunk(compressedScanlines.length, "IDAT", compressedScanlines); | ||||
| 
 | ||||
| 	const pngString = SIGNATURE + IHDR + IDAT + IEND; | ||||
| 	return pngString; | ||||
| } | ||||
							
								
								
									
										85
									
								
								front/packages/primitives/src/image/image.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								front/packages/primitives/src/image/image.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { useState } from "react"; | ||||
| import { ImageProps, ImageStyle, Platform, View, ViewStyle } from "react-native"; | ||||
| import { useYoshiki } from "yoshiki/native"; | ||||
| import { YoshikiEnhanced, WithLoading, Props, ImageLayout } from "./base-image"; | ||||
| import { Skeleton } from "../skeleton"; | ||||
| 
 | ||||
| export const Image = ({ | ||||
| 	src, | ||||
| 	quality, | ||||
| 	alt, | ||||
| 	isLoading: forcedLoading = false, | ||||
| 	layout, | ||||
| 	...props | ||||
| }: Props & { style?: ImageStyle } & { layout: ImageLayout }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [state, setState] = useState<"loading" | "errored" | "finished">( | ||||
| 		src ? "loading" : "errored", | ||||
| 	); | ||||
| 
 | ||||
| 	// This could be done with a key but this makes the API easier to use.
 | ||||
| 	// This unsures that the state is resetted when the source change (useful for recycler lists.)
 | ||||
| 	const [oldSource, setOldSource] = useState(src); | ||||
| 	if (oldSource !== src) { | ||||
| 		setState("loading"); | ||||
| 		setOldSource(src); | ||||
| 	} | ||||
| 
 | ||||
| 	const border = { borderRadius: 6 } satisfies ViewStyle; | ||||
| 
 | ||||
| 	if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />; | ||||
| 	if (!src || state === "errored") | ||||
| 		return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />; | ||||
| 
 | ||||
| 	const nativeProps = Platform.select<Partial<ImageProps>>({ | ||||
| 		web: { | ||||
| 			defaultSource: typeof src === "string" ? { uri: src } : Array.isArray(src) ? src[0] : src, | ||||
| 		}, | ||||
| 		default: {}, | ||||
| 	}); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<View {...css(layout)}> | ||||
| 			<Blurhash src={src.high} blurhash={src.blurhash} /> | ||||
| 		</View> | ||||
| 	); | ||||
| 
 | ||||
| 	// return (
 | ||||
| 	// 	<Skeleton variant="custom" show={state === "loading"} {...css([layout, border], props)}>
 | ||||
| 	// 		<Img
 | ||||
| 	// 			source={{ uri: src[quality || "high"] }}
 | ||||
| 	// 			accessibilityLabel={alt}
 | ||||
| 	// 			onLoad={() => setState("finished")}
 | ||||
| 	// 			onError={() => setState("errored")}
 | ||||
| 	// 			{...nativeProps}
 | ||||
| 	// 			{...css([
 | ||||
| 	// 				{
 | ||||
| 	// 					width: percent(100),
 | ||||
| 	// 					height: percent(100),
 | ||||
| 	// 					resizeMode: "cover",
 | ||||
| 	// 				},
 | ||||
| 	// 			])}
 | ||||
| 	// 		/>
 | ||||
| 	// 	</Skeleton>
 | ||||
| 	// );
 | ||||
| }; | ||||
							
								
								
									
										105
									
								
								front/packages/primitives/src/image/image.web.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								front/packages/primitives/src/image/image.web.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { useLayoutEffect, useState } from "react"; | ||||
| import { ImageStyle, View, ViewStyle } from "react-native"; | ||||
| import { StyleList, processStyleList } from "yoshiki/src/type"; | ||||
| import { useYoshiki } from "yoshiki/native"; | ||||
| import { Props, ImageLayout } from "./base-image"; | ||||
| import { blurHashToDataURL } from "./blurhash-web"; | ||||
| import { Skeleton } from "../skeleton"; | ||||
| import NextImage from "next/image"; | ||||
| 
 | ||||
| // Extract classnames from leftover props using yoshiki's internal.
 | ||||
| const extractClassNames = <Style,>(props: { | ||||
| 	style?: StyleList<{ $$css?: true; yoshiki?: string } | Style>; | ||||
| }) => { | ||||
| 	const inline = processStyleList(props.style); | ||||
| 	return "$$css" in inline && inline.$$css ? inline.yoshiki : undefined; | ||||
| }; | ||||
| 
 | ||||
| export const Image = ({ | ||||
| 	src, | ||||
| 	quality, | ||||
| 	alt, | ||||
| 	isLoading: forcedLoading = false, | ||||
| 	layout, | ||||
| 	...props | ||||
| }: Props & { style?: ImageStyle } & { layout: ImageLayout }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [state, setState] = useState<"loading" | "errored" | "finished">( | ||||
| 		src ? "finished" : "errored", | ||||
| 	); | ||||
| 
 | ||||
| 	useLayoutEffect(() => { | ||||
| 		setState("loading"); | ||||
| 	}, []); | ||||
| 
 | ||||
| 	const border = { borderRadius: 6 } satisfies ViewStyle; | ||||
| 
 | ||||
| 	if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />; | ||||
| 	if (!src || state === "errored") | ||||
| 		return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />; | ||||
| 
 | ||||
| 	const blurhash = blurHashToDataURL(src.blurhash); | ||||
| 	return ( | ||||
| 		<div | ||||
| 			style={{ | ||||
| 				// To reproduce view's behavior
 | ||||
| 				position: "relative", | ||||
| 				boxSizing: "border-box", | ||||
| 				borderStyle: "solid", | ||||
| 				overflow: "hidden", | ||||
| 
 | ||||
| 				// Use a blurhash here to nicely fade the NextImage when it is loaded completly
 | ||||
| 				// (this prevents loading the image line by line which is ugly and buggy on firefox)
 | ||||
| 				backgroundImage: `url(${blurhash})`, | ||||
| 				backgroundSize: "cover", | ||||
| 				backgroundRepeat: "no-repeat", | ||||
| 				backgroundPosition: "50% 50%", | ||||
| 
 | ||||
| 				// Use the layout's size and display the rounded border
 | ||||
| 				width: (layout as any).width, | ||||
| 				height: (layout as any).height, | ||||
| 				aspectRatio: (layout as any).aspectRatio, | ||||
| 				...border, | ||||
| 			}} | ||||
| 			// Gather classnames from props (to support parent's hover for example).
 | ||||
| 			className={extractClassNames(props)} | ||||
| 		> | ||||
| 			<NextImage | ||||
| 				src={src[quality ?? "high"]} | ||||
| 				alt={alt!} | ||||
| 				fill={true} | ||||
| 				style={{ | ||||
| 					objectFit: "cover", | ||||
| 					opacity: state === "loading" ? 0 : 1, | ||||
| 					transition: "opacity .2s ease-out", | ||||
| 				}} | ||||
| 				blurDataURL={blurhash} | ||||
| 				placeholder="blur" | ||||
| 				// Don't use next's server to reprocess images, they are already optimized by kyoo.
 | ||||
| 				unoptimized={true} | ||||
| 				onLoadingComplete={() => setState("finished")} | ||||
| 				onError={() => setState("errored")} | ||||
| 			/> | ||||
| 		</div> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										122
									
								
								front/packages/primitives/src/image/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								front/packages/primitives/src/image/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,122 @@ | ||||
| /* | ||||
|  * Kyoo - A portable and vast media library solution. | ||||
|  * Copyright (c) Kyoo. | ||||
|  * | ||||
|  * See AUTHORS.md and LICENSE file in the project root for full license information. | ||||
|  * | ||||
|  * Kyoo is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation, either version 3 of the License, or | ||||
|  * any later version. | ||||
|  * | ||||
|  * Kyoo is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | ||||
|  */ | ||||
| 
 | ||||
| import { ImageProps, ImageStyle, Platform, View, ViewProps, ViewStyle } from "react-native"; | ||||
| import { Props, ImageLayout, YoshikiEnhanced } from "./base-image"; | ||||
| import { Image } from "./image"; | ||||
| import { ComponentType, ReactNode, useState } from "react"; | ||||
| import { LinearGradient, LinearGradientProps } from "expo-linear-gradient"; | ||||
| import { ContrastArea, alpha } from "../themes"; | ||||
| import { percent } from "yoshiki/native"; | ||||
| 
 | ||||
| export { Image }; | ||||
| 
 | ||||
| export const Poster = ({ | ||||
| 	alt, | ||||
| 	isLoading = false, | ||||
| 	layout, | ||||
| 	...props | ||||
| }: Props & { style?: ImageStyle } & { | ||||
| 	layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>; | ||||
| }) => ( | ||||
| 	<Image isLoading={isLoading} alt={alt} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} /> | ||||
| ); | ||||
| 
 | ||||
| export const ImageBackground = <AsProps = ViewProps,>({ | ||||
| 	src, | ||||
| 	alt, | ||||
| 	gradient = true, | ||||
| 	as, | ||||
| 	children, | ||||
| 	containerStyle, | ||||
| 	imageStyle, | ||||
| 	isLoading, | ||||
| 	...asProps | ||||
| }: { | ||||
| 	as?: ComponentType<AsProps>; | ||||
| 	gradient?: Partial<LinearGradientProps> | boolean; | ||||
| 	children: ReactNode; | ||||
| 	containerStyle?: YoshikiEnhanced<ViewStyle>; | ||||
| 	imageStyle?: YoshikiEnhanced<ImageStyle>; | ||||
| } & AsProps & | ||||
| 	Props) => { | ||||
| 	const [isErrored, setErrored] = useState(false); | ||||
| 
 | ||||
| 	const nativeProps = Platform.select<Partial<ImageProps>>({ | ||||
| 		web: { | ||||
| 			defaultSource: typeof src === "string" ? { uri: src! } : Array.isArray(src) ? src[0] : src!, | ||||
| 		}, | ||||
| 		default: {}, | ||||
| 	}); | ||||
| 	const Container = as ?? View; | ||||
| 	return ( | ||||
| 		<ContrastArea contrastText> | ||||
| 			{({ css, theme }) => ( | ||||
| 				<Container {...(asProps as AsProps)}> | ||||
| 					<View | ||||
| 						{...css([ | ||||
| 							{ | ||||
| 								position: "absolute", | ||||
| 								top: 0, | ||||
| 								bottom: 0, | ||||
| 								left: 0, | ||||
| 								right: 0, | ||||
| 								zIndex: -1, | ||||
| 								bg: (theme) => theme.background, | ||||
| 							}, | ||||
| 							containerStyle, | ||||
| 						])} | ||||
| 					> | ||||
| 						{src && !isErrored && ( | ||||
| 							<Image | ||||
| 								source={typeof src === "string" ? { uri: src } : src} | ||||
| 								accessibilityLabel={alt} | ||||
| 								onError={() => setErrored(true)} | ||||
| 								{...nativeProps} | ||||
| 								{...css([ | ||||
| 									{ width: percent(100), height: percent(100), resizeMode: "cover" }, | ||||
| 									imageStyle, | ||||
| 								])} | ||||
| 							/> | ||||
| 						)} | ||||
| 						{gradient && ( | ||||
| 							<LinearGradient | ||||
| 								start={{ x: 0, y: 0.25 }} | ||||
| 								end={{ x: 0, y: 1 }} | ||||
| 								colors={["transparent", alpha(theme.colors.black, 0.6)]} | ||||
| 								{...css( | ||||
| 									{ | ||||
| 										position: "absolute", | ||||
| 										top: 0, | ||||
| 										bottom: 0, | ||||
| 										left: 0, | ||||
| 										right: 0, | ||||
| 									}, | ||||
| 									typeof gradient === "object" ? gradient : undefined, | ||||
| 								)} | ||||
| 							/> | ||||
| 						)} | ||||
| 					</View> | ||||
| 					{children} | ||||
| 				</Container> | ||||
| 			)} | ||||
| 		</ContrastArea> | ||||
| 	); | ||||
| }; | ||||
| @ -73,7 +73,7 @@ const TitleLine = ({ | ||||
| 	isLoading: boolean; | ||||
| 	slug: string; | ||||
| 	name?: string; | ||||
| 	tagline?: string; | ||||
| 	tagline?: string | null; | ||||
| 	date?: string | null; | ||||
| 	poster?: KyooImage | null; | ||||
| 	studio?: Studio | null; | ||||
|  | ||||
| @ -2525,12 +2525,14 @@ __metadata: | ||||
|     "@gorhom/portal": ^1.0.14 | ||||
|     "@tanstack/react-query": ^4.32.6 | ||||
|     "@types/react": 18.2.0 | ||||
|     blurhash: ^2.0.5 | ||||
|     solito: ^4.0.1 | ||||
|     typescript: ^5.1.6 | ||||
|   peerDependencies: | ||||
|     "@gorhom/portal": "*" | ||||
|     "@material-symbols/svg-400": "*" | ||||
|     "@radix-ui/react-dropdown-menu": "*" | ||||
|     blurhash: "*" | ||||
|     expo-linear-gradient: "*" | ||||
|     moti: "*" | ||||
|     react: "*" | ||||
| @ -2539,11 +2541,16 @@ __metadata: | ||||
|     react-native-reanimated: "*" | ||||
|     react-native-svg: "*" | ||||
|     yoshiki: "*" | ||||
|   dependenciesMeta: | ||||
|     blurhash: | ||||
|       optional: true | ||||
|   peerDependenciesMeta: | ||||
|     "@gorhom/portal": | ||||
|       optional: true | ||||
|     "@radix-ui/react-dropdown-menu": | ||||
|       optional: true | ||||
|     blurhash: | ||||
|       optional: true | ||||
|     react-native-blurhash: | ||||
|       optional: true | ||||
|     react-native-web: | ||||
| @ -5123,6 +5130,13 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "blurhash@npm:^2.0.5": | ||||
|   version: 2.0.5 | ||||
|   resolution: "blurhash@npm:2.0.5" | ||||
|   checksum: aa4d6855bbaae116065b118a7b1e889648c15047e72048c28bab3db426a042ce1dc032a30c55a52da6140c314534841b984ab11cc68303668dde446d6ca53bc6 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "body-parser@npm:^1.20.1": | ||||
|   version: 1.20.2 | ||||
|   resolution: "body-parser@npm:1.20.2" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user