// // KSPlayerView.swift // Nuvio // // Created by KSPlayer integration // import Foundation import KSPlayer import React @objc(KSPlayerView) class KSPlayerView: UIView { private var playerView: IOSVideoPlayerView! private var currentSource: NSDictionary? private var isPaused = false private var currentVolume: Float = 1.0 weak var viewManager: KSPlayerViewManager? // Event blocks for Fabric @objc var onLoad: RCTDirectEventBlock? @objc var onProgress: RCTDirectEventBlock? @objc var onBuffering: RCTDirectEventBlock? @objc var onEnd: RCTDirectEventBlock? @objc var onError: RCTDirectEventBlock? @objc var onBufferingProgress: RCTDirectEventBlock? // Property setters that React Native will call @objc var source: NSDictionary? { didSet { if let source = source { setSource(source) } } } @objc var paused: Bool = false { didSet { setPaused(paused) } } @objc var volume: NSNumber = 1.0 { didSet { setVolume(volume.floatValue) } } @objc var audioTrack: NSNumber = -1 { didSet { setAudioTrack(audioTrack.intValue) } } @objc var textTrack: NSNumber = -1 { didSet { setTextTrack(textTrack.intValue) } } override init(frame: CGRect) { super.init(frame: frame) setupPlayerView() } required init?(coder: NSCoder) { super.init(coder: coder) setupPlayerView() } private func setupPlayerView() { playerView = IOSVideoPlayerView() playerView.translatesAutoresizingMaskIntoConstraints = false // Hide native controls - we use custom React Native controls playerView.isUserInteractionEnabled = false // Hide KSPlayer's built-in overlay/controls playerView.controllerView.isHidden = true playerView.contentOverlayView.isHidden = true playerView.controllerView.alpha = 0 playerView.contentOverlayView.alpha = 0 playerView.controllerView.gestureRecognizers?.forEach { $0.isEnabled = false } addSubview(playerView) NSLayoutConstraint.activate([ playerView.topAnchor.constraint(equalTo: topAnchor), playerView.leadingAnchor.constraint(equalTo: leadingAnchor), playerView.trailingAnchor.constraint(equalTo: trailingAnchor), playerView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) // Set up player delegates and callbacks setupPlayerCallbacks() } private func setupPlayerCallbacks() { // Configure KSOptions (use static defaults where required) KSOptions.isAutoPlay = false #if targetEnvironment(simulator) // Simulator: disable hardware decode and MEPlayer to avoid VT/Vulkan issues KSOptions.hardwareDecode = false KSOptions.asynchronousDecompression = false KSOptions.secondPlayerType = nil #endif } func setSource(_ source: NSDictionary) { currentSource = source guard let uri = source["uri"] as? String else { print("KSPlayerView: No URI provided") sendEvent("onError", ["error": "No URI provided in source"]) return } // Validate URL before proceeding guard let url = URL(string: uri), url.scheme != nil else { print("KSPlayerView: Invalid URL format: \(uri)") sendEvent("onError", ["error": "Invalid URL format: \(uri)"]) return } var headers: [String: String] = [:] if let headersDict = source["headers"] as? [String: String] { headers = headersDict } // Choose player pipeline based on format let isMKV = uri.lowercased().contains(".mkv") #if targetEnvironment(simulator) if isMKV { // MKV not supported on AVPlayer in Simulator and MEPlayer is disabled sendEvent("onError", ["error": "MKV playback is not supported in the iOS Simulator. Test on a real device."]) } #else if isMKV { // Prefer MEPlayer (FFmpeg) for MKV on device KSOptions.firstPlayerType = KSMEPlayer.self KSOptions.secondPlayerType = nil } else { KSOptions.firstPlayerType = KSAVPlayer.self KSOptions.secondPlayerType = KSMEPlayer.self } #endif // Create KSPlayerResource with validated URL let resource = KSPlayerResource(url: url, options: createOptions(with: headers), name: "Video") print("KSPlayerView: Setting source: \(uri)") print("KSPlayerView: URL scheme: \(url.scheme ?? "unknown"), host: \(url.host ?? "unknown")") playerView.set(resource: resource) // Set up delegate after setting the resource playerView.playerLayer?.delegate = self // Apply current state if isPaused { playerView.pause() } else { playerView.play() } setVolume(currentVolume) } private func createOptions(with headers: [String: String]) -> KSOptions { let options = KSOptions() // Disable native player remote control center integration; use RN controls options.registerRemoteControll = false // Configure audio for proper dialogue mixing using FFmpeg's pan filter // This approach uses standard audio engineering practices for multi-channel downmixing // Use conservative center channel mixing that preserves spatial audio // c0 (Left) = 70% original left + 30% center (dialogue) + 20% rear left // c1 (Right) = 70% original right + 30% center (dialogue) + 20% rear right // This creates natural dialogue presence without the "playing on both ears" effect options.audioFilters.append("pan=stereo|c0=0.7*c0+0.3*c2+0.2*c4|c1=0.7*c1+0.3*c2+0.2*c5") // Alternative: Use FFmpeg's surround filter for more sophisticated downmixing // This provides better spatial audio processing and natural dialogue mixing // options.audioFilters.append("surround=ang=45") #if targetEnvironment(simulator) options.hardwareDecode = false options.asynchronousDecompression = false #else options.hardwareDecode = KSOptions.hardwareDecode #endif if !headers.isEmpty { // Clean and validate headers before adding var cleanHeaders: [String: String] = [:] for (key, value) in headers { // Remove any null or empty values if !value.isEmpty && value != "null" { cleanHeaders[key] = value } } if !cleanHeaders.isEmpty { options.appendHeader(cleanHeaders) print("KSPlayerView: Added headers: \(cleanHeaders.keys.joined(separator: ", "))") if let referer = cleanHeaders["Referer"] ?? cleanHeaders["referer"] { options.referer = referer print("KSPlayerView: Set referer: \(referer)") } } } return options } func setPaused(_ paused: Bool) { isPaused = paused if paused { playerView.pause() } else { playerView.play() } } func setVolume(_ volume: Float) { currentVolume = volume playerView.playerLayer?.player.playbackVolume = volume } func seek(to time: TimeInterval) { guard let playerLayer = playerView.playerLayer, playerLayer.player.isReadyToPlay, playerLayer.player.seekable else { print("KSPlayerView: Cannot seek - player not ready or not seekable") return } playerView.seek(time: time) { success in if success { print("KSPlayerView: Seek successful to \(time)") } else { print("KSPlayerView: Seek failed to \(time)") } } } func setAudioTrack(_ trackId: Int) { if let player = playerView.playerLayer?.player { let audioTracks = player.tracks(mediaType: .audio) print("KSPlayerView: Available audio tracks count: \(audioTracks.count)") print("KSPlayerView: Requested track ID: \(trackId)") // Debug: Print all track information for (index, track) in audioTracks.enumerated() { print("KSPlayerView: Track \(index) - ID: \(track.trackID), Name: '\(track.name)', Language: '\(track.language ?? "nil")', isEnabled: \(track.isEnabled)") } // First try to find track by trackID (proper way) var selectedTrack: MediaPlayerTrack? = nil var trackIndex: Int = -1 // Try to find by exact trackID match if let track = audioTracks.first(where: { Int($0.trackID) == trackId }) { selectedTrack = track trackIndex = audioTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1 print("KSPlayerView: Found track by trackID \(trackId) at index \(trackIndex)") } // Fallback: treat trackId as array index else if trackId >= 0 && trackId < audioTracks.count { selectedTrack = audioTracks[trackId] trackIndex = trackId print("KSPlayerView: Found track by array index \(trackId) (fallback)") } if let track = selectedTrack { print("KSPlayerView: Selecting track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))") // Use KSPlayer's select method which properly handles track selection player.select(track: track) print("KSPlayerView: Successfully selected audio track \(trackId)") // Verify the selection worked DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in let tracksAfter = player.tracks(mediaType: .audio) for (index, track) in tracksAfter.enumerated() { print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)") } } // Configure audio downmixing for multi-channel tracks configureAudioDownmixing(for: track) } else if trackId == -1 { // Disable all audio tracks (mute) for track in audioTracks { track.isEnabled = false } print("KSPlayerView: Disabled all audio tracks") } else { print("KSPlayerView: Track \(trackId) not found. Available track IDs: \(audioTracks.map { Int($0.trackID) }), array indices: 0..\(audioTracks.count - 1)") } } else { print("KSPlayerView: No player available for audio track selection") } } private func configureAudioDownmixing(for track: MediaPlayerTrack) { // Check if this is a multi-channel audio track that needs downmixing // This is a simplified check - in practice, you might want to check the actual channel layout let trackName = track.name.lowercased() let isMultiChannel = trackName.contains("5.1") || trackName.contains("7.1") || trackName.contains("truehd") || trackName.contains("dts") || trackName.contains("dolby") || trackName.contains("atmos") if isMultiChannel { print("KSPlayerView: Detected multi-channel audio track '\(track.name)', ensuring proper dialogue mixing") print("KSPlayerView: Using FFmpeg pan filter for natural stereo downmixing") } else { print("KSPlayerView: Stereo or mono audio track '\(track.name)', no additional downmixing needed") } } func setTextTrack(_ trackId: Int) { if let player = playerView.playerLayer?.player { let textTracks = player.tracks(mediaType: .subtitle) print("KSPlayerView: Available text tracks count: \(textTracks.count)") print("KSPlayerView: Requested text track ID: \(trackId)") // First try to find track by trackID (proper way) var selectedTrack: MediaPlayerTrack? = nil var trackIndex: Int = -1 // Try to find by exact trackID match if let track = textTracks.first(where: { Int($0.trackID) == trackId }) { selectedTrack = track trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1 print("KSPlayerView: Found text track by trackID \(trackId) at index \(trackIndex)") } // Fallback: treat trackId as array index else if trackId >= 0 && trackId < textTracks.count { selectedTrack = textTracks[trackId] trackIndex = trackId print("KSPlayerView: Found text track by array index \(trackId) (fallback)") } if let track = selectedTrack { print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))") // Use KSPlayer's select method which properly handles track selection player.select(track: track) print("KSPlayerView: Successfully selected text track \(trackId)") } else if trackId == -1 { // Disable all subtitles for track in textTracks { track.isEnabled = false } print("KSPlayerView: Disabled all text tracks") } else { print("KSPlayerView: Text track \(trackId) not found. Available track IDs: \(textTracks.map { Int($0.trackID) }), array indices: 0..\(textTracks.count - 1)") } } else { print("KSPlayerView: No player available for text track selection") } } // Get available tracks for React Native func getAvailableTracks() -> [String: Any] { guard let player = playerView.playerLayer?.player else { return ["audioTracks": [], "textTracks": []] } let audioTracks = player.tracks(mediaType: .audio).enumerated().map { index, track in return [ "id": Int(track.trackID), // Use actual track ID, not array index "index": index, // Keep index for backward compatibility "name": track.name, "language": track.language ?? "Unknown", "languageCode": track.languageCode ?? "", "isEnabled": track.isEnabled, "bitRate": track.bitRate, "bitDepth": track.bitDepth ] } let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in return [ "id": Int(track.trackID), // Use actual track ID, not array index "index": index, // Keep index for backward compatibility "name": track.name, "language": track.language ?? "Unknown", "languageCode": track.languageCode ?? "", "isEnabled": track.isEnabled, "isImageSubtitle": track.isImageSubtitle ] } return [ "audioTracks": audioTracks, "textTracks": textTracks ] } // Get current player state for React Native func getCurrentState() -> [String: Any] { guard let player = playerView.playerLayer?.player else { return [:] } return [ "currentTime": player.currentPlaybackTime, "duration": player.duration, "buffered": player.playableTime, "isPlaying": !isPaused, "volume": currentVolume ] } } extension KSPlayerView: KSPlayerLayerDelegate { func player(layer: KSPlayerLayer, state: KSPlayerState) { switch state { case .readyToPlay: // Send onLoad event to React Native with track information let p = layer.player let tracks = getAvailableTracks() sendEvent("onLoad", [ "duration": p.duration, "currentTime": p.currentPlaybackTime, "naturalSize": [ "width": p.naturalSize.width, "height": p.naturalSize.height ], "audioTracks": tracks["audioTracks"] ?? [], "textTracks": tracks["textTracks"] ?? [] ]) case .buffering: sendEvent("onBuffering", ["isBuffering": true]) case .bufferFinished: sendEvent("onBuffering", ["isBuffering": false]) case .playedToTheEnd: sendEvent("onEnd", [:]) case .error: // Error will be handled by the finish delegate method break default: break } } func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) { let p = layer.player // Ensure we have valid duration before sending progress updates if totalTime > 0 { sendEvent("onProgress", [ "currentTime": currentTime, "duration": totalTime, "bufferTime": p.playableTime ]) } } func player(layer: KSPlayerLayer, finish error: Error?) { if let error = error { let errorMessage = error.localizedDescription print("KSPlayerView: Player finished with error: \(errorMessage)") // Provide more specific error messages for common issues var detailedError = errorMessage if errorMessage.contains("avformat can't open input") { detailedError = "Unable to open video stream. This could be due to:\n• Invalid or malformed URL\n• Network connectivity issues\n• Server blocking the request\n• Unsupported video format\n• Missing required headers" } else if errorMessage.contains("timeout") { detailedError = "Stream connection timed out. The server may be slow or unreachable." } else if errorMessage.contains("404") || errorMessage.contains("Not Found") { detailedError = "Video stream not found. The URL may be expired or incorrect." } else if errorMessage.contains("403") || errorMessage.contains("Forbidden") { detailedError = "Access denied. The server may be blocking requests or require authentication." } sendEvent("onError", ["error": detailedError]) } } func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) { // Handle buffering progress if needed sendEvent("onBufferingProgress", [ "bufferedCount": bufferedCount, "consumeTime": consumeTime ]) } } extension KSPlayerView { private func sendEvent(_ eventName: String, _ body: [String: Any]) { DispatchQueue.main.async { switch eventName { case "onLoad": self.onLoad?(body) case "onProgress": self.onProgress?(body) case "onBuffering": self.onBuffering?(body) case "onEnd": self.onEnd?([:]) case "onError": self.onError?(body) case "onBufferingProgress": self.onBufferingProgress?(body) default: break } } } // Renamed to avoid clashing with React's UIView category method private func findHostViewController() -> UIViewController? { var responder: UIResponder? = self while let nextResponder = responder?.next { if let viewController = nextResponder as? UIViewController { return viewController } responder = nextResponder } return nil } }