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

View File

@@ -2,42 +2,48 @@ import { z } from "zod/v4";
export const User = z export const User = z
.object({ .object({
// // keep a default for older versions of the api
// .default({}),
id: z.string(), id: z.string(),
username: z.string(), username: z.string(),
email: z.string(), email: z.string(),
// permissions: z.array(z.string()), claims: z.object({
// hasPassword: z.boolean().default(true), permissions: z.array(z.string()),
// settings: z // hasPassword: z.boolean().default(true),
// .object({ settings: z
// downloadQuality: z .object({
// .union([ downloadQuality: z
// z.literal("original"), .union([
// z.literal("8k"), z.literal("original"),
// z.literal("4k"), z.literal("8k"),
// z.literal("1440p"), z.literal("4k"),
// z.literal("1080p"), z.literal("1440p"),
// z.literal("720p"), z.literal("1080p"),
// z.literal("480p"), z.literal("720p"),
// z.literal("360p"), z.literal("480p"),
// z.literal("240p"), z.literal("360p"),
// ]) z.literal("240p"),
// .default("original") ])
// .catch("original"), .catch("original"),
// audioLanguage: z.string().default("default").catch("default"), audioLanguage: z.string().catch("default"),
// subtitleLanguage: z.string().nullable().default(null).catch(null), subtitleLanguage: z.string().nullable().catch(null),
// }) })
// // keep a default for older versions of the api .default({
// .default({}), downloadQuality: "original",
// externalId: z audioLanguage: "default",
// .record( subtitleLanguage: null,
// z.string(), }),
// z.object({ // externalId: z
// id: z.string(), // .record(
// username: z.string().nullable().default(""), // z.string(),
// profileUrl: z.string().nullable(), // z.object({
// }), // id: z.string(),
// ) // username: z.string().nullable().default(""),
// .default({}), // profileUrl: z.string().nullable(),
// }),
// )
// .default({}),
}),
}) })
.transform((x) => ({ .transform((x) => ({
...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 // Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import type { AlertButton, AlertOptions } from "react-native"; import type { AlertButton, AlertOptions } from "react-native";

View File

@@ -1,4 +1,6 @@
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements"; export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
// export * from "./snackbar";
export * from "./alert";
export * from "./avatar"; export * from "./avatar";
export * from "./button"; export * from "./button";
export * from "./chip"; export * from "./chip";
@@ -9,11 +11,9 @@ export * from "./image";
export * from "./image-background"; export * from "./image-background";
export * from "./input"; export * from "./input";
export * from "./links"; export * from "./links";
// export * from "./snackbar";
// export * from "./alert";
export * from "./menu"; export * from "./menu";
export * from "./popup";
export * from "./progress"; export * from "./progress";
// export * from "./popup";
export * from "./select"; export * from "./select";
export * from "./skeleton"; export * from "./skeleton";
export * from "./slider"; 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 { usePortal } from "@gorhom/portal";
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { px, vh } from "yoshiki/native"; import { px, vh } from "yoshiki/native";
import { imageBorderRadius } from "./constants";
import { Container } from "./container"; import { Container } from "./container";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes"; import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./theme";
import { ts } from "./utils"; import { ts } from "./utils";
export const Popup = ({ export const Popup = ({
@@ -52,7 +31,7 @@ export const Popup = ({
<Container <Container
{...css( {...css(
{ {
borderRadius: px(imageBorderRadius), borderRadius: px(6),
paddingHorizontal: 0, paddingHorizontal: 0,
bg: (theme) => theme.background, bg: (theme) => theme.background,
overflow: "hidden", overflow: "hidden",

View File

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

View File

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

View File

@@ -1,81 +1,67 @@
/* import Username from "@material-symbols/svg-400/outlined/badge.svg";
* Kyoo - A portable and vast media library solution. import Mail from "@material-symbols/svg-400/outlined/mail.svg";
* Copyright (c) Kyoo. import Password from "@material-symbols/svg-400/outlined/password.svg";
* // import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
* See AUTHORS.md and LICENSE file in the project root for full license information. import Delete from "@material-symbols/svg-400/rounded/delete.svg";
* import Logout from "@material-symbols/svg-400/rounded/logout.svg";
* Kyoo is free software: you can redistribute it and/or modify // import * as ImagePicker from "expo-image-picker";
* 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 { type ComponentProps, useState } from "react"; import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/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 { Preference, SettingsContainer } from "./base";
import { useMutation } from "~/query";
import Username from "@material-symbols/svg-400/outlined/badge.svg"; // function dataURItoBlob(dataURI: string) {
import Mail from "@material-symbols/svg-400/outlined/mail.svg"; // const byteString = atob(dataURI.split(",")[1]);
import Password from "@material-symbols/svg-400/outlined/password.svg"; // const ab = new ArrayBuffer(byteString.length);
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg"; // const ia = new Uint8Array(ab);
import Delete from "@material-symbols/svg-400/rounded/delete.svg"; // for (let i = 0; i < byteString.length; i++) {
import Logout from "@material-symbols/svg-400/rounded/logout.svg"; // ia[i] = byteString.charCodeAt(i);
// }
function dataURItoBlob(dataURI: string) { // return new Blob([ab], { type: "image/jpeg" });
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 = () => { export const AccountSettings = () => {
const account = useAccount()!; const account = useAccount()!;
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation();
const [setPopup, close] = usePopup(); const [setPopup, close] = usePopup();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<Account>) => method: "PATCH",
await queryFn({ path: ["auth", "users", "me"],
path: ["auth", "me"], compute: (update: Partial<User>) => ({ body: update }),
method: "PATCH", optimistic: (update) => ({
body: update, ...account,
}), ...update,
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }), claims: { ...account.claims, ...update.claims },
}),
invalidate: ["auth", "users", "me"],
}); });
const { mutateAsync: editPassword } = useMutation({ const { mutateAsync: editPassword } = useMutation({
mutationFn: async (request: { newPassword: string; oldPassword: string }) => method: "PATCH",
await queryFn({ path: ["auth", "users", "me", "password"],
path: ["auth", "password-reset"], compute: (body: { oldPassword: string; newPassword: string }) => ({
method: "POST", body,
body: request, }),
}), invalidate: null,
}); });
return ( return (
@@ -106,7 +92,8 @@ export const AccountSettings = () => {
], ],
{ {
cancelable: true, cancelable: true,
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode, userInterfaceStyle:
theme.mode === "auto" ? "light" : theme.mode,
icon: "warning", icon: "warning",
}, },
); );
@@ -137,43 +124,47 @@ export const AccountSettings = () => {
} }
/> />
</Preference> </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 <Preference
icon={AccountCircle} icon={Mail}
customIcon={<Avatar src={account.logo} />} label={t("settings.account.email.label")}
label={t("settings.account.avatar.label")} description={account.email}
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={Mail} label={t("settings.account.email.label")} description={account.email}>
<Button <Button
text={t("misc.edit")} text={t("misc.edit")}
onPress={() => onPress={() =>
@@ -202,8 +193,10 @@ export const AccountSettings = () => {
<ChangePasswordPopup <ChangePasswordPopup
icon={Password} icon={Password}
label={t("settings.account.password.label")} label={t("settings.account.password.label")}
hasPassword={account.hasPassword} hasPassword={true}
apply={async (op, np) => await editPassword({ oldPassword: op, newPassword: np })} apply={async (op, np) =>
await editPassword({ oldPassword: op, newPassword: np })
}
close={close} close={close}
/>, />,
) )
@@ -236,7 +229,9 @@ const ChangePopup = ({
<Popup> <Popup>
{({ css }) => ( {({ css }) => (
<> <>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}> <View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} /> <Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1> <H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View> </View>
@@ -246,7 +241,13 @@ const ChangePopup = ({
value={value} value={value}
onChangeText={(v) => setValue(v)} 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 <Button
text={t("misc.cancel")} text={t("misc.cancel")}
onPress={() => close()} onPress={() => close()}
@@ -289,7 +290,9 @@ const ChangePasswordPopup = ({
<Popup> <Popup>
{({ css }) => ( {({ css }) => (
<> <>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}> <View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} /> <Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1> <H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View> </View>
@@ -303,14 +306,22 @@ const ChangePasswordPopup = ({
/> />
)} )}
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="new-password"
variant="big" variant="big"
value={newValue} value={newValue}
onChangeText={(v) => setNewValue(v)} onChangeText={(v) => setNewValue(v)}
placeholder={t("settings.account.password.newPassword")} placeholder={t("settings.account.password.newPassword")}
/> />
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>} {error && (
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}> <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>
)}
<View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button <Button
text={t("misc.cancel")} text={t("misc.cancel")}
onPress={() => close()} onPress={() => close()}
@@ -323,7 +334,7 @@ const ChangePasswordPopup = ({
await apply(oldValue, newValue); await apply(oldValue, newValue);
close(); close();
} catch (e) { } catch (e) {
setError((e as KyooErrors).errors[0]); setError((e as KyooError).message);
} }
}} }}
{...css({ minWidth: rem(6) })} {...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, setting: Setting,
) => { ) => {
const account = useAccount(); const account = useAccount();
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
method: "PATCH", method: "PATCH",
path: ["auth", "me"], path: ["auth", "users", "me"],
compute: (update: Partial<User["settings"]>) => ({ compute: (update: Partial<User["claims"]["settings"]>) => ({
body: { settings: { ...account!.settings, ...update } }, body: {
claims: {
...account!.claims,
settings: { ...account!.claims.settings, ...update },
},
},
}), }),
optimistic: (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; if (!account) return null;
return [ return [
account.settings[setting], account.claims.settings[setting],
async (value: User["settings"][Setting]) => { async (value: User["claims"]["settings"][Setting]) => {
await mutateAsync({ [setting]: value }); await mutateAsync({ [setting]: value });
}, },
] as const; ] 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 Language from "@material-symbols/svg-400/outlined/language.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg"; import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg"; import Public from "@material-symbols/svg-400/rounded/public.svg";

View File

@@ -1,18 +1,18 @@
import { ScrollView } from "react-native"; import { ScrollView } from "react-native";
import { ts } from "~/primitives"; import { ts } from "~/primitives";
import { useAccount } from "~/providers/account-context"; import { useAccount } from "~/providers/account-context";
// import { AccountSettings } from "./account"; import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general"; import { About, GeneralSettings } from "./general";
// import { OidcSettings } from "./oidc"; // import { OidcSettings } from "./oidc";
// import { PlaybackSettings } from "./playback"; import { PlaybackSettings } from "./playback";
export const SettingsPage = () => { export const SettingsPage = () => {
const account = useAccount(); const account = useAccount();
return ( return (
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}> <ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
<GeneralSettings /> <GeneralSettings />
{/* {account && <PlaybackSettings />} */} {account && <PlaybackSettings />}
{/* {account && <AccountSettings />} */} {account && <AccountSettings />}
{/* {account && <OidcSettings />} */} {/* {account && <OidcSettings />} */}
<About /> <About />
</ScrollView> </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 SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-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 AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import { useTranslation } from "react-i18next"; 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 = () => { export const PlaybackSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -61,7 +55,9 @@ export const PlaybackSettings = () => {
onValueChange={(value) => setAudio(value)} onValueChange={(value) => setAudio(value)}
values={["default", ...languageCodes]} values={["default", ...languageCodes]}
getLabel={(key) => getLabel={(key) =>
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key) key === "default"
? t("mediainfo.default")
: (getLanguageName(key) ?? key)
} }
/> />
</Preference> </Preference>
@@ -73,7 +69,9 @@ export const PlaybackSettings = () => {
<Select <Select
label={t("settings.playback.subtitleLanguage.label")} label={t("settings.playback.subtitleLanguage.label")}
value={subtitle ?? "none"} value={subtitle ?? "none"}
onValueChange={(value) => setSubtitle(value === "none" ? null : value)} onValueChange={(value) =>
setSubtitle(value === "none" ? null : value)
}
values={["none", "default", ...languageCodes]} values={["none", "default", ...languageCodes]}
getLabel={(key) => getLabel={(key) =>
key === "none" key === "none"