Rework settings page

This commit is contained in:
2025-11-09 16:34:26 +01:00
parent 39cfd501ac
commit 6bb905b388
15 changed files with 328 additions and 358 deletions

View File

@@ -1,128 +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 {
type QueryIdentifier,
type ServerInfo,
ServerInfoP,
queryFn,
useAccount,
useFetch,
} from "@kyoo/models";
import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { ImageBackground } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../../../../src/ui/errors";
import { Preference, SettingsContainer } from "./base";
import Badge from "@material-symbols/svg-400/outlined/badge.svg";
import Remove from "@material-symbols/svg-400/outlined/close.svg";
import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export const OidcSettings = () => {
const account = useAccount()!;
const { css } = useYoshiki();
const { t } = useTranslation();
const { data, error } = useFetch(OidcSettings.query());
const queryClient = useQueryClient();
const { mutateAsync: unlinkAccount } = useMutation({
mutationFn: async (provider: string) =>
await queryFn({
path: ["auth", "login", provider],
method: "DELETE",
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
});
return (
<SettingsContainer title={t("settings.oidc.label")}>
{error ? (
<ErrorView error={error} />
) : data ? (
Object.entries(data.oidc).map(([id, x]) => {
const acc = account.externalId[id];
return (
<Preference
key={x.displayName}
icon={Badge}
label={x.displayName}
description={
acc
? t("settings.oidc.connected", { username: acc.username })
: t("settings.oidc.not-connected")
}
customIcon={
x.logoUrl != null && (
<ImageBackground
source={{ uri: x.logoUrl }}
{...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
/>
)
}
>
{acc ? (
<>
{acc.profileUrl && (
<IconButton
icon={OpenProfile}
as={Link}
href={acc.profileUrl}
target="_blank"
{...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
/>
)}
<IconButton
icon={Remove}
onPress={() => unlinkAccount(id)}
{...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
/>
</>
) : (
<Button
text={t("settings.oidc.link")}
as={Link}
href={x.link}
{...css({ minWidth: rem(6) })}
/>
)}
</Preference>
);
})
) : (
[...Array(3)].map((_, i) => (
<Preference
key={i}
customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
icon={null!}
label={<Skeleton {...css({ width: rem(6) })} />}
description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
/>
))
)}
</SettingsContainer>
);
};
OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
path: ["info"],
parser: ServerInfoP,
});

View File

@@ -0,0 +1,3 @@
import { SettingsPage } from "~/ui/settings";
export default SettingsPage;

View File

@@ -2,7 +2,7 @@ import { Stack } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "yoshiki/native";
import { ErrorConsumer } from "~/providers/error-consumer";
import { NavbarTitle } from "~/ui/navbar";
import { NavbarProfile, NavbarTitle } from "~/ui/navbar";
export default function Layout() {
const insets = useSafeAreaInsets();
@@ -13,6 +13,7 @@ export default function Layout() {
<Stack
screenOptions={{
headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarProfile />,
contentStyle: {
paddingLeft: insets.left,
paddingRight: insets.right,

View File

@@ -2,32 +2,37 @@ import { z } from "zod/v4";
export const User = z
.object({
// // keep a default for older versions of the api
// .default({}),
id: z.string(),
username: z.string(),
email: z.string(),
// permissions: z.array(z.string()),
claims: z.object({
permissions: z.array(z.string()),
// hasPassword: z.boolean().default(true),
// settings: z
// .object({
// downloadQuality: z
// .union([
// z.literal("original"),
// z.literal("8k"),
// z.literal("4k"),
// z.literal("1440p"),
// z.literal("1080p"),
// z.literal("720p"),
// z.literal("480p"),
// z.literal("360p"),
// z.literal("240p"),
// ])
// .default("original")
// .catch("original"),
// audioLanguage: z.string().default("default").catch("default"),
// subtitleLanguage: z.string().nullable().default(null).catch(null),
// })
// // keep a default for older versions of the api
// .default({}),
settings: z
.object({
downloadQuality: z
.union([
z.literal("original"),
z.literal("8k"),
z.literal("4k"),
z.literal("1440p"),
z.literal("1080p"),
z.literal("720p"),
z.literal("480p"),
z.literal("360p"),
z.literal("240p"),
])
.catch("original"),
audioLanguage: z.string().catch("default"),
subtitleLanguage: z.string().nullable().catch(null),
})
.default({
downloadQuality: "original",
audioLanguage: "default",
subtitleLanguage: null,
}),
// externalId: z
// .record(
// z.string(),
@@ -38,6 +43,7 @@ export const User = z
// }),
// )
// .default({}),
}),
})
.transform((x) => ({
...x,

View File

@@ -1,23 +1,3 @@
/*
* 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/>.
*/
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import type { AlertButton, AlertOptions } from "react-native";

View File

@@ -1,4 +1,6 @@
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
// export * from "./snackbar";
export * from "./alert";
export * from "./avatar";
export * from "./button";
export * from "./chip";
@@ -9,11 +11,9 @@ export * from "./image";
export * from "./image-background";
export * from "./input";
export * from "./links";
// export * from "./snackbar";
// export * from "./alert";
export * from "./menu";
export * from "./popup";
export * from "./progress";
// export * from "./popup";
export * from "./select";
export * from "./skeleton";
export * from "./slider";

View File

@@ -1,30 +1,9 @@
/*
* 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 { usePortal } from "@gorhom/portal";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ScrollView, View } from "react-native";
import { px, vh } from "yoshiki/native";
import { imageBorderRadius } from "./constants";
import { Container } from "./container";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./theme";
import { ts } from "./utils";
export const Popup = ({
@@ -52,7 +31,7 @@ export const Popup = ({
<Container
{...css(
{
borderRadius: px(imageBorderRadius),
borderRadius: px(6),
paddingHorizontal: 0,
bg: (theme) => theme.background,
overflow: "hidden",

View File

@@ -1,5 +1,6 @@
import { PortalProvider } from "@gorhom/portal";
import type { ReactNode } from "react";
export const NativeProviders = ({ children }: { children: ReactNode }) => {
return children;
return <PortalProvider>{children}</PortalProvider>;
};

View File

@@ -268,7 +268,7 @@ type MutationParams = {
body?: object;
};
export const useMutation = <T = void, QueryRet>({
export const useMutation = <T = void, QueryRet = void>({
compute,
invalidate,
optimistic,

View File

@@ -1,81 +1,67 @@
/*
* 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 {
type Account,
type KyooErrors,
deleteAccount,
logout,
queryFn,
useAccount,
} from "@kyoo/models";
import { Alert, Avatar, Button, H1, Icon, Input, P, Popup, ts, usePopup } from "@kyoo/primitives";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as ImagePicker from "expo-image-picker";
import Username from "@material-symbols/svg-400/outlined/badge.svg";
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
// import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
// import * as ImagePicker from "expo-image-picker";
import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
import { PasswordInput } from "../../../../src/ui/login/password-input";
import type { KyooError, User } from "~/models";
import {
Alert,
Button,
H1,
Icon,
Input,
P,
Popup,
ts,
usePopup,
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { deleteAccount, logout } from "../login/logic";
import { PasswordInput } from "../login/password-input";
import { Preference, SettingsContainer } from "./base";
import { useMutation } from "~/query";
import Username from "@material-symbols/svg-400/outlined/badge.svg";
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
function dataURItoBlob(dataURI: string) {
const byteString = atob(dataURI.split(",")[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: "image/jpeg" });
}
// function dataURItoBlob(dataURI: string) {
// const byteString = atob(dataURI.split(",")[1]);
// const ab = new ArrayBuffer(byteString.length);
// const ia = new Uint8Array(ab);
// for (let i = 0; i < byteString.length; i++) {
// ia[i] = byteString.charCodeAt(i);
// }
// return new Blob([ab], { type: "image/jpeg" });
// }
export const AccountSettings = () => {
const account = useAccount()!;
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const [setPopup, close] = usePopup();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<Account>) =>
await queryFn({
path: ["auth", "me"],
method: "PATCH",
body: update,
path: ["auth", "users", "me"],
compute: (update: Partial<User>) => ({ body: update }),
optimistic: (update) => ({
...account,
...update,
claims: { ...account.claims, ...update.claims },
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
invalidate: ["auth", "users", "me"],
});
const { mutateAsync: editPassword } = useMutation({
mutationFn: async (request: { newPassword: string; oldPassword: string }) =>
await queryFn({
path: ["auth", "password-reset"],
method: "POST",
body: request,
method: "PATCH",
path: ["auth", "users", "me", "password"],
compute: (body: { oldPassword: string; newPassword: string }) => ({
body,
}),
invalidate: null,
});
return (
@@ -106,7 +92,8 @@ export const AccountSettings = () => {
],
{
cancelable: true,
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode,
userInterfaceStyle:
theme.mode === "auto" ? "light" : theme.mode,
icon: "warning",
},
);
@@ -137,43 +124,47 @@ export const AccountSettings = () => {
}
/>
</Preference>
{/* <Preference */}
{/* icon={AccountCircle} */}
{/* customIcon={<Avatar src={account.logo} />} */}
{/* label={t("settings.account.avatar.label")} */}
{/* description={t("settings.account.avatar.description")} */}
{/* > */}
{/* <Button */}
{/* text={t("misc.edit")} */}
{/* onPress={async () => { */}
{/* const img = await ImagePicker.launchImageLibraryAsync({ */}
{/* mediaTypes: ImagePicker.MediaTypeOptions.Images, */}
{/* aspect: [1, 1], */}
{/* quality: 1, */}
{/* base64: true, */}
{/* }); */}
{/* if (img.canceled || img.assets.length !== 1) return; */}
{/* const data = dataURItoBlob(img.assets[0].uri); */}
{/* const formData = new FormData(); */}
{/* formData.append("picture", data); */}
{/* await queryFn({ */}
{/* method: "POST", */}
{/* path: ["auth", "me", "logo"], */}
{/* formData, */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* <Button */}
{/* text={t("misc.delete")} */}
{/* onPress={async () => { */}
{/* await queryFn({ */}
{/* method: "DELETE", */}
{/* path: ["auth", "me", "logo"], */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* </Preference> */}
<Preference
icon={AccountCircle}
customIcon={<Avatar src={account.logo} />}
label={t("settings.account.avatar.label")}
description={t("settings.account.avatar.description")}
icon={Mail}
label={t("settings.account.email.label")}
description={account.email}
>
<Button
text={t("misc.edit")}
onPress={async () => {
const img = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
aspect: [1, 1],
quality: 1,
base64: true,
});
if (img.canceled || img.assets.length !== 1) return;
const data = dataURItoBlob(img.assets[0].uri);
const formData = new FormData();
formData.append("picture", data);
await queryFn({
method: "POST",
path: ["auth", "me", "logo"],
formData,
});
}}
/>
<Button
text={t("misc.delete")}
onPress={async () => {
await queryFn({
method: "DELETE",
path: ["auth", "me", "logo"],
});
}}
/>
</Preference>
<Preference icon={Mail} label={t("settings.account.email.label")} description={account.email}>
<Button
text={t("misc.edit")}
onPress={() =>
@@ -202,8 +193,10 @@ export const AccountSettings = () => {
<ChangePasswordPopup
icon={Password}
label={t("settings.account.password.label")}
hasPassword={account.hasPassword}
apply={async (op, np) => await editPassword({ oldPassword: op, newPassword: np })}
hasPassword={true}
apply={async (op, np) =>
await editPassword({ oldPassword: op, newPassword: np })
}
close={close}
/>,
)
@@ -236,7 +229,9 @@ const ChangePopup = ({
<Popup>
{({ css }) => (
<>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
<View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View>
@@ -246,7 +241,13 @@ const ChangePopup = ({
value={value}
onChangeText={(v) => setValue(v)}
/>
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}>
<View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button
text={t("misc.cancel")}
onPress={() => close()}
@@ -289,7 +290,9 @@ const ChangePasswordPopup = ({
<Popup>
{({ css }) => (
<>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
<View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View>
@@ -303,14 +306,22 @@ const ChangePasswordPopup = ({
/>
)}
<PasswordInput
autoComplete="password-new"
autoComplete="new-password"
variant="big"
value={newValue}
onChangeText={(v) => setNewValue(v)}
placeholder={t("settings.account.password.newPassword")}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}>
{error && (
<P {...css({ color: (theme) => theme.colors.red })}>{error}</P>
)}
<View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button
text={t("misc.cancel")}
onPress={() => close()}
@@ -323,7 +334,7 @@ const ChangePasswordPopup = ({
await apply(oldValue, newValue);
close();
} catch (e) {
setError((e as KyooErrors).errors[0]);
setError((e as KyooError).message);
}
}}
{...css({ minWidth: rem(6) })}

View File

@@ -115,26 +115,37 @@ export const SettingsContainer = ({
);
};
export const useSetting = <Setting extends keyof User["settings"]>(
export const useSetting = <Setting extends keyof User["claims"]["settings"]>(
setting: Setting,
) => {
const account = useAccount();
const { mutateAsync } = useMutation({
method: "PATCH",
path: ["auth", "me"],
compute: (update: Partial<User["settings"]>) => ({
body: { settings: { ...account!.settings, ...update } },
path: ["auth", "users", "me"],
compute: (update: Partial<User["claims"]["settings"]>) => ({
body: {
claims: {
...account!.claims,
settings: { ...account!.claims.settings, ...update },
},
},
}),
optimistic: (update) => ({
body: { ...account, settings: { ...account!.settings, ...update } },
body: {
...account,
claims: {
...account!.claims,
settings: { ...account!.claims.settings, ...update },
},
},
}),
invalidate: ["auth", "me"],
invalidate: ["auth", "users", "me"],
});
if (!account) return null;
return [
account.settings[setting],
async (value: User["settings"][Setting]) => {
account.claims.settings[setting],
async (value: User["claims"]["settings"][Setting]) => {
await mutateAsync({ [setting]: value });
},
] as const;

View File

@@ -1,4 +1,4 @@
import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
// import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
import Language from "@material-symbols/svg-400/outlined/language.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg";

View File

@@ -1,18 +1,18 @@
import { ScrollView } from "react-native";
import { ts } from "~/primitives";
import { useAccount } from "~/providers/account-context";
// import { AccountSettings } from "./account";
import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
// import { OidcSettings } from "./oidc";
// import { PlaybackSettings } from "./playback";
import { PlaybackSettings } from "./playback";
export const SettingsPage = () => {
const account = useAccount();
return (
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
<GeneralSettings />
{/* {account && <PlaybackSettings />} */}
{/* {account && <AccountSettings />} */}
{account && <PlaybackSettings />}
{account && <AccountSettings />}
{/* {account && <OidcSettings />} */}
<About />
</ScrollView>

View File

@@ -0,0 +1,108 @@
// import {
// type QueryIdentifier,
// type ServerInfo,
// ServerInfoP,
// queryFn,
// useAccount,
// useFetch,
// } from "@kyoo/models";
// import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
// import { useTranslation } from "react-i18next";
// import { ImageBackground } from "react-native";
// import { rem, useYoshiki } from "yoshiki/native";
// import { ErrorView } from "../errors";
// import { Preference, SettingsContainer } from "./base";
//
// import Badge from "@material-symbols/svg-400/outlined/badge.svg";
// import Remove from "@material-symbols/svg-400/outlined/close.svg";
// import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
// import { useMutation, useQueryClient } from "@tanstack/react-query";
//
// export const OidcSettings = () => {
// const account = useAccount()!;
// const { css } = useYoshiki();
// const { t } = useTranslation();
// const { data, error } = useFetch(OidcSettings.query());
// const queryClient = useQueryClient();
// const { mutateAsync: unlinkAccount } = useMutation({
// mutationFn: async (provider: string) =>
// await queryFn({
// path: ["auth", "login", provider],
// method: "DELETE",
// }),
// onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
// });
//
// return (
// <SettingsContainer title={t("settings.oidc.label")}>
// {error ? (
// <ErrorView error={error} />
// ) : data ? (
// Object.entries(data.oidc).map(([id, x]) => {
// const acc = account.externalId[id];
// return (
// <Preference
// key={x.displayName}
// icon={Badge}
// label={x.displayName}
// description={
// acc
// ? t("settings.oidc.connected", { username: acc.username })
// : t("settings.oidc.not-connected")
// }
// customIcon={
// x.logoUrl != null && (
// <ImageBackground
// source={{ uri: x.logoUrl }}
// {...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
// />
// )
// }
// >
// {acc ? (
// <>
// {acc.profileUrl && (
// <IconButton
// icon={OpenProfile}
// as={Link}
// href={acc.profileUrl}
// target="_blank"
// {...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
// />
// )}
// <IconButton
// icon={Remove}
// onPress={() => unlinkAccount(id)}
// {...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
// />
// </>
// ) : (
// <Button
// text={t("settings.oidc.link")}
// as={Link}
// href={x.link}
// {...css({ minWidth: rem(6) })}
// />
// )}
// </Preference>
// );
// })
// ) : (
// [...Array(3)].map((_, i) => (
// <Preference
// key={i}
// customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
// icon={null!}
// label={<Skeleton {...css({ width: rem(6) })} />}
// description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
// />
// ))
// )}
// </SettingsContainer>
// );
// };
//
// OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
// path: ["info"],
// parser: ServerInfoP,
// });

View File

@@ -1,32 +1,26 @@
/*
* 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 { languageCodes, useLanguageName } from "../utils";
import { Preference, SettingsContainer, useSetting } from "./base";
import { useLocalSetting } from "@kyoo/models";
import { Select } from "@kyoo/primitives";
import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg";
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import { useTranslation } from "react-i18next";
import { Select } from "~/primitives";
import { useLocalSetting } from "~/providers/settings";
import { useLanguageName } from "~/track-utils";
import { Preference, SettingsContainer, useSetting } from "./base";
import langmap from "langmap";
const seenNativeNames = new Set();
export const languageCodes = Object.keys(langmap)
.filter((x) => {
const nativeName = langmap[x]?.nativeName;
// Only include if nativeName is unique and defined
if (nativeName && !seenNativeNames.has(nativeName)) {
seenNativeNames.add(nativeName);
return true;
}
return false;
})
.filter((x) => !x.includes("@"));
export const PlaybackSettings = () => {
const { t } = useTranslation();
@@ -61,7 +55,9 @@ export const PlaybackSettings = () => {
onValueChange={(value) => setAudio(value)}
values={["default", ...languageCodes]}
getLabel={(key) =>
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key)
key === "default"
? t("mediainfo.default")
: (getLanguageName(key) ?? key)
}
/>
</Preference>
@@ -73,7 +69,9 @@ export const PlaybackSettings = () => {
<Select
label={t("settings.playback.subtitleLanguage.label")}
value={subtitle ?? "none"}
onValueChange={(value) => setSubtitle(value === "none" ? null : value)}
onValueChange={(value) =>
setSubtitle(value === "none" ? null : value)
}
values={["none", "default", ...languageCodes]}
getLabel={(key) =>
key === "none"