mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat(network): add local network auto-switch feature (#1334)
This commit is contained in:
committed by
GitHub
parent
ac9ac5d423
commit
467bea7192
7
app.json
7
app.json
@@ -17,6 +17,7 @@
|
|||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": ["audio", "fetch"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
|
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
},
|
},
|
||||||
@@ -28,6 +29,9 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.networking.wifi-info": true
|
||||||
|
},
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
],
|
],
|
||||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
|
|||||||
@@ -329,6 +329,24 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/network/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.network.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export default function settings() {
|
|||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.intro.title")}
|
title={t("home.settings.intro.title")}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
|
|||||||
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
48
app/(auth)/(tabs)/(home)/settings/network/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function NetworkSettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.network.current_server")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.remote_url")}
|
||||||
|
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.active_url")}
|
||||||
|
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<LocalNetworkSettings />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/_layout.tsx
141
app/_layout.tsx
@@ -22,6 +22,7 @@ import {
|
|||||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -384,77 +385,79 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<ServerUrlProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<IntroSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<IntroSheetProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
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>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<GlobalModal />
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</IntroSheetProvider>
|
||||||
headerShown: false,
|
</BottomSheetModalProvider>
|
||||||
title: "",
|
</GlobalModalProvider>
|
||||||
header: () => null,
|
</MusicPlayerProvider>
|
||||||
}}
|
</DownloadProvider>
|
||||||
/>
|
</WebSocketProvider>
|
||||||
<Stack.Screen
|
</LogProvider>
|
||||||
name='(auth)/now-playing'
|
</PlaySettingsProvider>
|
||||||
options={{
|
</NetworkStatusProvider>
|
||||||
headerShown: false,
|
</ServerUrlProvider>
|
||||||
presentation: "modal",
|
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
<GlobalModal />
|
|
||||||
</ThemeProvider>
|
|
||||||
</IntroSheetProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</NetworkStatusProvider>
|
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -39,6 +39,7 @@
|
|||||||
"expo-linear-gradient": "~15.0.8",
|
"expo-linear-gradient": "~15.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-localization": "~17.0.8",
|
"expo-localization": "~17.0.8",
|
||||||
|
"expo-location": "^19.0.8",
|
||||||
"expo-notifications": "~0.32.16",
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-router": "~6.0.21",
|
"expo-router": "~6.0.21",
|
||||||
"expo-screen-orientation": "~9.0.8",
|
"expo-screen-orientation": "~9.0.8",
|
||||||
@@ -1050,6 +1051,8 @@
|
|||||||
|
|
||||||
"expo-localization": ["expo-localization@17.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g=="],
|
"expo-localization": ["expo-localization@17.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g=="],
|
||||||
|
|
||||||
|
"expo-location": ["expo-location@19.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA=="],
|
||||||
|
|
||||||
"expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="],
|
"expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="],
|
||||||
|
|
||||||
"expo-modules-autolinking": ["expo-modules-autolinking@3.0.24", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ=="],
|
"expo-modules-autolinking": ["expo-modules-autolinking@3.0.24", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ=="],
|
||||||
|
|||||||
224
components/settings/LocalNetworkSettings.tsx
Normal file
224
components/settings/LocalNetworkSettings.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import {
|
||||||
|
getServerLocalConfig,
|
||||||
|
type LocalNetworkConfig,
|
||||||
|
updateServerLocalConfig,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: LocalNetworkConfig = {
|
||||||
|
localUrl: "",
|
||||||
|
homeWifiSSIDs: [],
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusDisplayProps {
|
||||||
|
currentSSID: string | null;
|
||||||
|
isUsingLocalUrl: boolean;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDisplay({
|
||||||
|
currentSSID,
|
||||||
|
isUsingLocalUrl,
|
||||||
|
t,
|
||||||
|
}: StatusDisplayProps): React.ReactElement {
|
||||||
|
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
|
||||||
|
const urlType = isUsingLocalUrl
|
||||||
|
? t("home.settings.network.local")
|
||||||
|
: t("home.settings.network.remote");
|
||||||
|
const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='px-4 py-2 bg-neutral-900 rounded-xl mt-4'>
|
||||||
|
<View className='flex-row justify-between items-center py-1'>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
{t("home.settings.network.current_wifi")}
|
||||||
|
</Text>
|
||||||
|
<Text>{wifiStatus}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row justify-between items-center py-1'>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
{t("home.settings.network.using_url")}
|
||||||
|
</Text>
|
||||||
|
<Text className={urlTypeColor}>{urlType}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { permissionStatus, requestPermission } = useWifiSSID();
|
||||||
|
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
|
||||||
|
|
||||||
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
|
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteUrl) {
|
||||||
|
const existingConfig = getServerLocalConfig(remoteUrl);
|
||||||
|
if (existingConfig) {
|
||||||
|
setConfig(existingConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [remoteUrl]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(
|
||||||
|
(newConfig: LocalNetworkConfig) => {
|
||||||
|
if (!remoteUrl) return;
|
||||||
|
setConfig(newConfig);
|
||||||
|
updateServerLocalConfig(remoteUrl, newConfig);
|
||||||
|
// Trigger URL re-evaluation after config change
|
||||||
|
refreshUrlState();
|
||||||
|
},
|
||||||
|
[remoteUrl, refreshUrlState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleEnabled = useCallback(
|
||||||
|
async (enabled: boolean) => {
|
||||||
|
if (enabled && permissionStatus !== "granted") {
|
||||||
|
const granted = await requestPermission();
|
||||||
|
if (!granted) {
|
||||||
|
toast.error(t("home.settings.network.permission_denied"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveConfig({ ...config, enabled });
|
||||||
|
},
|
||||||
|
[config, permissionStatus, requestPermission, saveConfig, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLocalUrlChange = useCallback(
|
||||||
|
(localUrl: string) => {
|
||||||
|
saveConfig({ ...config, localUrl });
|
||||||
|
},
|
||||||
|
[config, saveConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddCurrentNetwork = useCallback(() => {
|
||||||
|
if (!currentSSID) {
|
||||||
|
toast.error(t("home.settings.network.no_wifi_connected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.homeWifiSSIDs.includes(currentSSID)) {
|
||||||
|
toast.info(t("home.settings.network.network_already_added"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveConfig({
|
||||||
|
...config,
|
||||||
|
homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.network.network_added"));
|
||||||
|
}, [config, currentSSID, saveConfig, t]);
|
||||||
|
|
||||||
|
const handleRemoveNetwork = useCallback(
|
||||||
|
(ssidToRemove: string) => {
|
||||||
|
saveConfig({
|
||||||
|
...config,
|
||||||
|
homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config, saveConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!remoteUrl) return null;
|
||||||
|
|
||||||
|
const addNetworkButtonText = currentSSID
|
||||||
|
? t("home.settings.network.add_current_network", { ssid: currentSSID })
|
||||||
|
: t("home.settings.network.not_connected_to_wifi");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.network.local_network")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
|
>
|
||||||
|
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
{config.enabled && (
|
||||||
|
<View className='pt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.network.local_url")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.network.local_url_hint")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className=''>
|
||||||
|
<Input
|
||||||
|
placeholder={t("home.settings.network.local_url_placeholder")}
|
||||||
|
value={config.localUrl}
|
||||||
|
onChangeText={handleLocalUrlChange}
|
||||||
|
keyboardType='url'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.network.home_wifi_networks")}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
{config.homeWifiSSIDs.map((wifiSSID) => (
|
||||||
|
<ListItem key={wifiSSID} title={wifiSSID}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleRemoveNetwork(wifiSSID)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='close-circle' size={22} color='#EF4444' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{config.homeWifiSSIDs.length === 0 && (
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.network.no_networks_configured")}
|
||||||
|
subtitle={t("home.settings.network.add_network_hint")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='py-2'>
|
||||||
|
<Button
|
||||||
|
onPress={handleAddCurrentNetwork}
|
||||||
|
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||||
|
>
|
||||||
|
{addNetworkButtonText}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StatusDisplay
|
||||||
|
currentSSID={currentSSID}
|
||||||
|
isUsingLocalUrl={isUsingLocalUrl}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissionStatus === "denied" && (
|
||||||
|
<View className='py-2'>
|
||||||
|
<Text className='text-xs text-red-500'>
|
||||||
|
{t("home.settings.network.permission_denied_explanation")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
hooks/useWifiSSID.ts
Normal file
97
hooks/useWifiSSID.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as Location from "expo-location";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getSSID } from "@/modules/wifi-ssid";
|
||||||
|
|
||||||
|
export type PermissionStatus =
|
||||||
|
| "granted"
|
||||||
|
| "denied"
|
||||||
|
| "undetermined"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export interface UseWifiSSIDReturn {
|
||||||
|
ssid: string | null;
|
||||||
|
permissionStatus: PermissionStatus;
|
||||||
|
requestPermission: () => Promise<boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLocationStatus(
|
||||||
|
status: Location.PermissionStatus,
|
||||||
|
): PermissionStatus {
|
||||||
|
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>("undetermined");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchSSID = useCallback(async () => {
|
||||||
|
const result = await getSSID();
|
||||||
|
console.log("[WiFi Debug] Native module SSID:", result);
|
||||||
|
setSSID(result);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||||
|
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(() => {
|
||||||
|
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 (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,
|
||||||
|
};
|
||||||
|
}
|
||||||
8
modules/wifi-ssid/expo-module.config.json
Normal file
8
modules/wifi-ssid/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "wifi-ssid",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"platforms": ["ios"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["WifiSsidModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
45
modules/wifi-ssid/index.ts
Normal file
45
modules/wifi-ssid/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Platform, requireNativeModule } from "expo-modules-core";
|
||||||
|
|
||||||
|
// Only load the native module on iOS
|
||||||
|
const WifiSsidModule =
|
||||||
|
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current WiFi SSID on iOS.
|
||||||
|
* Returns null on Android or if not connected to WiFi.
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - Location permission granted
|
||||||
|
* - com.apple.developer.networking.wifi-info entitlement
|
||||||
|
* - Access WiFi Information capability enabled in Apple Developer Portal
|
||||||
|
*/
|
||||||
|
export async function getSSID(): Promise<string | null> {
|
||||||
|
if (!WifiSsidModule) {
|
||||||
|
console.log("[WifiSsid] Module not available on this platform");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ssid = await WifiSsidModule.getSSID();
|
||||||
|
return ssid ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WifiSsid] Error getting SSID:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
|
||||||
|
*/
|
||||||
|
export function getSSIDSync(): string | null {
|
||||||
|
if (!WifiSsidModule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return WifiSsidModule.getSSIDSync() ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
modules/wifi-ssid/ios/WifiSsid.podspec
Normal file
22
modules/wifi-ssid/ios/WifiSsid.podspec
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'WifiSsid'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Get WiFi SSID on iOS'
|
||||||
|
s.description = 'Native iOS module to get current WiFi SSID using NEHotspotNetwork'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://docs.expo.dev/modules/'
|
||||||
|
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.frameworks = 'NetworkExtension', 'SystemConfiguration'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
52
modules/wifi-ssid/ios/WifiSsidModule.swift
Normal file
52
modules/wifi-ssid/ios/WifiSsidModule.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import NetworkExtension
|
||||||
|
import SystemConfiguration.CaptiveNetwork
|
||||||
|
|
||||||
|
public class WifiSsidModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("WifiSsid")
|
||||||
|
|
||||||
|
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
|
||||||
|
AsyncFunction("getSSID") { () -> String? in
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
NEHotspotNetwork.fetchCurrent { network in
|
||||||
|
if let ssid = network?.ssid {
|
||||||
|
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
|
||||||
|
continuation.resume(returning: ssid)
|
||||||
|
} else {
|
||||||
|
// Fallback to CNCopyCurrentNetworkInfo for older iOS
|
||||||
|
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
|
||||||
|
let ssid = self.getSSIDViaCNCopy()
|
||||||
|
continuation.resume(returning: ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous version using only CNCopyCurrentNetworkInfo
|
||||||
|
Function("getSSIDSync") { () -> String? in
|
||||||
|
return self.getSSIDViaCNCopy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSSIDViaCNCopy() -> String? {
|
||||||
|
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
||||||
|
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for interface in interfaces {
|
||||||
|
guard let networkInfo = CNCopyCurrentNetworkInfo(interface as CFString) as? [String: Any] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
||||||
|
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
|
||||||
|
return ssid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"expo-linear-gradient": "~15.0.8",
|
"expo-linear-gradient": "~15.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-localization": "~17.0.8",
|
"expo-localization": "~17.0.8",
|
||||||
|
"expo-location": "^19.0.8",
|
||||||
"expo-notifications": "~0.32.16",
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-router": "~6.0.21",
|
"expo-router": "~6.0.21",
|
||||||
"expo-screen-orientation": "~9.0.8",
|
"expo-screen-orientation": "~9.0.8",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface JellyfinContextValue {
|
|||||||
password: string,
|
password: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
|
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
|
||||||
|
switchServerUrl: (newUrl: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
@@ -466,6 +467,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const switchServerUrl = useCallback(
|
||||||
|
(newUrl: string) => {
|
||||||
|
if (!jellyfin || !api?.accessToken) return;
|
||||||
|
|
||||||
|
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
||||||
|
setApi(newApi);
|
||||||
|
// Note: We don't update storage.set("serverUrl") here
|
||||||
|
// because we want to keep the original remote URL as the "primary" URL
|
||||||
|
},
|
||||||
|
[jellyfin, api?.accessToken],
|
||||||
|
);
|
||||||
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||||
|
|
||||||
@@ -541,6 +554,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
||||||
removeSavedCredential: (serverUrl, userId) =>
|
removeSavedCredential: (serverUrl, userId) =>
|
||||||
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||||
|
switchServerUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
139
providers/ServerUrlProvider.tsx
Normal file
139
providers/ServerUrlProvider.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { getServerLocalConfig } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface ServerUrlContextValue {
|
||||||
|
effectiveServerUrl: string | null;
|
||||||
|
isUsingLocalUrl: boolean;
|
||||||
|
currentSSID: string | null;
|
||||||
|
refreshUrlState: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerUrlContext = createContext<ServerUrlContextValue | null>(null);
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { switchServerUrl } = useJellyfin();
|
||||||
|
const { ssid, permissionStatus } = useWifiSSID();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[ServerUrlProvider] ssid:",
|
||||||
|
ssid,
|
||||||
|
"permissionStatus:",
|
||||||
|
permissionStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
||||||
|
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteUrlRef = useRef<string | null>(null);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastSSIDRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Sync remoteUrl from storage when api changes
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUrl = storage.getString("serverUrl");
|
||||||
|
if (storedUrl) {
|
||||||
|
remoteUrlRef.current = storedUrl;
|
||||||
|
}
|
||||||
|
if (api?.basePath && !effectiveServerUrl) {
|
||||||
|
setEffectiveServerUrl(api.basePath);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, effectiveServerUrl]);
|
||||||
|
|
||||||
|
// Function to evaluate and switch URL based on current config and SSID
|
||||||
|
const evaluateAndSwitchUrl = useCallback(() => {
|
||||||
|
const remoteUrl = remoteUrlRef.current;
|
||||||
|
if (!remoteUrl || !switchServerUrl) return;
|
||||||
|
|
||||||
|
const config = getServerLocalConfig(remoteUrl);
|
||||||
|
const shouldUseLocal = Boolean(
|
||||||
|
config?.enabled &&
|
||||||
|
config.localUrl &&
|
||||||
|
ssid !== null &&
|
||||||
|
config.homeWifiSSIDs.includes(ssid),
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl;
|
||||||
|
|
||||||
|
console.log("[ServerUrlProvider] evaluateAndSwitchUrl:", {
|
||||||
|
ssid,
|
||||||
|
shouldUseLocal,
|
||||||
|
targetUrl,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
switchServerUrl(targetUrl);
|
||||||
|
setIsUsingLocalUrl(shouldUseLocal);
|
||||||
|
setEffectiveServerUrl(targetUrl);
|
||||||
|
}, [ssid, switchServerUrl]);
|
||||||
|
|
||||||
|
// Manual refresh function for when config changes
|
||||||
|
const refreshUrlState = useCallback(() => {
|
||||||
|
console.log("[ServerUrlProvider] refreshUrlState called");
|
||||||
|
evaluateAndSwitchUrl();
|
||||||
|
}, [evaluateAndSwitchUrl]);
|
||||||
|
|
||||||
|
// Debounced SSID change handler
|
||||||
|
useEffect(() => {
|
||||||
|
if (permissionStatus !== "granted") return;
|
||||||
|
if (ssid === lastSSIDRef.current) return;
|
||||||
|
|
||||||
|
lastSSIDRef.current = ssid;
|
||||||
|
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
evaluateAndSwitchUrl();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerUrlContext.Provider
|
||||||
|
value={{
|
||||||
|
effectiveServerUrl,
|
||||||
|
isUsingLocalUrl,
|
||||||
|
currentSSID: ssid,
|
||||||
|
refreshUrlState,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ServerUrlContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerUrl(): ServerUrlContextValue {
|
||||||
|
const context = useContext(ServerUrlContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useServerUrl must be used within ServerUrlProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -123,6 +123,34 @@
|
|||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Network",
|
||||||
|
"local_network": "Local Network",
|
||||||
|
"auto_switch_enabled": "Auto-switch when at home",
|
||||||
|
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||||
|
"local_url": "Local URL",
|
||||||
|
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||||
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
|
"home_wifi_networks": "Home WiFi Networks",
|
||||||
|
"add_current_network": "Add \"{{ssid}}\"",
|
||||||
|
"not_connected_to_wifi": "Not connected to WiFi",
|
||||||
|
"no_networks_configured": "No networks configured",
|
||||||
|
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||||
|
"current_wifi": "Current WiFi",
|
||||||
|
"using_url": "Using",
|
||||||
|
"local": "Local URL",
|
||||||
|
"remote": "Remote URL",
|
||||||
|
"not_connected": "Not connected",
|
||||||
|
"current_server": "Current Server",
|
||||||
|
"remote_url": "Remote URL",
|
||||||
|
"active_url": "Active URL",
|
||||||
|
"not_configured": "Not configured",
|
||||||
|
"network_added": "Network added",
|
||||||
|
"network_already_added": "Network already added",
|
||||||
|
"no_wifi_connected": "Not connected to WiFi",
|
||||||
|
"permission_denied": "Location permission denied",
|
||||||
|
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||||
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "User Info",
|
"user_info_title": "User Info",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export interface SavedServerAccount {
|
|||||||
savedAt: number;
|
savedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local network configuration for automatic URL switching.
|
||||||
|
*/
|
||||||
|
export interface LocalNetworkConfig {
|
||||||
|
localUrl: string;
|
||||||
|
homeWifiSSIDs: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server with multiple saved accounts.
|
* Server with multiple saved accounts.
|
||||||
*/
|
*/
|
||||||
@@ -41,6 +50,7 @@ export interface SavedServer {
|
|||||||
address: string;
|
address: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
accounts: SavedServerAccount[];
|
accounts: SavedServerAccount[];
|
||||||
|
localNetworkConfig?: LocalNetworkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,6 +355,37 @@ export function addServerToList(serverUrl: string, serverName?: string): void {
|
|||||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update local network configuration for a server.
|
||||||
|
*/
|
||||||
|
export function updateServerLocalConfig(
|
||||||
|
serverUrl: string,
|
||||||
|
config: LocalNetworkConfig | undefined,
|
||||||
|
): void {
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
const updatedServers = servers.map((server) => {
|
||||||
|
if (server.address === serverUrl) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
localNetworkConfig: config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local network configuration for a server.
|
||||||
|
*/
|
||||||
|
export function getServerLocalConfig(
|
||||||
|
serverUrl: string,
|
||||||
|
): LocalNetworkConfig | undefined {
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
const server = servers.find((s) => s.address === serverUrl);
|
||||||
|
return server?.localNetworkConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate from legacy single-account format to multi-account format.
|
* Migrate from legacy single-account format to multi-account format.
|
||||||
* Should be called on app startup.
|
* Should be called on app startup.
|
||||||
|
|||||||
Reference in New Issue
Block a user