diff --git a/ios/KSPlayerManager.m b/ios/KSPlayerManager.m index f521027..1420d60 100644 --- a/ios/KSPlayerManager.m +++ b/ios/KSPlayerManager.m @@ -16,6 +16,8 @@ RCT_EXPORT_VIEW_PROPERTY(paused, BOOL) RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber) RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber) RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL) +RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL) // Event properties RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock) @@ -32,11 +34,17 @@ RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *) RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId) RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId) RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setAllowsExternalPlayback:(nonnull NSNumber *)node allows:(BOOL)allows) +RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive:(nonnull NSNumber *)node uses:(BOOL)uses) +RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node) @end @interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter) RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)nodeTag) @end diff --git a/ios/KSPlayerModule.swift b/ios/KSPlayerModule.swift index ee487c1..58ace7c 100644 --- a/ios/KSPlayerModule.swift +++ b/ios/KSPlayerModule.swift @@ -34,4 +34,26 @@ class KSPlayerModule: RCTEventEmitter { } } } + + @objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager { + viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject) + } else { + reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil) + } + } + } + + @objc func showAirPlayPicker(_ nodeTag: NSNumber) { + print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)") + DispatchQueue.main.async { + if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager { + print("[KSPlayerModule] Found KSPlayerViewManager, calling showAirPlayPicker") + viewManager.showAirPlayPicker(nodeTag) + } else { + print("[KSPlayerModule] Could not find KSPlayerViewManager") + } + } + } } diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift index 7ef84c2..209439c 100644 --- a/ios/KSPlayerView.swift +++ b/ios/KSPlayerView.swift @@ -8,6 +8,7 @@ import Foundation import KSPlayer import React +import AVKit @objc(KSPlayerView) class KSPlayerView: UIView { @@ -16,6 +17,8 @@ class KSPlayerView: UIView { private var isPaused = false private var currentVolume: Float = 1.0 weak var viewManager: KSPlayerViewManager? + + // AirPlay properties (removed duplicate declarations) // Event blocks for Fabric @objc var onLoad: RCTDirectEventBlock? @@ -57,6 +60,19 @@ class KSPlayerView: UIView { setTextTrack(textTrack.intValue) } } + + // AirPlay properties + @objc var allowsExternalPlayback: Bool = true { + didSet { + setAllowsExternalPlayback(allowsExternalPlayback) + } + } + + @objc var usesExternalPlaybackWhileExternalScreenIsActive: Bool = true { + didSet { + setUsesExternalPlaybackWhileExternalScreenIsActive(usesExternalPlaybackWhileExternalScreenIsActive) + } + } override init(frame: CGRect) { super.init(frame: frame) @@ -161,6 +177,12 @@ class KSPlayerView: UIView { } setVolume(currentVolume) + + // Ensure AirPlay is properly configured after setting source + DispatchQueue.main.async { + self.setAllowsExternalPlayback(self.allowsExternalPlayback) + self.setUsesExternalPlaybackWhileExternalScreenIsActive(self.usesExternalPlaybackWhileExternalScreenIsActive) + } } private func createOptions(with headers: [String: String]) -> KSOptions { @@ -283,7 +305,7 @@ class KSPlayerView: UIView { print("KSPlayerView: Successfully selected audio track \(trackId)") // Verify the selection worked - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { let tracksAfter = player.tracks(mediaType: .audio) for (index, track) in tracksAfter.enumerated() { print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)") @@ -399,6 +421,94 @@ class KSPlayerView: UIView { ] } + // AirPlay methods + func setAllowsExternalPlayback(_ allows: Bool) { + print("[KSPlayerView] Setting allowsExternalPlayback: \(allows)") + playerView.playerLayer?.player.allowsExternalPlayback = allows + } + + func setUsesExternalPlaybackWhileExternalScreenIsActive(_ uses: Bool) { + print("[KSPlayerView] Setting usesExternalPlaybackWhileExternalScreenIsActive: \(uses)") + playerView.playerLayer?.player.usesExternalPlaybackWhileExternalScreenIsActive = uses + } + + func showAirPlayPicker() { + print("[KSPlayerView] showAirPlayPicker called") + + DispatchQueue.main.async { + // Create a temporary route picker view for triggering AirPlay + let routePickerView = AVRoutePickerView() + routePickerView.tintColor = .white + routePickerView.alpha = 0.01 // Nearly invisible but still interactive + + // Find the current view controller + guard let viewController = self.findHostViewController() else { + print("[KSPlayerView] Could not find view controller for AirPlay picker") + return + } + + // Add to the view controller's view temporarily + viewController.view.addSubview(routePickerView) + + // Position it off-screen but still in the view hierarchy + routePickerView.frame = CGRect(x: -100, y: -100, width: 44, height: 44) + + // Force layout + viewController.view.setNeedsLayout() + viewController.view.layoutIfNeeded() + + // Wait a bit for the view to be ready, then trigger + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Find and trigger the AirPlay button + self.triggerAirPlayButton(routePickerView) + + // Clean up after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + routePickerView.removeFromSuperview() + print("[KSPlayerView] Cleaned up temporary AirPlay picker") + } + } + } + } + + private func triggerAirPlayButton(_ routePickerView: AVRoutePickerView) { + // Recursively find the button in the route picker view + func findButton(in view: UIView) -> UIButton? { + if let button = view as? UIButton { + return button + } + for subview in view.subviews { + if let button = findButton(in: subview) { + return button + } + } + return nil + } + + if let button = findButton(in: routePickerView) { + print("[KSPlayerView] Found AirPlay button, triggering tap") + button.sendActions(for: .touchUpInside) + } else { + print("[KSPlayerView] Could not find AirPlay button in route picker") + } + } + + func getAirPlayState() -> [String: Any] { + guard let player = playerView.playerLayer?.player else { + return [ + "allowsExternalPlayback": false, + "usesExternalPlaybackWhileExternalScreenIsActive": false, + "isExternalPlaybackActive": false + ] + } + + return [ + "allowsExternalPlayback": player.allowsExternalPlayback, + "usesExternalPlaybackWhileExternalScreenIsActive": player.usesExternalPlaybackWhileExternalScreenIsActive, + "isExternalPlaybackActive": player.isExternalPlaybackActive + ] + } + // Get current player state for React Native func getCurrentState() -> [String: Any] { guard let player = playerView.playerLayer?.player else { @@ -419,6 +529,15 @@ extension KSPlayerView: KSPlayerLayerDelegate { func player(layer: KSPlayerLayer, state: KSPlayerState) { switch state { case .readyToPlay: + // Ensure AirPlay is properly configured when player is ready + layer.player.allowsExternalPlayback = allowsExternalPlayback + layer.player.usesExternalPlaybackWhileExternalScreenIsActive = usesExternalPlaybackWhileExternalScreenIsActive + + // Determine player backend type + let uriString = currentSource?["uri"] as? String + let isMKV = uriString?.lowercased().contains(".mkv") ?? false + let playerBackend = isMKV ? "KSMEPlayer" : "KSAVPlayer" + // Send onLoad event to React Native with track information let p = layer.player let tracks = getAvailableTracks() @@ -430,7 +549,8 @@ extension KSPlayerView: KSPlayerLayerDelegate { "height": p.naturalSize.height ], "audioTracks": tracks["audioTracks"] ?? [], - "textTracks": tracks["textTracks"] ?? [] + "textTracks": tracks["textTracks"] ?? [], + "playerBackend": playerBackend ]) case .buffering: sendEvent("onBuffering", ["isBuffering": true]) @@ -453,7 +573,8 @@ extension KSPlayerView: KSPlayerLayerDelegate { sendEvent("onProgress", [ "currentTime": currentTime, "duration": totalTime, - "bufferTime": p.playableTime + "bufferTime": p.playableTime, + "airPlayState": getAirPlayState() ]) } } diff --git a/ios/KSPlayerViewManager.swift b/ios/KSPlayerViewManager.swift index ce9e3f0..1f6ca45 100644 --- a/ios/KSPlayerViewManager.swift +++ b/ios/KSPlayerViewManager.swift @@ -96,4 +96,44 @@ class KSPlayerViewManager: RCTViewManager { } } } + + // AirPlay methods + @objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) { + DispatchQueue.main.async { + if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView { + view.setAllowsExternalPlayback(allows) + } + } + } + + @objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) { + DispatchQueue.main.async { + if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView { + view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses) + } + } + } + + @objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView { + let airPlayState = view.getAirPlayState() + resolve(airPlayState) + } else { + reject("NO_VIEW", "KSPlayerView not found", nil) + } + } + } + + @objc func showAirPlayPicker(_ node: NSNumber) { + print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)") + DispatchQueue.main.async { + if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView { + print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker") + view.showAirPlayPicker() + } else { + print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)") + } + } + } } diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index a1ef76a..d3f62e0 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -460,8 +460,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -493,7 +493,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index d701baa..c1edaa9 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,101 +1,101 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.5 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 20 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - NSMicrophoneUsageDescription - This app does not require microphone access. - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - \ No newline at end of file + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.5 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 20 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + NSMicrophoneUsageDescription + This app does not require microphone access. + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + diff --git a/package-lock.json b/package-lock.json index 8a6cbf5..e9619c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "@types/crypto-js": "^4.2.2", "@types/react": "~18.3.12", "@types/react-native": "^0.72.8", + "@types/react-native-vector-icons": "^6.4.18", "babel-plugin-transform-remove-console": "^6.9.4", "patch-package": "^8.0.1", "react-native-svg-transformer": "^1.5.0", @@ -4208,6 +4209,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-native-vector-icons": { + "version": "6.4.18", + "resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz", + "integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/react-native": "^0.70" + } + }, + "node_modules/@types/react-native-vector-icons/node_modules/@types/react-native": { + "version": "0.70.19", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz", + "integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-native-video": { "version": "5.0.20", "resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz", diff --git a/package.json b/package.json index 267e782..8ce6708 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@types/crypto-js": "^4.2.2", "@types/react": "~18.3.12", "@types/react-native": "^0.72.8", + "@types/react-native-vector-icons": "^6.4.18", "babel-plugin-transform-remove-console": "^6.9.4", "patch-package": "^8.0.1", "react-native-svg-transformer": "^1.5.0", diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index ec8bd41..1d8bd4c 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native'; -import { LegendList } from '@legendapp/list'; +import { FlashList } from '@shopify/flash-list'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -97,6 +97,12 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { // Memoize the keyExtractor to prevent re-creation const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []); + // FlashList v2 optimization: getItemType for better performance + const getItemType = useCallback((item: StreamingContent) => { + // Return different types based on content for better recycling + return item.type === 'movie' ? 'movie' : 'series'; + }, []); + return ( { - { ItemSeparatorComponent={ItemSeparator} onEndReachedThreshold={0.7} onEndReached={() => {}} - recycleItems={true} - maintainVisibleContentPosition + // FlashList v2 optimizations + drawDistance={500} /> ); diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 40d1d1f..6a190a2 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1588,6 +1588,11 @@ const AndroidVideoPlayer: React.FC = () => { } controlsTimeout.current = setTimeout(hideControls, 5000); + + // Auto-fetch and load English external subtitles if available + if (imdbId) { + fetchAvailableSubtitles(undefined, true); + } } catch (error) { logger.error('[AndroidVideoPlayer] Error in onLoad:', error); // Set fallback values to prevent crashes diff --git a/src/components/player/KSPlayerComponent.tsx b/src/components/player/KSPlayerComponent.tsx index aa9d8fc..da9eb84 100644 --- a/src/components/player/KSPlayerComponent.tsx +++ b/src/components/player/KSPlayerComponent.tsx @@ -12,6 +12,8 @@ interface KSPlayerViewProps { volume?: number; audioTrack?: number; textTrack?: number; + allowsExternalPlayback?: boolean; + usesExternalPlaybackWhileExternalScreenIsActive?: boolean; onLoad?: (data: any) => void; onProgress?: (data: any) => void; onBuffering?: (data: any) => void; @@ -32,6 +34,10 @@ export interface KSPlayerRef { setAudioTrack: (trackId: number) => void; setTextTrack: (trackId: number) => void; getTracks: () => Promise<{ audioTracks: any[]; textTracks: any[] }>; + setAllowsExternalPlayback: (allows: boolean) => void; + setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => void; + getAirPlayState: () => Promise<{ allowsExternalPlayback: boolean; usesExternalPlaybackWhileExternalScreenIsActive: boolean; isExternalPlaybackActive: boolean }>; + showAirPlayPicker: () => void; } export interface KSPlayerProps { @@ -40,6 +46,8 @@ export interface KSPlayerProps { volume?: number; audioTrack?: number; textTrack?: number; + allowsExternalPlayback?: boolean; + usesExternalPlaybackWhileExternalScreenIsActive?: boolean; onLoad?: (data: any) => void; onProgress?: (data: any) => void; onBuffering?: (data: any) => void; @@ -109,6 +117,38 @@ const KSPlayer = forwardRef((props, ref) => { } return { audioTracks: [], textTracks: [] }; }, + setAllowsExternalPlayback: (allows: boolean) => { + if (nativeRef.current) { + const node = findNodeHandle(nativeRef.current); + // @ts-ignore legacy UIManager commands path for Paper + const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setAllowsExternalPlayback; + UIManager.dispatchViewManagerCommand(node, commandId, [allows]); + } + }, + setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => { + if (nativeRef.current) { + const node = findNodeHandle(nativeRef.current); + // @ts-ignore legacy UIManager commands path for Paper + const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setUsesExternalPlaybackWhileExternalScreenIsActive; + UIManager.dispatchViewManagerCommand(node, commandId, [uses]); + } + }, + getAirPlayState: async () => { + if (nativeRef.current) { + const node = findNodeHandle(nativeRef.current); + return await KSPlayerModule.getAirPlayState(node); + } + return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false }; + }, + showAirPlayPicker: () => { + if (nativeRef.current) { + const node = findNodeHandle(nativeRef.current); + console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node); + KSPlayerModule.showAirPlayPicker(node); + } else { + console.log('[KSPlayerComponent] nativeRef.current is null'); + } + }, })); // No need for event listeners - events are handled through props @@ -129,6 +169,8 @@ const KSPlayer = forwardRef((props, ref) => { volume={props.volume} audioTrack={props.audioTrack} textTrack={props.textTrack} + allowsExternalPlayback={props.allowsExternalPlayback} + usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive} onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)} onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)} onBuffering={(e: any) => props.onBuffering?.(e?.nativeEvent ?? e)} diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 672c418..6e0f006 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -118,6 +118,7 @@ const KSPlayerCore: React.FC = () => { const [textTracks, setTextTracks] = useState([]); const [selectedTextTrack, setSelectedTextTrack] = useState(-1); const [resizeMode, setResizeMode] = useState('contain'); + const [playerBackend, setPlayerBackend] = useState(''); const [buffered, setBuffered] = useState(0); const [seekPosition, setSeekPosition] = useState(null); const ksPlayerRef = useRef(null); @@ -260,6 +261,10 @@ const KSPlayerCore: React.FC = () => { const controlsTimeout = useRef(null); const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); + // AirPlay state + const [isAirPlayActive, setIsAirPlayActive] = useState(false); + const [allowsAirPlay, setAllowsAirPlay] = useState(true); + // Silent startup-timeout retry state const startupRetryCountRef = useRef(0); const startupRetryTimerRef = useRef(null); @@ -956,6 +961,18 @@ const KSPlayerCore: React.FC = () => { safeSetState(() => setBuffered(bufferedTime)); } + // Update AirPlay state if available + if (event.airPlayState) { + const wasAirPlayActive = isAirPlayActive; + setIsAirPlayActive(event.airPlayState.isExternalPlaybackActive); + setAllowsAirPlay(event.airPlayState.allowsExternalPlayback); + + // Log AirPlay state changes for debugging + if (wasAirPlayActive !== event.airPlayState.isExternalPlaybackActive) { + if (__DEV__) logger.log(`[VideoPlayer] AirPlay state changed: ${event.airPlayState.isExternalPlaybackActive ? 'ACTIVE' : 'INACTIVE'}`); + } + } + // Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay if (!isOpeningAnimationComplete) { setIsVideoLoaded(true); @@ -1039,6 +1056,24 @@ const KSPlayerCore: React.FC = () => { logger.error('[VideoPlayer] onLoad called with null/undefined data'); return; } + // Extract player backend information + if (data.playerBackend) { + const newPlayerBackend = data.playerBackend; + setPlayerBackend(newPlayerBackend); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Player backend: ${newPlayerBackend}`); + } + + // Reset AirPlay state if switching to KSMEPlayer (which doesn't support AirPlay) + if (newPlayerBackend === 'KSMEPlayer' && (isAirPlayActive || allowsAirPlay)) { + setIsAirPlayActive(false); + setAllowsAirPlay(false); + if (DEBUG_MODE) { + logger.log('[VideoPlayer] Reset AirPlay state for KSMEPlayer'); + } + } + } + // KSPlayer returns duration in seconds directly const videoDuration = data.duration; if (DEBUG_MODE) { @@ -1249,6 +1284,11 @@ const KSPlayerCore: React.FC = () => { } controlsTimeout.current = setTimeout(hideControls, 5000); + + // Auto-fetch and load English external subtitles if available + if (imdbId) { + fetchAvailableSubtitles(undefined, true); + } } catch (error) { logger.error('[VideoPlayer] Error in onLoad:', error); // Set fallback values to prevent crashes @@ -2333,6 +2373,27 @@ const KSPlayerCore: React.FC = () => { } }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]); + // AirPlay handler + const handleAirPlayPress = async () => { + if (!ksPlayerRef.current) return; + + try { + // First ensure AirPlay is enabled + if (!allowsAirPlay) { + ksPlayerRef.current.setAllowsExternalPlayback(true); + setAllowsAirPlay(true); + logger.log(`[VideoPlayer] AirPlay enabled before showing picker`); + } + + // Show the AirPlay picker + ksPlayerRef.current.showAirPlayPicker(); + + logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`); + } catch (error) { + logger.error('[VideoPlayer] Error showing AirPlay picker:', error); + } + }; + const handleSelectStream = async (newStream: any) => { if (newStream.url === currentStreamUrl) { setShowSourcesModal(false); @@ -2665,6 +2726,8 @@ const KSPlayerCore: React.FC = () => { volume={volume / 100} audioTrack={selectedAudioTrack ?? undefined} textTrack={useCustomSubtitles ? -1 : selectedTextTrack} + allowsExternalPlayback={allowsAirPlay} + usesExternalPlaybackWhileExternalScreenIsActive={true} onProgress={handleProgress} onLoad={onLoad} onEnd={onEnd} @@ -2706,8 +2769,12 @@ const KSPlayerCore: React.FC = () => { onSlidingComplete={handleSlidingComplete} buffered={buffered} formatTime={formatTime} + playerBackend={playerBackend} cyclePlaybackSpeed={cyclePlaybackSpeed} currentPlaybackSpeed={playbackSpeed} + isAirPlayActive={isAirPlayActive} + allowsAirPlay={allowsAirPlay} + onAirPlayPress={handleAirPlayPress} /> {showPauseOverlay && ( diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 3051030..ae7d05a 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import Feather from 'react-native-vector-icons/Feather'; import { LinearGradient } from 'expo-linear-gradient'; import Slider from '@react-native-community/slider'; import { styles } from '../utils/playerStyles'; @@ -43,6 +44,10 @@ interface PlayerControlsProps { buffered: number; formatTime: (seconds: number) => string; playerBackend?: string; + // AirPlay props + isAirPlayActive?: boolean; + allowsAirPlay?: boolean; + onAirPlayPress?: () => void; } export const PlayerControls: React.FC = ({ @@ -80,6 +85,9 @@ export const PlayerControls: React.FC = ({ buffered, formatTime, playerBackend, + isAirPlayActive, + allowsAirPlay, + onAirPlayPress, }) => { const { currentTheme } = useTheme(); const deviceWidth = Dimensions.get('window').width; @@ -249,6 +257,26 @@ export const PlayerControls: React.FC = ({ )} + + {/* AirPlay Button - iOS only, KSAVPlayer only */} + {Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && ( + + + + {allowsAirPlay ? 'AirPlay' : 'AirPlay Off'} + + + )}