From d3e6f6311e9380c1dc16f780086c775a5d8d364a Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:22:59 +0200 Subject: [PATCH] feat: android tv menu to tabs Change for the android tv side to use tabs instead of the mobile menu Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app/(auth)/(tabs)/_layout.tsx | 108 ++++++++++++++++++++- components/home/TVHeroCarousel.tsx | 2 +- components/tv/TVNavBar.tsx | 148 +++++++++++++++++++++++++++++ components/tv/index.ts | 2 + hooks/useTVBackHandler.ts | 65 ++++++++----- 5 files changed, 299 insertions(+), 26 deletions(-) create mode 100644 components/tv/TVNavBar.tsx diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 53fbeb910..e94e7b4fc 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -3,16 +3,24 @@ import { type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationOptions, } from "@bottom-tabs/react-navigation"; -import { withLayoutContext } from "expo-router"; +import { Stack, useSegments, withLayoutContext } from "expo-router"; import type { ParamListBase, TabNavigationState, } from "expo-router/react-navigation"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; +import type { TVNavBarTab } from "@/components/tv/TVNavBar"; +import { TVNavBar } from "@/components/tv/TVNavBar"; import { Colors } from "@/constants/Colors"; -import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; +import useRouter from "@/hooks/useAppRouter"; +import { + isTabRoute, + useTVHomeBackHandler, + useTVTabRootBackHandler, +} from "@/hooks/useTVBackHandler"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -33,13 +41,107 @@ export const NativeTabs = withLayoutContext< NativeBottomTabNavigationEventMap >(Navigator); +const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android"; + +function TVTabLayout() { + const { settings } = useSettings(); + const { t } = useTranslation(); + const segments = useSegments(); + const router = useRouter(); + + const currentTab = segments.find(isTabRoute); + const atTabRoot = isTabRoute(segments[segments.length - 1] ?? ""); + + const tabs: TVNavBarTab[] = useMemo( + () => + [ + { key: "(home)", label: t("tabs.home") }, + { key: "(search)", label: t("tabs.search") }, + { key: "(favorites)", label: t("tabs.favorites") }, + !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab + ? null + : { key: "(watchlists)", label: t("watchlists.title") }, + { key: "(libraries)", label: t("tabs.library") }, + !settings?.showCustomMenuLinks + ? null + : { key: "(custom-links)", label: t("tabs.custom_links") }, + { key: "(settings)", label: t("tabs.settings") }, + ].filter((tab): tab is TVNavBarTab => tab !== null), + [ + settings?.streamyStatsServerUrl, + settings?.hideWatchlistsTab, + settings?.showCustomMenuLinks, + t, + ], + ); + + const activeTabKey = currentTab ?? "(home)"; + + const visibleKeys = useMemo( + () => new Set(tabs.map((tab) => tab.key)), + [tabs], + ); + + const handleTabChange = useCallback( + (key: string) => { + if (key === currentTab) return; + + if (key === "(home)") eventBus.emit("scrollToTop"); + if (key === "(search)") eventBus.emit("searchTabPressed"); + + router.replace(`/(auth)/(tabs)/${key}`); + }, + [currentTab, router], + ); + + const navigateHome = useCallback(() => { + router.replace("/(auth)/(tabs)/(home)"); + }, [router]); + useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab); + + // If current tab is no longer visible (setting changed), navigate to home + useEffect(() => { + if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") { + router.replace("/(auth)/(tabs)/(home)"); + } + }, [visibleKeys, activeTabKey, router]); + + return ( + + + ); +} + export default function TabLayout() { const { settings } = useSettings(); const { t } = useTranslation(); - // Handle TV back button - prevent app exit when at root + // Must be called before any conditional return (rules of hooks) useTVHomeBackHandler(); + if (IS_ANDROID_TV) { + return ; + } + return (