mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
Compare commits
4 Commits
fix/networ
...
fix/nav-do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f7d24d63f | ||
|
|
7b15b15af3 | ||
|
|
f8b8cddbfa | ||
|
|
3bd7507462 |
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
284
app/_layout.tsx
284
app/_layout.tsx
@@ -27,7 +27,6 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
|||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
@@ -86,8 +85,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -352,12 +350,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -426,146 +421,143 @@ function Layout() {
|
|||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<InactivityProvider>
|
<InactivityProvider>
|
||||||
<WifiSsidProvider>
|
<ServerUrlProvider>
|
||||||
<ServerUrlProvider>
|
<NetworkStatusProvider>
|
||||||
<NetworkStatusProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<DownloadProvider>
|
||||||
<DownloadProvider>
|
<MusicPlayerProvider>
|
||||||
<MusicPlayerProvider>
|
<GlobalModalProvider>
|
||||||
<GlobalModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<IntroSheetProvider>
|
||||||
<IntroSheetProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style='light' hidden={false} />
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name='(auth)/(tabs)'
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/player'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/now-playing'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent:
|
|
||||||
Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-action-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-user-switch-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
<Stack.Screen
|
||||||
</ThemeProvider>
|
name='(auth)/player'
|
||||||
</IntroSheetProvider>
|
options={{
|
||||||
</BottomSheetModalProvider>
|
headerShown: false,
|
||||||
</GlobalModalProvider>
|
title: "",
|
||||||
</MusicPlayerProvider>
|
header: () => null,
|
||||||
</DownloadProvider>
|
}}
|
||||||
</WebSocketProvider>
|
/>
|
||||||
</LogProvider>
|
<Stack.Screen
|
||||||
</PlaySettingsProvider>
|
name='(auth)/now-playing'
|
||||||
</NetworkStatusProvider>
|
options={{
|
||||||
</ServerUrlProvider>
|
headerShown: false,
|
||||||
</WifiSsidProvider>
|
presentation: "modal",
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
</ThemeProvider>
|
||||||
|
</IntroSheetProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</MusicPlayerProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
|
</ServerUrlProvider>
|
||||||
</InactivityProvider>
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Keyboard, Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
@@ -16,10 +16,17 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Dismiss the keyboard before navigating — otherwise it lingers over the
|
||||||
|
// previous screen (e.g. leaving the Jellyseerr login while typing).
|
||||||
|
const handleBack = () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={handleBack}
|
||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
@@ -30,7 +37,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={() => router.back()} {...pressableProps}>
|
<Pressable onPress={handleBack} {...pressableProps}>
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
intensity={100}
|
intensity={100}
|
||||||
@@ -48,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={handleBack}
|
||||||
className=' rounded-full p-2'
|
className=' rounded-full p-2'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { Switch, TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
import { openLocationSettings } from "@/modules/wifi-ssid";
|
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
import {
|
||||||
@@ -27,26 +26,16 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
|
|||||||
|
|
||||||
interface StatusDisplayProps {
|
interface StatusDisplayProps {
|
||||||
currentSSID: string | null;
|
currentSSID: string | null;
|
||||||
connectedToWifi: boolean;
|
|
||||||
isUsingLocalUrl: boolean;
|
isUsingLocalUrl: boolean;
|
||||||
locationBlocked: boolean;
|
|
||||||
onOpenLocationSettings: () => void;
|
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDisplay({
|
function StatusDisplay({
|
||||||
currentSSID,
|
currentSSID,
|
||||||
connectedToWifi,
|
|
||||||
isUsingLocalUrl,
|
isUsingLocalUrl,
|
||||||
locationBlocked,
|
|
||||||
onOpenLocationSettings,
|
|
||||||
t,
|
t,
|
||||||
}: StatusDisplayProps): React.ReactElement {
|
}: StatusDisplayProps): React.ReactElement {
|
||||||
const wifiStatus = currentSSID
|
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
|
||||||
? currentSSID
|
|
||||||
: connectedToWifi
|
|
||||||
? t("home.settings.network.ssid_hidden")
|
|
||||||
: t("home.settings.network.not_connected");
|
|
||||||
const urlType = isUsingLocalUrl
|
const urlType = isUsingLocalUrl
|
||||||
? t("home.settings.network.local")
|
? t("home.settings.network.local")
|
||||||
: t("home.settings.network.remote");
|
: t("home.settings.network.remote");
|
||||||
@@ -66,23 +55,6 @@ function StatusDisplay({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text className={urlTypeColor}>{urlType}</Text>
|
<Text className={urlTypeColor}>{urlType}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{locationBlocked && (
|
|
||||||
<View className='mt-2 pt-2 border-t border-neutral-800'>
|
|
||||||
<Text className='text-xs text-amber-400'>
|
|
||||||
{t("home.settings.network.location_off_description")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onOpenLocationSettings}
|
|
||||||
className='mt-2 self-start'
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<Text className='text-xs text-blue-400 font-semibold'>
|
|
||||||
{t("home.settings.network.open_location_settings")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,17 +62,7 @@ function StatusDisplay({
|
|||||||
export function LocalNetworkSettings(): React.ReactElement | null {
|
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { permissionStatus, requestPermission } = useWifiSSID();
|
const { permissionStatus, requestPermission } = useWifiSSID();
|
||||||
const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
|
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
|
||||||
useServerUrl();
|
|
||||||
|
|
||||||
// Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
|
|
||||||
// on Android this means device Location services are off.
|
|
||||||
const locationBlocked =
|
|
||||||
permissionStatus === "granted" && connectedToWifi && !currentSSID;
|
|
||||||
|
|
||||||
const handleOpenLocationSettings = useCallback(() => {
|
|
||||||
openLocationSettings();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const remoteUrl = storage.getString("serverUrl");
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||||
@@ -233,23 +195,18 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
{!locationBlocked && (
|
<View className='py-2'>
|
||||||
<View className='py-2'>
|
<Button
|
||||||
<Button
|
onPress={handleAddCurrentNetwork}
|
||||||
onPress={handleAddCurrentNetwork}
|
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
>
|
||||||
>
|
{addNetworkButtonText}
|
||||||
{addNetworkButtonText}
|
</Button>
|
||||||
</Button>
|
</View>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StatusDisplay
|
<StatusDisplay
|
||||||
currentSSID={currentSSID}
|
currentSSID={currentSSID}
|
||||||
connectedToWifi={connectedToWifi}
|
|
||||||
isUsingLocalUrl={isUsingLocalUrl}
|
isUsingLocalUrl={isUsingLocalUrl}
|
||||||
locationBlocked={locationBlocked}
|
|
||||||
onOpenLocationSettings={handleOpenLocationSettings}
|
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
Alert.alert(
|
try {
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
await deleteAllFiles();
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
successHapticFeedback();
|
||||||
[
|
} catch (_e) {
|
||||||
{
|
errorHapticFeedback();
|
||||||
text: t("common.cancel"),
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
style: "cancel",
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.ok"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
successHapticFeedback();
|
|
||||||
} catch (_e) {
|
|
||||||
errorHapticFeedback();
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
} finally {
|
|
||||||
// Reflect the freed space immediately instead of waiting for
|
|
||||||
// the next poll.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
|
||||||
|
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { NavigationContext } from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for expo-router's useRouter that automatically
|
* Drop-in replacement for expo-router's useRouter that automatically
|
||||||
* preserves offline state across navigation.
|
* preserves offline state across navigation and guards against duplicate
|
||||||
|
* screens from rapid taps.
|
||||||
*
|
*
|
||||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
* - For string URLs, passes through unchanged (caller handles offline param)
|
||||||
|
* - push() is a no-op while the source screen is not focused, so taps fired
|
||||||
|
* before the pushed screen has rendered (slow devices) can't stack duplicates
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* import useRouter from "@/hooks/useAppRouter";
|
* import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -19,8 +25,36 @@ export function useAppRouter() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
|
||||||
|
// Optional: undefined when used outside a navigator (root layout, providers).
|
||||||
|
// When present it reflects the focus state of the screen this hook lives in.
|
||||||
|
const navigation = useContext(NavigationContext);
|
||||||
|
|
||||||
|
// Synchronous re-entry guard for TV: a single remote "select" on Android TV
|
||||||
|
// can fire onPress more than once within the same JS batch
|
||||||
|
// (react-native-tvos#110/#138), BEFORE react-navigation commits the pushed
|
||||||
|
// route — so isFocused() still reads true for the duplicate. The ref flips
|
||||||
|
// synchronously on the first push and resets when the screen regains focus.
|
||||||
|
const pushInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navigation) return;
|
||||||
|
return navigation.addListener("focus", () => {
|
||||||
|
pushInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(href: Parameters<typeof router.push>[0]) => {
|
(href: Parameters<typeof router.push>[0]) => {
|
||||||
|
// Rapid-push guard: a push blurs the source screen synchronously in the
|
||||||
|
// navigation state (only the native render is slow). Any further push from
|
||||||
|
// this screen — duplicate or not — is dropped until focus returns, so taps
|
||||||
|
// fired before the pushed screen renders can't stack screens.
|
||||||
|
// No navigation context => nothing to guard (deep-link pushes from root).
|
||||||
|
if (navigation) {
|
||||||
|
if (navigation.isFocused?.() === false || pushInFlightRef.current)
|
||||||
|
return;
|
||||||
|
pushInFlightRef.current = true;
|
||||||
|
}
|
||||||
if (typeof href === "string") {
|
if (typeof href === "string") {
|
||||||
router.push(href as any);
|
router.push(href as any);
|
||||||
} else {
|
} else {
|
||||||
@@ -36,7 +70,7 @@ export function useAppRouter() {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[router, isOffline, navigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replace = useCallback(
|
const replace = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,125 @@
|
|||||||
export {
|
import { useCallback, useEffect, useState } from "react";
|
||||||
type PermissionStatus,
|
import { Platform } from "react-native";
|
||||||
type UseWifiSSIDReturn,
|
import { getSSID } from "@/modules/wifi-ssid";
|
||||||
useWifiSsid as useWifiSSID,
|
|
||||||
} from "@/providers/WifiSsidProvider";
|
export type PermissionStatus =
|
||||||
|
| "granted"
|
||||||
|
| "denied"
|
||||||
|
| "undetermined"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export interface UseWifiSSIDReturn {
|
||||||
|
ssid: string | null;
|
||||||
|
permissionStatus: PermissionStatus;
|
||||||
|
requestPermission: () => Promise<boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi SSID is not available on tvOS
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// Export a stub hook for tvOS
|
||||||
|
module.exports = {
|
||||||
|
useWifiSSID: (): UseWifiSSIDReturn => ({
|
||||||
|
ssid: null,
|
||||||
|
permissionStatus: "unavailable" as PermissionStatus,
|
||||||
|
requestPermission: async () => false,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only import Location on non-TV platforms
|
||||||
|
const Location = Platform.isTV ? null : require("expo-location");
|
||||||
|
|
||||||
|
function mapLocationStatus(status: number | undefined): PermissionStatus {
|
||||||
|
if (!Location) return "unavailable";
|
||||||
|
switch (status) {
|
||||||
|
case Location.PermissionStatus?.GRANTED:
|
||||||
|
return "granted";
|
||||||
|
case Location.PermissionStatus?.DENIED:
|
||||||
|
return "denied";
|
||||||
|
default:
|
||||||
|
return "undetermined";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWifiSSID(): UseWifiSSIDReturn {
|
||||||
|
const [ssid, setSSID] = useState<string | null>(null);
|
||||||
|
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
|
||||||
|
Platform.isTV ? "unavailable" : "undetermined",
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(!Platform.isTV);
|
||||||
|
|
||||||
|
const fetchSSID = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
const result = await getSSID();
|
||||||
|
setSSID(result);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (Platform.isTV || !Location) {
|
||||||
|
setPermissionStatus("unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
const newStatus = mapLocationStatus(status);
|
||||||
|
setPermissionStatus(newStatus);
|
||||||
|
|
||||||
|
if (newStatus === "granted") {
|
||||||
|
await fetchSSID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStatus === "granted";
|
||||||
|
} catch {
|
||||||
|
setPermissionStatus("unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [fetchSSID]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV || !Location) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { status } = await Location.getForegroundPermissionsAsync();
|
||||||
|
const mappedStatus = mapLocationStatus(status);
|
||||||
|
setPermissionStatus(mappedStatus);
|
||||||
|
|
||||||
|
if (mappedStatus === "granted") {
|
||||||
|
await fetchSSID();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPermissionStatus("unavailable");
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
}, [fetchSSID]);
|
||||||
|
|
||||||
|
// Refresh SSID when permission status changes to granted
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (permissionStatus === "granted") {
|
||||||
|
fetchSSID();
|
||||||
|
|
||||||
|
// Also set up an interval to periodically check SSID
|
||||||
|
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [permissionStatus, fetchSSID]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ssid,
|
||||||
|
permissionStatus,
|
||||||
|
requestPermission,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
apply plugin: 'expo-module-gradle-plugin'
|
|
||||||
|
|
||||||
group = 'expo.modules.wifissid'
|
|
||||||
version = '1.0.0'
|
|
||||||
|
|
||||||
expoModule {
|
|
||||||
canBePublished false
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "expo.modules.wifissid"
|
|
||||||
defaultConfig {
|
|
||||||
versionCode 1
|
|
||||||
versionName "1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- Reading the connected Wi-Fi SSID requires ACCESS_WIFI_STATE, and (since
|
|
||||||
Android 8) ACCESS_FINE_LOCATION granted at runtime. Location is requested
|
|
||||||
at runtime by the app via expo-location; the manifest declares the
|
|
||||||
Wi-Fi/network permissions this module needs directly. -->
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
||||||
</manifest>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package expo.modules.wifissid
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.wifi.WifiInfo
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.util.Log
|
|
||||||
import expo.modules.kotlin.modules.Module
|
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
|
||||||
|
|
||||||
class WifiSsidModule : Module() {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "WifiSsid"
|
|
||||||
private const val UNKNOWN_SSID = "<unknown ssid>"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val context
|
|
||||||
get() = requireNotNull(appContext.reactContext)
|
|
||||||
|
|
||||||
override fun definition() = ModuleDefinition {
|
|
||||||
Name("WifiSsid")
|
|
||||||
|
|
||||||
AsyncFunction("getSSID") {
|
|
||||||
val (ssid, connectedToWifi) = readWifi()
|
|
||||||
mapOf(
|
|
||||||
"ssid" to ssid,
|
|
||||||
"connectedToWifi" to connectedToWifi,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Function("getSSIDSync") {
|
|
||||||
val (ssid, connectedToWifi) = readWifi()
|
|
||||||
mapOf(
|
|
||||||
"ssid" to ssid,
|
|
||||||
"connectedToWifi" to connectedToWifi,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Function("openLocationSettings") {
|
|
||||||
openLocationSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current Wi-Fi SSID (or null when it can't be read) and whether
|
|
||||||
* the device is connected to a Wi-Fi network. When the device is connected but
|
|
||||||
* the SSID is null, the OS is withholding the network name — on Android this
|
|
||||||
* typically means device Location services are off (the SSID is treated as
|
|
||||||
* location-sensitive).
|
|
||||||
*/
|
|
||||||
private fun readWifi(): Pair<String?, Boolean> {
|
|
||||||
val androidContext = context.applicationContext
|
|
||||||
var connectedToWifi = false
|
|
||||||
var ssid: String? = null
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
try {
|
|
||||||
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
|
||||||
val caps = cm?.let { manager ->
|
|
||||||
manager.activeNetwork?.let { net -> manager.getNetworkCapabilities(net) }
|
|
||||||
}
|
|
||||||
connectedToWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
|
||||||
if (hasFineLocationPermission()) {
|
|
||||||
ssid = (caps?.transportInfo as? WifiInfo)?.ssid?.sanitize()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager", e)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Pre-Q (API 26–28): NetworkCapabilities/transportInfo aren't available, so
|
|
||||||
// detect a Wi-Fi connection via the legacy active-network API. Without this,
|
|
||||||
// connectedToWifi stays false on Android 8/9 even when connected, so the
|
|
||||||
// "connected but SSID hidden" state never surfaces there.
|
|
||||||
try {
|
|
||||||
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
connectedToWifi = cm?.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager (legacy)", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ssid == null && hasFineLocationPermission()) {
|
|
||||||
try {
|
|
||||||
val wm = androidContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
ssid = wm?.connectionInfo?.ssid?.sanitize()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error reading SSID via WifiManager", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pair(ssid, connectedToWifi)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openLocationSettings() {
|
|
||||||
try {
|
|
||||||
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.applicationContext.startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Unable to open Location settings", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasFineLocationPermission(): Boolean =
|
|
||||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
private fun String.sanitize(): String? {
|
|
||||||
val cleaned = trim('"')
|
|
||||||
return if (cleaned.isBlank() || cleaned == UNKNOWN_SSID) null else cleaned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "wifi-ssid",
|
"name": "wifi-ssid",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"platforms": ["ios", "android"],
|
"platforms": ["ios"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["WifiSsidModule"]
|
"modules": ["WifiSsidModule"]
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.wifissid.WifiSsidModule"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,44 @@
|
|||||||
import { Platform, requireNativeModule } from "expo-modules-core";
|
import { Platform, requireNativeModule } from "expo-modules-core";
|
||||||
import { Linking } from "react-native";
|
|
||||||
|
|
||||||
export interface WifiSsidResult {
|
// Only load the native module on iOS
|
||||||
ssid: string | null;
|
const WifiSsidModule =
|
||||||
/**
|
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
|
||||||
* Whether the device is connected to a Wi-Fi network. When true but `ssid` is
|
|
||||||
* null, the OS is withholding the network name — on Android this happens when
|
|
||||||
* device Location services are off (the SSID is location-sensitive).
|
|
||||||
*/
|
|
||||||
connectedToWifi: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the native module on iOS and Android (not TV/web). Wrapped so a
|
|
||||||
// missing/unlinked module degrades to null instead of crashing the bundle.
|
|
||||||
const WifiSsidModule = (() => {
|
|
||||||
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
|
|
||||||
try {
|
|
||||||
return requireNativeModule("WifiSsid");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current WiFi SSID and whether the device is on a Wi-Fi network.
|
* Get the current WiFi SSID on iOS.
|
||||||
|
* Returns null on Android or if not connected to WiFi.
|
||||||
*
|
*
|
||||||
* iOS requirements:
|
* Requires:
|
||||||
* - Location permission granted
|
* - Location permission granted
|
||||||
* - com.apple.developer.networking.wifi-info entitlement
|
* - com.apple.developer.networking.wifi-info entitlement
|
||||||
*
|
* - Access WiFi Information capability enabled in Apple Developer Portal
|
||||||
* Android requirements:
|
|
||||||
* - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
|
|
||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<WifiSsidResult> {
|
export async function getSSID(): Promise<string | null> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
return { ssid: null, connectedToWifi: false };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return normalize(await WifiSsidModule.getSSID());
|
const ssid = await WifiSsidModule.getSSID();
|
||||||
|
return ssid ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[WifiSsid] Error getting SSID:", error);
|
console.error("[WifiSsid] Error getting SSID:", error);
|
||||||
return { ssid: null, connectedToWifi: false };
|
return null;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synchronous version (uses the older CNCopyCurrentNetworkInfo API on iOS). */
|
|
||||||
export function getSSIDSync(): WifiSsidResult {
|
|
||||||
if (!WifiSsidModule) {
|
|
||||||
return { ssid: null, connectedToWifi: false };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return normalize(WifiSsidModule.getSSIDSync());
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
|
||||||
return { ssid: null, connectedToWifi: false };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the system Location settings so the user can enable Location services
|
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
|
||||||
* (required on Android for the OS to reveal the Wi-Fi SSID). Falls back to the
|
|
||||||
* app's settings page where there is no dedicated native action (e.g. iOS).
|
|
||||||
*/
|
*/
|
||||||
export function openLocationSettings(): void {
|
export function getSSIDSync(): string | null {
|
||||||
try {
|
if (!WifiSsidModule) {
|
||||||
if (WifiSsidModule?.openLocationSettings) {
|
return null;
|
||||||
WifiSsidModule.openLocationSettings();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[WifiSsid] Error opening location settings:", error);
|
|
||||||
}
|
}
|
||||||
Linking.openSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both native modules return { ssid, connectedToWifi }. The string branch is a
|
try {
|
||||||
// defensive fallback in case a build returns the older plain-string shape.
|
return WifiSsidModule.getSSIDSync() ?? null;
|
||||||
function normalize(result: unknown): WifiSsidResult {
|
} catch (error) {
|
||||||
if (typeof result === "string") {
|
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
||||||
const ssid = result || null;
|
return null;
|
||||||
return { ssid, connectedToWifi: ssid !== null };
|
|
||||||
}
|
}
|
||||||
if (result && typeof result === "object") {
|
|
||||||
const obj = result as { ssid?: string | null; connectedToWifi?: boolean };
|
|
||||||
return {
|
|
||||||
ssid: obj.ssid ?? null,
|
|
||||||
connectedToWifi: Boolean(obj.connectedToWifi),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ssid: null, connectedToWifi: false };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,50 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
import Network
|
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import SystemConfiguration.CaptiveNetwork
|
import SystemConfiguration.CaptiveNetwork
|
||||||
#endif
|
#endif
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class WifiSsidModule: Module {
|
public class WifiSsidModule: Module {
|
||||||
#if !os(tvOS)
|
|
||||||
// NWPathMonitor reports whether the active path uses Wi-Fi WITHOUT requiring
|
|
||||||
// the wifi-info entitlement, so we can tell "on Wi-Fi but can't read the SSID"
|
|
||||||
// apart from "not connected to Wi-Fi" even when fetchCurrent returns nil.
|
|
||||||
private let pathMonitor = NWPathMonitor()
|
|
||||||
private let monitorQueue = DispatchQueue(label: "expo.modules.wifissid.pathmonitor")
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var cachedOnWifi = false
|
|
||||||
private var pathMonitorStarted = false
|
|
||||||
|
|
||||||
private var isOnWifi: Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return cachedOnWifi
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPathMonitorIfNeeded() {
|
|
||||||
lock.lock()
|
|
||||||
if pathMonitorStarted {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pathMonitorStarted = true
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
pathMonitor.pathUpdateHandler = { [weak self] path in
|
|
||||||
guard let self else { return }
|
|
||||||
let onWifi = path.status == .satisfied && path.usesInterfaceType(.wifi)
|
|
||||||
self.lock.lock()
|
|
||||||
self.cachedOnWifi = onWifi
|
|
||||||
self.lock.unlock()
|
|
||||||
}
|
|
||||||
pathMonitor.start(queue: monitorQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
pathMonitor.cancel()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("WifiSsid")
|
Name("WifiSsid")
|
||||||
|
|
||||||
AsyncFunction("getSSID") { () -> [String: Any] in
|
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
|
||||||
|
// Not available on tvOS
|
||||||
|
AsyncFunction("getSSID") { () -> String? in
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
return ["ssid": NSNull(), "connectedToWifi": false]
|
return nil
|
||||||
#else
|
#else
|
||||||
self.startPathMonitorIfNeeded()
|
return await withCheckedContinuation { continuation in
|
||||||
let ssid = await self.resolveSSID()
|
NEHotspotNetwork.fetchCurrent { network in
|
||||||
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
|
if let ssid = network?.ssid {
|
||||||
if let ssid = ssid {
|
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
|
||||||
result["ssid"] = ssid
|
continuation.resume(returning: ssid)
|
||||||
} else {
|
} else {
|
||||||
result["ssid"] = NSNull()
|
// Fallback to CNCopyCurrentNetworkInfo for older iOS
|
||||||
}
|
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
|
||||||
return result
|
let ssid = self.getSSIDViaCNCopy()
|
||||||
#endif
|
continuation.resume(returning: ssid)
|
||||||
}
|
}
|
||||||
|
|
||||||
Function("getSSIDSync") { () -> [String: Any] in
|
|
||||||
#if os(tvOS)
|
|
||||||
return ["ssid": NSNull(), "connectedToWifi": false]
|
|
||||||
#else
|
|
||||||
self.startPathMonitorIfNeeded()
|
|
||||||
let ssid = self.getSSIDViaCNCopy()
|
|
||||||
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
|
|
||||||
if let ssid = ssid {
|
|
||||||
result["ssid"] = ssid
|
|
||||||
} else {
|
|
||||||
result["ssid"] = NSNull()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
Function("openLocationSettings") {
|
|
||||||
#if !os(tvOS)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronous version using only CNCopyCurrentNetworkInfo
|
||||||
|
// Not available on tvOS
|
||||||
|
Function("getSSIDSync") { () -> String? in
|
||||||
|
#if os(tvOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
|
return self.getSSIDViaCNCopy()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
private func resolveSSID() async -> String? {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
NEHotspotNetwork.fetchCurrent { network in
|
|
||||||
if let ssid = network?.ssid {
|
|
||||||
continuation.resume(returning: ssid)
|
|
||||||
} else {
|
|
||||||
continuation.resume(returning: self.getSSIDViaCNCopy())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getSSIDViaCNCopy() -> String? {
|
private func getSSIDViaCNCopy() -> String? {
|
||||||
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
||||||
|
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +54,12 @@ public class WifiSsidModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
||||||
|
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
|
||||||
return ssid
|
return ssid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => {
|
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
|
||||||
// as video + trickplay, the total must match.
|
|
||||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
|
||||||
// Read the live file size on disk so the total reflects actual usage and
|
|
||||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
|
||||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
|
||||||
// value if the file can't be stat'd.
|
|
||||||
if (item.videoFilePath) {
|
|
||||||
try {
|
|
||||||
const file = new File(filePathToUri(item.videoFilePath));
|
|
||||||
if (file.exists) {
|
|
||||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to stat downloaded file for size:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
let totalSize = calculateTotalDownloadedSize();
|
const totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
// Also count in-progress downloads (they write straight to their final
|
|
||||||
// path) so the growing file shows up as app usage instead of drifting
|
|
||||||
// into the generic device share until completion.
|
|
||||||
for (const process of processes) {
|
|
||||||
try {
|
|
||||||
const file = new File(
|
|
||||||
Paths.document,
|
|
||||||
`${generateFilename(process.item)}.mp4`,
|
|
||||||
);
|
|
||||||
if (file.exists) {
|
|
||||||
totalSize += file.size ?? 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File not created yet — ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [processes]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -19,7 +18,6 @@ interface ServerUrlContextValue {
|
|||||||
effectiveServerUrl: string | null;
|
effectiveServerUrl: string | null;
|
||||||
isUsingLocalUrl: boolean;
|
isUsingLocalUrl: boolean;
|
||||||
currentSSID: string | null;
|
currentSSID: string | null;
|
||||||
connectedToWifi: boolean;
|
|
||||||
refreshUrlState: () => void;
|
refreshUrlState: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ interface Props {
|
|||||||
export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { switchServerUrl } = useJellyfin();
|
const { switchServerUrl } = useJellyfin();
|
||||||
const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
|
const { ssid, permissionStatus } = useWifiSSID();
|
||||||
|
|
||||||
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
||||||
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
||||||
@@ -103,25 +101,15 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
|||||||
};
|
};
|
||||||
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
|
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
effectiveServerUrl,
|
|
||||||
isUsingLocalUrl,
|
|
||||||
currentSSID: ssid,
|
|
||||||
connectedToWifi,
|
|
||||||
refreshUrlState,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
effectiveServerUrl,
|
|
||||||
isUsingLocalUrl,
|
|
||||||
ssid,
|
|
||||||
connectedToWifi,
|
|
||||||
refreshUrlState,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerUrlContext.Provider value={value}>
|
<ServerUrlContext.Provider
|
||||||
|
value={{
|
||||||
|
effectiveServerUrl,
|
||||||
|
isUsingLocalUrl,
|
||||||
|
currentSSID: ssid,
|
||||||
|
refreshUrlState,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ServerUrlContext.Provider>
|
</ServerUrlContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { getSSID } from "@/modules/wifi-ssid";
|
|
||||||
|
|
||||||
export type PermissionStatus =
|
|
||||||
| "granted"
|
|
||||||
| "denied"
|
|
||||||
| "undetermined"
|
|
||||||
| "unavailable";
|
|
||||||
|
|
||||||
export interface UseWifiSSIDReturn {
|
|
||||||
ssid: string | null;
|
|
||||||
connectedToWifi: boolean;
|
|
||||||
permissionStatus: PermissionStatus;
|
|
||||||
requestPermission: () => Promise<boolean>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WifiSsidContext = createContext<UseWifiSSIDReturn | null>(null);
|
|
||||||
|
|
||||||
// WiFi SSID is not available on tvOS, and expo-location isn't needed there.
|
|
||||||
const Location = Platform.isTV ? null : require("expo-location");
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 10_000;
|
|
||||||
|
|
||||||
function mapLocationStatus(status: number | undefined): PermissionStatus {
|
|
||||||
if (!Location) return "unavailable";
|
|
||||||
switch (status) {
|
|
||||||
case Location.PermissionStatus?.GRANTED:
|
|
||||||
return "granted";
|
|
||||||
case Location.PermissionStatus?.DENIED:
|
|
||||||
return "denied";
|
|
||||||
default:
|
|
||||||
return "undetermined";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the current WiFi SSID and the location permission
|
|
||||||
* that gates it. State is shared via context so that granting permission in one
|
|
||||||
* place (e.g. the network settings screen) is reflected everywhere else — most
|
|
||||||
* importantly ServerUrlProvider, which switches between the local and remote
|
|
||||||
* server URLs based on the connected SSID.
|
|
||||||
*
|
|
||||||
* Previously each consumer created its own useWifiSSID instance with independent
|
|
||||||
* state, so the provider never learned that permission had been granted later
|
|
||||||
* in the session and reported "not connected to WiFi" forever.
|
|
||||||
*/
|
|
||||||
export function WifiSsidProvider({ children }: Props): React.ReactElement {
|
|
||||||
const [ssid, setSSID] = useState<string | null>(null);
|
|
||||||
const [connectedToWifi, setConnectedToWifi] = useState(false);
|
|
||||||
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
|
|
||||||
Platform.isTV ? "unavailable" : "undetermined",
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(!Platform.isTV);
|
|
||||||
|
|
||||||
const fetchSSID = useCallback(async () => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
const result = await getSSID();
|
|
||||||
setSSID(result.ssid);
|
|
||||||
setConnectedToWifi(result.connectedToWifi);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
|
||||||
if (Platform.isTV || !Location) {
|
|
||||||
setPermissionStatus("unavailable");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
||||||
const newStatus = mapLocationStatus(status);
|
|
||||||
setPermissionStatus(newStatus);
|
|
||||||
return newStatus === "granted";
|
|
||||||
} catch {
|
|
||||||
setPermissionStatus("unavailable");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check the location permission once on mount. The poll effect below takes
|
|
||||||
// over fetching the SSID whenever permission becomes "granted".
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV || !Location) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
async function initialize() {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const { status } = await Location.getForegroundPermissionsAsync();
|
|
||||||
if (cancelled) return;
|
|
||||||
setPermissionStatus(mapLocationStatus(status));
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setPermissionStatus("unavailable");
|
|
||||||
}
|
|
||||||
if (!cancelled) setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch the SSID immediately, then poll, whenever permission is granted.
|
|
||||||
// Because permissionStatus is shared, this also covers the case where
|
|
||||||
// permission was granted later (from another consumer) than this provider's
|
|
||||||
// mount time — the original root cause of the "not connected to WiFi" bug.
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV || permissionStatus !== "granted") return;
|
|
||||||
|
|
||||||
fetchSSID();
|
|
||||||
const interval = setInterval(fetchSSID, POLL_INTERVAL_MS);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [permissionStatus, fetchSSID]);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
ssid,
|
|
||||||
connectedToWifi,
|
|
||||||
permissionStatus,
|
|
||||||
requestPermission,
|
|
||||||
isLoading,
|
|
||||||
}),
|
|
||||||
[ssid, connectedToWifi, permissionStatus, requestPermission, isLoading],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WifiSsidContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</WifiSsidContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWifiSsid(): UseWifiSSIDReturn {
|
|
||||||
const context = useContext(WifiSsidContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useWifiSsid must be used within WifiSsidProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occurred": "An unexpected error occurred",
|
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -174,10 +174,7 @@
|
|||||||
"network_already_added": "Network already added",
|
"network_already_added": "Network already added",
|
||||||
"no_wifi_connected": "Not connected to Wi-Fi",
|
"no_wifi_connected": "Not connected to Wi-Fi",
|
||||||
"permission_denied": "Location permission denied",
|
"permission_denied": "Location permission denied",
|
||||||
"permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings.",
|
"permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings."
|
||||||
"ssid_hidden": "SSID hidden",
|
|
||||||
"location_off_description": "Connected to Wi-Fi, but the network name is hidden. Enable device Location services and grant the app location permission to detect it.",
|
|
||||||
"open_location_settings": "Open Location settings"
|
|
||||||
},
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "User info",
|
"user_info_title": "User info",
|
||||||
@@ -191,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_authorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -301,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -387,8 +384,6 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
|
||||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -603,7 +598,7 @@
|
|||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user