airplay ios

This commit is contained in:
tapframe 2025-10-20 02:26:42 +05:30
parent fd4efe6c7f
commit a8b4dc5a01
13 changed files with 471 additions and 108 deletions

View file

@ -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

View file

@ -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")
}
}
}
}

View file

@ -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()
])
}
}

View file

@ -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)")
}
}
}
}

View file

@ -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";

View file

@ -1,101 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>20</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>20</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

22
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 (
<Animated.View
style={styles.catalogContainer}
@ -163,10 +169,11 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
</TouchableOpacity>
</View>
<LegendList
<FlashList
data={catalog.items}
renderItem={renderContentItem}
keyExtractor={keyExtractor}
getItemType={getItemType}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={StyleSheet.flatten([
@ -179,8 +186,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
recycleItems={true}
maintainVisibleContentPosition
// FlashList v2 optimizations
drawDistance={500}
/>
</Animated.View>
);

View file

@ -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

View file

@ -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<KSPlayerRef, KSPlayerProps>((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<KSPlayerRef, KSPlayerProps>((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)}

View file

@ -118,6 +118,7 @@ const KSPlayerCore: React.FC = () => {
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
const [playerBackend, setPlayerBackend] = useState<string>('');
const [buffered, setBuffered] = useState(0);
const [seekPosition, setSeekPosition] = useState<number | null>(null);
const ksPlayerRef = useRef<KSPlayerRef>(null);
@ -260,6 +261,10 @@ const KSPlayerCore: React.FC = () => {
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
// AirPlay state
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Silent startup-timeout retry state
const startupRetryCountRef = useRef(0);
const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(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 && (

View file

@ -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<PlayerControlsProps> = ({
@ -80,6 +85,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
buffered,
formatTime,
playerBackend,
isAirPlayActive,
allowsAirPlay,
onAirPlayPress,
}) => {
const { currentTheme } = useTheme();
const deviceWidth = Dimensions.get('window').width;
@ -249,6 +257,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</Text>
</TouchableOpacity>
)}
{/* AirPlay Button - iOS only, KSAVPlayer only */}
{Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
<TouchableOpacity
style={styles.bottomButton}
onPress={onAirPlayPress}
>
<Feather
name="airplay"
size={20}
color={isAirPlayActive ? currentTheme.colors.primary : "white"}
/>
<Text style={[
styles.bottomButtonText,
isAirPlayActive && { color: currentTheme.colors.primary }
]}>
{allowsAirPlay ? 'AirPlay' : 'AirPlay Off'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</LinearGradient>