mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Update code to fix biome errors
This commit is contained in:
parent
871aaa032d
commit
bd71be580d
@ -42,9 +42,6 @@ const config = {
|
||||
icon: "./assets/icon.png",
|
||||
userInterfaceStyle: "automatic",
|
||||
splash,
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
assetBundlePatterns: ["**/*"],
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
@ -59,6 +56,7 @@ const config = {
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/55de6b52-c649-4a15-9a45-569ff5ed036c",
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: "sdkVersion",
|
||||
|
@ -30,7 +30,7 @@ export default function PublicLayout() {
|
||||
const { error } = useContext(ConnectionErrorContext);
|
||||
const oldAccount = useRef<Account | null>(account);
|
||||
|
||||
if (account && !error && account != oldAccount.current) return <Redirect href="/" />;
|
||||
if (account && !error && account !== oldAccount.current) return <Redirect href="/" />;
|
||||
oldAccount.current = account;
|
||||
|
||||
return (
|
||||
|
@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
const path = require("node:path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const defaultConfig = getDefaultConfig(projectRoot);
|
||||
|
@ -18,7 +18,7 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const path = require("node:path");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const DefinePlugin = require("webpack").DefinePlugin;
|
||||
|
||||
|
@ -56,8 +56,6 @@
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"react-native": "0.73.2",
|
||||
"typescript": "^5.3.3",
|
||||
"webpack": "^5.90.0"
|
||||
|
@ -40,18 +40,15 @@ export const withTranslations = (
|
||||
};
|
||||
|
||||
const AppWithTranslations = (props: AppProps) => {
|
||||
const li18n = useMemo(
|
||||
() =>
|
||||
typeof window === "undefined"
|
||||
? i18n
|
||||
: (i18next.init({
|
||||
...commonOptions,
|
||||
lng: props.pageProps.__lang,
|
||||
resources: props.pageProps.__resources,
|
||||
}),
|
||||
i18next),
|
||||
[props.pageProps.__lang, props.pageProps.__resources],
|
||||
);
|
||||
const li18n = useMemo(() => {
|
||||
if (typeof window === "undefined") return i18n;
|
||||
i18next.init({
|
||||
...commonOptions,
|
||||
lng: props.pageProps.__lang,
|
||||
resources: props.pageProps.__resources,
|
||||
});
|
||||
return i18next;
|
||||
}, [props.pageProps.__lang, props.pageProps.__resources]);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={li18n}>
|
||||
|
@ -114,7 +114,6 @@ const GlobalCssTheme = () => {
|
||||
|
||||
const YoshikiDebug = ({ children }: { children: JSX.Element }) => {
|
||||
if (typeof window === "undefined") return children;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const registry = useStyleRegistry();
|
||||
return <StyleRegistryProvider registry={registry}>{children}</StyleRegistryProvider>;
|
||||
};
|
||||
|
@ -24,10 +24,18 @@
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useImportType": "off",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"useEnumInitializers": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off"
|
||||
}
|
||||
},
|
||||
"ignore": [
|
||||
|
@ -10,13 +10,10 @@
|
||||
"build:mobile:apk": "yarn workspace mobile build:apk",
|
||||
"build:mobile:dev": "yarn workspace mobile build:dev",
|
||||
"update": "yarn workspace mobile update",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier -c .",
|
||||
"format:fix": "prettier -w ."
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format .",
|
||||
"format:fix": "biome format . --write"
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"next-env.d.ts"
|
||||
],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
|
@ -49,8 +49,8 @@ export const setCookie = (key: string, val?: unknown) => {
|
||||
const d = new Date();
|
||||
// A year
|
||||
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const expires = value ? "expires=" + d.toUTCString() : "expires=Thu, 01 Jan 1970 00:00:01 GMT";
|
||||
document.cookie = key + "=" + value + ";" + expires + ";path=/;samesite=strict";
|
||||
const expires = value ? `expires=${d.toUTCString()}` : "expires=Thu, 01 Jan 1970 00:00:01 GMT";
|
||||
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
|
||||
return null;
|
||||
};
|
||||
|
||||
@ -65,10 +65,10 @@ export const readCookie = <T extends ZodTypeAny>(
|
||||
const ca = decodedCookie.split(";");
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) == " ") {
|
||||
while (c.charAt(0) === " ") {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
if (c.indexOf(name) === 0) {
|
||||
const str = c.substring(name.length, c.length);
|
||||
return parser ? parser.parse(JSON.parse(str)) : str;
|
||||
}
|
||||
@ -85,7 +85,7 @@ export const addAccount = (account: Account) => {
|
||||
const accounts = readAccounts();
|
||||
|
||||
// Prevent the user from adding the same account twice.
|
||||
if (accounts.find((x) => x.id == account.id)) {
|
||||
if (accounts.find((x) => x.id === account.id)) {
|
||||
updateAccount(account.id, account);
|
||||
return;
|
||||
}
|
||||
@ -106,7 +106,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
|
||||
|
||||
export const updateAccount = (id: string, account: Account) => {
|
||||
const accounts = readAccounts();
|
||||
const idx = accounts.findIndex((x) => x.id == id);
|
||||
const idx = accounts.findIndex((x) => x.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const selected = account.selected;
|
||||
|
@ -72,7 +72,6 @@ export const ConnectionErrorContext = createContext<{
|
||||
setError: (error: KyooErrors) => void;
|
||||
}>({ error: null, loading: true, setError: () => {} });
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
export const AccountProvider = ({
|
||||
children,
|
||||
ssrAccount,
|
||||
@ -115,7 +114,7 @@ export const AccountProvider = ({
|
||||
acc?.map((account) => ({
|
||||
...account,
|
||||
select: () => updateAccount(account.id, { ...account, selected: true }),
|
||||
remove: () => removeAccounts((x) => x.id == x.id),
|
||||
remove: () => removeAccounts((x) => x.id === account.id),
|
||||
})) ?? [],
|
||||
[acc],
|
||||
);
|
||||
@ -151,6 +150,7 @@ export const AccountProvider = ({
|
||||
useEffect(() => {
|
||||
// if the user change account (or connect/disconnect), reset query cache.
|
||||
if (
|
||||
// biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined
|
||||
selected?.id != oldSelected.current?.id ||
|
||||
(userIsError && selected?.token.access_token !== oldSelected.current?.token)
|
||||
) {
|
||||
|
@ -94,7 +94,7 @@ let running: ReturnType<typeof getTokenWJ> | null = null;
|
||||
|
||||
export const getTokenWJ = async (
|
||||
acc?: Account | null,
|
||||
forceRefresh: boolean = false,
|
||||
forceRefresh = false,
|
||||
): Promise<readonly [string, Token, null] | readonly [null, null, KyooErrors | null]> => {
|
||||
if (acc === undefined) acc = getCurrentAccount();
|
||||
if (!acc) return [null, null, null] as const;
|
||||
@ -150,6 +150,7 @@ export const useToken = () => {
|
||||
account ? `${account.token.token_type} ${account.token.access_token}` : null,
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Refresh token when account change
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const nToken = await getTokenWJ();
|
||||
|
@ -66,7 +66,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
|
||||
.join("/")
|
||||
.replace("//", "/")
|
||||
.replace("/?", "?");
|
||||
let resp;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(path, {
|
||||
method: context.method,
|
||||
@ -97,11 +97,11 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
|
||||
if (resp.status === 403 && iToken === undefined && token) {
|
||||
const [newToken, _, error] = await getTokenWJ(undefined, true);
|
||||
if (newToken) return await queryFn(context, type, newToken);
|
||||
else console.error("refresh error while retrying a forbidden", error);
|
||||
console.error("refresh error while retrying a forbidden", error);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const error = await resp.text();
|
||||
let data;
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = JSON.parse(error);
|
||||
} catch (e) {
|
||||
@ -122,7 +122,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
|
||||
|
||||
if ("plainText" in context && context.plainText) return (await resp.text()) as unknown;
|
||||
|
||||
let data;
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
@ -204,11 +204,10 @@ export const toQueryKey = (query: {
|
||||
query.options?.apiUrl,
|
||||
...query.path,
|
||||
query.params
|
||||
? "?" +
|
||||
Object.entries(query.params)
|
||||
? `?${Object.entries(query.params)
|
||||
.filter(([_, v]) => v !== undefined)
|
||||
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
|
||||
.join("&")
|
||||
.join("&")}`
|
||||
: null,
|
||||
].filter((x) => x);
|
||||
};
|
||||
@ -267,12 +266,11 @@ export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string
|
||||
queryFn: (ctx) => queryFn(ctx, Paged(query.parser), authToken),
|
||||
initialPageParam: undefined,
|
||||
});
|
||||
} else {
|
||||
return client.prefetchQuery({
|
||||
queryKey: toQueryKey(query),
|
||||
queryFn: (ctx) => queryFn(ctx, query.parser, authToken),
|
||||
});
|
||||
}
|
||||
return client.prefetchQuery({
|
||||
queryKey: toQueryKey(query),
|
||||
queryFn: (ctx) => queryFn(ctx, query.parser, authToken),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return client;
|
||||
|
@ -196,8 +196,9 @@ export const WatchInfoP = z
|
||||
|
||||
// from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
|
||||
const humanFileSize = (size: number): string => {
|
||||
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
// @ts-ignore I'm not gonna fix stackoverflow's working code.
|
||||
// biome-ignore lint: same as above
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
|
||||
|
@ -24,7 +24,6 @@ import { Platform } from "react-native";
|
||||
|
||||
export const useUserTheme = (ssrTheme?: "light" | "dark" | "auto") => {
|
||||
if (Platform.OS === "web" && typeof window === "undefined" && ssrTheme) return ssrTheme;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [value] = useMMKVString("theme", storage);
|
||||
if (!value) return "auto";
|
||||
return value as "light" | "dark" | "auto";
|
||||
|
@ -38,7 +38,8 @@ export const getDisplayDate = (data: Show | Movie) => {
|
||||
return startAir.getFullYear().toString();
|
||||
}
|
||||
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
|
||||
} else if (airDate) {
|
||||
}
|
||||
if (airDate) {
|
||||
return airDate.getFullYear().toString();
|
||||
}
|
||||
};
|
||||
|
@ -23,6 +23,7 @@
|
||||
import { type AlertButton, type AlertOptions } from "react-native";
|
||||
import Swal, { type SweetAlertIcon } from "sweetalert2";
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: Compatibility with rn
|
||||
export class Alert {
|
||||
static alert(
|
||||
title: string,
|
||||
|
@ -27,14 +27,13 @@ import { ComponentType, forwardRef, RefAttributes } from "react";
|
||||
|
||||
const stringToColor = (string: string) => {
|
||||
let hash = 0;
|
||||
let i;
|
||||
|
||||
for (i = 0; i < string.length; i += 1) {
|
||||
for (let i = 0; i < string.length; i += 1) {
|
||||
hash = string.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += `00${value.toString(16)}`.slice(-2);
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export const Chip = ({
|
||||
|
||||
textProps ??= {};
|
||||
|
||||
const sizeMult = size == "medium" ? 1 : size == "small" ? 0.5 : 1.5;
|
||||
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
@ -33,7 +33,7 @@ export type Props = {
|
||||
src?: KyooImage | null;
|
||||
quality: "low" | "medium" | "high";
|
||||
alt?: string;
|
||||
Error?: ReactElement | null;
|
||||
Err?: ReactElement | null;
|
||||
forcedLoading?: boolean;
|
||||
};
|
||||
|
||||
|
@ -22,17 +22,14 @@ import { decode } from "blurhash";
|
||||
import {
|
||||
HTMLAttributes,
|
||||
ReactElement,
|
||||
createElement,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useYoshiki } from "yoshiki";
|
||||
import { Stylable, nativeStyleToCss } from "yoshiki/native";
|
||||
import { StyleList, processStyleList } from "yoshiki/src/type";
|
||||
import { nativeStyleToCss } from "yoshiki/native";
|
||||
|
||||
// The blurhashToUrl has been stolen from https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff
|
||||
export function blurHashToDataURL(hash: string | undefined): string | undefined {
|
||||
@ -53,7 +50,7 @@ function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) {
|
||||
typeof Buffer !== "undefined"
|
||||
? Buffer.from(getPngArray(pngString)).toString("base64")
|
||||
: btoa(pngString);
|
||||
return "data:image/png;base64," + dataURL;
|
||||
return `data:image/png;base64,${dataURL}`;
|
||||
}
|
||||
|
||||
function getPngArray(pngString: string) {
|
||||
@ -70,6 +67,7 @@ function generatePng(width: number, height: number, rgbaString: string) {
|
||||
const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
|
||||
const NO_FILTER = String.fromCharCode(0);
|
||||
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let n, c, k;
|
||||
|
||||
// make crc table
|
||||
@ -89,7 +87,9 @@ function generatePng(width: number, height: number, rgbaString: string) {
|
||||
function inflateStore(data: string) {
|
||||
const MAX_STORE_LENGTH = 65535;
|
||||
let storeBuffer = "";
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let remaining;
|
||||
// biome-ignore lint: not gonna fix stackowerflow code that works
|
||||
let blockType;
|
||||
|
||||
for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) {
|
||||
@ -113,7 +113,7 @@ function generatePng(width: number, height: number, rgbaString: string) {
|
||||
}
|
||||
|
||||
function adler32(data: string) {
|
||||
let MOD_ADLER = 65521;
|
||||
const MOD_ADLER = 65521;
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
|
||||
@ -179,7 +179,7 @@ function generatePng(width: number, height: number, rgbaString: string) {
|
||||
const IHDR = createIHDR(width, height);
|
||||
|
||||
let scanlines = "";
|
||||
let scanline;
|
||||
let scanline: string;
|
||||
|
||||
for (let y = 0; y < rgbaString.length; y += width * 4) {
|
||||
scanline = NO_FILTER;
|
||||
|
@ -33,7 +33,7 @@ export const Image = ({
|
||||
alt,
|
||||
forcedLoading = false,
|
||||
layout,
|
||||
Error,
|
||||
Err,
|
||||
...props
|
||||
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
|
||||
const { css } = useYoshiki();
|
||||
@ -53,8 +53,8 @@ export const Image = ({
|
||||
|
||||
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
|
||||
if (!src || state === "errored") {
|
||||
return Error !== undefined ? (
|
||||
Error
|
||||
return Err !== undefined ? (
|
||||
Err
|
||||
) : (
|
||||
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ export const Image = ({
|
||||
alt,
|
||||
forcedLoading = false,
|
||||
layout,
|
||||
Error,
|
||||
Err,
|
||||
...props
|
||||
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
|
||||
const { css } = useYoshiki();
|
||||
@ -45,8 +45,8 @@ export const Image = ({
|
||||
|
||||
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
|
||||
if (!src || state === "errored") {
|
||||
return Error !== undefined ? (
|
||||
Error
|
||||
return Err !== undefined ? (
|
||||
Err
|
||||
) : (
|
||||
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
|
||||
);
|
||||
|
@ -111,7 +111,7 @@ export const ImageBackground = <AsProps = ViewProps,>({
|
||||
forcedLoading={forcedLoading}
|
||||
alt={alt!}
|
||||
layout={{ width: percent(100), height: percent(100) }}
|
||||
Error={hideLoad ? null : undefined}
|
||||
Err={hideLoad ? null : undefined}
|
||||
{...(css([{ borderWidth: 0, borderRadius: 0 }, imageStyle]) as {
|
||||
style: ImageStyle;
|
||||
})}
|
||||
|
@ -22,7 +22,7 @@ import { forwardRef, ReactNode } from "react";
|
||||
import { Platform, Pressable, TextProps, View, PressableProps, Linking } from "react-native";
|
||||
import { TextLink, useLink } from "solito/link";
|
||||
import { useTheme, useYoshiki } from "yoshiki/native";
|
||||
import type { UrlObject } from "url";
|
||||
import type { UrlObject } from "node:url";
|
||||
import { alpha } from "./themes";
|
||||
import { parseNextPath } from "solito/router";
|
||||
|
||||
|
@ -66,7 +66,6 @@ const Menu = <AsProps,>({
|
||||
const insets = useSafeAreaInsets();
|
||||
const alreadyRendered = useRef(false);
|
||||
const [isOpen, setOpen] =
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
outerOpen !== undefined && outerSetOpen ? [outerOpen, outerSetOpen] : useState(false);
|
||||
|
||||
// deos the same as a useMemo but for props.
|
||||
|
@ -111,9 +111,7 @@ const selectMode = (
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const auto = useAutomaticTheme("theme", { light, dark });
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const alternate = useAutomaticTheme("alternate", { dark: light, light: dark });
|
||||
return {
|
||||
...options,
|
||||
|
@ -31,7 +31,7 @@ export const Head = ({
|
||||
}) => {
|
||||
return (
|
||||
<NextHead>
|
||||
{title && <title>{title + " - Kyoo"}</title>}
|
||||
{title && <title>{`${title} - Kyoo`}</title>}
|
||||
{description && <meta name="description" content={description} />}
|
||||
{image && <meta property="og:image" content={image} />}
|
||||
</NextHead>
|
||||
|
@ -82,7 +82,7 @@ export const Scanner = () => {
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
{data != null && data.length == 0 && <P>{t("admin.scanner.empty")}</P>}
|
||||
{data != null && data.length === 0 && <P>{t("admin.scanner.empty")}</P>}
|
||||
</>
|
||||
</SettingsContainer>
|
||||
);
|
||||
|
@ -121,14 +121,14 @@ export const BrowseSettings = ({
|
||||
<IconButton
|
||||
icon={GridView}
|
||||
onPress={() => setLayout(Layout.Grid)}
|
||||
color={layout == Layout.Grid ? theme.accent : undefined}
|
||||
color={layout === Layout.Grid ? theme.accent : undefined}
|
||||
{...tooltip(t("browse.switchToGrid"))}
|
||||
{...css({ padding: ts(0.5), marginY: "auto" })}
|
||||
/>
|
||||
<IconButton
|
||||
icon={ViewList}
|
||||
onPress={() => setLayout(Layout.List)}
|
||||
color={layout == Layout.List ? theme.accent : undefined}
|
||||
color={layout === Layout.List ? theme.accent : undefined}
|
||||
{...tooltip(t("browse.switchToList"))}
|
||||
{...css({ padding: ts(0.5), marginY: "auto" })}
|
||||
/>
|
||||
|
@ -35,15 +35,14 @@ const MediaInfoTable = ({
|
||||
const { css } = useYoshiki();
|
||||
const formatBitrate = (b: number) => `${(b / 1000000).toFixed(2)} Mbps`;
|
||||
const formatTrackTable = (trackTable: (Audio | Subtitle)[], s: string) => {
|
||||
if (trackTable.length == 0) {
|
||||
if (trackTable.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const singleTrack = trackTable.length == 1;
|
||||
const singleTrack = trackTable.length === 1;
|
||||
return trackTable.reduce(
|
||||
(collected, audioTrack, index) => ({
|
||||
...collected,
|
||||
(collected, audioTrack, index) => {
|
||||
// If there is only one track, we do not need to show an index
|
||||
[singleTrack ? t(s) : `${t(s)} ${index + 1}`]: [
|
||||
collected[singleTrack ? t(s) : `${t(s)} ${index + 1}`] = [
|
||||
audioTrack.displayName,
|
||||
// Only show it if there is more than one track
|
||||
audioTrack.isDefault && !singleTrack ? t("mediainfo.default") : undefined,
|
||||
@ -51,8 +50,9 @@ const MediaInfoTable = ({
|
||||
audioTrack.codec,
|
||||
]
|
||||
.filter((x) => x !== undefined)
|
||||
.join(" - "),
|
||||
}),
|
||||
.join(" - ");
|
||||
return collected;
|
||||
},
|
||||
{} as Record<string, string | undefined>,
|
||||
);
|
||||
};
|
||||
@ -94,7 +94,7 @@ const MediaInfoTable = ({
|
||||
<Skeleton>{value ? <P>{value}</P> : undefined}</Skeleton>
|
||||
</View>
|
||||
</View>
|
||||
{index == l.length - 1 && <HR />}
|
||||
{index === l.length - 1 && <HR />}
|
||||
</Fragment>
|
||||
)),
|
||||
)}
|
||||
|
@ -295,6 +295,7 @@ export const EpisodeLine = ({
|
||||
>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
|
||||
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
|
||||
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
|
||||
</H6>
|
||||
|
@ -42,7 +42,7 @@ import { KyooImage } from "@kyoo/models";
|
||||
import { Atom, useAtomValue } from "jotai";
|
||||
import DownloadForOffline from "@material-symbols/svg-400/rounded/download_for_offline.svg";
|
||||
import Downloading from "@material-symbols/svg-400/rounded/downloading.svg";
|
||||
import Error from "@material-symbols/svg-400/rounded/error.svg";
|
||||
import ErrorIcon from "@material-symbols/svg-400/rounded/error.svg";
|
||||
import NotStarted from "@material-symbols/svg-400/rounded/not_started.svg";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
@ -126,6 +126,7 @@ const DownloadedItem = ({
|
||||
})}
|
||||
>
|
||||
<View {...css({ flexGrow: 1, flexShrink: 1 })}>
|
||||
{/* biome-ignore lint/a11y/useValidAriaValues: use h6 for style only */}
|
||||
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
|
||||
{name ?? t("show.episodeNoMetadata")}
|
||||
</H6>
|
||||
@ -193,10 +194,9 @@ const downloadIcon = (status: State["status"]) => {
|
||||
case "DOWNLOADING":
|
||||
return Downloading;
|
||||
case "FAILED":
|
||||
return Error;
|
||||
return ErrorIcon;
|
||||
case "PAUSED":
|
||||
case "STOPPED":
|
||||
default:
|
||||
return NotStarted;
|
||||
}
|
||||
};
|
||||
|
@ -38,7 +38,7 @@ import { Player } from "../player";
|
||||
import { atom, useSetAtom, PrimitiveAtom, useStore } from "jotai";
|
||||
import { getCurrentAccount, storage } from "@kyoo/models/src/account-internal";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { Platform, ToastAndroid } from "react-native";
|
||||
import { ToastAndroid } from "react-native";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { Router } from "expo-router/build/types";
|
||||
import { z } from "zod";
|
||||
@ -257,7 +257,7 @@ export const DownloadProvider = ({ children }: { children: ReactNode }) => {
|
||||
const dls: { data: Episode | Movie; info: WatchInfo; path: string; state: State }[] =
|
||||
JSON.parse(storage.getString("downloads") ?? "[]");
|
||||
const downloads = dls.map((dl) => {
|
||||
const t = tasks.find((x) => x.id == dl.data.id);
|
||||
const t = tasks.find((x) => x.id === dl.data.id);
|
||||
if (t) return setupDownloadTask(dl, t, store, queryClient);
|
||||
|
||||
const stateAtom = atom({
|
||||
|
@ -35,7 +35,7 @@ export const ConnectionError = () => {
|
||||
const { error, retry } = useContext(ConnectionErrorContext);
|
||||
const account = useAccount();
|
||||
|
||||
if (error && (error.status === 401 || error.status == 403)) {
|
||||
if (error && (error.status === 401 || error.status === 403)) {
|
||||
if (!account) {
|
||||
return (
|
||||
<View
|
||||
|
@ -36,7 +36,7 @@ export const ErrorView = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// if this is a permission error, make it go up the tree to have a whole page login screen.
|
||||
if (!noBubble && (error.status === 401 || error.status == 403)) setError(error);
|
||||
if (!noBubble && (error.status === 401 || error.status === 403)) setError(error);
|
||||
}, [error, noBubble, setError]);
|
||||
console.log(error);
|
||||
return (
|
||||
|
@ -78,6 +78,7 @@ const InfiniteScroll = <Props,>({
|
||||
|
||||
// Automatically trigger a scroll check on start and after a fetch end in case the user is already
|
||||
// at the bottom of the page or if there is no scroll bar (ultrawide or something like that)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Check for scroll pause after fetch ends
|
||||
useEffect(() => {
|
||||
onScroll();
|
||||
}, [isFetching, onScroll]);
|
||||
@ -92,13 +93,13 @@ const InfiniteScroll = <Props,>({
|
||||
// the as any is due to differencies between css types of native and web (already accounted for in yoshiki)
|
||||
gridGap: layout.gap as any,
|
||||
},
|
||||
layout.layout == "vertical" && {
|
||||
layout.layout === "vertical" && {
|
||||
gridTemplateColumns: "1fr",
|
||||
alignItems: "stretch",
|
||||
overflowY: "auto",
|
||||
paddingY: layout.gap as any,
|
||||
},
|
||||
layout.layout == "horizontal" && {
|
||||
layout.layout === "horizontal" && {
|
||||
alignItems: "stretch",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
|
@ -29,9 +29,9 @@ import { DefaultLayout } from "../layout";
|
||||
|
||||
export const cleanApiUrl = (apiUrl: string) => {
|
||||
if (Platform.OS === "web") return undefined;
|
||||
if (!/https?:\/\//.test(apiUrl)) apiUrl = "http://" + apiUrl;
|
||||
if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`;
|
||||
apiUrl = apiUrl.replace(/\/$/, "");
|
||||
return apiUrl + "/api";
|
||||
return `${apiUrl}/api`;
|
||||
};
|
||||
|
||||
const query: QueryIdentifier<ServerInfo> = {
|
||||
|
@ -168,7 +168,7 @@ const VolumeSlider = () => {
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
isMuted || volume == 0
|
||||
isMuted || volume === 0
|
||||
? VolumeOff
|
||||
: volume < 25
|
||||
? VolumeMute
|
||||
@ -204,7 +204,12 @@ const ProgressText = (props: Stylable) => {
|
||||
|
||||
export const toTimerString = (timer?: number, duration?: number) => {
|
||||
if (!duration) duration = timer;
|
||||
if (timer === undefined || duration === undefined || isNaN(duration) || isNaN(timer))
|
||||
if (
|
||||
timer === undefined ||
|
||||
duration === undefined ||
|
||||
Number.isNaN(duration) ||
|
||||
Number.isNaN(timer)
|
||||
)
|
||||
return "??:??";
|
||||
const h = Math.floor(timer / 3600);
|
||||
const min = Math.floor((timer / 60) % 60);
|
||||
|
@ -41,7 +41,12 @@ type Thumb = {
|
||||
|
||||
const parseTs = (time: string) => {
|
||||
const times = time.split(":");
|
||||
return (parseInt(times[0]) * 3600 + parseInt(times[1]) * 60 + parseFloat(times[2])) * 1000;
|
||||
return (
|
||||
(Number.parseInt(times[0]) * 3600 +
|
||||
Number.parseInt(times[1]) * 60 +
|
||||
Number.parseFloat(times[2])) *
|
||||
1000
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrubber = (url: string) => {
|
||||
@ -64,7 +69,7 @@ export const useScrubber = (url: string) => {
|
||||
for (let i = 0; i < ret.length; i++) {
|
||||
const times = lines[i * 2].split(" --> ");
|
||||
const url = lines[i * 2 + 1].split("#xywh=");
|
||||
const xywh = url[1].split(",").map((x) => parseInt(x));
|
||||
const xywh = url[1].split(",").map((x) => Number.parseInt(x));
|
||||
ret[i] = {
|
||||
from: parseTs(times[0]),
|
||||
to: parseTs(times[1]),
|
||||
@ -164,7 +169,7 @@ export const BottomScrubber = ({ url, chapters }: { url: string; chapters?: Chap
|
||||
<View {...css({ overflow: "hidden" })}>
|
||||
<View
|
||||
{...(Platform.OS === "web"
|
||||
? css({ transform: `translateX(50%)` })
|
||||
? css({ transform: "translateX(50%)" })
|
||||
: {
|
||||
// react-native does not support translateX by percentage so we simulate it
|
||||
style: { transform: [{ translateX: scrubberWidth / 2 }] },
|
||||
|
@ -124,13 +124,11 @@ export const Player = ({
|
||||
title={
|
||||
data.type === "movie"
|
||||
? data.name
|
||||
: data.show!.name +
|
||||
" " +
|
||||
episodeDisplayNumber({
|
||||
: `${data.show!.name} ${episodeDisplayNumber({
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
absoluteNumber: data.absoluteNumber,
|
||||
})
|
||||
})}`
|
||||
}
|
||||
description={data.overview}
|
||||
/>
|
||||
|
@ -68,7 +68,7 @@ export const reducerAtom = atom(null, (get, set, action: Action) => {
|
||||
case "volume":
|
||||
set(volumeAtom, Math.max(0, Math.min(get(volumeAtom) + action.value, 100)));
|
||||
break;
|
||||
case "subtitle":
|
||||
case "subtitle": {
|
||||
const subtitle = get(subtitleAtom);
|
||||
const index = subtitle ? action.subtitles.findIndex((x) => x.index === subtitle.index) : -1;
|
||||
set(
|
||||
@ -76,6 +76,7 @@ export const reducerAtom = atom(null, (get, set, action: Action) => {
|
||||
index === -1 ? null : action.subtitles[(index + 1) % action.subtitles.length],
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -143,22 +143,24 @@ export const Video = memo(function Video({
|
||||
const account = useAccount();
|
||||
const defaultSubLanguage = account?.settings.subtitleLanguage;
|
||||
const setSubtitle = useSetAtom(subtitleAtom);
|
||||
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
||||
useEffect(() => {
|
||||
if (!subtitles) return;
|
||||
setSubtitle((subtitle) => {
|
||||
const subRet = subtitle ? subtitles.find((x) => x.language === subtitle.language) : null;
|
||||
if (subRet) return subRet;
|
||||
if (!defaultSubLanguage) return null;
|
||||
if (defaultSubLanguage == "default") return subtitles.find((x) => x.isDefault) ?? null;
|
||||
if (defaultSubLanguage === "default") return subtitles.find((x) => x.isDefault) ?? null;
|
||||
return subtitles.find((x) => x.language === defaultSubLanguage) ?? null;
|
||||
});
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// Also include the player ref, it can be initalised after the subtitles.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subtitles, setSubtitle, defaultSubLanguage, ref.current]);
|
||||
|
||||
const defaultAudioLanguage = account?.settings.audioLanguage ?? "default";
|
||||
const setAudio = useSetAtom(audioAtom);
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
||||
useEffect(() => {
|
||||
if (!audios) return;
|
||||
setAudio((audio) => {
|
||||
@ -172,9 +174,6 @@ export const Video = memo(function Video({
|
||||
}
|
||||
return audios.find((x) => x.isDefault) ?? audios[0];
|
||||
});
|
||||
// When the video change, try to persist the subtitle language.
|
||||
// Also include the player ref, it can be initalised after the subtitles.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audios, setAudio, defaultAudioLanguage, ref.current]);
|
||||
|
||||
const volume = useAtomValue(volumeAtom);
|
||||
@ -225,7 +224,7 @@ export const Video = memo(function Video({
|
||||
label: t("player.unsupportedError"),
|
||||
duration: 3,
|
||||
});
|
||||
if (mode == PlayMode.Direct) setPlayMode(PlayMode.Hls);
|
||||
if (mode === PlayMode.Direct) setPlayMode(PlayMode.Hls);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -33,9 +33,9 @@ declare module "react-native-video" {
|
||||
|
||||
export * from "react-native-video";
|
||||
|
||||
import { Audio, Subtitle, getToken, useToken } from "@kyoo/models";
|
||||
import { Audio, Subtitle, useToken } from "@kyoo/models";
|
||||
import { IconButton, Menu } from "@kyoo/primitives";
|
||||
import { ComponentProps, forwardRef, useEffect, useRef } from "react";
|
||||
import { ComponentProps, forwardRef, useEffect, } from "react";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import NativeVideo, {
|
||||
VideoRef,
|
||||
@ -160,13 +160,13 @@ export const QualitiesMenu = (props: CustomMenu) => {
|
||||
<Menu {...props}>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={mode == PlayMode.Direct}
|
||||
selected={mode === PlayMode.Direct}
|
||||
onSelect={() => setPlayMode(PlayMode.Direct)}
|
||||
/>
|
||||
<Menu.Item
|
||||
// TODO: Display the currently selected quality (impossible with rn-video right now)
|
||||
label={t("player.auto")}
|
||||
selected={video === -1 && mode == PlayMode.Hls}
|
||||
selected={video === -1 && mode === PlayMode.Hls}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
setVideo(-1);
|
||||
|
@ -47,13 +47,13 @@ function uuidv4(): string {
|
||||
);
|
||||
}
|
||||
|
||||
let client_id = typeof window === "undefined" ? "ssr" : uuidv4();
|
||||
const client_id = typeof window === "undefined" ? "ssr" : uuidv4();
|
||||
|
||||
const initHls = (): Hls => {
|
||||
if (hls !== null) return hls;
|
||||
const loadPolicy: LoadPolicy = {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: Infinity,
|
||||
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: {
|
||||
maxNumRetry: 2,
|
||||
@ -74,14 +74,14 @@ const initHls = (): Hls => {
|
||||
xhr.setRequestHeader("X-CLIENT-ID", client_id);
|
||||
},
|
||||
autoStartLoad: false,
|
||||
startLevel: Infinity,
|
||||
startLevel: Number.POSITIVE_INFINITY,
|
||||
abrEwmaDefaultEstimate: 35_000_000,
|
||||
abrEwmaDefaultEstimateMax: 50_000_000,
|
||||
// debug: true,
|
||||
lowLatencyMode: false,
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: Infinity,
|
||||
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: {
|
||||
maxNumRetry: 5,
|
||||
@ -148,6 +148,7 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
useSubtitle(ref, subtitle, fonts);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: onError changes should not restart the playback.
|
||||
useLayoutEffect(() => {
|
||||
if (!ref?.current || !source.uri) return;
|
||||
if (!hls || oldHls.current !== source.hls) {
|
||||
@ -172,12 +173,11 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
});
|
||||
});
|
||||
}
|
||||
// onError changes should not restart the playback.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [source.uri, source.hls]);
|
||||
|
||||
const mode = useAtomValue(playModeAtom);
|
||||
const audio = useAtomValue(audioAtom);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: also change when the mode change
|
||||
useEffect(() => {
|
||||
if (!hls) return;
|
||||
const update = () => {
|
||||
@ -389,12 +389,12 @@ export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => {
|
||||
<Menu {...props}>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={hls === null || mode == PlayMode.Direct}
|
||||
selected={hls === null || mode === PlayMode.Direct}
|
||||
onSelect={() => setPlayMode(PlayMode.Direct)}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={
|
||||
hls != null && hls.autoLevelEnabled && hls.currentLevel >= 0
|
||||
hls?.autoLevelEnabled && hls.currentLevel >= 0
|
||||
? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})`
|
||||
: t("player.auto")
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ export const WatchStatusObserver = ({
|
||||
await queryClient.invalidateQueries({ queryKey: [type === "episode" ? "show" : type, slug] }),
|
||||
});
|
||||
const mutate = useCallback(
|
||||
(seconds: number) =>
|
||||
(type: string, slug: string, seconds: number) =>
|
||||
_mutate({
|
||||
method: "POST",
|
||||
path: [type, slug, "watchStatus"],
|
||||
@ -52,7 +52,7 @@ export const WatchStatusObserver = ({
|
||||
percent: Math.round((seconds / duration) * 100),
|
||||
},
|
||||
}),
|
||||
[_mutate, type, slug, duration],
|
||||
[_mutate, duration],
|
||||
);
|
||||
const readProgress = useAtomCallback(
|
||||
useCallback((get) => {
|
||||
@ -65,19 +65,20 @@ export const WatchStatusObserver = ({
|
||||
useEffect(() => {
|
||||
if (!account) return;
|
||||
const timer = setInterval(() => {
|
||||
mutate(readProgress());
|
||||
mutate(type, slug, readProgress());
|
||||
}, 10_000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
mutate(readProgress());
|
||||
mutate(type, slug, readProgress());
|
||||
};
|
||||
}, [account, type, slug, readProgress, mutate]);
|
||||
|
||||
// update watch status when play status change (and on mount).
|
||||
const isPlaying = useAtomValue(playAtom);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Include isPlaying
|
||||
useEffect(() => {
|
||||
if (!account) return;
|
||||
mutate(readProgress());
|
||||
mutate(type, slug, readProgress());
|
||||
}, [account, type, slug, isPlaying, readProgress, mutate]);
|
||||
return null;
|
||||
};
|
||||
|
@ -44,7 +44,7 @@ const query = (
|
||||
params: {
|
||||
q: query,
|
||||
sortBy:
|
||||
sortKey && sortKey != SearchSort.Relevance ? `${sortKey}:${sortOrd ?? "asc"}` : undefined,
|
||||
sortKey && sortKey !== SearchSort.Relevance ? `${sortKey}:${sortOrd ?? "asc"}` : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -40,7 +40,7 @@ function dataURItoBlob(dataURI: string) {
|
||||
const byteString = atob(dataURI.split(",")[1]);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (var i = 0; i < byteString.length; i++) {
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([ab], { type: "image/jpeg" });
|
||||
|
@ -31,7 +31,7 @@ import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-f
|
||||
|
||||
// I gave up on finding a way to retrive this using the Intl api (probably does not exist)
|
||||
// Simply copy pasted the list of languages from https://www.localeplanet.com/api/codelist.json
|
||||
// prettier-ignore
|
||||
// biome-ignore format: way too long
|
||||
const allLanguages = ["af", "agq", "ak", "am", "ar", "as", "asa", "ast", "az", "bas", "be", "bem", "bez", "bg", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ccp", "ce", "cgg", "chr", "ckb", "cs", "cy", "da", "dav", "de", "dje", "dsb", "dua", "dyo", "dz", "ebu", "ee", "el", "en", "eo", "es", "et", "eu", "ewo", "fa", "ff", "fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guz", "gv", "ha", "haw", "he", "hi", "hr", "hsb", "hu", "hy", "id", "ig", "ii", "is", "it", "ja", "jgo", "jmc", "ka", "kab", "kam", "kde", "kea", "khq", "ki", "kk", "kkj", "kl", "kln", "km", "kn", "ko", "kok", "ks", "ksb", "ksf", "ksh", "kw", "ky", "lag", "lb", "lg", "lkt", "ln", "lo", "lrc", "lt", "lu", "luo", "luy", "lv", "mas", "mer", "mfe", "mg", "mgh", "mgo", "mk", "ml", "mn", "mr", "ms", "mt", "mua", "my", "mzn", "naq", "nb", "nd", "nds", "ne", "nl", "nmg", "nn", "nnh", "nus", "nyn", "om", "or", "os", "pa", "pl", "ps", "pt", "qu", "rm", "rn", "ro", "rof", "ru", "rw", "rwk", "sah", "saq", "sbp", "se", "seh", "ses", "sg", "shi", "si", "sk", "sl", "smn", "sn", "so", "sq", "sr", "sv", "sw", "ta", "te", "teo", "tg", "th", "ti", "to", "tr", "tt", "twq", "tzm", "ug", "uk", "ur", "uz", "vai", "vi", "vun", "wae", "wo", "xog", "yav", "yi", "yo", "yue", "zgh", "zh", "zu",];
|
||||
|
||||
export const PlaybackSettings = () => {
|
||||
|
1560
front/yarn.lock
1560
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user