mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-30 19:54:16 -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