mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-27 00:01:11 +01:00
Merge branch 'develop' into feat/kefintweaks-watchlist
This commit is contained in:
32
.github/workflows/build-apps.yml
vendored
32
.github/workflows/build-apps.yml
vendored
@@ -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') }}
|
||||
|
||||
4
.github/workflows/check-lockfile.yml
vendored
4
.github/workflows/check-lockfile.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci-codeql.yml
vendored
2
.github/workflows/ci-codeql.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/linting.yml
vendored
8
.github/workflows/linting.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/trivy-scan.yml
vendored
2
.github/workflows/trivy-scan.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<Stack
|
||||
screenOptions={{ headerShown: false, animation: "none" }}
|
||||
initialRouteName='(home)'
|
||||
>
|
||||
<Stack.Screen name='index' redirect />
|
||||
</Stack>
|
||||
<TVNavBar
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
onTabChange={handleTabChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 <TVTabLayout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
|
||||
8
bun.lock
8
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=="],
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
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 (
|
||||
|
||||
155
components/tv/TVNavBar.tsx
Normal file
155
components/tv/TVNavBar.tsx
Normal file
@@ -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<ViewStyle>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: bg,
|
||||
borderRadius: scaleSize(24),
|
||||
borderWidth: isActive && !focused ? 1 : 0,
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
paddingHorizontal: scaleSize(28),
|
||||
paddingVertical: scaleSize(14),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
color: textColor,
|
||||
fontWeight: isActive || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
||||
tabs,
|
||||
activeTabKey,
|
||||
onTabChange,
|
||||
style,
|
||||
}) => {
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
||||
{},
|
||||
);
|
||||
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 (
|
||||
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
gap: scaleSize(12),
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TVNavBarTabItem
|
||||
key={tab.key}
|
||||
label={tab.label}
|
||||
isActive={tab.key === activeTabKey}
|
||||
onSelect={() => handleTabChange(tab.key)}
|
||||
onLayout={handleTabLayout(tab.key)}
|
||||
hasTVPreferredFocus={tab.key === activeTabKey}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user