diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index 3a50064c..15a7b03a 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -43,7 +43,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -79,7 +79,7 @@ jobs:
java-version: "17"
- name: 💾 Cache Gradle global
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -143,7 +143,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -157,7 +157,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -179,7 +179,7 @@ jobs:
java-version: "17"
- name: 💾 Cache Gradle global
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -230,7 +230,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -244,7 +244,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -302,7 +302,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -316,7 +316,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -369,7 +369,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -383,7 +383,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -439,7 +439,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -453,7 +453,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml
index efb5f221..d4165055 100644
--- a/.github/workflows/check-lockfile.yml
+++ b/.github/workflows/check-lockfile.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache
diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml
index b77665f5..b9921780 100644
--- a/.github/workflows/ci-codeql.yml
+++ b/.github/workflows/ci-codeql.yml
@@ -27,7 +27,7 @@ jobs:
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index 39883d8c..c14fe48f 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -23,7 +23,7 @@ jobs:
steps:
- name: 📥 Checkout Repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml
index 26da4f57..ebf515d7 100644
--- a/.github/workflows/detect-duplicate.yml
+++ b/.github/workflows/detect-duplicate.yml
@@ -21,7 +21,7 @@ jobs:
contents: read
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index d36da31f..f8799f26 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-26.04
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -104,7 +104,7 @@ jobs:
steps:
- name: "📥 Checkout PR code"
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -114,7 +114,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# renovate: datasource=node-version depName=node versioning=node
- node-version: "24.16.0"
+ node-version: "24.18.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 454f8645..027eab0d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -64,7 +64,7 @@ jobs:
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
@@ -77,7 +77,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -184,7 +184,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
show-progress: false
diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml
index 2f02dcfc..2e0f307b 100644
--- a/.github/workflows/trivy-scan.yml
+++ b/.github/workflows/trivy-scan.yml
@@ -27,7 +27,7 @@ jobs:
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Trivy's own action caches the vulnerability DB + binary internally
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml
index 0754735e..7f1ace97 100644
--- a/.github/workflows/update-issue-form.yml
+++ b/.github/workflows/update-issue-form.yml
@@ -26,7 +26,7 @@ jobs:
pull-requests: write
steps:
- name: 📥 Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
diff --git a/README.md b/README.md
index 258005ef..3d4221f4 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## 🛣️ Roadmap
-Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
+Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 29461b49..759dae85 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -305,6 +305,8 @@ export default function SearchPage() {
},
hideWhenScrolling: false,
autoFocus: false,
+ // Android: color of the user-typed text (was dark and unreadable on the dark header)
+ textColor: "#fff",
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 53fbeb91..45f246a5 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,108 @@ 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 lastSegment = segments[segments.length - 1] ?? "";
+ const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
+
+ 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/bun.lock b/bun.lock
index 8ca83cb3..112ab885 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,7 +16,7 @@
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
- "@shopify/flash-list": "2.0.2",
+ "@shopify/flash-list": "2.0.3",
"@tanstack/query-sync-storage-persister": "^5.100.14",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.100.14",
@@ -111,7 +111,7 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
- "lint-staged": "17.0.7",
+ "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "6.0.3",
},
@@ -536,7 +536,7 @@
"@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
- "@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="],
+ "@shopify/flash-list": ["@shopify/flash-list@2.0.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA=="],
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
@@ -1270,7 +1270,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
- "lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
+ "lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx
index 11339e0c..d99b1d97 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 00000000..759c283a
--- /dev/null
+++ b/components/tv/TVNavBar.tsx
@@ -0,0 +1,155 @@
+import React from "react";
+import {
+ Animated,
+ Pressable,
+ ScrollView,
+ StyleProp,
+ View,
+ ViewStyle,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { TVPadding } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVNavBarTab {
+ key: string;
+ label: string;
+}
+
+export interface TVNavBarProps {
+ tabs: TVNavBarTab[];
+ activeTabKey: string;
+ onTabChange: (key: string) => void;
+ style?: StyleProp;
+}
+
+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 cb988695..19701acc 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 8277d0a7..5de841da 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]);
+}
diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
index 8b6808fd..93776d10 100644
--- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
+++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt
@@ -4,6 +4,7 @@ import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.content.res.AssetManager
+import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -35,6 +36,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
+ /**
+ * True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
+ * decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
+ * mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
+ * (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
+ * → ANR. We force software decoding here.
+ *
+ * Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
+ * false positive would needlessly drop shipping hardware to software decoding. The
+ * emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
+ * emulator build fingerprint, none of which appear on real devices.
+ */
+ private fun isEmulator(): Boolean {
+ val hardware = Build.HARDWARE.lowercase()
+ if (hardware == "goldfish" || hardware == "ranchu") return true
+
+ val product = Build.PRODUCT
+ if (product == "sdk" || product.startsWith("sdk_")) return true
+
+ val fingerprint = Build.FINGERPRINT
+ return fingerprint.startsWith("generic") ||
+ fingerprint.contains("emulator", ignoreCase = true)
+ }
+
interface Delegate {
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean)
@@ -169,15 +194,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
- // Hardware video decoding
- // TV: zero-copy (mediacodec) for better performance on low-power devices
- // Mobile: copy mode (mediacodec-copy) for better compatibility
- val isTV = isTvDevice()
- if (isTV) {
- MPVLib.setOptionString("hwdec", "mediacodec")
- MPVLib.setOptionString("profile", "fast")
- } else {
- MPVLib.setOptionString("hwdec", "mediacodec-copy")
+ // Hardware decode path:
+ // - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices).
+ // - Real phone: `mediacodec-copy` (broadest compatibility).
+ // - Emulator: software decode. Its MediaCodec can't bind an output surface
+ // (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software,
+ // but H.264 "opens" deceptively and wedges the core with no fallback (black
+ // video, then any command — seek/pause — deadlocks the UI thread → ANR).
+ // hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected.
+ when {
+ isEmulator() -> MPVLib.setOptionString("hwdec", "no")
+ isTvDevice() -> {
+ MPVLib.setOptionString("hwdec", "mediacodec")
+ MPVLib.setOptionString("profile", "fast")
+ }
+ else -> MPVLib.setOptionString("hwdec", "mediacodec-copy")
}
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
diff --git a/package.json b/package.json
index fbf33a84..4e3ecd38 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
- "@shopify/flash-list": "2.0.2",
+ "@shopify/flash-list": "2.0.3",
"@tanstack/query-sync-storage-persister": "^5.100.14",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.100.14",
@@ -134,7 +134,7 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
- "lint-staged": "17.0.7",
+ "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "6.0.3"
},