mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
airplay ios
This commit is contained in:
parent
fd4efe6c7f
commit
a8b4dc5a01
13 changed files with 471 additions and 108 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
22
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue