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.
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)
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
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)
}
...
}
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
// 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
// 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)
}
}