mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
519 lines
20 KiB
Swift
519 lines
20 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
|