import ExpoModulesCore import UIKit import VLCKit import os public class VLCPlayerView: UIView { func setupView(parent: UIView) { self.backgroundColor = .black self.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), self.topAnchor.constraint(equalTo: parent.topAnchor), self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), ]) } public override func layoutSubviews() { super.layoutSubviews() for subview in subviews { subview.frame = bounds } } } class VLCPlayerWrapper: NSObject { private var lastProgressCall = Date().timeIntervalSince1970 public var player: VLCMediaPlayer = VLCMediaPlayer() private var updatePlayerState: (() -> Void)? private var updateVideoProgress: (() -> Void)? private var playerView: VLCPlayerView = VLCPlayerView() public weak var pipController: VLCPictureInPictureWindowControlling? override public init() { super.init() player.delegate = self player.drawable = self player.scaleFactor = 0 } public func setup( parent: UIView, updatePlayerState: (() -> Void)?, updateVideoProgress: (() -> Void)? ) { self.updatePlayerState = updatePlayerState self.updateVideoProgress = updateVideoProgress player.delegate = self parent.addSubview(playerView) playerView.setupView(parent: parent) } public func getPlayerView() -> UIView { return playerView } } // MARK: - VLCPictureInPictureDrawable extension VLCPlayerWrapper: VLCPictureInPictureDrawable { public func mediaController() -> (any VLCPictureInPictureMediaControlling)! { return self } public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! { return { [weak self] controller in self?.pipController = controller } } } // MARK: - VLCPictureInPictureMediaControlling extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling { func mediaTime() -> Int64 { return player.time.value?.int64Value ?? 0 } func mediaLength() -> Int64 { return player.media?.length.value?.int64Value ?? 0 } func play() { player.play() } func pause() { player.pause() } func seek(by offset: Int64, completion: @escaping () -> Void) { player.jump(withOffset: Int32(offset), completion: completion) } func isMediaSeekable() -> Bool { return player.isSeekable } func isMediaPlaying() -> Bool { return player.isPlaying } } // MARK: - VLCDrawable extension VLCPlayerWrapper: VLCDrawable { public func addSubview(_ view: UIView) { playerView.addSubview(view) } public func bounds() -> CGRect { return playerView.bounds } } // MARK: - VLCMediaPlayerDelegate extension VLCPlayerWrapper: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } let timeNow = Date().timeIntervalSince1970 if timeNow - self.lastProgressCall >= 1 { self.lastProgressCall = timeNow self.updateVideoProgress?() } } } func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.updatePlayerState?() guard let pipController = self.pipController else { return } pipController.invalidatePlaybackState() } } } // MARK: - VLCMediaDelegate extension VLCPlayerWrapper: VLCMediaDelegate { // Implement VLCMediaDelegate methods if needed } class VlcPlayerView: ExpoView { let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView") private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var isPaused: Bool = false private var customSubtitles: [(internalName: String, originalName: String)] = [] private var startPosition: Int32 = 0 private var externalTrack: [String: String]? private var isStopping: Bool = false // Define isStopping here private var externalSubtitles: [[String: String]]? var hasSource = false // MARK: - Initialization required init(appContext: AppContext? = nil) { super.init(appContext: appContext) setupVLC() setupNotifications() VLCManager.shared.listeners.append(self) } // MARK: - Setup private func setupVLC() { vlc.setup( parent: self, updatePlayerState: updatePlayerState, updateVideoProgress: updateVideoProgress ) } 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) } // MARK: - Public Methods func startPictureInPicture() { self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in self.onPipStarted?(["pipStarted": isStarted]) } self.vlc.pipController?.startPictureInPicture() } @objc func play() { self.vlc.player.play() self.isPaused = false logger.debug("Play") } @objc func pause() { self.vlc.player.pause() self.isPaused = true } @objc func seekTo(_ time: Int32) { let wasPlaying = vlc.player.isPlaying if wasPlaying { self.pause() } if let duration = vlc.player.media?.length.intValue { logger.debug("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 vlc.player.time = VLCTime(int: seekTime) self.updatePlayerState() // Let mediaPlayerStateChanged handle play state change DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if wasPlaying { self.play() } } } else { logger.error("Unable to retrieve video duration") } } @objc func setSource(_ source: [String: Any]) { logger.debug("Setting source...") DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.hasSource { return } var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] self.externalTrack = source["externalTrack"] as? [String: String] let initOptions: [String] = source["initOptions"] as? [String] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] for item in initOptions { let option = item.components(separatedBy: "=") mediaOptions.updateValue( option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) } guard let uri = source["uri"] as? String, !uri.isEmpty else { logger.error("Invalid or empty URI") self.onVideoError?(["error": "Invalid or empty URI"]) return } let autoplay = source["autoplay"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) let media: VLCMedia! if isNetwork { logger.debug("Loading network file: \(uri)") media = VLCMedia(url: URL(string: uri)!) } else { logger.debug("Loading local file: \(uri)") if uri.starts(with: "file://"), let url = URL(string: uri) { media = VLCMedia(url: url) } else { media = VLCMedia(path: uri) } } logger.debug("Media options: \(mediaOptions)") media.addOptions(mediaOptions) self.vlc.player.media = media self.setInitialExternalSubtitles() self.hasSource = true if autoplay { logger.info("Playing...") self.play() self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) } } } @objc func setAudioTrack(_ trackIndex: Int) { print("Setting audio track: \(trackIndex)") let track = self.vlc.player.audioTracks[trackIndex] track.isSelectedExclusively = true } @objc func getAudioTracks() -> [[String: Any]]? { return vlc.player.audioTracks.enumerated().map { return ["name": $1.trackName, "index": $0] } } @objc func setSubtitleTrack(_ trackIndex: Int) { logger.debug("Attempting to set subtitle track to index: \(trackIndex)") if trackIndex == -1 { logger.debug("Disabling all subtitles") for track in self.vlc.player.textTracks { track.isSelected = false } return } let track = self.vlc.player.textTracks[trackIndex] track.isSelectedExclusively = true; logger.debug("Current subtitle track index after setting: \(track.trackName)") } @objc func setSubtitleURL(_ subtitleURL: String, name: String) { guard let url = URL(string: subtitleURL) else { logger.error("Invalid subtitle URL") return } let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false) if result == 0 { let internalName = "Track \(self.customSubtitles.count)" self.customSubtitles.append((internalName: internalName, originalName: name)) logger.debug("Subtitle added with result: \(result) \(internalName)") } else { logger.debug("Failed to add subtitle") } } @objc func getSubtitleTracks() -> [[String: Any]]? { if self.vlc.player.textTracks.count == 0 { return nil } logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)") let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in if let customSubtitle = customSubtitles.first(where: { $0.internalName == track.trackName }) { return ["name": customSubtitle.originalName, "index": index] } else { return ["name": track.trackName, "index": index] } } logger.debug("Subtitle tracks: \(tracks)") return tracks } @objc func stop(completion: (() -> Void)? = nil) { logger.debug("Stopping media...") 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 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) } } } } private func performStop(completion: (() -> Void)? = nil) { // Stop the media player vlc.player.stop() // Remove observer NotificationCenter.default.removeObserver(self) // Clear the video view vlc.getPlayerView().removeFromSuperview() isStopping = false completion?() } private func updateVideoProgress() { guard self.vlc.player.media != nil else { return } let currentTimeMs = self.vlc.player.time.intValue let durationMs = self.vlc.player.media?.length.intValue ?? 0 logger.debug("Current time: \(currentTimeMs)") self.onVideoProgress?([ "currentTime": currentTimeMs, "duration": durationMs, ]) } private func updatePlayerState() { let player = self.vlc.player self.onVideoStateChange?([ "target": self.reactTag ?? NSNull(), "currentTime": player.time.intValue, "duration": player.media?.length.intValue ?? 0, "error": false, "isPlaying": player.isPlaying, "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, "state": player.state.description, ]) } // 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 { logger.debug("Deinitialization") performStop() VLCManager.shared.listeners.removeAll() } } // MARK: - SimpleAppLifecycleListener extension VlcPlayerView: SimpleAppLifecycleListener { func applicationDidEnterBackground() { logger.debug("Entering background") } func applicationDidEnterForeground() { logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)") if !self.vlc.getPlayerView().isDescendant(of: self) { logger.debug("Player view is missing. Adding back as subview") self.addSubview(self.vlc.getPlayerView()) } // Current solution to fixing black screen when re-entering application if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }), !self.vlc.isMediaPlaying() { videoTrack.isSelected = false videoTrack.isSelectedExclusively = true self.vlc.player.play() self.vlc.player.pause() } } } 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 .error: return "Error" case .stopping: return "Stopping" @unknown default: return "Unknown" } } }