diff --git a/app.json b/app.json index 0261dd8a..288f3e3a 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,7 @@ "NSMicrophoneUsageDescription": "The app needs access to your microphone.", "UIBackgroundModes": ["audio", "fetch"], "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": { "NSAllowsArbitraryLoads": true }, @@ -28,6 +29,9 @@ "usesNonExemptEncryption": false }, "supportsTablet": true, + "entitlements": { + "com.apple.developer.networking.wifi-info": true + }, "bundleIdentifier": "com.fredrikburmester.streamyfin", "icon": "./assets/images/icon-ios-liquid-glass.icon", "appleTeamId": "MWD5K362T8" @@ -44,7 +48,8 @@ "permissions": [ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", - "android.permission.WRITE_SETTINGS" + "android.permission.WRITE_SETTINGS", + "android.permission.ACCESS_FINE_LOCATION" ], "blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"], "googleServicesFile": "./google-services.json" diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 8886cde2..7cef4d13 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -329,6 +329,24 @@ export default function IndexLayout() { ), }} /> + ( + _router.back()} + className='pl-0.5' + style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} + > + + + ), + }} + /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ))} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 3372e8e0..415c93bf 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -90,6 +90,11 @@ export default function settings() { showArrow title={t("home.settings.intro.title")} /> + router.push("/settings/network/page")} + showArrow + title={t("home.settings.network.title")} + /> router.push("/settings/logs/page")} showArrow diff --git a/app/(auth)/(tabs)/(home)/settings/network/page.tsx b/app/(auth)/(tabs)/(home)/settings/network/page.tsx new file mode 100644 index 00000000..9f3727e4 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/network/page.tsx @@ -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 ( + + + + + + + + + + + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 9d13871e..3de9dd1b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -22,6 +22,7 @@ import { import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; +import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { useSettings } from "@/utils/atoms/settings"; import { @@ -384,77 +385,79 @@ function Layout() { }} > - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/bun.lock b/bun.lock index ecc78ccc..c09c7e16 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", + "expo-location": "^19.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.21", "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-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-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=="], diff --git a/components/settings/LocalNetworkSettings.tsx b/components/settings/LocalNetworkSettings.tsx new file mode 100644 index 00000000..8bf99dbd --- /dev/null +++ b/components/settings/LocalNetworkSettings.tsx @@ -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 ( + + + + {t("home.settings.network.current_wifi")} + + {wifiStatus} + + + + {t("home.settings.network.using_url")} + + {urlType} + + + ); +} + +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(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 ( + + + + + + + + {config.enabled && ( + + + {t("home.settings.network.local_url_hint")} + + } + > + + + + + + + {config.homeWifiSSIDs.map((wifiSSID) => ( + + handleRemoveNetwork(wifiSSID)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + ))} + {config.homeWifiSSIDs.length === 0 && ( + + )} + + + + + + + + + )} + + {permissionStatus === "denied" && ( + + + {t("home.settings.network.permission_denied_explanation")} + + + )} + + ); +} diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts new file mode 100644 index 00000000..05005cfe --- /dev/null +++ b/hooks/useWifiSSID.ts @@ -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; + 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(null); + const [permissionStatus, setPermissionStatus] = + useState("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 => { + 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, + }; +} diff --git a/modules/wifi-ssid/expo-module.config.json b/modules/wifi-ssid/expo-module.config.json new file mode 100644 index 00000000..0b38b99e --- /dev/null +++ b/modules/wifi-ssid/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "wifi-ssid", + "version": "1.0.0", + "platforms": ["ios"], + "ios": { + "modules": ["WifiSsidModule"] + } +} diff --git a/modules/wifi-ssid/index.ts b/modules/wifi-ssid/index.ts new file mode 100644 index 00000000..71bfbcfb --- /dev/null +++ b/modules/wifi-ssid/index.ts @@ -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 { + 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; + } +} diff --git a/modules/wifi-ssid/ios/WifiSsid.podspec b/modules/wifi-ssid/ios/WifiSsid.podspec new file mode 100644 index 00000000..427a477d --- /dev/null +++ b/modules/wifi-ssid/ios/WifiSsid.podspec @@ -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 diff --git a/modules/wifi-ssid/ios/WifiSsidModule.swift b/modules/wifi-ssid/ios/WifiSsidModule.swift new file mode 100644 index 00000000..0a2a5faa --- /dev/null +++ b/modules/wifi-ssid/ios/WifiSsidModule.swift @@ -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 + } +} diff --git a/package.json b/package.json index 202e60dd..4ae95599 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", + "expo-location": "^19.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.21", "expo-screen-orientation": "~9.0.8", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index e12829af..fa1615ab 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -74,6 +74,7 @@ interface JellyfinContextValue { password: string, ) => Promise; removeSavedCredential: (serverUrl: string, userId: string) => Promise; + switchServerUrl: (newUrl: string) => void; } const JellyfinContext = createContext( @@ -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 [initialLoaded, setInitialLoaded] = useState(false); @@ -541,6 +554,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }), removeSavedCredential: (serverUrl, userId) => removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }), + switchServerUrl, }; useEffect(() => { diff --git a/providers/ServerUrlProvider.tsx b/providers/ServerUrlProvider.tsx new file mode 100644 index 00000000..17ce1773 --- /dev/null +++ b/providers/ServerUrlProvider.tsx @@ -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(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( + null, + ); + + const remoteUrlRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const lastSSIDRef = useRef(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 ( + + {children} + + ); +} + +export function useServerUrl(): ServerUrlContextValue { + const context = useContext(ServerUrlContext); + if (!context) { + throw new Error("useServerUrl must be used within ServerUrlProvider"); + } + return context; +} diff --git a/translations/en.json b/translations/en.json index e0da1638..7b9320be 100644 --- a/translations/en.json +++ b/translations/en.json @@ -123,6 +123,34 @@ "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "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_title": "User Info", "user": "User", diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index 2172d526..f64a56d7 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -34,6 +34,15 @@ export interface SavedServerAccount { savedAt: number; } +/** + * Local network configuration for automatic URL switching. + */ +export interface LocalNetworkConfig { + localUrl: string; + homeWifiSSIDs: string[]; + enabled: boolean; +} + /** * Server with multiple saved accounts. */ @@ -41,6 +50,7 @@ export interface SavedServer { address: string; name?: string; accounts: SavedServerAccount[]; + localNetworkConfig?: LocalNetworkConfig; } /** @@ -345,6 +355,37 @@ export function addServerToList(serverUrl: string, serverName?: string): void { 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. * Should be called on app startup.