Compare commits

..

5 Commits

Author SHA1 Message Date
Gauvain
a2802b4570 Merge remote-tracking branch 'origin/develop' into fix/settings-spacing
# Conflicts:
#	components/settings/AppearanceSettings.tsx
2026-07-17 16:12:03 +02:00
Gauvain
e79ae53e4e feat(appearance): episode images toggle for Next Up & Continue Watching (#1817)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-17 15:13:27 +02:00
Gauvain
d5d0976ff9 Merge branch 'develop' into fix/settings-spacing 2026-07-17 12:28:44 +02:00
lance chant
dbdf4767a5 Merge branch 'develop' into fix/settings-spacing 2026-07-17 08:08:48 +02:00
Gauvain
bee8323110 fix(settings): tidy spacing, uniform Android row heights & toggle layout 2026-07-15 18:49:20 +02:00
37 changed files with 521 additions and 761 deletions

View File

@@ -854,6 +854,13 @@ export default function SettingsTV() {
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.use_episode_images_next_up")}
value={settings.useEpisodeImagesForNextUp}
onToggle={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}

View File

@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
>
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
className='px-4 pt-4'
>
<ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
<SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({

View File

@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
<SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({

View File

@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
<SettingSwitch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
<SettingSwitch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
})}
/>
</ListGroup>
<ListGroup>
<ListGroup className='mt-4'>
<ListItem
textColor='red'
onPress={onDeleteDownloadedSongsClicked}

View File

@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<View>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
@@ -72,7 +72,7 @@ export default function MarlinSearchPage() {
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4'>
<DisabledSetting disabled={disabled} className='px-4 pt-4'>
<ListGroup>
<DisabledSetting
disabled={
@@ -90,7 +90,7 @@ export default function MarlinSearchPage() {
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
<SettingSwitch
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
@@ -134,7 +134,7 @@ export default function StreamystatsPage() {
paddingRight: insets.right,
}}
>
<View className='px-4'>
<View className='px-4 pt-4'>
<ListGroup className='flex-1'>
<ListItem
title={t("home.settings.plugins.streamystats.url")}
@@ -173,7 +173,7 @@ export default function StreamystatsPage() {
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
>
<Switch
<SettingSwitch
value={useForSearch}
disabled={!isStreamystatsEnabled}
onValueChange={setUseForSearch}
@@ -187,7 +187,7 @@ export default function StreamystatsPage() {
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
>
<Switch
<SettingSwitch
value={movieRecs}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
@@ -201,7 +201,7 @@ export default function StreamystatsPage() {
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
>
<Switch
<SettingSwitch
value={seriesRecs}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
@@ -215,7 +215,7 @@ export default function StreamystatsPage() {
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
>
<Switch
<SettingSwitch
value={promotedWatchlists}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
@@ -225,7 +225,7 @@ export default function StreamystatsPage() {
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
>
<Switch
<SettingSwitch
value={hideWatchlistsTab}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}

View File

@@ -27,7 +27,6 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
@@ -426,146 +425,143 @@ function Layout() {
>
<JellyfinProvider>
<InactivityProvider>
<WifiSsidProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
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.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",
},
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</WifiSsidProvider>
<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 />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider>
</JellyfinProvider>
</PersistQueryClientProvider>

View File

@@ -0,0 +1,40 @@
import type React from "react";
import { Platform, Switch, type SwitchProps, View } from "react-native";
/**
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
* FIXED-SIZE box (overflow hidden) and center it:
* - the fixed height caps the row height (compact, uniform rows),
* - the fixed width + centering keep the switch in the exact same spot in the
* on/off states (a non-fixed wrapper let its width fluctuate between states,
* which shifted the switch sideways on toggle).
* iOS renders the switch untouched.
*
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
*/
const BOX_W = 40;
const BOX_H = 30;
const SCALE = 0.9;
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
if (Platform.OS !== "android") return <Switch {...props} />;
return (
<View
style={{
width: BOX_W,
height: BOX_H,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Switch
{...props}
style={[props.style, { transform: [{ scale: SCALE }] }]}
/>
</View>
);
};

View File

@@ -15,6 +15,7 @@ import {
import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { Colors } from "../../constants/Colors";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [isSuccess, onLoaded]);
const { t } = useTranslation();
const { settings } = useSettings();
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
const allItems = useMemo(() => {
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
<ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />

View File

@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
});
const { t } = useTranslation();
const { settings } = useSettings();
const allItems = useMemo(() => {
const items = data?.pages.flat() ?? [];
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
width={itemWidth}
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
/>
</View>
);
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions,
handleItemFocus,
ITEM_GAP,
settings?.useEpisodeImagesForNextUp,
],
);

View File

@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView";
import { useSettings } from "@/utils/atoms/settings";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
});
const { t } = useTranslation();
const { settings } = useSettings();
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
<ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />

View File

@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
return (
<View {...props}>
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
{title}
</Text>
{title ? (
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
{title}
</Text>
) : null}
<View
style={[]}
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import type { PropsWithChildren, ReactNode } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text";
interface Props extends ViewProps {
@@ -34,12 +34,17 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
}) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
const isDisabled = disabled || disabledByAdmin;
// Keep the row floor uniform; Android trims padding slightly (its native
// controls sit taller). Switch height is capped via SettingSwitch so toggle
// rows match non-toggle rows.
const rowSizing =
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
if (onPress)
return (
<TouchableOpacity
disabled={isDisabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
<ListItemContent
@@ -58,7 +63,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...viewProps}
>
<ListItemContent

View File

@@ -1,7 +1,8 @@
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native";
import { Linking } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
<ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
)
}
>
<Switch
<SettingSwitch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
</ListItem>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
subtitle={t(
"home.settings.appearance.merge_next_up_continue_watching_hint",
)}
>
<Switch
<SettingSwitch
value={settings.mergeNextUpAndContinueWatching}
onValueChange={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.use_episode_images_next_up")}
subtitle={t(
"home.settings.appearance.use_episode_images_next_up_hint",
)}
>
<SettingSwitch
value={settings.useEpisodeImagesForNextUp}
onValueChange={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
subtitle={t(
"home.settings.appearance.hide_remote_session_button_hint",
)}
>
<SettingSwitch
value={settings.hideRemoteSessionButton}
onValueChange={(value) =>
updateSettings({ hideRemoteSessionButton: value })
}
/>
</ListItem>
<ListItem
onPress={() =>
router.push("/settings/appearance/hide-libraries/page")
}
title={t("home.settings.other.hide_libraries")}
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
showArrow
/>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
>
<Switch
value={settings.hideRemoteSessionButton}
onValueChange={(value) =>
updateSettings({ hideRemoteSessionButton: value })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch
<SettingSwitch
value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) =>

View File

@@ -1,4 +1,5 @@
import { Switch, View } from "react-native";
import { View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
<View {...props}>
<ListGroup title={"Chromecast"}>
<ListItem title={"Enable H265 for Chromecast"}>
<Switch
<SettingSwitch
value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast })

View File

@@ -2,7 +2,7 @@ import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native";
import { Switch } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
>
<Switch
<SettingSwitch
value={settings.enableHorizontalSwipeSkip}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
onValueChange={(enableHorizontalSwipeSkip) =>
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
>
<Switch
<SettingSwitch
value={settings.enableLeftSideBrightnessSwipe}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
onValueChange={(enableLeftSideBrightnessSwipe) =>
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
>
<Switch
<SettingSwitch
value={settings.enableRightSideVolumeSwipe}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
onValueChange={(enableRightSideVolumeSwipe) =>
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)}
disabled={pluginSettings?.hideVolumeSlider?.locked}
>
<Switch
<SettingSwitch
value={settings.hideVolumeSlider}
disabled={pluginSettings?.hideVolumeSlider?.locked}
onValueChange={(hideVolumeSlider) =>
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
>
<Switch
<SettingSwitch
value={settings.hideBrightnessSlider}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
onValueChange={(hideBrightnessSlider) =>

View File

@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Switch, Text, View } from "react-native";
import { Text, View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useSettings } from "@/utils/atoms/settings";
export const KefinTweaksSettings = () => {
@@ -20,7 +21,7 @@ export const KefinTweaksSettings = () => {
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
</Text>
<Switch
<SettingSwitch
value={isEnabled}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}

View File

@@ -2,10 +2,10 @@ 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 { TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useWifiSSID } from "@/hooks/useWifiSSID";
import { openLocationSettings } from "@/modules/wifi-ssid";
import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv";
import {
@@ -27,26 +27,16 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
interface StatusDisplayProps {
currentSSID: string | null;
connectedToWifi: boolean;
isUsingLocalUrl: boolean;
locationBlocked: boolean;
onOpenLocationSettings: () => void;
t: (key: string) => string;
}
function StatusDisplay({
currentSSID,
connectedToWifi,
isUsingLocalUrl,
locationBlocked,
onOpenLocationSettings,
t,
}: StatusDisplayProps): React.ReactElement {
const wifiStatus = currentSSID
? currentSSID
: connectedToWifi
? t("home.settings.network.ssid_hidden")
: t("home.settings.network.not_connected");
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl
? t("home.settings.network.local")
: t("home.settings.network.remote");
@@ -66,23 +56,6 @@ function StatusDisplay({
</Text>
<Text className={urlTypeColor}>{urlType}</Text>
</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>
);
}
@@ -90,17 +63,7 @@ function StatusDisplay({
export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID();
const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
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 { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
@@ -185,7 +148,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
title={t("home.settings.network.auto_switch_enabled")}
subtitle={t("home.settings.network.auto_switch_description")}
>
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
<SettingSwitch
value={config.enabled}
onValueChange={handleToggleEnabled}
/>
</ListItem>
</ListGroup>
@@ -233,23 +199,18 @@ export function LocalNetworkSettings(): React.ReactElement | null {
)}
</ListGroup>
{!locationBlocked && (
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
)}
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
<StatusDisplay
currentSSID={currentSSID}
connectedToWifi={connectedToWifi}
isUsingLocalUrl={isUsingLocalUrl}
locationBlocked={locationBlocked}
onOpenLocationSettings={handleOpenLocationSettings}
t={t}
/>
</View>

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { Platform, Switch, View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -122,7 +123,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
)}
<ListItem title='Opaque Background'>
<Switch
<SettingSwitch
value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) =>
updateSettings({ mpvSubtitleBackgroundEnabled: value })

View File

@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Switch, View } from "react-native";
import { Linking, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
<SettingSwitch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
)
}
>
<Switch
<SettingSwitch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
<SettingSwitch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>

View File

@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
return (
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''>
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={pluginSettings?.defaultVideoOrientation?.locked}
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
<SettingSwitch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
<SettingSwitch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.auto_play_next_episode")}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
>
<Switch
<SettingSwitch
value={settings.autoPlayNextEpisode}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
onValueChange={(autoPlayNextEpisode) =>

View File

@@ -125,7 +125,7 @@ export const StorageSettings = () => {
</View>
</View>
{!Platform.isTV && (
<ListGroup>
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
<ListItem
textColor='red'
onPress={onDeleteClicked}

View File

@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Input } from "@/components/common/Input";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Stepper } from "@/components/inputs/Stepper";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup
className='mb-4'
title={t("home.settings.subtitles.subtitle_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
>
<Switch
<SettingSwitch
value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) =>

View File

@@ -72,6 +72,9 @@ export interface TVPosterCardProps {
/** Custom image URL getter - if not provided, uses smart URL logic */
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** For horizontal episodes, prefer the episode's own image over the series thumb */
preferEpisodeImage?: boolean;
}
/**
@@ -108,6 +111,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
glowColor = "white",
scaleAmount = 1.05,
imageUrlGetter,
preferEpisodeImage = false,
}) => {
const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes();
@@ -139,6 +143,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
if (orientation === "horizontal") {
// Episode: prefer series thumb image for consistent look (like hero section)
if (item.Type === "Episode") {
// Opt-in: use the episode's own image instead of the series thumb.
if (preferEpisodeImage && item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
}
// First try parent/series thumb (horizontal series artwork).
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
@@ -174,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
item,
width: width * 2, // 2x for quality on large screens
});
}, [api, item, orientation, width, imageUrlGetter]);
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
// Progress calculation
const progress = useMemo(() => {

View File

@@ -1,5 +1,125 @@
export {
type PermissionStatus,
type UseWifiSSIDReturn,
useWifiSsid as useWifiSSID,
} from "@/providers/WifiSsidProvider";
import { useCallback, useEffect, 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;
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,
};
}

View File

@@ -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 {
}

View File

@@ -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>

View File

@@ -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 2628): 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
}
}

View File

@@ -1,11 +1,8 @@
{
"name": "wifi-ssid",
"version": "1.0.0",
"platforms": ["ios", "android"],
"platforms": ["ios"],
"ios": {
"modules": ["WifiSsidModule"]
},
"android": {
"modules": ["expo.modules.wifissid.WifiSsidModule"]
}
}

View File

@@ -1,92 +1,44 @@
import { Platform, requireNativeModule } from "expo-modules-core";
import { Linking } from "react-native";
export interface WifiSsidResult {
ssid: string | 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;
}
})();
// Only load the native module on iOS
const WifiSsidModule =
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : 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
* - com.apple.developer.networking.wifi-info entitlement
*
* Android requirements:
* - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
* - Access WiFi Information capability enabled in Apple Developer Portal
*/
export async function getSSID(): Promise<WifiSsidResult> {
export async function getSSID(): Promise<string | null> {
if (!WifiSsidModule) {
return { ssid: null, connectedToWifi: false };
return null;
}
try {
return normalize(await WifiSsidModule.getSSID());
const ssid = await WifiSsidModule.getSSID();
return ssid ?? null;
} catch (error) {
console.error("[WifiSsid] Error getting SSID:", error);
return { ssid: null, connectedToWifi: false };
}
}
/** 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 };
return null;
}
}
/**
* Open the system Location settings so the user can enable Location services
* (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).
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
*/
export function openLocationSettings(): void {
try {
if (WifiSsidModule?.openLocationSettings) {
WifiSsidModule.openLocationSettings();
return;
}
} catch (error) {
console.error("[WifiSsid] Error opening location settings:", error);
export function getSSIDSync(): string | null {
if (!WifiSsidModule) {
return null;
}
Linking.openSettings();
}
// Both native modules return { ssid, connectedToWifi }. The string branch is a
// defensive fallback in case a build returns the older plain-string shape.
function normalize(result: unknown): WifiSsidResult {
if (typeof result === "string") {
const ssid = result || null;
return { ssid, connectedToWifi: ssid !== null };
try {
return WifiSsidModule.getSSIDSync() ?? null;
} catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error);
return 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 };
}

View File

@@ -1,113 +1,50 @@
import ExpoModulesCore
#if !os(tvOS)
import Network
import NetworkExtension
import SystemConfiguration.CaptiveNetwork
#endif
import UIKit
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 {
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)
return ["ssid": NSNull(), "connectedToWifi": false]
return nil
#else
self.startPathMonitorIfNeeded()
let ssid = await self.resolveSSID()
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
if let ssid = ssid {
result["ssid"] = ssid
} else {
result["ssid"] = NSNull()
}
return result
#endif
}
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)
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)
}
}
}
#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)
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? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
return nil
}
@@ -117,10 +54,12 @@ public class WifiSsidModule: Module {
}
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
}
#endif

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -19,7 +18,6 @@ interface ServerUrlContextValue {
effectiveServerUrl: string | null;
isUsingLocalUrl: boolean;
currentSSID: string | null;
connectedToWifi: boolean;
refreshUrlState: () => void;
}
@@ -34,7 +32,7 @@ interface Props {
export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin();
const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
const { ssid, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
@@ -103,25 +101,15 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
};
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
const value = useMemo(
() => ({
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
connectedToWifi,
refreshUrlState,
}),
[
effectiveServerUrl,
isUsingLocalUrl,
ssid,
connectedToWifi,
refreshUrlState,
],
);
return (
<ServerUrlContext.Provider value={value}>
<ServerUrlContext.Provider
value={{
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
refreshUrlState,
}}
>
{children}
</ServerUrlContext.Provider>
);

View File

@@ -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;
}

View File

@@ -137,7 +137,11 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
"hide_remote_session_button": "Hide remote session button",
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
"show_home_backdrop": "Dynamic home backdrop",
"show_hero_carousel": "Hero carousel",
"show_series_poster_on_episode": "Show series poster on episodes",
@@ -174,10 +178,7 @@
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to Wi-Fi",
"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.",
"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"
"permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings."
},
"user_info": {
"user_info_title": "User info",
@@ -299,6 +300,7 @@
},
"safe_area_in_controls": "Safe area in controls",
"show_custom_menu_links": "Show custom menu links",
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
"show_large_home_carousel": "Show large home carousel (beta)",
"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.",

View File

@@ -274,6 +274,9 @@ export type Settings = {
hideBrightnessSlider: boolean;
usePopularPlugin: boolean;
mergeNextUpAndContinueWatching: boolean;
// Use the episode's own image (instead of the series thumb) for the
// "Next Up" and "Continue Watching" home rows.
useEpisodeImagesForNextUp: boolean;
// TV-specific settings
showHomeBackdrop: boolean;
showTVHeroCarousel: boolean;
@@ -382,6 +385,7 @@ export const defaultValues: Settings = {
hideBrightnessSlider: false,
usePopularPlugin: true,
mergeNextUpAndContinueWatching: false,
useEpisodeImagesForNextUp: false,
// TV-specific settings
showHomeBackdrop: true,
showTVHeroCarousel: true,