Skip to content

Instantly share code, notes, and snippets.

@davidleee
Forked from 4np/WKWebView-and-AVPlayer.md
Created September 30, 2022 01:43
Show Gist options
  • Save davidleee/541e419b96f007b9a5b88aca187f0845 to your computer and use it in GitHub Desktop.
Save davidleee/541e419b96f007b9a5b88aca187f0845 to your computer and use it in GitHub Desktop.
Detecting Video Playback inside a WKWebView

Detecting Video Playback inside a WKWebView

Observing video playback inside a black-boxed WKWebView is difficult as you don't have direct access to the video player.

Another complicating matter is that depending on the video, the video might be played using a HTML5 video player, while others might launch the native AVPlayerViewController for playback. While it might be possible to detect HTML5 based playback by injecting custom JavaScript using a WKUserContentController, this is not the approach we will follow in the document as these depend on what HTML5 Video Player is involved and is, as such, not a generic solution.

Making sure AVKit is used

In order to make sure AVKit is being used for video playback, the WKWebView should be configured to disallow inline media playback:

/// WebView Configuration
lazy var webViewConfiguration: WKWebViewConfiguration = {
    let configuration = WKWebViewConfiguration()

    // Don't supress rendering content before everything is in memory.
    configuration.suppressesIncrementalRendering = false
    // Disallow inline HTML5 Video playback, as we need to be able to
    // hook into the AVPlayer to detect whether or not videos are being
    // played. HTML5 Video Playback makes that impossible.
    configuration.allowsInlineMediaPlayback = false
    // Picture in Picture.
    configuration.allowsPictureInPictureMediaPlayback = false
    // AirPlay.
    configuration.allowsAirPlayForMediaPlayback = false
    // All audiovisual media will require a user gesture to begin playing.
    configuration.mediaTypesRequiringUserActionForPlayback = .all
    
    return configuration
}()

Which can be used as follows:

let webView = WKWebView(frame: .zero, configuration: webViewConfiguration)

Approach 1

The most straightforward way is to listen for AVKit notifications being posted. When the WKWebView is configured to disallow inline media playback (e.g. the HTML5 video players), you can just listen for AVKit notifications.

Some potential candidates to listen for:

  • AVSecondScreenConnectionPlayingDidChangeNotification
  • AVSecondScreenConnectionReadyDidChangeNotification
  • AVVolumeControllerVolumeChangedNotification
  • Several private _MRMedia* notifications

This approach would then look something like this:

import os.log

/// The notification to listen for.
fileprivate extension Notification.Name {
    static let videoIsBeingPlayed = Notification.Name("AVSecondScreenConnectionPlayingDidChangeNotification")
}

SomeClass {
	...

    /// The notification center.
    lazy var notificationCenter: NotificationCenter = {
        return NotificationCenter.default
    }()
    
    /// One time now playing observer for detecting video playback has occured.
    private var oneTimeRemoteNowPlayingObserver: NSObjectProtocol?
    
    private func addVideoPlayingObserver() {
        oneTimeRemoteNowPlayingObserver = notificationCenter.addObserver(forName: . videoIsBeingPlayed, object: nil, queue: nil) { [weak self] (_) in
            os_log("An embedded video has (at least in part) been watched.", log: .default, type: .debug)
            
            // Move to main as it called from a background queue
            DispatchQueue.main.async {
                // Remove the one time observer (we only need a single notification).
                self?.removeVideoPlayingObserver()

                // do something, for example call a delegate.
            }
        }
    }
    
    /// Remove the video playing observer (if any).
    private func removeVideoPlayingObserver() {
        guard let observer = oneTimeRemoteNowPlayingObserver else { return }
        notificationCenter.removeObserver(observer)
    }
       
	...
}

Approach 2

Another approach is to detect if AVPlayerViewController is launched, which can be accomplished by swizzling UIViewController.viewWillAppear(_:) and UIViewController.viewWillDisappear(_:).

As this involves detecting AVKit members, this solution also does not work for HTML5 players. This means that WKWebView.configuration's allowsInlineMediaPlayback should be set to false.

In the example below this logic is wrapped inside a SwizzlingManager which allows the methods swizzling to be started and stopped:

SwizzlingManager.default.startSwizzling()
SwizzlingManager.default.stopSwizzling()

Now when a video starts or stops playing inside a WKWebView (or else), respectively .videoPlayerWillAppear or .videoPlayerWillDisappear will be posted. Observe as follows:

let _ = notificationCenter.addObserver(forName: . videoPlayerWillAppear, object: nil, queue: nil) { [weak self] (notification) in ... }

Note that swizzling is dangerous, and might lead to unexpected behaviour. Use at your own risk.

SwizzlingManager.swift

//
//  SwizzlingManager.swift
//  Swizzling
//
//  Created by Jeroen Wesbeek on 04/04/2019.
//  Copyright © 2019 Jeroen Wesbeek. All rights reserved.
//

import Foundation

/**
 SwizzlingManager handles and keeps track of app-wide swizzling.
 */
public class SwizzlingManager {
    /// Singleton.
    public static let `default` = SwizzlingManager()
    
    /// Whether or not UIViewController is being swizzled.
    public internal(set) var isSwizzlingUIViewController = false
    
    // MARK: Lifecycle
    
    /// Initialization.
    private init() {
        // Do not allow non-private initialization.
    }
    
    /// Deinitialization.
    deinit {
        // Clean up
        stopSwizzling()
    }
}

public extension SwizzlingManager {
    func startSwizzling() {
        startSwizzlingUIViewController()
    }
    
    func stopSwizzling() {
        stopSwizzlingUIViewController()
    }
}

SwizzlingManager+UIViewController.swift

//
//  SwizzlingManager+UIViewController.swift
//  Swizzling
//
//  Created by Jeroen Wesbeek on 04/04/2019.
//  Copyright © 2019 Jeroen Wesbeek. All rights reserved.
//

import UIKit
import AVKit
import os.log

public extension Notification.Name {
    static let videoPlayerWillAppear = Notification.Name("videoPlayerWillAppear")
    static let videoPlayerWillDisappear = Notification.Name("videoPlayerWillDisappear")
}

public extension SwizzlingManager {
    // MARK: Public API
    
    func startSwizzlingUIViewController() {
        guard !isSwizzlingUIViewController else { return }
        
        // Swizzle UIViewController methods to swizzled implementations.
        UIViewController.swizzle()
        
        isSwizzlingUIViewController = true
        
        os_log("Started swizzling UIViewController methods.", log: .default, type: .debug)
    }
    
    func stopSwizzlingUIViewController() {
        guard isSwizzlingUIViewController else { return }
        
        // Swizzle back to original implementation.
        UIViewController.swizzle()
        
        isSwizzlingUIViewController = false
        
        os_log("Stopped swizzling UIViewController methods.", log: .default, type: .debug)
    }
}

// MARK: UIViewController Swizzling
fileprivate extension UIViewController {
    static func swizzle() {
        // Set up all swizzled methods
        UIViewController.swizzleViewWillAppear()
        UIViewController.swizzleViewWillDisappear()
    }
    
    // MARK: View wil appear
    
    @objc
    private func swizzledViewWillAppear(_ animated: Bool) {
        // Always call the original implementation.
        defer {
            self.swizzledViewWillAppear(animated)
        }
        
        // Determine if this is a view controller we
        // need to perform something special for.
        switch self {
        case let controller as AVPlayerViewController:
            // Send notification
            NotificationCenter.default.post(name: .videoPlayerWillAppear, object: controller)
        default:
            // Do nothing
            break
        }
    }
    
    /**
     Swizzle UIViewController.viewWillAppear
     */
    private static func swizzleViewWillAppear() {
        let originalSelector = #selector(UIViewController.viewWillAppear(_:))
        let swizzledSelector = #selector(UIViewController.swizzledViewWillAppear(_:))
        
        guard
            let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector),
            let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)
            else {
                return
        }
        
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
    
    // MARK: View will disappear
    
    @objc
    private func swizzledViewWillDisappear(_ animated: Bool) {
        // Always call the original implementation.
        defer {
            self.swizzledViewWillDisappear(animated)
        }
        
        // Determine if this is a view controller we
        // need to perform something special for.
        switch self {
        case let controller as AVPlayerViewController:
            // Send notification
            NotificationCenter.default.post(name: .videoPlayerWillDisappear, object: controller)
        default:
            // Do nothing
            break
        }
    }
    
    /**
     Swizzle UIViewController.viewWillDisappear
     */
    private static func swizzleViewWillDisappear() {
        let originalSelector = #selector(UIViewController.viewWillDisappear(_:))
        let swizzledSelector = #selector(UIViewController.swizzledViewWillDisappear(_:))
        
        guard
            let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector),
            let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)
            else {
                return
        }
        
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment