Events Playback

Pre-requisite: The user has to be signed in to perform the following operations.

Events video urls are in m3u8 format which can be streamed over via IJKMediaPlayer.


Generating an Event Playback Url.

The video URL in the event object may or may not contain an Authorization query param. If the Url contains the query params you are to replace it.

  • Start by fetching the Event Tokens
  • For the event that you need to play replace the Autorization in the video url with the token after matching the bucket names
  • The Newly constructed url can be played using IJKFFMoviePlayerController.
  • You will also need to use IJKFFOptions to pass authentication to IJKMediaPlayer.

For Example: If the video url in the event is https://www.example.com/video.m3u8 and the event entity has the bucket name 180-days then we need to look the tokens and look for the bucket named 180-days this object will give us the authentication token so the newly formed url would be https://www.example.cpm/video.m3u8?Authorization=123123123123123


Using IJKMediaPlayer

    func startPlayingEvent(event: EventModel) {
        setupAudioSession()
        playerController?.stop()
        var url = event?.videoAuthUrl ?? ""
        var token = ""
        if var comp = URLComponents(string: url) {
            var queryItems = comp.queryItems
            if let item = comp.queryItems?.first(where: { $0.name == AppConfig.eventAuthParam}),
               let value = item.value {
                token = value
            }

            queryItems?.removeAll(where: { $0.name == AppConfig.eventAuthParam })
            if let queryItems, !queryItems.isEmpty {
                comp.queryItems = queryItems
            } else {
                comp.queryItems = nil
            }
            url = comp.url?.absoluteString ?? ""
        }

        self.setupPlayer(urlString: url, token: token)
    }

    func setupPlayer(urlString: String, token: String) {
        if let url = URL(string: urlString) {
            loadDataFromCustomSource(url: url, token: token, eventId: event?.id ?? "") { [weak self] url, error in
                guard let self else { return }
                if error == nil {

                    let options = IJKFFOptions.byDefault()
                    // Add custom headers
                    let headers = "Authorization: \(token)\r\n"
                    options?.setFormatOptionValue(headers, forKey: "headers")
                    options?.setFormatOptionValue("file,http,https,tcp,tls,ts", forKey: "protocol_whitelist")
                    options?.setPlayerOptionIntValue(1, forKey: "videotoolbox")

                    playerController?.shutdown()
                    self.playerController?.view.removeFromSuperview()
                    self.playerController = nil
                    setProgressTimer()
                    setMediaPlayerState()
                    let playerController = IJKFFMoviePlayerController(contentURL: url, with: options)
                    self.playerController = playerController
                    self.eventPlayerViewId = UUID().uuidString
                    playerController?.scalingMode = .aspectFit
                    playerController?.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
                    playerController?.playbackVolume = isMuted ? 0:1
                    playerController?.view.tag = Int.random(in: 1...100)
                    playerController?.prepareToPlay()
                    playerController?.play()
                }
            }
        }
    }

    private let endListTag = "#EXT-X-ENDLIST"
    private var cancellable: AnyCancellable?

    func loadDataFromCustomSource(url: URL, token: String, eventId: String, comp: @escaping((URL?, Error?) -> Void)) {
        var responseData: Data?
        Logger.debugLog("AVPlayer start download m3u8 request", Date())
        cancellable?.cancel()
        cancellable = Factory.systemService.eventM3U8(url: url.absoluteString+"?Authorization=\(token)")
            .sink(receiveValue: { [weak self] data in
                guard let self else { return }
                switch data {
                case .loading:
                   break
                case let .success(result):
                    var needLocalFileURLForPlayback = false
                    if let playlistContent = String.init(data: result, encoding: .utf8) {
                        var modifiedPlaylist: [String] = []

                        let lines = playlistContent.components(separatedBy: "\n")

                        var components = URLComponents(url: url.deletingLastPathComponent(), resolvingAgainstBaseURL: false)
                        components?.query = nil
                        components?.fragment = nil
                        let baseUrl = components?.url?.absoluteString ?? ""
                        var hasEndListTag = false
                        for line in lines.enumerated() {
                            if line.element.hasSuffix(".ts") {
                                let modifiedURL = line.element + "?" + AppConfig.eventAuthParam + "=" + token
                                modifiedPlaylist.append(baseUrl + modifiedURL)
                            } else {
                                modifiedPlaylist.append(line.element)
                            }

                            if line.element.contains(endListTag) {
                                hasEndListTag = true
                            }
                        }

                        if !hasEndListTag {
                            //Add "#EXT-X-ENDLIST" in the last of m3u8 if not there already
                            // Case 1 - If endTime is not nil then add endList tag if not there already
                            // Case 2 - StartTime is more then 10 mints(600000 milliseconds)
                            modifiedPlaylist.append(endListTag)
                            needLocalFileURLForPlayback = true
                        }

                        // Create a modified M3U8 playlist
                        let modifiedPlaylistContent = modifiedPlaylist.joined(separator: "\n")

                        // Play the modified playlist with AVPlayer
                        if let modifiedPlaylistData = modifiedPlaylistContent.data(using: .utf8) {
                            responseData = modifiedPlaylistData
                        }
                    } else  {
                        responseData = result
                    }
                    let localUrl = saveFileToLocal(eventId: eventId, fileData: responseData)
                    // Send actual URL if has end list or stream is not 10 mins old
                    if needLocalFileURLForPlayback {
                        comp(localUrl, nil)
                    } else {
                        comp(url, nil)
                    }
                    Logger.debugLog("AVPlayer end download m3u8 request", Date())
                case let .error(error):
                    comp(nil, error)
                @unknown default:
                    break
                }
            })
    }

    private func saveFileToLocal(eventId: String, fileData: Data?) -> URL? {
        let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("events-\(eventId)")
        if FileManager.default.fileExists(atPath: fileURL.path) {
            do {
                try FileManager.default.removeItem(at: fileURL)
                Logger.debugLog("Existing file removed: \(fileURL.path)")
            } catch {
                Logger.debugLog("Failed to remove existing file: \(error)")
            }
        }

        // Write the data to the file
        do {
            try fileData?.write(to: fileURL)
            Logger.debugLog("File written to: \(fileURL.path)")
            return fileURL
        } catch {
            Logger.debugLog("Failed to write file: \(error)")
            return nil
        }
    }
    
    func pauseEventPlayer() {
        playerController?.pause()
    }
    
    func stopEventPlayer() {
        playerController?.stop()
    }
    
    func playEventPlayer() {
        if playerController?.currentPlaybackTime == 0 {
            startPlayingEvent(showDownload: showDownload)
        } else {
            // Set current position based on slider so it can handle case of draggging event after stop
            playerController?.currentPlaybackTime = current
            Logger.debugLog("Event player state current time", current)
            if playerController?.playbackState != .playing {
                playerController?.play()
            }
        }
    }
    
    func muteEventPlayer(_ isMuted: Bool) {
        playerController?.playbackVolume = isMuted ? 0:1
    }

    func setMediaPlayerState() {
        NotificationCenter.default.removeObserver(self, name: .IJKMPMoviePlayerPlaybackStateDidChange, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(mediaPlayerStateChanged), name: .IJKMPMoviePlayerPlaybackStateDidChange, object: nil)

        NotificationCenter.default.removeObserver(self, name: .IJKMPMoviePlayerPlaybackDidFinish, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(mediaPlayerStateFinished), name: .IJKMPMoviePlayerPlaybackDidFinish, object: nil)

        NotificationCenter.default.removeObserver(self, name: .IJKMPMoviePlayerLoadStateDidChange, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(mediaPlayerLoadStateFinished), name: .IJKMPMoviePlayerLoadStateDidChange, object: nil)
    }

    @objc func mediaPlayerLoadStateFinished() {
        if playerController?.loadState.contains(.playable) ?? false {
            Logger.debugLog("Event player state playable")
        }
        if playerController?.loadState.contains(.playthroughOK) ?? false {
            Logger.debugLog("Event player state playthroughOK")
        }
        if playerController?.loadState.contains(.stalled) ?? false {
            Logger.debugLog("Event player state stalled")
        }
    }

    @objc func mediaPlayerStateFinished(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
                  let reasonValue = userInfo[IJKMPMoviePlayerPlaybackDidFinishReasonUserInfoKey] as? NSNumber,
                  let reason = IJKMPMovieFinishReason(rawValue: reasonValue.intValue) else {
                return
            }

            switch reason {
            case .playbackEnded:
                Logger.debugLog("Event player state playbackEnded")
                playerController?.pause()
            case .playbackError:
                Logger.debugLog("Event player state playbackError")
            case .userExited:
                Logger.debugLog("Event player state userExited")
            @unknown default:
                Logger.debugLog("Event player state finish reason unknown")
            }
    }

    @objc func mediaPlayerStateChanged() {
       guard let playbackState = playerController?.playbackState else { return }
        switch playbackState {
        case .paused:
            Logger.debugLog("Event player state paused")
        case .stopped:
            Logger.debugLog("Event player state stopped")
        case .playing:
            Logger.debugLog("Event player state playing")
        case .interrupted:
            Logger.debugLog("Event player state interrupted")
        case .seekingForward:
            Logger.debugLog("Event player state seekingForward")
        case .seekingBackward:
            Logger.debugLog("Event player state seekingBackward")
        default:
            Logger.debugLog("Event player state change unknown")
        }
    }
    
    var progressTimer: Timer?
    func setProgressTimer() {
        progressTimer?.invalidate()
        progressTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            Task { @MainActor in
                if let value = self?.isPlaybackPaused, !value {
                    //Update the progress
                }
            }
        }
    }

    func cancelProgressTimer() {
        progressTimer?.invalidate()
    }

    private func setupAudioSession() {
        do {
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: isMuted ? .mixWithOthers : [])
            try? AVAudioSession.sharedInstance().setActive(isMuted ? false: true, options: isMuted ? .notifyOthersOnDeactivation : [])
        } catch {
            Logger.debugLog("Failed to configure AVAudioSession: \(error.localizedDescription)")
        }

        // Add observer for interruption notifications
        NotificationCenter.default.addObserver(self, selector: #selector(handleAudioInterruption), name: AVAudioSession.interruptionNotification, object: nil)
    }

    @objc func handleAudioInterruption(notification: Notification) {
        guard let userInfo = notification.userInfo,
              let interruptionTypeRawValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
              let interruptionType = AVAudioSession.InterruptionType(rawValue: interruptionTypeRawValue) else {
            return
        }

        switch interruptionType {
        case .began:
            // Audio interruption began (e.g., phone call, Siri)
            Logger.debugLog("Audio interruption began")
            // Mute the AVPlayer
            playerController?.playbackVolume = isMuted ? 0:1

        case .ended:
            // Audio interruption ended, resume playback if needed
            if let optionsRawValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt{
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsRawValue)

                if options.contains(.shouldResume) {
                    Logger.debugLog("Audio interruption ended with shouldResume option")
                    // Reset player mute property to user choice
                    playerController?.playbackVolume = isMuted ? 0:1
                }
            }

        @unknown default:
            break
        }
    }

    deinit {
        Logger.debugLog("deinit event player view model")
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
    }