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 (
diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx
index 11339e0c3..d99b1d972 100644
--- a/components/home/TVHeroCarousel.tsx
+++ b/components/home/TVHeroCarousel.tsx
@@ -379,7 +379,7 @@ export const TVHeroCarousel: React.FC = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
- const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
+ const tvosTopPadding = scaleSize(145);
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
diff --git a/components/tv/TVNavBar.tsx b/components/tv/TVNavBar.tsx
new file mode 100644
index 000000000..abf39ff55
--- /dev/null
+++ b/components/tv/TVNavBar.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+import { Animated, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { TVPadding } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVNavBarTab {
+ key: string;
+ label: string;
+}
+
+export interface TVNavBarProps {
+ tabs: TVNavBarTab[];
+ activeTabKey: string;
+ onTabChange: (key: string) => void;
+ style?: ViewStyleProp;
+}
+
+const TVNavBarTabItem: React.FC<{
+ label: string;
+ isActive: boolean;
+ onSelect: () => void;
+ onLayout: (e: {
+ nativeEvent: { layout: { x: number; width: number } };
+ }) => void;
+ hasTVPreferredFocus: boolean;
+}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ const bg = focused
+ ? "rgba(255, 255, 255, 0.95)"
+ : isActive
+ ? "rgba(255, 255, 255, 0.15)"
+ : "transparent";
+
+ const textColor = focused
+ ? "#000"
+ : isActive
+ ? "#fff"
+ : "rgba(255, 255, 255, 0.7)";
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVNavBar: React.FC = ({
+ tabs,
+ activeTabKey,
+ onTabChange,
+ style,
+}) => {
+ const scrollRef = React.useRef(null);
+ const tabLayouts = React.useRef>(
+ {},
+ );
+ const insets = useSafeAreaInsets();
+
+ const handleTabLayout = React.useCallback(
+ (key: string) =>
+ (e: { nativeEvent: { layout: { x: number; width: number } } }) => {
+ tabLayouts.current[key] = e.nativeEvent.layout;
+ },
+ [],
+ );
+
+ const handleTabChange = React.useCallback(
+ (key: string) => {
+ onTabChange(key);
+
+ const layout = tabLayouts.current[key];
+ if (layout && scrollRef.current) {
+ scrollRef.current.scrollTo({
+ x: Math.max(0, layout.x - TVPadding.horizontal / 2),
+ animated: true,
+ });
+ }
+ },
+ [onTabChange],
+ );
+
+ if (tabs.length === 0) return null;
+
+ return (
+
+
+ {tabs.map((tab) => (
+ handleTabChange(tab.key)}
+ onLayout={handleTabLayout(tab.key)}
+ hasTVPreferredFocus={tab.key === activeTabKey}
+ />
+ ))}
+
+
+ );
+};
diff --git a/components/tv/index.ts b/components/tv/index.ts
index a35104eb0..99f626a0f 100644
--- a/components/tv/index.ts
+++ b/components/tv/index.ts
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
+export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
+export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";
diff --git a/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts
index 8277d0a79..5de841dae 100644
--- a/hooks/useTVBackHandler.ts
+++ b/hooks/useTVBackHandler.ts
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
+ useTVBackPress,
} from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress";
+/** All tab route names used in the bottom tab navigator. */
+export const TAB_ROUTES = [
+ "(home)",
+ "(search)",
+ "(favorites)",
+ "(libraries)",
+ "(watchlists)",
+ "(custom-links)",
+ "(settings)",
+] as const;
+
+export type TabRoute = (typeof TAB_ROUTES)[number];
+
+/** Check if a segment string is a tab route. */
+export function isTabRoute(s: string): s is TabRoute {
+ return (TAB_ROUTES as readonly string[]).includes(s);
+}
+
/**
* Check if we're at the root of a tab
*/
function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1];
- const tabNames = [
- "(home)",
- "(search)",
- "(favorites)",
- "(libraries)",
- "(watchlists)",
- "(settings)",
- "(custom-links)",
- ];
- return tabNames.includes(lastSegment) || lastSegment === "index";
+ return isTabRoute(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
-function getCurrentTab(segments: string[]): string | undefined {
- return segments.find(
- (s) =>
- s === "(home)" ||
- s === "(search)" ||
- s === "(favorites)" ||
- s === "(libraries)" ||
- s === "(watchlists)" ||
- s === "(settings)" ||
- s === "(custom-links)",
- );
+function getCurrentTab(segments: string[]): TabRoute | undefined {
+ return segments.find(isTabRoute);
}
/**
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
- // Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}
+
+/**
+ * Handles back press at a non-Home tab root on Android TV by navigating to Home.
+ *
+ * Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
+ * built-in tab-level back handling — pressing back at a tab root would pop the
+ * Stack entirely and exit the tab navigator. This hook intercepts that and routes
+ * to Home instead.
+ */
+export function useTVTabRootBackHandler(
+ onNavigateHome: () => void,
+ isAtTabRoot: boolean,
+ currentTab: string | undefined,
+) {
+ useTVBackPress(() => {
+ if (!Platform.isTV || Platform.OS !== "android") return false;
+ if (!isAtTabRoot || currentTab === "(home)") return false;
+ onNavigateHome();
+ return true;
+ }, [isAtTabRoot, currentTab, onNavigateHome]);
+}