import ExpoModulesCore import MediaPlayer import AVFoundation #if os(tvOS) import TVVLCKit #else import MobileVLCKit #endif class VlcPlayerView: ExpoView { private var mediaPlayer: VLCMediaPlayer? private var videoView: UIView? private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var isPaused: Bool = false private var currentGeometryCString: [CChar]? private var lastReportedState: VLCMediaPlayerState? private var lastReportedIsPlaying: Bool? private var customSubtitles: [(internalName: String, originalName: String)] = [] private var startPosition: Int32 = 0 private var externalSubtitles: [[String: String]]? private var externalTrack: [String: String]? private var progressTimer: DispatchSourceTimer? private var isStopping: Bool = false // Define isStopping here private var lastProgressCall = Date().timeIntervalSince1970 var hasSource = false var isTranscoding = false private var initialSeekPerformed: Bool = false private var nowPlayingMetadata: [String: String]? private var artworkImage: UIImage? private var artworkDownloadTask: URLSessionDataTask? // MARK: - Initialization required init(appContext: AppContext? = nil) { super.init(appContext: appContext) setupView() setupNotifications() setupRemoteCommandCenter() setupAudioSession() } // MARK: - Setup private func setupView() { DispatchQueue.main.async { self.backgroundColor = .black self.videoView = UIView() self.videoView?.translatesAutoresizingMaskIntoConstraints = false if let videoView = self.videoView { self.addSubview(videoView) NSLayoutConstraint.activate([ videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor), videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor), videoView.topAnchor.constraint(equalTo: self.topAnchor), videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor), ]) } } } private func setupNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) #if !os(tvOS) // Handle audio session interruptions (e.g., incoming calls, other apps playing audio) NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil) #endif } private func setupAudioSession() { #if !os(tvOS) do { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .moviePlayback, options: []) try audioSession.setActive(true) print("Audio session configured for media controls") } catch { print("Failed to setup audio session: \(error)") } #endif } private func setupRemoteCommandCenter() { #if !os(tvOS) let commandCenter = MPRemoteCommandCenter.shared() // Play command commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [weak self] _ in self?.play() return .success } // Pause command commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] _ in self?.pause() return .success } // Toggle play/pause command commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in guard let self = self, let player = self.mediaPlayer else { return .commandFailed } if player.isPlaying { self.pause() } else { self.play() } return .success } // Seek forward command commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.preferredIntervals = [15] commandCenter.skipForwardCommand.addTarget { [weak self] event in guard let self = self, let player = self.mediaPlayer else { return .commandFailed } let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15 let currentTime = player.time.intValue self.seekTo(currentTime + Int32(skipInterval * 1000)) return .success } // Seek backward command commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.preferredIntervals = [15] commandCenter.skipBackwardCommand.addTarget { [weak self] event in guard let self = self, let player = self.mediaPlayer else { return .commandFailed } let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15 let currentTime = player.time.intValue self.seekTo(max(0, currentTime - Int32(skipInterval * 1000))) return .success } // Change playback position command (scrubbing) commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let self = self, let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } let positionTime = event.positionTime self.seekTo(Int32(positionTime * 1000)) return .success } print("Remote command center configured") #endif } private func cleanupRemoteCommandCenter() { #if !os(tvOS) let commandCenter = MPRemoteCommandCenter.shared() // Remove all command targets to prevent memory leaks commandCenter.playCommand.removeTarget(nil) commandCenter.pauseCommand.removeTarget(nil) commandCenter.togglePlayPauseCommand.removeTarget(nil) commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.skipBackwardCommand.removeTarget(nil) commandCenter.changePlaybackPositionCommand.removeTarget(nil) // Disable commands commandCenter.playCommand.isEnabled = false commandCenter.pauseCommand.isEnabled = false commandCenter.togglePlayPauseCommand.isEnabled = false commandCenter.skipForwardCommand.isEnabled = false commandCenter.skipBackwardCommand.isEnabled = false commandCenter.changePlaybackPositionCommand.isEnabled = false print("Remote command center cleaned up") #endif } // MARK: - Public Methods func startPictureInPicture() {} @objc func play() { DispatchQueue.main.async { self.mediaPlayer?.play() self.isPaused = false self.updateNowPlayingInfo() print("Play") } } @objc func pause() { DispatchQueue.main.async { self.mediaPlayer?.pause() self.isPaused = true self.updateNowPlayingInfo() } } @objc func handleAudioSessionInterruption(_ notification: Notification) { #if !os(tvOS) guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: // Interruption began - pause the video print("Audio session interrupted - pausing video") self.pause() case .ended: // Interruption ended - check if we should resume if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { print("Audio session interruption ended - can resume") // Don't auto-resume - let user manually resume playback } else { print("Audio session interruption ended - should not resume") } } @unknown default: break } #endif } @objc func seekTo(_ time: Int32) { DispatchQueue.main.async { guard let player = self.mediaPlayer else { return } let wasPlaying = player.isPlaying if wasPlaying { player.pause() } if let duration = player.media?.length.intValue { print("Seeking to time: \(time) Video Duration \(duration)") // If the specified time is greater than the duration, seek to the end let seekTime = time > duration ? duration - 1000 : time player.time = VLCTime(int: seekTime) if wasPlaying { player.play() } self.updatePlayerState() self.updateNowPlayingInfo() } else { print("Error: Unable to retrieve video duration") } } } @objc func setSource(_ source: [String: Any]) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.hasSource { return } let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] self.externalTrack = source["externalTrack"] as? [String: String] var initOptions = source["initOptions"] as? [Any] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] guard let uri = source["uri"] as? String, !uri.isEmpty else { print("Error: Invalid or empty URI") self.onVideoError?(["error": "Invalid or empty URI"]) return } self.isTranscoding = uri.contains("m3u8") if !self.isTranscoding, self.startPosition > 0 { initOptions.append("--start-time=\(self.startPosition)") } let autoplay = source["autoplay"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) self.mediaPlayer = VLCMediaPlayer(options: initOptions) self.mediaPlayer?.delegate = self self.mediaPlayer?.drawable = self.videoView self.mediaPlayer?.scaleFactor = 0 self.initialSeekPerformed = false let media: VLCMedia if isNetwork { print("Loading network file: \(uri)") media = VLCMedia(url: URL(string: uri)!) } else { print("Loading local file: \(uri)") if uri.starts(with: "file://"), let url = URL(string: uri) { media = VLCMedia(url: url) } else { media = VLCMedia(path: uri) } } print("Debug: Media options: \(mediaOptions)") media.addOptions(mediaOptions) self.mediaPlayer?.media = media self.setInitialExternalSubtitles() self.hasSource = true if autoplay { print("Playing...") self.play() } } } @objc func setAudioTrack(_ trackIndex: Int) { self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex) } @objc func getAudioTracks() -> [[String: Any]]? { guard let trackNames = mediaPlayer?.audioTrackNames, let trackIndexes = mediaPlayer?.audioTrackIndexes else { return nil } return zip(trackNames, trackIndexes).map { name, index in return ["name": name, "index": index] } } @objc func setSubtitleTrack(_ trackIndex: Int) { print("Debug: Attempting to set subtitle track to index: \(trackIndex)") self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) print( "Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" ) } @objc func setSubtitleURL(_ subtitleURL: String, name: String) { guard let url = URL(string: subtitleURL) else { print("Error: Invalid subtitle URL") return } let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false) if let result = result { let internalName = "Track \(self.customSubtitles.count)" print("Subtitle added with result: \(result) \(internalName)") self.customSubtitles.append((internalName: internalName, originalName: name)) } else { print("Failed to add subtitle") } } private func setInitialExternalSubtitles() { if let externalSubtitles = self.externalSubtitles { for subtitle in externalSubtitles { if let subtitleName = subtitle["name"], let subtitleURL = subtitle["DeliveryUrl"] { print("Setting external subtitle: \(subtitleName) \(subtitleURL)") self.setSubtitleURL(subtitleURL, name: subtitleName) } } } } @objc func getSubtitleTracks() -> [[String: Any]]? { guard let mediaPlayer = self.mediaPlayer else { return nil } let count = mediaPlayer.numberOfSubtitlesTracks print("Debug: Number of subtitle tracks: \(count)") guard count > 0 else { return nil } var tracks: [[String: Any]] = [] if let names = mediaPlayer.videoSubTitlesNames as? [String], let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] { for (index, name) in zip(indexes, names) { if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) { tracks.append(["name": customSubtitle.originalName, "index": index.intValue]) } else { tracks.append(["name": name, "index": index.intValue]) } } } print("Debug: Subtitle tracks: \(tracks)") return tracks } @objc func setVideoAspectRatio(_ aspectRatio: String?) { DispatchQueue.main.async { if let aspectRatio = aspectRatio { // Convert String to C string for VLC let cString = strdup(aspectRatio) self.mediaPlayer?.videoAspectRatio = cString } else { // Reset to default (let VLC determine aspect ratio) self.mediaPlayer?.videoAspectRatio = nil } } } @objc func setVideoScaleFactor(_ scaleFactor: Float) { DispatchQueue.main.async { self.mediaPlayer?.scaleFactor = scaleFactor print("Set video scale factor: \(scaleFactor)") } } @objc func setNowPlayingMetadata(_ metadata: [String: String]) { // Cancel any existing artwork download to prevent race conditions artworkDownloadTask?.cancel() artworkDownloadTask = nil self.nowPlayingMetadata = metadata print("[NowPlaying] Metadata received: \(metadata)") // Load artwork asynchronously if provided if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) { print("[NowPlaying] Loading artwork from: \(artworkUri)") artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } if let error = error as NSError?, error.code == NSURLErrorCancelled { print("[NowPlaying] Artwork download cancelled") return } if let error = error { print("[NowPlaying] Artwork loading error: \(error)") DispatchQueue.main.async { self.updateNowPlayingInfo() } } else if let data = data, let image = UIImage(data: data) { print("[NowPlaying] Artwork loaded successfully, size: \(image.size)") self.artworkImage = image DispatchQueue.main.async { self.updateNowPlayingInfo() } } else { print("[NowPlaying] Failed to create image from data") // Update Now Playing info without artwork on failure DispatchQueue.main.async { self.updateNowPlayingInfo() } } } artworkDownloadTask?.resume() } else { // No artwork URI provided - update immediately print("[NowPlaying] No artwork URI provided") artworkImage = nil DispatchQueue.main.async { self.updateNowPlayingInfo() } } } @objc func stop(completion: (() -> Void)? = nil) { guard !isStopping else { completion?() return } isStopping = true // If we're not on the main thread, dispatch to main thread if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.performStop(completion: completion) } } else { performStop(completion: completion) } } // MARK: - Private Methods @objc private func applicationWillResignActive() { } @objc private func applicationDidBecomeActive() { } private func performStop(completion: (() -> Void)? = nil) { // Stop the media player mediaPlayer?.stop() // Cancel any in-flight artwork downloads artworkDownloadTask?.cancel() artworkDownloadTask = nil artworkImage = nil // Cleanup remote command center targets cleanupRemoteCommandCenter() #if !os(tvOS) // Deactivate audio session to allow other apps to use audio do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) print("Audio session deactivated") } catch { print("Failed to deactivate audio session: \(error)") } // Clear Now Playing info MPNowPlayingInfoCenter.default().nowPlayingInfo = nil #endif // Remove observer NotificationCenter.default.removeObserver(self) // Clear the video view videoView?.removeFromSuperview() videoView = nil // Release the media player mediaPlayer?.delegate = nil mediaPlayer = nil isStopping = false completion?() } private func updateVideoProgress() { guard let player = self.mediaPlayer else { return } let currentTimeMs = player.time.intValue let durationMs = player.media?.length.intValue ?? 0 print("Debug: Current time: \(currentTimeMs)") if currentTimeMs >= 0 && currentTimeMs < durationMs { if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 { player.time = VLCTime(int: self.startPosition * 1000) self.initialSeekPerformed = true } self.onVideoProgress?([ "currentTime": currentTimeMs, "duration": durationMs, ]) } // Update Now Playing info to sync elapsed playback time // iOS needs periodic updates to keep progress indicator in sync DispatchQueue.main.async { self.updateNowPlayingInfo() } } private func updateNowPlayingInfo() { #if !os(tvOS) guard let player = self.mediaPlayer else { return } var nowPlayingInfo = [String: Any]() // Playback rate (0.0 = paused, 1.0 = playing at normal speed) nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0 // Current playback time in seconds let currentTimeSeconds = Double(player.time.intValue) / 1000.0 nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds // Total duration in seconds if let duration = player.media?.length.intValue { let durationSeconds = Double(duration) / 1000.0 nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds } // Add metadata if available if let metadata = self.nowPlayingMetadata { if let title = metadata["title"] { nowPlayingInfo[MPMediaItemPropertyTitle] = title print("[NowPlaying] Setting title: \(title)") } if let artist = metadata["artist"] { nowPlayingInfo[MPMediaItemPropertyArtist] = artist print("[NowPlaying] Setting artist: \(artist)") } if let albumTitle = metadata["albumTitle"] { nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle print("[NowPlaying] Setting album: \(albumTitle)") } } // Add artwork if available if let artwork = self.artworkImage { print("[NowPlaying] Setting artwork with size: \(artwork.size)") let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in return artwork } nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem } MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo #endif } // MARK: - Expo Events @objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoStateChange: RCTDirectEventBlock? @objc var onVideoProgress: RCTDirectEventBlock? @objc var onVideoLoadEnd: RCTDirectEventBlock? @objc var onVideoError: RCTDirectEventBlock? @objc var onPipStarted: RCTDirectEventBlock? // MARK: - Deinitialization deinit { performStop() } } extension VlcPlayerView: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification) { // self?.updateVideoProgress() let timeNow = Date().timeIntervalSince1970 if timeNow - lastProgressCall >= 1 { lastProgressCall = timeNow updateVideoProgress() } } func mediaPlayerStateChanged(_ aNotification: Notification) { self.updatePlayerState() } private func updatePlayerState() { guard let player = self.mediaPlayer else { return } let currentState = player.state var stateInfo: [String: Any] = [ "target": self.reactTag ?? NSNull(), "currentTime": player.time.intValue, "duration": player.media?.length.intValue ?? 0, "error": false, ] if player.isPlaying { stateInfo["isPlaying"] = true stateInfo["isBuffering"] = false stateInfo["state"] = "Playing" } else { stateInfo["isPlaying"] = false stateInfo["state"] = "Paused" } if player.state == VLCMediaPlayerState.buffering { stateInfo["isBuffering"] = true stateInfo["state"] = "Buffering" } else if player.state == VLCMediaPlayerState.error { print("player.state ~ error") stateInfo["state"] = "Error" self.onVideoLoadEnd?(stateInfo) } else if player.state == VLCMediaPlayerState.opening { print("player.state ~ opening") stateInfo["state"] = "Opening" } if self.lastReportedState != currentState || self.lastReportedIsPlaying != player.isPlaying { self.lastReportedState = currentState self.lastReportedIsPlaying = player.isPlaying self.onVideoStateChange?(stateInfo) } } } extension VlcPlayerView: VLCMediaDelegate { // Implement VLCMediaDelegate methods if needed } extension VLCMediaPlayerState { var description: String { switch self { case .opening: return "Opening" case .buffering: return "Buffering" case .playing: return "Playing" case .paused: return "Paused" case .stopped: return "Stopped" case .ended: return "Ended" case .error: return "Error" case .esAdded: return "ESAdded" @unknown default: return "Unknown" } } }