mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-26 07:40:31 +01:00
Compare commits
1 Commits
develop
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6980cfc8e |
20
.github/workflows/build-apps.yml
vendored
20
.github/workflows/build-apps.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -157,7 +157,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -179,7 +179,7 @@ jobs:
|
|||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -244,7 +244,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -316,7 +316,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -383,7 +383,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -453,7 +453,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|||||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
|||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
# renovate: datasource=node-version depName=node versioning=node
|
||||||
node-version: "24.18.0"
|
node-version: "24.17.0"
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
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 { Colors } from "@/constants/Colors";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import {
|
|
||||||
isTabRoute,
|
|
||||||
useTVHomeBackHandler,
|
|
||||||
useTVTabRootBackHandler,
|
|
||||||
} from "@/hooks/useTVBackHandler";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(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() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Must be called before any conditional return (rules of hooks)
|
// Handle TV back button - prevent app exit when at root
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
if (IS_ANDROID_TV) {
|
|
||||||
return <TVTabLayout />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -111,7 +111,7 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.7",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
},
|
},
|
||||||
@@ -1270,7 +1270,7 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
"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;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = scaleSize(145);
|
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
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,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
|
||||||
export { TVNavBar } from "./TVNavBar";
|
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
@@ -4,42 +4,41 @@ import { Platform } from "react-native";
|
|||||||
import {
|
import {
|
||||||
disableTVMenuKeyInterception,
|
disableTVMenuKeyInterception,
|
||||||
enableTVMenuKeyInterception,
|
enableTVMenuKeyInterception,
|
||||||
useTVBackPress,
|
|
||||||
} from "./useTVBackPress";
|
} from "./useTVBackPress";
|
||||||
|
|
||||||
export { enableTVMenuKeyInterception } 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
|
* Check if we're at the root of a tab
|
||||||
*/
|
*/
|
||||||
function isAtTabRoot(segments: string[]): boolean {
|
function isAtTabRoot(segments: string[]): boolean {
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
return isTabRoute(lastSegment) || lastSegment === "index";
|
const tabNames = [
|
||||||
|
"(home)",
|
||||||
|
"(search)",
|
||||||
|
"(favorites)",
|
||||||
|
"(libraries)",
|
||||||
|
"(watchlists)",
|
||||||
|
"(settings)",
|
||||||
|
"(custom-links)",
|
||||||
|
];
|
||||||
|
return tabNames.includes(lastSegment) || lastSegment === "index";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current tab name from segments
|
* Get the current tab name from segments
|
||||||
*/
|
*/
|
||||||
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
function getCurrentTab(segments: string[]): string | undefined {
|
||||||
return segments.find(isTabRoute);
|
return segments.find(
|
||||||
|
(s) =>
|
||||||
|
s === "(home)" ||
|
||||||
|
s === "(search)" ||
|
||||||
|
s === "(favorites)" ||
|
||||||
|
s === "(libraries)" ||
|
||||||
|
s === "(watchlists)" ||
|
||||||
|
s === "(settings)" ||
|
||||||
|
s === "(custom-links)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
|
|||||||
export function useTVHomeBackHandler() {
|
export function useTVHomeBackHandler() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
|
// Get current state
|
||||||
const currentTab = getCurrentTab(segments);
|
const currentTab = getCurrentTab(segments);
|
||||||
const atTabRoot = isAtTabRoot(segments);
|
const atTabRoot = isAtTabRoot(segments);
|
||||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||||
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
|
|||||||
enableTVMenuKeyInterception();
|
enableTVMenuKeyInterception();
|
||||||
}, [isOnHomeRoot]);
|
}, [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]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.7",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,9 +44,22 @@ export const isSubtitleInMpv = (
|
|||||||
/**
|
/**
|
||||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||||
*
|
*
|
||||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
* MPV track IDs are 1-based, but MPV's track list is NOT in MediaStreams order:
|
||||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
* 1. Embedded/HLS subs are enumerated from the container (or HLS playlist)
|
||||||
* the one matching the Jellyfin index.
|
* first, in MediaStreams order.
|
||||||
|
* 2. External subs are appended via `sub-add` AFTER the file loads, in the
|
||||||
|
* order they are passed to MPV (here, also MediaStreams order — see
|
||||||
|
* direct-player.tsx where the externalSubtitles array is built by
|
||||||
|
* filtering MediaStreams).
|
||||||
|
*
|
||||||
|
* Iterating in pure MediaStreams order produces the wrong MPV ID whenever an
|
||||||
|
* External sub is listed before an Embed sub in MediaStreams (common when
|
||||||
|
* Jellyfin prepends a converted SRT/VTT ahead of an original PGS/ASS track),
|
||||||
|
* causing e.g. English to select Spanish. We therefore count in two phases
|
||||||
|
* that mirror MPV's actual ordering.
|
||||||
|
*
|
||||||
|
* Image-based subs (PGS/VOBSUB) during transcoding are burned into the video
|
||||||
|
* and absent from MPV's track list; they are skipped in both phases.
|
||||||
*
|
*
|
||||||
* @param mediaSource - The media source containing subtitle streams
|
* @param mediaSource - The media source containing subtitle streams
|
||||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||||
@@ -74,15 +87,31 @@ export const getMpvSubtitleId = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count MPV track position (1-based)
|
const isExternal = (sub: MediaStream) =>
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||||
|
|
||||||
let mpvIndex = 0;
|
let mpvIndex = 0;
|
||||||
|
|
||||||
|
// Phase 1: embedded / HLS subs — these occupy MPV track IDs first because
|
||||||
|
// they come from the container or HLS playlist.
|
||||||
for (const sub of allSubs) {
|
for (const sub of allSubs) {
|
||||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
if (isExternal(sub)) continue;
|
||||||
|
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||||
mpvIndex++;
|
mpvIndex++;
|
||||||
if (sub.Index === jellyfinSubtitleIndex) {
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
return mpvIndex;
|
return mpvIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: external subs — appended via `sub-add` after the file loads,
|
||||||
|
// so they come last in MPV's track list.
|
||||||
|
for (const sub of allSubs) {
|
||||||
|
if (!isExternal(sub)) continue;
|
||||||
|
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||||
|
mpvIndex++;
|
||||||
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
|
return mpvIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user