mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 00:10:24 +01:00
Compare commits
7 Commits
fix/tv-see
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e6f6311e | ||
|
|
2597b4af49 | ||
|
|
2ad9753957 | ||
|
|
c2c6bf0b45 | ||
|
|
1685571406 | ||
|
|
7f68506ceb | ||
|
|
36ed7539a2 |
60
.github/workflows/trivy-scan.yml
vendored
Normal file
60
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: 🛡️ Trivy Security Scan
|
||||||
|
|
||||||
|
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
||||||
|
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
||||||
|
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
||||||
|
# upload needs a write token that fork PRs don't get).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
schedule:
|
||||||
|
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: trivy-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trivy:
|
||||||
|
name: 🔎 Filesystem scan
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
||||||
|
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||||
|
- name: 🗓️ Compute weekly Trivy cache key
|
||||||
|
id: trivy-cache-key
|
||||||
|
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 💾 Cache Trivy vulnerability DB
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/trivy
|
||||||
|
key: ${{ steps.trivy-cache-key.outputs.value }}
|
||||||
|
restore-keys: trivy-db-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 🔎 Run Trivy filesystem scan
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: vuln,secret,misconfig
|
||||||
|
ignore-unfixed: true
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
|
||||||
|
- name: 📤 Upload results to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Directory, Paths } from "expo-file-system";
|
import { Directory, Paths } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
@@ -52,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
|||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -61,51 +59,6 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
|
||||||
useJellyseerr();
|
|
||||||
|
|
||||||
// Jellyseerr state
|
|
||||||
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
|
||||||
settings.jellyseerrServerUrl || "",
|
|
||||||
);
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
|
||||||
|
|
||||||
const isJellyseerrLocked =
|
|
||||||
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
|
||||||
const isJellyseerrConnected = !!jellyseerrApi;
|
|
||||||
|
|
||||||
const handleJellyseerrUrlBlur = useCallback(() => {
|
|
||||||
const url = jellyseerrServerUrl.trim();
|
|
||||||
updateSettings({ jellyseerrServerUrl: url || undefined });
|
|
||||||
}, [jellyseerrServerUrl, updateSettings]);
|
|
||||||
|
|
||||||
const jellyseerrLoginMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const url = jellyseerrServerUrl.trim();
|
|
||||||
if (!url) throw new Error("Missing server url");
|
|
||||||
if (!user?.Name) throw new Error("Missing user info");
|
|
||||||
const tempApi = new JellyseerrApi(url);
|
|
||||||
const testResult = await tempApi.test();
|
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
|
||||||
return tempApi.login(user.Name, jellyseerrPassword);
|
|
||||||
},
|
|
||||||
onSuccess: (loggedInUser) => {
|
|
||||||
setJellyseerrUser(loggedInUser);
|
|
||||||
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setJellyseerrPassword("");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDisconnectJellyseerr = useCallback(() => {
|
|
||||||
clearAllJellyseerData();
|
|
||||||
setJellyseerrServerUrl("");
|
|
||||||
setJellyseerrPassword("");
|
|
||||||
}, [clearAllJellyseerData]);
|
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -924,81 +877,6 @@ export default function SettingsTV() {
|
|||||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* seerr Section */}
|
|
||||||
<TVSectionHeader title='seerr' />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#9CA3AF",
|
|
||||||
fontSize: typography.callout - 2,
|
|
||||||
marginBottom: 16,
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
|
|
||||||
"Enter your Jellyseerr server URL to enable discover and request features."}
|
|
||||||
</Text>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={
|
|
||||||
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
|
||||||
}
|
|
||||||
value={jellyseerrServerUrl}
|
|
||||||
placeholder={
|
|
||||||
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
|
||||||
"https://jellyseerr.example.com"
|
|
||||||
}
|
|
||||||
onChangeText={setJellyseerrServerUrl}
|
|
||||||
onBlur={handleJellyseerrUrlBlur}
|
|
||||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={
|
|
||||||
t("home.settings.plugins.jellyseerr.password") || "Password"
|
|
||||||
}
|
|
||||||
value={jellyseerrPassword}
|
|
||||||
placeholder={
|
|
||||||
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
|
||||||
username: user?.Name,
|
|
||||||
}) || `Jellyfin password`
|
|
||||||
}
|
|
||||||
onChangeText={setJellyseerrPassword}
|
|
||||||
secureTextEntry
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={
|
|
||||||
jellyseerrLoginMutation.isPending
|
|
||||||
? t("common.connecting", "Connecting...") || "Connecting..."
|
|
||||||
: t("common.connect", "Connect") || "Connect"
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TVSettingsRow
|
|
||||||
label={
|
|
||||||
isJellyseerrConnected
|
|
||||||
? t("common.connected", "Connected") || "Connected"
|
|
||||||
: t("common.not_connected", "Not connected") || "Not connected"
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
showChevron={false}
|
|
||||||
/>
|
|
||||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={
|
|
||||||
t(
|
|
||||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
|
||||||
) || "Disconnect"
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
onPress={handleDisconnectJellyseerr}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Storage Section */}
|
{/* Storage Section */}
|
||||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {
|
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||||
useIsFocused,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { orderBy, uniqBy } from "lodash";
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -25,13 +20,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -52,10 +41,7 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
useJellyseerr,
|
|
||||||
validateJellyseerrSession,
|
|
||||||
} from "@/hooks/useJellyseerr";
|
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -120,44 +106,8 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const isFocused = useIsFocused();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
// Alert when seerr server is configured but user hasn't connected (only when focused)
|
|
||||||
const jellyseerrAlertedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
|
||||||
if (jellyseerrAlertedRef.current) return;
|
|
||||||
jellyseerrAlertedRef.current = true;
|
|
||||||
Alert.alert(
|
|
||||||
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
|
|
||||||
t(
|
|
||||||
"jellyseerr.connect_in_settings",
|
|
||||||
"Jellyseerr is available. Connect in Settings to enable request features.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
|
||||||
|
|
||||||
// Validate jellyseerr session when switching to Discover
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
searchType !== "Discover" ||
|
|
||||||
!jellyseerrApi ||
|
|
||||||
!settings?.jellyseerrServerUrl
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
|
||||||
if (status.valid) return;
|
|
||||||
Alert.alert(
|
|
||||||
t("jellyseerr.session_expired", "Session expired"),
|
|
||||||
t(
|
|
||||||
"jellyseerr.session_expired_connect_again",
|
|
||||||
"Your Jellyseerr session has expired. Please reconnect in Settings.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
import { Stack, useSegments, 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 { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -33,13 +41,107 @@ 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 atTabRoot = isTabRoute(segments[segments.length - 1] ?? "");
|
||||||
|
|
||||||
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "(home)", label: t("tabs.home") },
|
||||||
|
{ key: "(search)", label: t("tabs.search") },
|
||||||
|
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||||
|
? null
|
||||||
|
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||||
|
{ key: "(libraries)", label: t("tabs.library") },
|
||||||
|
!settings?.showCustomMenuLinks
|
||||||
|
? null
|
||||||
|
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||||
|
{ key: "(settings)", label: t("tabs.settings") },
|
||||||
|
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||||
|
[
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
settings?.hideWatchlistsTab,
|
||||||
|
settings?.showCustomMenuLinks,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTabKey = currentTab ?? "(home)";
|
||||||
|
|
||||||
|
const visibleKeys = useMemo(
|
||||||
|
() => new Set(tabs.map((tab) => tab.key)),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === currentTab) return;
|
||||||
|
|
||||||
|
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||||
|
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/${key}`);
|
||||||
|
},
|
||||||
|
[currentTab, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateHome = useCallback(() => {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}, [router]);
|
||||||
|
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||||
|
|
||||||
|
// If current tab is no longer visible (setting changed), navigate to home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}
|
||||||
|
}, [visibleKeys, activeTabKey, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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();
|
||||||
|
|
||||||
// Handle TV back button - prevent app exit when at root
|
// Must be called before any conditional return (rules of hooks)
|
||||||
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' />
|
||||||
|
|||||||
@@ -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 = Platform.OS === "ios" ? scaleSize(145) : 0;
|
const tvosTopPadding = scaleSize(145);
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +22,8 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
interface TVDiscoverPosterProps {
|
interface TVDiscoverPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
@@ -33,7 +34,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -50,8 +50,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
const posterWidth = sizes.posters.poster;
|
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
@@ -73,7 +71,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -83,9 +81,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: sizes.gaps.small,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -142,12 +140,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year != null && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
@@ -168,7 +166,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -234,14 +231,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
if (!flatData || flatData.length === 0) return null;
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -252,9 +249,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
@@ -15,21 +14,20 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
interface TVJellyseerrPosterProps {
|
interface TVJellyseerrPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
@@ -45,22 +43,18 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
const posterWidth = sizes.posters.poster;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
hasTVPreferredFocus={isFirstItem && !disabled}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -70,9 +64,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: sizes.gaps.small,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -123,13 +117,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 12,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year != null && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
@@ -148,16 +142,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
interface TVJellyseerrPersonPosterProps {
|
interface TVJellyseerrPersonPosterProps {
|
||||||
item: PersonResult;
|
item: PersonResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation();
|
useTVFocusAnimation();
|
||||||
@@ -166,21 +157,13 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const avatarSize = Math.round(sizes.posters.poster * 0.67);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||||
onPress={onPress}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
>
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: avatarSize,
|
width: 160,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
@@ -191,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: avatarSize,
|
width: 140,
|
||||||
height: avatarSize,
|
height: 140,
|
||||||
borderRadius: avatarSize / 2,
|
borderRadius: 70,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
borderWidth: focused ? 3 : 0,
|
borderWidth: focused ? 3 : 0,
|
||||||
@@ -215,11 +198,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||||
name='person'
|
|
||||||
size={Math.round(avatarSize * 0.35)}
|
|
||||||
color='rgba(255,255,255,0.4)'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -228,7 +207,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 12,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
@@ -244,7 +223,6 @@ interface TVJellyseerrMovieSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: MovieResult[];
|
items: MovieResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: MovieResult) => void;
|
onItemPress: (item: MovieResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,22 +230,20 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -278,9 +254,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -288,7 +264,6 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -300,7 +275,6 @@ interface TVJellyseerrTvSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: TvResult[];
|
items: TvResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: TvResult) => void;
|
onItemPress: (item: TvResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,22 +282,20 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -334,9 +306,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -344,7 +316,6 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -356,7 +327,6 @@ interface TVJellyseerrPersonSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: PersonResult[];
|
items: PersonResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: PersonResult) => void;
|
onItemPress: (item: PersonResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,22 +334,20 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection: _isFirstSection = false,
|
isFirstSection: _isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -390,16 +358,15 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TVJellyseerrPersonPoster
|
<TVJellyseerrPersonPoster
|
||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +384,6 @@ export interface TVJellyseerrSearchResultsProps {
|
|||||||
onMoviePress: (item: MovieResult) => void;
|
onMoviePress: (item: MovieResult) => void;
|
||||||
onTvPress: (item: TvResult) => void;
|
onTvPress: (item: TvResult) => void;
|
||||||
onPersonPress: (item: PersonResult) => void;
|
onPersonPress: (item: PersonResult) => void;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVJellyseerrSearchResults: React.FC<
|
export const TVJellyseerrSearchResults: React.FC<
|
||||||
@@ -432,10 +398,8 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
onMoviePress,
|
onMoviePress,
|
||||||
onTvPress,
|
onTvPress,
|
||||||
onPersonPress,
|
onPersonPress,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -446,7 +410,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: 24,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -454,9 +418,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||||
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
|
||||||
>
|
|
||||||
"{searchQuery}"
|
"{searchQuery}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -473,21 +435,18 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -166,7 +166,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
||||||
|
|
||||||
// Image URL getter for music items
|
// Image URL getter for music items
|
||||||
const getImageUrl = useMemo(() => {
|
const getImageUrl = useMemo(() => {
|
||||||
@@ -271,9 +270,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
defaultValue=''
|
defaultValue=''
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
|
||||||
hasTVPreferredFocus
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -294,7 +290,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
showDiscover={showDiscover}
|
showDiscover={showDiscover}
|
||||||
disabled={isSearchFocused}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -321,7 +316,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
// every keystroke as results re-render. User navigates down to the
|
// every keystroke as results re-render. User navigates down to the
|
||||||
// grid manually.
|
// grid manually.
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={isSearchFocused}
|
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
@@ -345,7 +339,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
loading={jellyseerrLoading}
|
loading={jellyseerrLoading}
|
||||||
noResults={jellyseerrNoResults}
|
noResults={jellyseerrNoResults}
|
||||||
searchQuery={debouncedSearch}
|
searchQuery={debouncedSearch}
|
||||||
disabled={isSearchFocused}
|
|
||||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||||
|
|||||||
148
components/tv/TVNavBar.tsx
Normal file
148
components/tv/TVNavBar.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPadding } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVNavBarTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVNavBarProps {
|
||||||
|
tabs: TVNavBarTab[];
|
||||||
|
activeTabKey: string;
|
||||||
|
onTabChange: (key: string) => void;
|
||||||
|
style?: ViewStyleProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVNavBarTabItem: React.FC<{
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onLayout: (e: {
|
||||||
|
nativeEvent: { layout: { x: number; width: number } };
|
||||||
|
}) => void;
|
||||||
|
hasTVPreferredFocus: boolean;
|
||||||
|
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bg = focused
|
||||||
|
? "rgba(255, 255, 255, 0.95)"
|
||||||
|
: isActive
|
||||||
|
? "rgba(255, 255, 255, 0.15)"
|
||||||
|
: "transparent";
|
||||||
|
|
||||||
|
const textColor = focused
|
||||||
|
? "#000"
|
||||||
|
: isActive
|
||||||
|
? "#fff"
|
||||||
|
: "rgba(255, 255, 255, 0.7)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 { 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";
|
||||||
|
|||||||
@@ -70,30 +70,6 @@ export const clearJellyseerrStorageData = () => {
|
|||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.remove(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JellyseerrSessionStatus =
|
|
||||||
| { valid: true }
|
|
||||||
| { valid: false; reason: "no_session" | "expired" };
|
|
||||||
|
|
||||||
export async function validateJellyseerrSession(
|
|
||||||
serverUrl: string,
|
|
||||||
): Promise<JellyseerrSessionStatus> {
|
|
||||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
|
||||||
|
|
||||||
if (!user || !cookies) {
|
|
||||||
return { valid: false, reason: "no_session" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = new JellyseerrApi(serverUrl);
|
|
||||||
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
|
|
||||||
return { valid: true };
|
|
||||||
} catch {
|
|
||||||
clearJellyseerrStorageData();
|
|
||||||
return { valid: false, reason: "expired" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
STATUS = "/status",
|
STATUS = "/status",
|
||||||
API_V1 = "/api/v1",
|
API_V1 = "/api/v1",
|
||||||
@@ -474,8 +450,7 @@ export const useJellyseerr = () => {
|
|||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
setJellyseerrUser(undefined);
|
setJellyseerrUser(undefined);
|
||||||
updateSettings({ jellyseerrServerUrl: undefined });
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
}, []);
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
|
|||||||
@@ -4,41 +4,42 @@ 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];
|
||||||
const tabNames = [
|
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
"(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[]): string | undefined {
|
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||||
return segments.find(
|
return segments.find(isTabRoute);
|
||||||
(s) =>
|
|
||||||
s === "(home)" ||
|
|
||||||
s === "(search)" ||
|
|
||||||
s === "(favorites)" ||
|
|
||||||
s === "(libraries)" ||
|
|
||||||
s === "(watchlists)" ||
|
|
||||||
s === "(settings)" ||
|
|
||||||
s === "(custom-links)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | 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,3 +65,24 @@ 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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
|
||||||
<application>
|
<application>
|
||||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
|||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
internal object TvRecommendationsPublisher {
|
internal object TvRecommendationsPublisher {
|
||||||
private const val TAG = "TvRecommendations"
|
private const val TAG = "TvRecommendations"
|
||||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||||
private const val KEY_PAYLOAD = "payload"
|
private const val KEY_PAYLOAD = "payload"
|
||||||
private const val KEY_CHANNEL_ID = "channelId"
|
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||||
private const val KEY_PROGRAM_IDS = "programIds"
|
private const val KEY_PROGRAM_IDS = "programIds"
|
||||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||||
|
|
||||||
@@ -162,16 +163,24 @@ internal object TvRecommendationsPublisher {
|
|||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||||
try {
|
try {
|
||||||
val programIds = JSONObject(programIdsJson)
|
val channelMap = JSONObject(programIdsJson)
|
||||||
val keys = programIds.keys()
|
val channelKeys = channelMap.keys()
|
||||||
while (keys.hasNext()) {
|
while (channelKeys.hasNext()) {
|
||||||
val key = keys.next()
|
val channelId = channelKeys.next()
|
||||||
if (programIds.optLong(key, -1L) == programId) {
|
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||||
programIds.remove(key)
|
val providerKeys = inner.keys()
|
||||||
|
while (providerKeys.hasNext()) {
|
||||||
|
val providerId = providerKeys.next()
|
||||||
|
if (inner.optLong(providerId, -1L) == programId) {
|
||||||
|
inner.remove(providerId)
|
||||||
|
if (inner.length() == 0) {
|
||||||
|
channelMap.remove(channelId)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
|
}
|
||||||
|
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||||
}
|
}
|
||||||
@@ -322,7 +331,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
val channelKey = getChannelKey(displayName)
|
||||||
|
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
@@ -363,7 +373,7 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
// Channel truly doesn't exist in provider — recreate
|
// Channel truly doesn't exist in provider — recreate
|
||||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
prefs.edit().remove(channelKey).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new channel
|
// Create a new channel
|
||||||
@@ -384,6 +394,7 @@ internal object TvRecommendationsPublisher {
|
|||||||
} ?: return -1L
|
} ?: return -1L
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
|
prefs.edit().putLong(channelKey, channelId).apply()
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
storeChannelLogo(context, channelId)
|
storeChannelLogo(context, channelId)
|
||||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||||
@@ -391,6 +402,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getChannelKey(displayName: String): String {
|
||||||
|
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
private fun upsertPreviewProgram(
|
private fun upsertPreviewProgram(
|
||||||
context: Context,
|
context: Context,
|
||||||
channelId: Long,
|
channelId: Long,
|
||||||
@@ -462,13 +477,18 @@ internal object TvRecommendationsPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a cache-busting parameter to ensure unique URIs when images change.
|
* Append a stable cache key derived from the image URL.
|
||||||
* Per Android docs: "Use unique Uris for all images... the old image will
|
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||||
* continue to appear if you don't change the Uri."
|
* that changes whenever the image content changes, so a deterministic
|
||||||
|
* hash of the URL is sufficient — the param only changes when the URL
|
||||||
|
* (and therefore the image) actually changes, avoiding unnecessary
|
||||||
|
* re-downloads on every sync.
|
||||||
*/
|
*/
|
||||||
private fun appendCacheBuster(imageUrl: String): String {
|
private fun appendCacheBuster(imageUrl: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||||
|
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||||
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
|
return "$imageUrl${separator}_v=$hash"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
@@ -531,8 +551,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelId(context: Context): Long {
|
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||||
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
|
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
@@ -567,8 +587,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: SecurityException) {
|
} catch (error: SecurityException) {
|
||||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||||
}
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ContentUris
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
|
||||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
when (intent.action) {
|
||||||
return
|
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||||
}
|
|
||||||
|
|
||||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||||
TvRecommendationsPublisher.refreshFromCache(context)
|
TvRecommendationsPublisher.refreshFromCache(context)
|
||||||
}
|
}
|
||||||
|
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||||
|
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||||
|
if (programId > 0L) {
|
||||||
|
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||||
|
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, [api, secret, headers, jellyfin]);
|
}, [api, secret, headers, jellyfin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
store.set(apiAtom, api);
|
store.set(apiAtom, api);
|
||||||
}, [api]);
|
}, [api]);
|
||||||
@@ -547,20 +553,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Refresh plugin settings
|
// Refresh plugin settings
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
|
||||||
const jellyseerrApi = new JellyseerrApi(
|
|
||||||
recentPluginSettings.jellyseerrServerUrl.value,
|
|
||||||
);
|
|
||||||
await jellyseerrApi.test().then((result) => {
|
|
||||||
if (result.isValid && result.requiresPass) {
|
|
||||||
jellyseerrApi
|
|
||||||
.login(username, password)
|
|
||||||
.then(setJellyseerrUser)
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@@ -592,8 +592,7 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh"
|
||||||
"loading": "Loading..."
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -822,11 +821,7 @@
|
|||||||
"report_issue_button": "Report issue",
|
"report_issue_button": "Report issue",
|
||||||
"request_button": "Request",
|
"request_button": "Request",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||||
"failed_to_login": "Failed to Login",
|
"failed_to_login": "Failed to log in",
|
||||||
"connect_to_jellyseerr": "Connect to Jellyseerr",
|
|
||||||
"session_expired": "Session expired",
|
|
||||||
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
|
|
||||||
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
|
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
|
|||||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||||
console.log("Downloading item directly");
|
console.log("Downloading item directly");
|
||||||
return {
|
return {
|
||||||
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||||
mediaSource: streamDetails?.mediaSource ?? null,
|
mediaSource: streamDetails?.mediaSource ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user