mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-30 10:12:36 -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": "*", | 		"@gorhom/portal": "*", | ||||||
| 		"@material-symbols/svg-400": "*", | 		"@material-symbols/svg-400": "*", | ||||||
| 		"@radix-ui/react-dropdown-menu": "*", | 		"@radix-ui/react-dropdown-menu": "*", | ||||||
|  | 		"blurhash": "*", | ||||||
| 		"expo-linear-gradient": "*", | 		"expo-linear-gradient": "*", | ||||||
| 		"moti": "*", | 		"moti": "*", | ||||||
| 		"react": "*", | 		"react": "*", | ||||||
| @ -28,6 +29,9 @@ | |||||||
| 		"@radix-ui/react-dropdown-menu": { | 		"@radix-ui/react-dropdown-menu": { | ||||||
| 			"optional": true | 			"optional": true | ||||||
| 		}, | 		}, | ||||||
|  | 		"blurhash": { | ||||||
|  | 			"optional": true | ||||||
|  | 		}, | ||||||
| 		"react-native-blurhash": { | 		"react-native-blurhash": { | ||||||
| 			"optional": true | 			"optional": true | ||||||
| 		}, | 		}, | ||||||
| @ -39,5 +43,8 @@ | |||||||
| 		"@expo/html-elements": "^0.5.1", | 		"@expo/html-elements": "^0.5.1", | ||||||
| 		"@tanstack/react-query": "^4.32.6", | 		"@tanstack/react-query": "^4.32.6", | ||||||
| 		"solito": "^4.0.1" | 		"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; | 	isLoading: boolean; | ||||||
| 	slug: string; | 	slug: string; | ||||||
| 	name?: string; | 	name?: string; | ||||||
| 	tagline?: string; | 	tagline?: string | null; | ||||||
| 	date?: string | null; | 	date?: string | null; | ||||||
| 	poster?: KyooImage | null; | 	poster?: KyooImage | null; | ||||||
| 	studio?: Studio | null; | 	studio?: Studio | null; | ||||||
|  | |||||||
| @ -2525,12 +2525,14 @@ __metadata: | |||||||
|     "@gorhom/portal": ^1.0.14 |     "@gorhom/portal": ^1.0.14 | ||||||
|     "@tanstack/react-query": ^4.32.6 |     "@tanstack/react-query": ^4.32.6 | ||||||
|     "@types/react": 18.2.0 |     "@types/react": 18.2.0 | ||||||
|  |     blurhash: ^2.0.5 | ||||||
|     solito: ^4.0.1 |     solito: ^4.0.1 | ||||||
|     typescript: ^5.1.6 |     typescript: ^5.1.6 | ||||||
|   peerDependencies: |   peerDependencies: | ||||||
|     "@gorhom/portal": "*" |     "@gorhom/portal": "*" | ||||||
|     "@material-symbols/svg-400": "*" |     "@material-symbols/svg-400": "*" | ||||||
|     "@radix-ui/react-dropdown-menu": "*" |     "@radix-ui/react-dropdown-menu": "*" | ||||||
|  |     blurhash: "*" | ||||||
|     expo-linear-gradient: "*" |     expo-linear-gradient: "*" | ||||||
|     moti: "*" |     moti: "*" | ||||||
|     react: "*" |     react: "*" | ||||||
| @ -2539,11 +2541,16 @@ __metadata: | |||||||
|     react-native-reanimated: "*" |     react-native-reanimated: "*" | ||||||
|     react-native-svg: "*" |     react-native-svg: "*" | ||||||
|     yoshiki: "*" |     yoshiki: "*" | ||||||
|  |   dependenciesMeta: | ||||||
|  |     blurhash: | ||||||
|  |       optional: true | ||||||
|   peerDependenciesMeta: |   peerDependenciesMeta: | ||||||
|     "@gorhom/portal": |     "@gorhom/portal": | ||||||
|       optional: true |       optional: true | ||||||
|     "@radix-ui/react-dropdown-menu": |     "@radix-ui/react-dropdown-menu": | ||||||
|       optional: true |       optional: true | ||||||
|  |     blurhash: | ||||||
|  |       optional: true | ||||||
|     react-native-blurhash: |     react-native-blurhash: | ||||||
|       optional: true |       optional: true | ||||||
|     react-native-web: |     react-native-web: | ||||||
| @ -5123,6 +5130,13 @@ __metadata: | |||||||
|   languageName: node |   languageName: node | ||||||
|   linkType: hard |   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": | "body-parser@npm:^1.20.1": | ||||||
|   version: 1.20.2 |   version: 1.20.2 | ||||||
|   resolution: "body-parser@npm:1.20.2" |   resolution: "body-parser@npm:1.20.2" | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user