mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 09:23:07 +01:00
Compare commits
1 Commits
fix/ui-hea
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cdfebe908 |
3
bun.lock
3
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -1001,6 +1002,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import type {
|
import type {
|
||||||
AccountSecurityType,
|
AccountSecurityType,
|
||||||
SavedServer,
|
SavedServer,
|
||||||
@@ -37,11 +38,13 @@ export const Login: React.FC = () => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const {
|
const {
|
||||||
setServer,
|
setServer,
|
||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
|
stopQuickConnectPolling,
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
@@ -64,6 +67,32 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Close the code sheet as soon as the session is authorized — the native
|
||||||
|
// Alert used before had no programmatic dismiss and stayed open after login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) setQuickConnectCode(null);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
};
|
||||||
|
}, [stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Going back to server selection keeps this component mounted (same screen,
|
||||||
|
// different state), so the unmount cleanup above doesn't run. Without this a
|
||||||
|
// code authorized after leaving would silently log the user in later.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api?.basePath) {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
setQuickConnectCode(null);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, stopQuickConnectPolling]);
|
||||||
|
|
||||||
// Save account state
|
// Save account state
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
@@ -259,15 +288,7 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(
|
setQuickConnectCode(code);
|
||||||
t("login.quick_connect"),
|
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -402,7 +423,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
aria-label={t("server.server_url")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -453,6 +474,13 @@ export const Login: React.FC = () => {
|
|||||||
onSave={handleSaveAccountConfirm}
|
onSave={handleSaveAccountConfirm}
|
||||||
username={pendingLogin?.username || credentials.username}
|
username={pendingLogin?.username || credentials.username}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Dismissing only hides the code — polling continues so the login still
|
||||||
|
completes if the code is authorized from another device afterwards. */}
|
||||||
|
<QuickConnectCodeModal
|
||||||
|
code={quickConnectCode}
|
||||||
|
onClose={() => setQuickConnectCode(null)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The Quick Connect code to display, or null when hidden. */
|
||||||
|
code: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the Quick Connect code while the app polls for authorization.
|
||||||
|
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||||
|
* session is authorized — a native alert has no programmatic dismiss and
|
||||||
|
* lingers over the app after login completes.
|
||||||
|
*/
|
||||||
|
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||||
|
// when the parent clears the code to close the sheet.
|
||||||
|
const lastCodeRef = useRef<string | null>(null);
|
||||||
|
if (code) lastCodeRef.current = code;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else if (isPresentedRef.current) {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
const value = code ?? lastCodeRef.current;
|
||||||
|
if (!value) return;
|
||||||
|
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||||
|
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||||
|
// and skip — importing the JS wrapper there would error out.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
toast.success(t("login.code_copied"));
|
||||||
|
}, [code, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||||
|
onPress={copyCode}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className='text-center font-bold text-5xl text-neutral-100'
|
||||||
|
style={{ letterSpacing: 10 }}
|
||||||
|
>
|
||||||
|
{code ?? lastCodeRef.current}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='copy-outline'
|
||||||
|
size={22}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||||
|
{t("login.tap_code_to_copy")}
|
||||||
|
</Text>
|
||||||
|
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||||
|
{t("login.quick_connect_instructions")}
|
||||||
|
</Text>
|
||||||
|
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||||
|
{t("login.got_it")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -5,11 +6,13 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
|
const pasteCode = useCallback(async () => {
|
||||||
|
// Builds without the expo-clipboard native module: probe first (no-op).
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = await Clipboard.getStringAsync();
|
||||||
|
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
||||||
|
if (digits) setQuickConnectCode(digits);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={pasteCode}
|
||||||
|
className='flex-row items-center justify-center self-center'
|
||||||
|
>
|
||||||
|
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{t("home.settings.quick_connect.paste_code")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"login_button": "Log in",
|
"login_button": "Log in",
|
||||||
"quick_connect": "Quick Connect",
|
"quick_connect": "Quick Connect",
|
||||||
"enter_code_to_login": "Enter code {{code}} to log in",
|
"enter_code_to_login": "Enter code {{code}} to log in",
|
||||||
|
"quick_connect_instructions": "Enter this code on a signed-in device — you'll be logged in automatically.",
|
||||||
|
"tap_code_to_copy": "Tap the code to copy it",
|
||||||
|
"code_copied": "Code copied",
|
||||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
"previous_servers": "Previous servers",
|
"previous_servers": "Previous servers",
|
||||||
"clear_button": "Clear all",
|
"clear_button": "Clear all",
|
||||||
|
"server_url": "Server URL",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "Swipe to remove",
|
||||||
"search_for_local_servers": "Search for local servers",
|
"search_for_local_servers": "Search for local servers",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
@@ -191,7 +195,8 @@
|
|||||||
"quick_connect_authorized": "Quick Connect authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize",
|
||||||
|
"paste_code": "Paste code"
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Media controls",
|
"media_controls_title": "Media controls",
|
||||||
|
|||||||
Reference in New Issue
Block a user