mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
6 Commits
develop
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a377317710 | ||
|
|
85929c2854 | ||
|
|
214832f81c | ||
|
|
17f7b42728 | ||
|
|
b8c586139f | ||
|
|
4dd5e97971 |
@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AddNewServer } from "@/components/settings/AddNewServer";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
@@ -17,6 +18,7 @@ import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { ServerSwitcher } from "@/components/settings/ServerSwitcher";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
@@ -64,6 +66,10 @@ export default function settings() {
|
||||
<View className='p-4 flex flex-col gap-y-4'>
|
||||
<UserInfo />
|
||||
|
||||
<ServerSwitcher className='mb-4' />
|
||||
|
||||
<AddNewServer className='mb-4' />
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<MediaProvider>
|
||||
|
||||
@@ -8,6 +8,10 @@ import { ListItem } from "./list/ListItem";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
serverName?: string;
|
||||
serverId?: string;
|
||||
lastUsername?: string;
|
||||
savedToken?: string;
|
||||
}
|
||||
|
||||
interface PreviousServersListProps {
|
||||
@@ -26,6 +30,20 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getServerDisplayName = (server: Server) => {
|
||||
if (server.serverName) {
|
||||
return `${server.serverName}`;
|
||||
}
|
||||
return server.address;
|
||||
};
|
||||
|
||||
const getServerSubtitle = (server: Server) => {
|
||||
if (server.lastUsername) {
|
||||
return `${server.address} • ${server.lastUsername}`;
|
||||
}
|
||||
return server.address;
|
||||
};
|
||||
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
@@ -35,7 +53,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
<ListItem
|
||||
key={s.address}
|
||||
onPress={() => onServerSelect(s)}
|
||||
title={s.address}
|
||||
title={getServerDisplayName(s)}
|
||||
subtitle={getServerSubtitle(s)}
|
||||
icon={s.savedToken ? "key" : "server"}
|
||||
showArrow
|
||||
/>
|
||||
))}
|
||||
|
||||
124
components/settings/AddNewServer.tsx
Normal file
124
components/settings/AddNewServer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, View, type ViewProps } from "react-native";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { Input } from "../common/Input";
|
||||
import { Button } from "../Button";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const AddNewServer: React.FC<Props> = ({ ...props }) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addNewServer } = useJellyfin();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAddServer = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
Alert.alert(t("login.error_title"), "Please enter a server URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Validate URL format
|
||||
const cleanUrl = serverUrl.trim().replace(/\/$/, "");
|
||||
|
||||
// Test connection to the server
|
||||
const baseUrl = cleanUrl.replace(/^https?:\/\//i, "");
|
||||
const protocols = ["https", "http"];
|
||||
let validUrl: string | null = null;
|
||||
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
||||
{ mode: "cors" }
|
||||
);
|
||||
if (response.ok) {
|
||||
validUrl = `${protocol}://${baseUrl}`;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue to next protocol
|
||||
}
|
||||
}
|
||||
|
||||
if (!validUrl) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the server to the list
|
||||
await addNewServer({ address: validUrl });
|
||||
|
||||
Alert.alert(
|
||||
"Success",
|
||||
`Server ${validUrl} has been added to your server list. You can now switch to it from Quick Switch Servers.`
|
||||
);
|
||||
|
||||
setServerUrl("");
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to add server:", error);
|
||||
Alert.alert(
|
||||
t("login.error_title"),
|
||||
"Failed to add server. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={t("server.add_new_server")}>
|
||||
{!showForm ? (
|
||||
<ListItem
|
||||
onPress={() => setShowForm(true)}
|
||||
title="Add Server"
|
||||
icon="add"
|
||||
showArrow
|
||||
/>
|
||||
) : (
|
||||
<View className="p-4 space-y-4">
|
||||
<Input
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
keyboardType="url"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<View className="flex-row space-x-2">
|
||||
<Button
|
||||
onPress={handleAddServer}
|
||||
loading={loading}
|
||||
disabled={loading || !serverUrl.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setShowForm(false);
|
||||
setServerUrl("");
|
||||
}}
|
||||
className="flex-1 bg-neutral-800 border border-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
89
components/settings/ServerSwitcher.tsx
Normal file
89
components/settings/ServerSwitcher.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
serverName?: string;
|
||||
serverId?: string;
|
||||
lastUsername?: string;
|
||||
savedToken?: string;
|
||||
}
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ServerSwitcher: React.FC<Props> = ({ ...props }) => {
|
||||
const [_previousServers] = useMMKVString("previousServers");
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [switchingServer, setSwitchingServer] = useState<string | null>(null);
|
||||
const { switchServer } = useJellyfin();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const previousServers = useMemo(() => {
|
||||
const servers = JSON.parse(_previousServers || "[]") as Server[];
|
||||
// Filter out the current server since we don't need to "switch" to it
|
||||
const currentServer = api?.basePath;
|
||||
return servers.filter((server) => server.address !== currentServer);
|
||||
}, [_previousServers, api?.basePath]);
|
||||
|
||||
const handleServerSwitch = async (server: Server) => {
|
||||
try {
|
||||
setSwitchingServer(server.address);
|
||||
await switchServer(server);
|
||||
} catch (error) {
|
||||
console.error("Failed to switch server:", error);
|
||||
setSwitchingServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getServerDisplayName = (server: Server) => {
|
||||
if (server.serverName) {
|
||||
return `${server.serverName} (${server.address})`;
|
||||
}
|
||||
return server.address;
|
||||
};
|
||||
|
||||
const getServerSubtitle = (server: Server) => {
|
||||
if (server.lastUsername) {
|
||||
const hasToken = !!server.savedToken;
|
||||
return hasToken
|
||||
? `${server.lastUsername} • Auto-login available`
|
||||
: `Last user: ${server.lastUsername}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (!previousServers.length) {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={t("server.quick_switch")}>
|
||||
<ListItem title={t("server.no_previous_servers")} disabled />
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={t("server.quick_switch")}>
|
||||
{previousServers.map((server) => (
|
||||
<ListItem
|
||||
key={server.address}
|
||||
onPress={() => handleServerSwitch(server)}
|
||||
title={getServerDisplayName(server)}
|
||||
subtitle={getServerSubtitle(server)}
|
||||
icon={server.savedToken ? "key" : "server"}
|
||||
showArrow
|
||||
disabled={switchingServer === server.address}
|
||||
/>
|
||||
))}
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,10 @@ import { store } from "@/utils/store";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
serverName?: string;
|
||||
serverId?: string;
|
||||
lastUsername?: string;
|
||||
savedToken?: string;
|
||||
}
|
||||
|
||||
export const apiAtom = atom<Api | null>(null);
|
||||
@@ -40,7 +44,9 @@ interface JellyfinContextValue {
|
||||
discoverServers: (url: string) => Promise<Server[]>;
|
||||
setServer: (server: Server) => Promise<void>;
|
||||
removeServer: () => void;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
switchServer: (server: Server) => Promise<void>;
|
||||
addNewServer: (server: Server) => Promise<void>;
|
||||
login: (username: string, password: string, saveCredentials?: boolean) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
}
|
||||
@@ -180,6 +186,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||
|
||||
// Get server info to obtain serverId and serverName
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${server.address}/System/Info/Public`,
|
||||
{ mode: "cors" }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
server.serverId = data.Id;
|
||||
server.serverName = data.ServerName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not get server info:", error);
|
||||
}
|
||||
|
||||
setApi(apiInstance);
|
||||
storage.set("serverUrl", server.address);
|
||||
},
|
||||
@@ -215,9 +236,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
mutationFn: async ({
|
||||
username,
|
||||
password,
|
||||
saveCredentials = true,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
saveCredentials?: boolean;
|
||||
}) => {
|
||||
if (!api || !jellyfin) throw new Error("API not initialized");
|
||||
|
||||
@@ -230,6 +253,26 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
storage.set("token", auth.data?.AccessToken);
|
||||
|
||||
// Save token to the current server if requested
|
||||
if (saveCredentials && api.basePath) {
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as Server[];
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === api.basePath) {
|
||||
return {
|
||||
...server,
|
||||
lastUsername: username,
|
||||
savedToken: auth.data.AccessToken
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||
const jellyseerrApi = new JellyseerrApi(
|
||||
@@ -297,6 +340,112 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const switchServerMutation = useMutation({
|
||||
mutationFn: async (server: Server) => {
|
||||
// Get current server info for comparison
|
||||
const currentServerId = await getCurrentServerId();
|
||||
|
||||
// If switching to same server (different URL), try auto-login with saved token
|
||||
if (server.serverId && server.serverId === currentServerId && server.savedToken) {
|
||||
try {
|
||||
// Create API instance with saved token
|
||||
const apiInstance = jellyfin?.createApi(server.address, server.savedToken);
|
||||
if (!apiInstance) throw new Error("Failed to create API instance");
|
||||
|
||||
// Validate the token by making an authenticated request
|
||||
const userApi = getUserApi(apiInstance);
|
||||
const userResponse = await userApi.getCurrentUser();
|
||||
|
||||
if (userResponse.data) {
|
||||
// Token is valid, update the API and user
|
||||
setApi(apiInstance);
|
||||
setUser(userResponse.data);
|
||||
storage.set("serverUrl", server.address);
|
||||
storage.set("token", server.savedToken);
|
||||
storage.set("user", JSON.stringify(userResponse.data));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Saved token is invalid, falling back to manual login:", error);
|
||||
// Remove invalid token from server
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as Server[];
|
||||
|
||||
const updatedServers = previousServers.map((s) => {
|
||||
if (s.address === server.address) {
|
||||
const { savedToken, ...serverWithoutToken } = s;
|
||||
return serverWithoutToken;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
}
|
||||
|
||||
// For different servers or if auto-login fails, do the normal logout → set server flow
|
||||
await logoutMutation.mutateAsync();
|
||||
await setServerMutation.mutateAsync(server);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to switch server:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const addNewServerMutation = useMutation({
|
||||
mutationFn: async (server: Server) => {
|
||||
// Add a new server to the list without switching to it
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as Server[];
|
||||
|
||||
// Get server info first
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${server.address}/System/Info/Public`,
|
||||
{ mode: "cors" }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
server.serverId = data.Id;
|
||||
server.serverName = data.ServerName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not get server info:", error);
|
||||
}
|
||||
|
||||
const updatedServers = [
|
||||
server,
|
||||
...previousServers.filter((s: Server) => s.address !== server.address),
|
||||
];
|
||||
storage.set(
|
||||
"previousServers",
|
||||
JSON.stringify(updatedServers.slice(0, 5)),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to add new server:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const getCurrentServerId = async (): Promise<string | null> => {
|
||||
if (!api?.basePath) return null;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${api.basePath}/System/Info/Public`,
|
||||
{ mode: "cors" }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.Id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not get current server ID:", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||
|
||||
@@ -311,6 +460,23 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (!jellyfin) return;
|
||||
|
||||
try {
|
||||
// Migrate any existing savedCredentials to remove them
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]",
|
||||
) as any[];
|
||||
|
||||
if (previousServers.length > 0) {
|
||||
const migratedServers = previousServers.map((server) => {
|
||||
if (server.savedCredentials) {
|
||||
// Remove savedCredentials field for security
|
||||
const { savedCredentials, ...serverWithoutCredentials } = server;
|
||||
return serverWithoutCredentials;
|
||||
}
|
||||
return server;
|
||||
});
|
||||
storage.set("previousServers", JSON.stringify(migratedServers));
|
||||
}
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const serverUrl = getServerUrlFromStorage();
|
||||
const storedUser = getUserFromStorage();
|
||||
@@ -340,8 +506,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
discoverServers,
|
||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||
removeServer: () => removeServerMutation.mutateAsync(),
|
||||
login: (username, password) =>
|
||||
loginMutation.mutateAsync({ username, password }),
|
||||
switchServer: (server) => switchServerMutation.mutateAsync(server),
|
||||
addNewServer: (server) => addNewServerMutation.mutateAsync(server),
|
||||
login: (username, password, saveCredentials = true) =>
|
||||
loginMutation.mutateAsync({ username, password, saveCredentials }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
"clear_button": "Clear",
|
||||
"search_for_local_servers": "Search for local servers",
|
||||
"searching": "Searching...",
|
||||
"servers": "Servers"
|
||||
"servers": "Servers",
|
||||
"quick_switch": "Quick Switch Servers",
|
||||
"switch_server": "Switch Server",
|
||||
"no_previous_servers": "No previous servers available",
|
||||
"add_new_server": "Add New Server",
|
||||
"auto_login_available": "Auto-login available"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "No Internet",
|
||||
|
||||
Reference in New Issue
Block a user