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'}
+
+
+ )}