diff --git a/.gitignore b/.gitignore index 1878db42..496a7870 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ package-lock.json /ios /android +/iostv +/iosmobile +/androidmobile +/androidtv modules/player/android diff --git a/app.config.js b/app.config.js new file mode 100644 index 00000000..30ae0d5b --- /dev/null +++ b/app.config.js @@ -0,0 +1,11 @@ +module.exports = ({ config }) => { + if (process.env.EXPO_TV != "1") { + config.plugins.push([ + "react-native-google-cast", + { useDefaultExpandedMediaControls: true }, + ]); + } + return { + ...config, + }; +}; diff --git a/app.json b/app.json index 81e3d47a..fd31d80f 100644 --- a/app.json +++ b/app.json @@ -12,13 +12,18 @@ "resizeMode": "contain" }, "jsEngine": "hermes", - "assetBundlePatterns": ["**/*"], + "assetBundlePatterns": [ + "**/*" + ], "ios": { "requireFullScreen": true, "infoPlist": { "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.", - "UIBackgroundModes": ["audio", "fetch"], + "UIBackgroundModes": [ + "audio", + "fetch" + ], "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true @@ -47,15 +52,10 @@ ] }, "plugins": [ + "@react-native-tvos/config-tv", "expo-router", "expo-font", "@config-plugins/ffmpeg-kit-react-native", - [ - "react-native-google-cast", - { - "useDefaultExpandedMediaControls": true - } - ], [ "react-native-video", { @@ -74,9 +74,11 @@ { "ios": { "deploymentTarget": "15.6", - "useFrameworks": "static" + "useFrameworks": "static", + "newArchEnabled": false }, "android": { + "newArchEnabled": false, "android": { "compileSdkVersion": 34, "targetSdkVersion": 34, @@ -110,12 +112,24 @@ "expo-asset", [ "react-native-edge-to-edge", - { "android": { "parentTheme": "Material3" } } + { + "android": { + "parentTheme": "Material3" + } + } ], - ["react-native-bottom-tabs"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], - ["./plugins/withGoogleCastActivity.js"], - ["./plugins/withTrustLocalCerts.js"] + [ + "react-native-bottom-tabs" + ], + [ + "./plugins/withChangeNativeAndroidTextToWhite.js" + ], + [ + "./plugins/withGoogleCastActivity.js" + ], + [ + "./plugins/withTrustLocalCerts.js" + ] ], "experiments": { "typedRoutes": true @@ -136,4 +150,4 @@ "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" } } -} +} \ No newline at end of file diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx index b7607569..5d06a3ff 100644 --- a/app/(auth)/(tabs)/(custom-links)/index.tsx +++ b/app/(auth)/(tabs)/(custom-links)/index.tsx @@ -1,14 +1,16 @@ +import { Platform } from "react-native"; import { FlatList, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import React, { useCallback, useEffect, useState } from "react"; import { useAtom } from "jotai/index"; import { apiAtom } from "@/providers/JellyfinProvider"; import { ListItem } from "@/components/list/ListItem"; -import * as WebBrowser from "expo-web-browser"; import Ionicons from "@expo/vector-icons/Ionicons"; import { Text } from "@/components/common/Text"; import { useTranslation } from "react-i18next"; +const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null; + export interface MenuLink { name: string; url: string; @@ -52,7 +54,13 @@ export default function menuLinks() { }} data={menuLinks} renderItem={({ item }) => ( - WebBrowser.openBrowserAsync(item.url)}> + { + if (!Platform.isTV) { + WebBrowser.openBrowserAsync(item.url); + } + }} + > } diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index ba215dbf..f6c185a8 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,10 +1,11 @@ -import { Chromecast } from "@/components/Chromecast"; -import { Text } from "@/components/common/Text"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { Feather } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { Platform, TouchableOpacity, View } from "react-native"; import { useTranslation } from "react-i18next"; +import { lazy } from "react"; +// const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null; +const Chromecast = lazy(() => import("@/components/Chromecast")); export default function IndexLayout() { const router = useRouter(); @@ -25,7 +26,7 @@ export default function IndexLayout() { headerShadowVisible: false, headerRight: () => ( - + {!Platform.isTV && } { router.push("/(auth)/settings"); diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index d99f6509..0a1d8fca 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -27,6 +27,7 @@ import { QueryFunction, useQuery } from "@tanstack/react-query"; import { useNavigation, useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -77,30 +78,38 @@ export default function index() { const [isConnected, setIsConnected] = useState(null); const [loadingRetry, setLoadingRetry] = useState(false); - const { downloadedFiles, cleanCacheDirectory } = useDownload(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - useEffect(() => { - const hasDownloads = downloadedFiles && downloadedFiles.length > 0; - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className="p-2" - > - - - ), - }); - }, [downloadedFiles, navigation, router]); + if (!Platform.isTV) { + const { downloadedFiles, cleanCacheDirectory } = useDownload(); + useEffect(() => { + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [downloadedFiles, navigation, router]); + + useEffect(() => { + cleanCacheDirectory().catch((e) => + console.error("Something went wrong cleaning cache directory") + ); + }, []); + } const checkConnection = useCallback(async () => { setLoadingRetry(true); @@ -120,9 +129,9 @@ export default function index() { setIsConnected(state.isConnected); }); - cleanCacheDirectory().catch((e) => - console.error("Something went wrong cleaning cache directory") - ); + // cleanCacheDirectory().catch((e) => + // console.error("Something went wrong cleaning cache directory") + // ); return () => { unsubscribe(); diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 80a515dc..5828c88f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,8 +1,8 @@ +import { Platform } from "react-native"; import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import { AudioToggles } from "@/components/settings/AudioToggles"; -import { DownloadSettings } from "@/components/settings/DownloadSettings"; import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaToggles } from "@/components/settings/MediaToggles"; import { OtherSettings } from "@/components/settings/OtherSettings"; @@ -17,10 +17,13 @@ import { clearLogs } from "@/utils/log"; import { useHaptic } from "@/hooks/useHaptic"; import { useNavigation, useRouter } from "expo-router"; import { t } from "i18next"; -import React, { useEffect } from "react"; +import React, { lazy, useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { storage } from "@/utils/mmkv"; +const DownloadSettings = lazy( + () => import("@/components/settings/DownloadSettings") +); export default function settings() { const router = useRouter(); @@ -42,7 +45,9 @@ export default function settings() { logout(); }} > - {t("home.settings.log_out_button")} + + {t("home.settings.log_out_button")} + ), }); @@ -66,11 +71,12 @@ export default function settings() { - + + {!Platform.isTV && } - + { const insets = useSafeAreaInsets(); @@ -79,7 +85,8 @@ const Page: React.FC = () => { }, }); - const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); + const [canRequest, hasAdvancedRequestPermission] = + useJellyseerrCanRequest(details); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -112,20 +119,22 @@ const Page: React.FC = () => { seasons: (details as TvDetails)?.seasons ?.filter?.((s) => s.seasonNumber !== 0) ?.map?.((s) => s.seasonNumber), - } + }; if (hasAdvancedRequestPermission) { - advancedReqModalRef?.current?.present?.(body) - return + advancedReqModalRef?.current?.present?.(body); + return; } requestMedia(mediaTitle, body, refetch); }, [details, result, requestMedia, hasAdvancedRequestPermission]); const isAnime = useMemo( - () => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV, + () => + (details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) && + result.mediaType === MediaType.TV, [details] - ) + ); useEffect(() => { if (details) { @@ -247,7 +256,7 @@ const Page: React.FC = () => { hasAdvancedRequest={hasAdvancedRequestPermission} onAdvancedRequest={(data) => advancedReqModalRef?.current?.present(data) - } + } /> )} { type={result.mediaType as MediaType} isAnime={isAnime} onRequested={() => { - advancedReqModalRef?.current?.close() - refetch() + advancedReqModalRef?.current?.close(); + refetch(); }} /> { collisionPadding={0} sideOffset={0} > - {t("jellyseerr.types")} + + {t("jellyseerr.types")} + {Object.entries(IssueTypeName) .reverse() .map(([key, value], idx) => ( diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 15c9aa52..a7b9cc1f 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useLocalSearchParams, useNavigation } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo } from "react"; import { FlatList, useWindowDimensions, View } from "react-native"; diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 5cce9784..84beca5a 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -3,7 +3,7 @@ import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; import { Stack } from "expo-router"; import { Platform } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import { useTranslation } from "react-i18next"; export default function IndexLayout() { @@ -27,166 +27,171 @@ export default function IndexLayout() { }, headerTransparent: Platform.OS === "ios" ? true : false, headerShadowVisible: false, - headerRight: () => ( + headerRight: () => !pluginSettings?.libraryOptions?.locked && - - - - - - {t("library.options.display")} - - - - {t("library.options.display")} - - + + + + + + {t("library.options.display")} + + + + + {t("library.options.display")} + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }) + } + > + + + {t("library.options.row")} + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }) + } + > + + + {t("library.options.list")} + + + + + + + {t("library.options.image_style")} + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }) + } + > + + + {t("library.options.poster")} + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }) + } + > + + + {t("library.options.cover")} + + + + + + + { + if (settings.libraryOptions.imageStyle === "poster") + return; + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: newValue === "on" ? true : false, + }, + }); + }} > - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "row", - }, - }) - } - > - - - {t("library.options.row")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "list", - }, - }) - } - > - - - {t("library.options.list")} - - - - - - - {t("library.options.image_style")} - - + + {t("library.options.show_titles")} + + + { + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: newValue === "on" ? true : false, + }, + }); + }} > - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "poster", - }, - }) - } - > - - - {t("library.options.poster")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "cover", - }, - }) - } - > - - - {t("library.options.cover")} - - - - - - - { - if (settings.libraryOptions.imageStyle === "poster") - return; - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showTitles: newValue === "on" ? true : false, - }, - }); - }} - > - - - {t("library.options.show_titles")} - - - { - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showStats: newValue === "on" ? true : false, - }, - }); - }} - > - - - {t("library.options.show_stats")} - - - + + + {t("library.options.show_stats")} + + + - - - - ), + + + + ), }} /> ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: false, - }), -}); +if (!Platform.isTV) { + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); +} function useNotificationObserver() { + if (Platform.isTV) return; + useEffect(() => { let isMounted = true; @@ -85,99 +91,101 @@ function useNotificationObserver() { }, []); } -TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - console.log("TaskManager ~ trigger"); +if (!Platform.isTV) { + TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { + console.log("TaskManager ~ trigger"); - const now = Date.now(); + const now = Date.now(); - const settingsData = storage.getString("settings"); + const settingsData = storage.getString("settings"); - if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; + if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; - const settings: Partial = JSON.parse(settingsData); - const url = settings?.optimizedVersionsServerUrl; + const settings: Partial = JSON.parse(settingsData); + const url = settings?.optimizedVersionsServerUrl; - if (!settings?.autoDownload || !url) - return BackgroundFetch.BackgroundFetchResult.NoData; + if (!settings?.autoDownload || !url) + return BackgroundFetch.BackgroundFetchResult.NoData; - const token = getTokenFromStorage(); - const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; + const token = getTokenFromStorage(); + const deviceId = getOrSetDeviceId(); + const baseDirectory = FileSystem.documentDirectory; - if (!token || !deviceId || !baseDirectory) - return BackgroundFetch.BackgroundFetchResult.NoData; + if (!token || !deviceId || !baseDirectory) + return BackgroundFetch.BackgroundFetchResult.NoData; - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader: token, - url, - }); + const jobs = await getAllJobsByDeviceId({ + deviceId, + authHeader: token, + url, + }); - console.log("TaskManager ~ Active jobs: ", jobs.length); + console.log("TaskManager ~ Active jobs: ", jobs.length); - for (let job of jobs) { - if (job.status === "completed") { - const downloadUrl = url + "download/" + job.id; - const tasks = await checkForExistingDownloads(); + for (let job of jobs) { + if (job.status === "completed") { + const downloadUrl = url + "download/" + job.id; + const tasks = await BackGroundDownloader.checkForExistingDownloads(); - if (tasks.find((task) => task.id === job.id)) { - console.log("TaskManager ~ Download already in progress: ", job.id); - continue; + if (tasks.find((task) => task.id === job.id)) { + console.log("TaskManager ~ Download already in progress: ", job.id); + continue; + } + + BackGroundDownloader.download({ + id: job.id, + url: downloadUrl, + destination: `${baseDirectory}${job.item.Id}.mp4`, + headers: { + Authorization: token, + }, + }) + .begin(() => { + console.log("TaskManager ~ Download started: ", job.id); + }) + .done(() => { + console.log("TaskManager ~ Download completed: ", job.id); + saveDownloadedItemInfo(job.item); + BackGroundDownloader.completeHandler(job.id); + cancelJobById({ + authHeader: token, + id: job.id, + url: url, + }); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download completed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }) + .error((error) => { + console.log("TaskManager ~ Download error: ", job.id, error); + completeHandler(job.id); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download failed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }); } - - download({ - id: job.id, - url: downloadUrl, - destination: `${baseDirectory}${job.item.Id}.mp4`, - headers: { - Authorization: token, - }, - }) - .begin(() => { - console.log("TaskManager ~ Download started: ", job.id); - }) - .done(() => { - console.log("TaskManager ~ Download completed: ", job.id); - saveDownloadedItemInfo(job.item); - completeHandler(job.id); - cancelJobById({ - authHeader: token, - id: job.id, - url: url, - }); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download completed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }) - .error((error) => { - console.log("TaskManager ~ Download error: ", job.id, error); - completeHandler(job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download failed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }); } - } - console.log(`Auto download started: ${new Date(now).toISOString()}`); + console.log(`Auto download started: ${new Date(now).toISOString()}`); - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; -}); + // Be sure to return the successful result type! + return BackgroundFetch.BackgroundFetchResult.NewData; + }); +} const checkAndRequestPermissions = async () => { try { @@ -242,24 +250,7 @@ const queryClient = new QueryClient({ function Layout() { const [settings] = useSettings(); - - useKeepAwake(); - useNotificationObserver(); - - const { i18n } = useTranslation(); - - useEffect(() => { - checkAndRequestPermissions(); - }, []); - - useEffect(() => { - if (settings?.autoRotate === true) - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); - else - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP - ); - }, [settings]); + const appState = useRef(AppState.currentState); useEffect(() => { i18n.changeLanguage( @@ -267,24 +258,45 @@ function Layout() { ); }, [settings?.preferedLanguage, i18n]); - const appState = useRef(AppState.currentState); + if (!Platform.isTV) { + useKeepAwake(); + useNotificationObserver(); - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === "active" - ) { - checkForExistingDownloads(); - } - }); + const { i18n } = useTranslation(); - checkForExistingDownloads(); + useEffect(() => { + checkAndRequestPermissions(); + }, []); - return () => { - subscription.remove(); - }; - }, []); + useEffect(() => { + if (settings?.autoRotate === true) + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); + else + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + }, [settings]); + + useEffect(() => { + const subscription = AppState.addEventListener( + "change", + (nextAppState) => { + if ( + appState.current.match(/inactive|background/) && + nextAppState === "active" + ) { + BackGroundDownloader.checkForExistingDownloads(); + } + } + ); + + BackGroundDownloader.checkForExistingDownloads(); + + return () => { + subscription.remove(); + }; + }, []); + } const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..5d6a7a4a Binary files /dev/null and b/bun.lockb differ diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index b4ab7b9a..579dab79 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -39,7 +39,9 @@ export const AudioTrackSelector: React.FC = ({ - {t("item_card.audio")} + + {t("item_card.audio")} + {selectedAudioSteam?.DisplayTitle} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index be00cc9e..94cf14e1 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -77,7 +77,9 @@ export const BitrateSelector: React.FC = ({ - {t("item_card.quality")} + + {t("item_card.quality")} + {BITRATES.find((b) => b.value === selected?.value)?.key} diff --git a/components/Button.tsx b/components/Button.tsx index 4f7e25c4..ca41d06f 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -1,6 +1,6 @@ import { useHaptic } from "@/hooks/useHaptic"; import React, { PropsWithChildren, ReactNode, useMemo } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import { Platform, Text, TouchableOpacity, View } from "react-native"; import { Loader } from "./Loader"; export interface ButtonProps diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 6eb1d2a9..314bc4ee 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -1,5 +1,4 @@ import { Feather } from "@expo/vector-icons"; -import { BlurView } from "expo-blur"; import React, { useCallback, useEffect } from "react"; import { Platform, TouchableOpacity, ViewProps } from "react-native"; import GoogleCast, { @@ -18,12 +17,12 @@ interface Props extends ViewProps { background?: "blur" | "transparent"; } -export const Chromecast: React.FC = ({ +export default function Chromecast({ width = 48, height = 48, background = "transparent", ...props -}) => { +}) { const client = useRemoteMediaClient(); const castDevice = useCastDevice(); const devices = useDevices(); @@ -83,4 +82,4 @@ export const Chromecast: React.FC = ({ ); -}; +} diff --git a/components/Chromecast.tv.tsx b/components/Chromecast.tv.tsx new file mode 100644 index 00000000..e69de29b diff --git a/components/ContextMenu.ts b/components/ContextMenu.ts new file mode 100644 index 00000000..71954e83 --- /dev/null +++ b/components/ContextMenu.ts @@ -0,0 +1 @@ +export * from "zeego/context-menu"; diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts new file mode 100644 index 00000000..e69de29b diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index de10e375..c4e53ba2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { DownloadSingleItem } from "@/components/DownloadItem"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; @@ -24,12 +25,13 @@ import { } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useNavigation } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useAtom } from "jotai"; -import React, { useEffect, useMemo, useState } from "react"; -import { View } from "react-native"; +import React, { lazy, useEffect, useMemo, useState } from "react"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Chromecast } from "./Chromecast"; +// const Chromecast = !Platform.isTV ? require("./Chromecast") : null; +const Chromecast = lazy(() => import("./Chromecast")); import { ItemHeader } from "./ItemHeader"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { MediaSourceSelector } from "./MediaSourceSelector"; @@ -81,23 +83,25 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( defaultMediaSource, ]); - useEffect(() => { - navigation.setOptions({ - headerRight: () => - item && ( - - - {item.Type !== "Program" && ( - - - - - - )} - - ), - }); - }, [item]); + if (!Platform.isTV) { + useEffect(() => { + navigation.setOptions({ + headerRight: () => + item && ( + + + {item.Type !== "Program" && ( + + + + + + )} + + ), + }); + }, [item]); + } useEffect(() => { if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) @@ -189,9 +193,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( } > + {/* {!Platform.isTV && ( */} - {item.Type !== "Program" && ( + {item.Type !== "Program" && !Platform.isTV && ( = React.memo( )} - + {!Platform.isTV && ( + + )} {item.Type === "Episode" && ( diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 1a187e62..7a0ea2ca 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -61,7 +61,9 @@ export const MediaSourceSelector: React.FC = ({ - {t("item_card.video")} + + {t("item_card.video")} + {selectedName} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 611999d4..7c56b9ae 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,3 +1,4 @@ +import { Platform } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; @@ -31,7 +32,9 @@ import Animated, { } from "react-native-reanimated"; import { Button } from "./Button"; import { SelectedOptions } from "./ItemContent"; -import { chromecastProfile } from "@/utils/profiles/chromecast"; +const chromecastProfile = !Platform.isTV + ? require("@/utils/profiles/chromecast") + : null; import { useTranslation } from "react-i18next"; import { useHaptic } from "@/hooks/useHaptic"; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index f389e453..641d4ef0 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -51,7 +51,9 @@ export const SubtitleTrackSelector: React.FC = ({ - {t("item_card.subtitles")} + + {t("item_card.subtitles")} + {selectedSubtitleSteam diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx index 90f9c336..198d5a45 100644 --- a/components/common/JellyseerrItemRouter.tsx +++ b/components/common/JellyseerrItemRouter.tsx @@ -1,11 +1,14 @@ -import {useRouter, useSegments} from "expo-router"; -import React, {PropsWithChildren, useCallback, useMemo} from "react"; -import {TouchableOpacity, TouchableOpacityProps} from "react-native"; -import * as ContextMenu from "zeego/context-menu"; -import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; -import {useJellyseerr} from "@/hooks/useJellyseerr"; -import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions"; -import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import { useRouter, useSegments } from "expo-router"; +import React, { PropsWithChildren, useCallback, useMemo } from "react"; +import { TouchableOpacity, TouchableOpacityProps } from "react-native"; +import * as ContextMenu from "@/components/ContextMenu"; +import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { + hasPermission, + Permission, +} from "@/utils/jellyseerr/server/lib/permissions"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; interface Props extends TouchableOpacityProps { result: MovieResult | TvResult; @@ -26,26 +29,27 @@ export const TouchableJellyseerrRouter: React.FC> = ({ }) => { const router = useRouter(); const segments = useSegments(); - const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr() + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const from = segments[2]; const autoApprove = useMemo(() => { - return jellyseerrUser && hasPermission( - Permission.AUTO_APPROVE, - jellyseerrUser.permissions, - {type: 'or'} - ) - }, [jellyseerrApi, jellyseerrUser]) + return ( + jellyseerrUser && + hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, { + type: "or", + }) + ); + }, [jellyseerrApi, jellyseerrUser]); - const request = useCallback(() => + const request = useCallback( + () => requestMedia(mediaTitle, { mediaId: result.id, - mediaType: result.mediaType - } - ), + mediaType: result.mediaType, + }), [jellyseerrApi, result] - ) + ); if (from === "(home)" || from === "(search)" || from === "(libraries)") return ( @@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC> = ({ { // @ts-ignore - router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}}); + router.push({ + pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, + params: { + ...result, + mediaTitle, + releaseYear, + canRequest, + posterSrc, + }, + }); }} {...props} > @@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC> = ({ > Actions {canRequest && result.mediaType === MediaType.MOVIE && ( - { - if (autoApprove) { - request() - } + { + if (autoApprove) { + request(); + } + }} + shouldDismissMenuOnSelect + > + + Request + + - Request - - - )} + androidIconName="download" + /> + + )} diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 774b043d..e3dc325c 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -6,7 +6,6 @@ import { import { useRouter, useSegments } from "expo-router"; import { PropsWithChildren, useCallback } from "react"; import { TouchableOpacity, TouchableOpacityProps } from "react-native"; -import * as ContextMenu from "zeego/context-menu"; import { useActionSheet } from "@expo/react-native-action-sheet"; import * as Haptics from "expo-haptics"; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 47c79f5d..960cc3f2 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -4,12 +4,16 @@ import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; +const BackGroundDownloader = !Platform.isTV + ? require("@kesha-antonov/react-native-background-downloader") + : null; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; -import { FFmpegKit } from "ffmpeg-kit-react-native"; +const FFmpegKit = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; +import { useAtom } from "jotai"; import { ActivityIndicator, + Platform, TouchableOpacity, TouchableOpacityProps, View, @@ -63,7 +67,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { if (settings?.downloadMethod === DownloadMethod.Optimized) { try { - const tasks = await checkForExistingDownloads(); + const tasks = await BackGroundDownloader.checkForExistingDownloads(); for (const task of tasks) { if (task.id === id) { task.stop(); diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index b9c1d77d..438966a3 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -92,7 +92,9 @@ export const SeasonDropdown: React.FC = ({ - {t("item_card.season")} {seasonIndex} + + {t("item_card.season")} {seasonIndex} + diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx index 5fdddba8..1c296038 100644 --- a/components/settings/AppLanguageSelector.tsx +++ b/components/settings/AppLanguageSelector.tsx @@ -1,5 +1,5 @@ import * as DropdownMenu from "zeego/dropdown-menu"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { Platform, TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "../common/Text"; import { useSettings } from "@/utils/atoms/settings"; import { ListGroup } from "../list/ListGroup"; @@ -15,62 +15,63 @@ export const AppLanguageSelector: React.FC = ({ ...props }) => { if (!settings) return null; + // todo: fix + if (Platform.isTV) return null; + return ( - + - - - - - {APP_LANGUAGES.find( - (l) => l.value === settings?.preferedLanguage - )?.label || t("home.settings.languages.system")} - - - - - - {t("home.settings.languages.title")} - - { - updateSettings({ - preferedLanguage: undefined, - }); - }} + + + + + {APP_LANGUAGES.find( + (l) => l.value === settings?.preferedLanguage + )?.label || t("home.settings.languages.system")} + + + + - - {t("home.settings.languages.system")} - - - {APP_LANGUAGES?.map((l) => ( + + {t("home.settings.languages.title")} + { updateSettings({ - preferedLanguage: l.value, + preferedLanguage: undefined, }); }} > - {l.label} + + {t("home.settings.languages.system")} + - ))} - - - - - + {APP_LANGUAGES?.map((l) => ( + { + updateSettings({ + preferedLanguage: l.value, + }); + }} + > + {l.label} + + ))} + + + + + ); }; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 44c1a2a8..b9508191 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; -import {useSettings} from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; interface Props extends ViewProps {} @@ -47,7 +47,8 @@ export const AudioToggles: React.FC = ({ ...props }) => { - {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")} + {settings?.defaultAudioLanguage?.DisplayName || + t("home.settings.audio.none")} = ({ ...props }) => { collisionPadding={8} sideOffset={8} > - {t("home.settings.audio.language")} + + {t("home.settings.audio.language")} + { @@ -74,7 +77,9 @@ export const AudioToggles: React.FC = ({ ...props }) => { }); }} > - {t("home.settings.audio.none")} + + {t("home.settings.audio.none")} + {cultures?.map((l) => ( { +export default function DownloadSettings({ ...props }) { const [settings, updateSettings, pluginSettings] = useSettings(); const { setProcesses } = useDownload(); const router = useRouter(); @@ -61,7 +61,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => { collisionPadding={8} sideOffset={8} > - {t("home.settings.downloads.methods")} + + {t("home.settings.downloads.methods")} + { @@ -69,7 +71,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => { setProcesses([]); }} > - {t("home.settings.downloads.default")} + + {t("home.settings.downloads.default")} + { queryClient.invalidateQueries({ queryKey: ["search"] }); }} > - {t("home.settings.downloads.optimized")} + + {t("home.settings.downloads.optimized")} + @@ -134,4 +140,4 @@ export const DownloadSettings: React.FC = ({ ...props }) => { ); -}; +} diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index 8ddbca48..e05c14e4 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -1,3 +1,4 @@ +import { Platform } from "react-native"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { BACKGROUND_FETCH_TASK, @@ -5,10 +6,12 @@ import { unregisterBackgroundFetchAsync, } from "@/utils/background-tasks"; import { Ionicons } from "@expo/vector-icons"; -import * as BackgroundFetch from "expo-background-fetch"; +const BackgroundFetch = !Platform.isTV + ? require("expo-background-fetch") + : null; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; import { useRouter } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; -import * as TaskManager from "expo-task-manager"; import React, { useEffect, useMemo } from "react"; import { Linking, Switch, TouchableOpacity } from "react-native"; import { toast } from "sonner-native"; @@ -29,6 +32,8 @@ export const OtherSettings: React.FC = () => { * Background task *******************/ const checkStatusAsync = async () => { + if (Platform.isTV) return; + await BackgroundFetch.getStatusAsync(); return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); }; diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index c3559b62..834c8268 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -49,16 +49,25 @@ export const QuickConnect: React.FC = ({ ...props }) => { }); if (res.status === 200) { successHapticFeedback(); - Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized")); + Alert.alert( + t("home.settings.quick_connect.success"), + t("home.settings.quick_connect.quick_connect_autorized") + ); setQuickConnectCode(undefined); bottomSheetModalRef?.current?.close(); } else { errorHapticFeedback(); - Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); + Alert.alert( + t("home.settings.quick_connect.error"), + t("home.settings.quick_connect.invalid_code") + ); } } catch (e) { errorHapticFeedback(); - Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); + Alert.alert( + t("home.settings.quick_connect.error"), + t("home.settings.quick_connect.invalid_code") + ); } } }, [api, user, quickConnectCode]); @@ -96,7 +105,9 @@ export const QuickConnect: React.FC = ({ ...props }) => { { {t("home.settings.storage.storage_title")} {size && ( - {t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})} + {t("home.settings.storage.size_used", { + used: Number(size.total - size.remaining).bytesToReadable(), + total: size.total?.bytesToReadable(), + })} )} @@ -79,13 +82,20 @@ export const StorageSettings = () => { - {t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})} + {t("home.settings.storage.app_usage", { + usedSpace: calculatePercentage(size.app, size.total), + })} - {t("home.settings.storage.device_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})} + {t("home.settings.storage.device_usage", { + availableSpace: calculatePercentage( + size.total - size.remaining - size.app, + size.total + ), + })} diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 3748d0e5..3d2e1117 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,5 +1,5 @@ import { TouchableOpacity, View, ViewProps } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import * as DropdownMenu from "@/components/DropdownMenu"; import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; import { Switch } from "react-native-gesture-handler"; diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx index 65ab7b9f..90326204 100644 --- a/components/video-player/controls/AudioSlider.tsx +++ b/components/video-player/controls/AudioSlider.tsx @@ -1,8 +1,11 @@ import React, { useEffect, useRef } from "react"; -import { View, StyleSheet } from "react-native"; +import { View, StyleSheet, Platform } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { Slider } from "react-native-awesome-slider"; -import { VolumeManager } from "react-native-volume-manager"; +// import { VolumeManager } from "react-native-volume-manager"; +const VolumeManager = !Platform.isTV + ? require("react-native-volume-manager") + : null; import { Ionicons } from "@expo/vector-icons"; interface AudioSliderProps { @@ -10,6 +13,8 @@ interface AudioSliderProps { } const AudioSlider: React.FC = ({ setVisibility }) => { + if (Platform.isTV) return; + const volume = useSharedValue(50); // Explicitly type as number const min = useSharedValue(0); // Explicitly type as number const max = useSharedValue(100); // Explicitly type as number diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx index f7b0f392..be74e9cc 100644 --- a/components/video-player/controls/BrightnessSlider.tsx +++ b/components/video-player/controls/BrightnessSlider.tsx @@ -1,12 +1,15 @@ import React, { useEffect } from "react"; -import { View, StyleSheet } from "react-native"; +import { View, StyleSheet, Platform } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { Slider } from "react-native-awesome-slider"; -import * as Brightness from "expo-brightness"; +// import * as Brightness from "expo-brightness"; +const Brightness = !Platform.isTV ? require("expo-brightness") : null; import { Ionicons } from "@expo/vector-icons"; import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; const BrightnessSlider = () => { + if (Platform.isTV) return; + const brightness = useSharedValue(50); const min = useSharedValue(0); const max = useSharedValue(100); diff --git a/hooks/useHaptic.ts b/hooks/useHaptic.ts index c992def1..5e7f7e65 100644 --- a/hooks/useHaptic.ts +++ b/hooks/useHaptic.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { Platform } from "react-native"; -import * as Haptics from "expo-haptics"; import { useSettings } from "@/utils/atoms/settings"; +const Haptics = !Platform.isTV ? require("expo-haptics") : null; export type HapticFeedbackType = | "light" @@ -15,15 +15,21 @@ export type HapticFeedbackType = export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { const [settings] = useSettings(); + if (Platform.isTV) { + return () => {}; + } + const createHapticHandler = useCallback( (type: Haptics.ImpactFeedbackStyle) => { - return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type); + return Platform.OS === "web" || Platform.isTV + ? () => {} + : () => Haptics.impactAsync(type); }, [] ); const createNotificationFeedback = useCallback( (type: Haptics.NotificationFeedbackType) => { - return Platform.OS === "web" + return Platform.OS === "web" || Platform.isTV ? () => {} : () => Haptics.notificationAsync(type); }, @@ -35,7 +41,10 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light), medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium), heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy), - selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync, + selection: + Platform.OS === "web" || Platform.isTV + ? () => {} + : Haptics.selectionAsync, success: createNotificationFeedback( Haptics.NotificationFeedbackType.Success ), diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts index c7250c86..b3a8ff90 100644 --- a/hooks/useImageColors.ts +++ b/hooks/useImageColors.ts @@ -10,7 +10,9 @@ import { storage } from "@/utils/mmkv"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useAtom, useAtomValue } from "jotai"; import { useEffect, useMemo } from "react"; -import { getColors } from "react-native-image-colors"; +import { Platform } from "react-native"; +// import { getColors } from "react-native-image-colors"; +const Colors = !Platform.isTV ? require("react-native-image-colors") : null; /** * Custom hook to extract and manage image colors for a given item. @@ -28,6 +30,8 @@ export const useImageColors = ({ url?: string | null; disabled?: boolean; }) => { + if (Platform.isTV) return; + const api = useAtomValue(apiAtom); const [, setPrimaryColor] = useAtom(itemThemeColorAtom); @@ -62,7 +66,7 @@ export const useImageColors = ({ } // Extract colors from the image - getColors(source.uri, { + Colors.getColors(source.uri, { fallback: "#fff", cache: false, }) diff --git a/hooks/useOrientation.ts b/hooks/useOrientation.ts index 1ecb31ac..dff4015d 100644 --- a/hooks/useOrientation.ts +++ b/hooks/useOrientation.ts @@ -1,12 +1,17 @@ import orientationToOrientationLock from "@/utils/OrientationLockConverter"; -import * as ScreenOrientation from "expo-screen-orientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useEffect, useState } from "react"; +import { Platform } from "react-native"; export const useOrientation = () => { const [orientation, setOrientation] = useState( - ScreenOrientation.OrientationLock.UNKNOWN + Platform.isTV + ? ScreenOrientation.OrientationLock.LANDSCAPE + : ScreenOrientation.OrientationLock.UNKNOWN ); + if (Platform.isTV) return { orientation, setOrientation }; + useEffect(() => { const orientationSubscription = ScreenOrientation.addOrientationChangeListener((event) => { diff --git a/hooks/useOrientationSettings.ts b/hooks/useOrientationSettings.ts index 907e9bf2..7b657d77 100644 --- a/hooks/useOrientationSettings.ts +++ b/hooks/useOrientationSettings.ts @@ -1,8 +1,11 @@ import { useSettings } from "@/utils/atoms/settings"; -import * as ScreenOrientation from "expo-screen-orientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useEffect } from "react"; +import { Platform } from "react-native"; export const useOrientationSettings = () => { + if (Platform.isTV) return; + const [settings] = useSettings(); useEffect(() => { diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index e3990965..9c03a5ba 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -9,7 +9,8 @@ import { import { useQueryClient } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; +// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; +const FFmpegKit = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; import { useAtomValue } from "jotai"; import { useCallback } from "react"; import { toast } from "sonner-native"; @@ -18,6 +19,7 @@ import useDownloadHelper from "@/utils/download"; import { Api } from "@jellyfin/sdk"; import { useSettings } from "@/utils/atoms/settings"; import { JobStatus } from "@/utils/optimize-server"; +import { Platform } from "react-native"; import { useTranslation } from "react-i18next"; const createFFmpegCommand = (url: string, output: string) => [ @@ -55,7 +57,12 @@ export const useRemuxHlsToMp4 = () => { const [settings] = useSettings(); const { saveImage } = useImageStorage(); const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload(); + const { + saveDownloadedItemInfo, + setProcesses, + processes, + APP_CACHE_DOWNLOAD_DIRECTORY, + } = useDownload(); const onSaveAssets = async (api: Api, item: BaseItemDto) => { await saveSeriesPrimaryImage(item); @@ -79,9 +86,9 @@ export const useRemuxHlsToMp4 = () => { if (returnCode.isValueSuccess()) { const stat = await session.getLastReceivedStatistics(); await FileSystem.moveAsync({ - from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, - to: `${FileSystem.documentDirectory}${item.Id}.mp4` - }) + from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, + to: `${FileSystem.documentDirectory}${item.Id}.mp4`, + }); await queryClient.invalidateQueries({ queryKey: ["downloadedItems"], }); @@ -133,12 +140,16 @@ export const useRemuxHlsToMp4 = () => { const startRemuxing = useCallback( async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { - const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY); + const cacheDir = await FileSystem.getInfoAsync( + APP_CACHE_DOWNLOAD_DIRECTORY + ); if (!cacheDir.exists) { - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true}) + await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { + intermediates: true, + }); } - const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4` + const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`; if (!api) throw new Error("API is not defined"); if (!item.Id) throw new Error("Item must have an Id"); diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 00000000..7d041df7 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,14 @@ +const { getDefaultConfig } = require("expo/metro-config"); + +const config = getDefaultConfig(__dirname); + +if (process.env?.EXPO_TV === "1") { + const originalSourceExts = config.resolver.sourceExts; + const tvSourceExts = [ + ...originalSourceExts.map((e) => `tv.${e}`), + ...originalSourceExts, + ]; + config.resolver.sourceExts = tvSourceExts; +} + +module.exports = config; diff --git a/modules/vlc-player/ios/VlcPlayer.podspec b/modules/vlc-player/ios/VlcPlayer.podspec index 642026ae..eaa622ac 100644 --- a/modules/vlc-player/ios/VlcPlayer.podspec +++ b/modules/vlc-player/ios/VlcPlayer.podspec @@ -10,7 +10,8 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' - s.dependency 'MobileVLCKit', '~> 3.6.1b1' + s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1' + s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1' # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index f63aff44..cb75721f 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -1,5 +1,9 @@ import ExpoModulesCore +#if os(tvOS) +import TVVLCKit +#else import MobileVLCKit +#endif import UIKit class VlcPlayerView: ExpoView { diff --git a/package.json b/package.json index 11ab9cc1..500bd54c 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,14 @@ "submodule-reload": "git submodule update --init --remote --recursive", "clean": "echo y | expo prebuild --clean", "start": "bun run submodule-reload && expo start", - "reset-project": "node ./scripts/reset-project.js", - "android": "bun run submodule-reload && expo run:android", - "ios": "bun run submodule-reload && expo run:ios", - "web": "bun run submodule-reload && expo start --web", + "ios": "EXPO_TV=0 expo run:ios", + "ios:tv": "EXPO_TV=1 expo run:ios", + "android": "EXPO_TV=0 expo run:android", + "android:tv": "EXPO_TV=1 expo run:android", + "prebuild": "EXPO_TV=0 expo prebuild --clean", + "prebuild:tv": "EXPO_TV=1 expo prebuild --clean", + "prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; EXPO_TV=1 expo prebuild --clean", + "test": "jest --watchAll", "lint": "expo lint", "postinstall": "patch-package" }, @@ -72,8 +76,8 @@ "nativewind": "^2.0.11", "react": "18.2.0", "react-dom": "18.2.0", + "react-native": "npm:react-native-tvos@~0.74.5-0", "react-i18next": "^15.4.0", - "react-native": "0.74.5", "react-native-awesome-slider": "^2.5.6", "react-native-bottom-tabs": "0.8.3", "react-native-circular-progress": "^1.4.1", @@ -100,7 +104,7 @@ "react-native-uitextview": "^1.4.0", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.2", - "react-native-video": "^6.7.0", + "react-native-video": "6.8.2", "react-native-volume-manager": "^1.10.0", "react-native-web": "~0.19.13", "react-native-webview": "13.8.6", @@ -112,6 +116,8 @@ "zod": "^3.23.8" }, "devDependencies": { + "@react-native-community/cli": "15.1.3", + "@react-native-tvos/config-tv": "^0.1.1", "@babel/core": "^7.26.0", "@types/jest": "^29.5.14", "@types/react": "~18.2.79", @@ -121,5 +127,12 @@ "react-test-renderer": "18.2.0", "typescript": "~5.3.3" }, - "private": true -} + "private": true, + "expo": { + "install": { + "exclude": [ + "react-native" + ] + } + } +} \ No newline at end of file diff --git a/packages/expo-screen-orientation.ts b/packages/expo-screen-orientation.ts new file mode 100644 index 00000000..7b29bfe1 --- /dev/null +++ b/packages/expo-screen-orientation.ts @@ -0,0 +1 @@ +export * from "expo-screen-orientation"; diff --git a/packages/expo-screen-orientation.tv.ts b/packages/expo-screen-orientation.tv.ts new file mode 100644 index 00000000..b994b846 --- /dev/null +++ b/packages/expo-screen-orientation.tv.ts @@ -0,0 +1,66 @@ +export enum Orientation { + /** + * An unknown screen orientation. For example, the device is flat, perhaps on a table. + */ + UNKNOWN = 0, + /** + * Right-side up portrait interface orientation. + */ + PORTRAIT_UP = 1, + /** + * Upside down portrait interface orientation. + */ + PORTRAIT_DOWN = 2, + /** + * Left landscape interface orientation. + */ + LANDSCAPE_LEFT = 3, + /** + * Right landscape interface orientation. + */ + LANDSCAPE_RIGHT = 4, +} + +export enum OrientationLock { + /** + * The default orientation. On iOS, this will allow all orientations except `Orientation.PORTRAIT_DOWN`. + * On Android, this lets the system decide the best orientation. + */ + DEFAULT = 0, + /** + * All four possible orientations + */ + ALL = 1, + /** + * Any portrait orientation. + */ + PORTRAIT = 2, + /** + * Right-side up portrait only. + */ + PORTRAIT_UP = 3, + /** + * Upside down portrait only. + */ + PORTRAIT_DOWN = 4, + /** + * Any landscape orientation. + */ + LANDSCAPE = 5, + /** + * Left landscape only. + */ + LANDSCAPE_LEFT = 6, + /** + * Right landscape only. + */ + LANDSCAPE_RIGHT = 7, + /** + * A platform specific orientation. This is not a valid policy that can be applied in [`lockAsync`](#screenorientationlockasyncorientationlock). + */ + OTHER = 8, + /** + * An unknown screen orientation lock. This is not a valid policy that can be applied in [`lockAsync`](#screenorientationlockasyncorientationlock). + */ + UNKNOWN = 9, +} diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 149cdd96..568ceb76 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -13,12 +13,15 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { - checkForExistingDownloads, - completeHandler, - download, - setConfig, -} from "@kesha-antonov/react-native-background-downloader"; +// import { +// checkForExistingDownloads, +// completeHandler, +// download, +// setConfig, +// } from "@kesha-antonov/react-native-background-downloader"; +const BackGroundDownloader = !Platform.isTV + ? require("@kesha-antonov/react-native-background-downloader") + : null; import MMKV from "react-native-mmkv"; import { focusManager, @@ -42,7 +45,8 @@ import React, { import { AppState, AppStateStatus, Platform } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; -import * as Notifications from "expo-notifications"; +// import * as Notifications from "expo-notifications"; +const Notifications = !Platform.isTV ? require("expo-notifications") : null; import { getItemImage } from "@/utils/getItemImage"; import useImageStorage from "@/hooks/useImageStorage"; import { storage } from "@/utils/mmkv"; @@ -68,6 +72,7 @@ const DownloadContext = createContext | null>(null); function useDownloadProvider() { + if (Platform.isTV) return; const queryClient = useQueryClient(); const { t } = useTranslation(); const [settings] = useSettings(); @@ -174,7 +179,7 @@ function useDownloadProvider() { useEffect(() => { const checkIfShouldStartDownload = async () => { if (processes.length === 0) return; - await checkForExistingDownloads(); + await BackGroundDownloader.checkForExistingDownloads(); }; checkIfShouldStartDownload(); @@ -218,7 +223,7 @@ function useDownloadProvider() { ) ); - setConfig({ + BackGroundDownloader.setConfig({ isLogsEnabled: true, progressInterval: 500, headers: { @@ -238,7 +243,7 @@ function useDownloadProvider() { const baseDirectory = FileSystem.documentDirectory; - download({ + BackGroundDownloader.download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, destination: `${baseDirectory}/${process.item.Id}.mp4`, @@ -288,7 +293,7 @@ function useDownloadProvider() { }, }); setTimeout(() => { - completeHandler(process.id); + BackGroundDownloader.completeHandler(process.id); removeProcess(process.id); }, 1000); }) diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 13e77235..222417ef 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -23,7 +23,10 @@ import { getDeviceName } from "react-native-device-info"; import { useTranslation } from "react-i18next"; import { useSettings } from "@/utils/atoms/settings"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; -import { useSplashScreenLoading, useSplashScreenVisible } from "./SplashScreenProvider"; +import { + useSplashScreenLoading, + useSplashScreenVisible, +} from "./SplashScreenProvider"; interface Server { address: string; @@ -267,7 +270,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ case 401: throw new Error(t("login.invalid_username_or_password")); case 403: - throw new Error(t("login.user_does_not_have_permission_to_log_in")); + throw new Error( + t("login.user_does_not_have_permission_to_log_in") + ); case 408: throw new Error( t("login.server_is_taking_too_long_to_respond_try_again_later") @@ -280,7 +285,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ throw new Error(t("login.there_is_a_server_error")); default: throw new Error( - t("login.an_unexpected_error_occured_did_you_enter_the_correct_url") + t( + "login.an_unexpected_error_occured_did_you_enter_the_correct_url" + ) ); } } @@ -344,10 +351,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ let isLoadingOrFetching = isLoading || isFetching; useProtectedRoute(user, isLoadingOrFetching); - + // show splash screen until everything loaded - useSplashScreenLoading(isLoadingOrFetching) - const splashScreenVisible = useSplashScreenVisible() + useSplashScreenLoading(isLoadingOrFetching); + const splashScreenVisible = useSplashScreenVisible(); return ( diff --git a/scripts/symlink-native-dirs.js b/scripts/symlink-native-dirs.js new file mode 100644 index 00000000..d7819611 --- /dev/null +++ b/scripts/symlink-native-dirs.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const process = require("process"); +const { execSync } = require("child_process"); + +const root = process.cwd(); +// const tvosPath = path.join(root, 'iostv'); +// const iosPath = path.join(root, 'iosmobile'); +// const androidPath = path.join(root, 'androidmobile'); +// const androidTVPath = path.join(root, 'androidtv'); +// const device = process.argv[2]; +// const platform = process.argv[2]; +const isTV = process.env.EXPO_TV || false; + +const paths = new Map([ + ["tvos", path.join(root, "iostv")], + ["ios", path.join(root, "iosmobile")], + ["android", path.join(root, "androidmobile")], + ["androidtv", path.join(root, "androidtv")], +]); + +// const platformPath = paths.get(platform); + +if (isTV) { + stdout = execSync( + `mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios` + ); + console.log(stdout.toString()); + stdout = execSync( + `mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get( + "androidtv" + )} android` + ); + console.log(stdout.toString()); +} else { + stdout = execSync( + `mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios` + ); + console.log(stdout.toString()); + stdout = execSync( + `mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android` + ); + console.log(stdout.toString()); +} + +// target = ""; +// switch (platform) { +// case "tvos": +// target = "ios"; +// break; +// case "ios": +// target = "ios"; +// break; +// case "android": +// target = "android"; +// break; +// case "androidtv": +// target = "android"; +// break; +// } diff --git a/utils/OrientationLockConverter.ts b/utils/OrientationLockConverter.ts index 748ffcc6..498e01cb 100644 --- a/utils/OrientationLockConverter.ts +++ b/utils/OrientationLockConverter.ts @@ -1,4 +1,7 @@ -import { Orientation, OrientationLock } from "expo-screen-orientation"; +import { + Orientation, + OrientationLock, +} from "@/packages/expo-screen-orientation"; function orientationToOrientationLock( orientation: Orientation diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts index e4680fe3..4ee340a2 100644 --- a/utils/atoms/orientation.ts +++ b/utils/atoms/orientation.ts @@ -1,4 +1,4 @@ -import * as ScreenOrientation from "expo-screen-orientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { atom } from "jotai"; export const orientationAtom = atom( diff --git a/utils/background-tasks.ts b/utils/background-tasks.ts index 1d7f0a70..0e9a408a 100644 --- a/utils/background-tasks.ts +++ b/utils/background-tasks.ts @@ -1,4 +1,7 @@ -import * as BackgroundFetch from "expo-background-fetch"; +import { Platform } from "react-native"; +const BackgroundFetch = !Platform.isTV + ? require("expo-background-fetch") + : null; export const BACKGROUND_FETCH_TASK = "background-fetch";