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
Autorizationin 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
IJKFFOptionsto 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)
}