diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index 030d554c..540f153c 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -23,7 +23,7 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( diff --git a/components/common/Input.tsx b/components/common/Input.tsx index 8d7f602f..948e4685 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -1,50 +1,125 @@ -import React, { useState } from "react"; +import { useRef, useState } from "react"; import { + Animated, + Easing, Platform, + Pressable, TextInput, type TextInputProps, - TouchableOpacity, + View, } from "react-native"; interface InputProps extends TextInputProps { - extraClassName?: string; // new prop for additional classes + extraClassName?: string; } export function Input(props: InputProps) { const { style, extraClassName = "", ...otherProps } = props; - const inputRef = React.useRef(null); + const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; - return Platform.isTV ? ( - inputRef?.current?.focus?.()} - activeOpacity={1} - > - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - {...otherProps} - /> - - ) : ( + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + if (Platform.isTV) { + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + > + + {/* Outer glow when focused */} + {isFocused && ( + + )} + + + {/* Purple accent bar at top when focused */} + {isFocused && ( + + )} + + + + + + ); + } + + // Mobile version unchanged + return ( { ]} > - {Platform.OS === "ios" ? ( + {Platform.OS === "ios" && !Platform.isTV ? ( ({ + ssid: null, + permissionStatus: "unavailable" as PermissionStatus, + requestPermission: async () => false, + isLoading: false, + }), + }; +} + +// Only import Location on non-TV platforms +const Location = Platform.isTV ? null : require("expo-location"); + +function mapLocationStatus(status: number | undefined): PermissionStatus { + if (!Location) return "unavailable"; switch (status) { - case Location.PermissionStatus.GRANTED: + case Location.PermissionStatus?.GRANTED: return "granted"; - case Location.PermissionStatus.DENIED: + case Location.PermissionStatus?.DENIED: return "denied"; default: return "undetermined"; @@ -30,17 +45,24 @@ function mapLocationStatus( export function useWifiSSID(): UseWifiSSIDReturn { const [ssid, setSSID] = useState(null); - const [permissionStatus, setPermissionStatus] = - useState("undetermined"); - const [isLoading, setIsLoading] = useState(true); + const [permissionStatus, setPermissionStatus] = useState( + Platform.isTV ? "unavailable" : "undetermined", + ); + const [isLoading, setIsLoading] = useState(!Platform.isTV); const fetchSSID = useCallback(async () => { + if (Platform.isTV) return; const result = await getSSID(); console.log("[WiFi Debug] Native module SSID:", result); setSSID(result); }, []); const requestPermission = useCallback(async (): Promise => { + if (Platform.isTV || !Location) { + setPermissionStatus("unavailable"); + return false; + } + try { const { status } = await Location.requestForegroundPermissionsAsync(); const newStatus = mapLocationStatus(status); @@ -58,6 +80,11 @@ export function useWifiSSID(): UseWifiSSIDReturn { }, [fetchSSID]); useEffect(() => { + if (Platform.isTV || !Location) { + setIsLoading(false); + return; + } + async function initialize() { setIsLoading(true); try { @@ -79,6 +106,8 @@ export function useWifiSSID(): UseWifiSSIDReturn { // Refresh SSID when permission status changes to granted useEffect(() => { + if (Platform.isTV) return; + if (permissionStatus === "granted") { fetchSSID(); diff --git a/index.js b/index.js index dbc9139f..7a0294a3 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,10 @@ import "react-native-url-polyfill/auto"; -import TrackPlayer from "react-native-track-player"; -import { PlaybackService } from "./services/PlaybackService"; +import { Platform } from "react-native"; import "expo-router/entry"; -TrackPlayer.registerPlaybackService(() => PlaybackService); +// TrackPlayer is not supported on tvOS +if (!Platform.isTV) { + const TrackPlayer = require("react-native-track-player").default; + const { PlaybackService } = require("./services/PlaybackService"); + TrackPlayer.registerPlaybackService(() => PlaybackService); +} diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index af346763..ad61bc64 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -219,7 +219,7 @@ final class MPVLayerRenderer { DispatchQueue.main.async { [weak self] in guard let self else { return } - if #available(iOS 18.0, *) { + if #available(iOS 18.0, tvOS 17.0, *) { self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) } else { self.displayLayer.flushAndRemoveImage() diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 608448b8..71ee21e2 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -72,9 +72,11 @@ class MpvPlayerView: ExpoView { displayLayer.frame = bounds displayLayer.videoGravity = .resizeAspect + #if !os(tvOS) if #available(iOS 17.0, *) { displayLayer.wantsExtendedDynamicRangeContent = true } + #endif displayLayer.backgroundColor = UIColor.black.cgColor videoContainer.layer.addSublayer(displayLayer) diff --git a/modules/wifi-ssid/ios/WifiSsidModule.swift b/modules/wifi-ssid/ios/WifiSsidModule.swift index 0a2a5faa..5254fcb5 100644 --- a/modules/wifi-ssid/ios/WifiSsidModule.swift +++ b/modules/wifi-ssid/ios/WifiSsidModule.swift @@ -1,13 +1,19 @@ import ExpoModulesCore +#if !os(tvOS) import NetworkExtension import SystemConfiguration.CaptiveNetwork +#endif public class WifiSsidModule: Module { public func definition() -> ModuleDefinition { Name("WifiSsid") // Get current WiFi SSID using NEHotspotNetwork (iOS 14+) + // Not available on tvOS AsyncFunction("getSSID") { () -> String? in + #if os(tvOS) + return nil + #else return await withCheckedContinuation { continuation in NEHotspotNetwork.fetchCurrent { network in if let ssid = network?.ssid { @@ -21,14 +27,21 @@ public class WifiSsidModule: Module { } } } + #endif } // Synchronous version using only CNCopyCurrentNetworkInfo + // Not available on tvOS Function("getSSIDSync") { () -> String? in + #if os(tvOS) + return nil + #else return self.getSSIDViaCNCopy() + #endif } } + #if !os(tvOS) private func getSSIDViaCNCopy() -> String? { guard let interfaces = CNCopySupportedInterfaces() as? [String] else { print("[WifiSsid] CNCopySupportedInterfaces returned nil") @@ -49,4 +62,5 @@ public class WifiSsidModule: Module { print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo") return nil } + #endif } diff --git a/package.json b/package.json index 2679912c..d4750406 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-i18next": "16.5.3", - "react-native": "0.81.5", + "react-native": "npm:react-native-tvos@0.81.5-2", "react-native-awesome-slider": "^2.9.0", "react-native-bottom-tabs": "1.1.0", "react-native-circular-progress": "^1.4.1", diff --git a/patches/react-native-bottom-tabs+1.1.0.patch b/patches/react-native-bottom-tabs+1.1.0.patch new file mode 100644 index 00000000..0f5331fe --- /dev/null +++ b/patches/react-native-bottom-tabs+1.1.0.patch @@ -0,0 +1,72 @@ +diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift +--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift ++++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift +@@ -8,7 +8,7 @@ + self.delegate = delegate + } + +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { + var placementValue = "none" +diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift +--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift +@@ -67,11 +67,11 @@ + } + + func body(content: Content) -> some View { +- #if os(macOS) +- // tabViewBottomAccessory is not available on macOS ++ #if os(macOS) || os(tvOS) ++ // tabViewBottomAccessory is not available on macOS or tvOS + content + #else +- if #available(iOS 26.0, tvOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { ++ if #available(iOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil { + content + .tabViewBottomAccessory { + renderBottomAccessoryView() +@@ -84,7 +84,7 @@ + + @ViewBuilder + private func renderBottomAccessoryView() -> some View { +- #if !os(macOS) ++ #if !os(macOS) && !os(tvOS) + if let bottomAccessoryView { + if #available(iOS 26.0, *) { + BottomAccessoryRepresentableView(view: bottomAccessoryView) +@@ -94,7 +94,7 @@ + } + } + +-#if !os(macOS) ++#if !os(macOS) && !os(tvOS) + @available(iOS 26.0, *) + struct BottomAccessoryRepresentableView: PlatformViewRepresentable { + @Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement +diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift +--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift +@@ -281,7 +281,7 @@ + + @ViewBuilder + func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View { +- #if compiler(>=6.2) ++ #if compiler(>=6.2) && !os(tvOS) + if #available(iOS 26.0, macOS 26.0, *) { + if let behavior { + self.tabBarMinimizeBehavior(behavior.convert()) +diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift +--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift ++++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift +@@ -6,7 +6,7 @@ + case onScrollUp + case onScrollDown + +-#if compiler(>=6.2) ++#if compiler(>=6.2) && !os(tvOS) + @available(iOS 26.0, macOS 26.0, *) + func convert() -> TabBarMinimizeBehavior { + #if os(macOS) diff --git a/react-native.config.js b/react-native.config.js index 6e8801ee..3e85d555 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -37,6 +37,11 @@ const dependencies = { ), "react-native-ios-utilities": disableForTV("react-native-ios-utilities"), "react-native-pager-view": disableForTV("react-native-pager-view"), + "react-native-track-player": disableForTV("react-native-track-player"), + "expo-location": disableForTV("expo-location"), + "react-native-glass-effect-view": disableForTV( + "react-native-glass-effect-view", + ), }; // Filter out undefined values