How to force WKWebView to ignore hardware silent switch on iOS?
Asked Answered
L

3

8

The ask here is to play all kinds of web sounds regardless of the hardware silent switch , both muted and not muted devices must keep playing the sound in HTML pages while the app is foregrounded. The solution for deprecated UIWebView is quite easy

let localWebView = UIWebView(frame: .zero)
localWebView.allowsInlineMediaPlayback = true
localWebView.mediaPlaybackRequiresUserAction = false

How the same behavior can be achieved for WKWebView?

Linsang answered 5/6, 2019 at 12:18 Comment(0)
L
20

Update: Added new hack working flawlessly also on iOS 14 and newer (reflected in code, see bottom for extra details).
Since I have a solution to this nontrivial problem, I'd like to share it:

override func viewDidLoad() {
    super.viewDidLoad()
    NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive),
                                               name: NSNotification.Name.UIApplicationDidBecomeActive, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(willResignActive),
                                               name: NSNotification.Name.UIApplicationWillResignActive, object: nil)
    let configuration = WKWebViewConfiguration()
    configuration.allowsInlineMediaPlayback = true
    configuration.mediaTypesRequiringUserActionForPlayback = []
    wkWebView = WKWebView(frame: .zero, configuration: configuration)
}

@objc func willResignActive() {
    disableIgnoreSilentSwitch(wkWebView)
}

@objc func didBecomeActive() {
    //Always creates new js Audio object to ensure the audio session behaves correctly
    forceIgnoreSilentHardwareSwitch(wkWebView, initialSetup: false)
}   


And most importantly in WKNavigationDelegate:

private func disableIgnoreSilentSwitch(_ webView: WKWebView) {
    //Muting the js Audio object src is critical to restore the audio sound session to consistent state for app background/foreground cycle
    let jsInject = "document.getElementById('wkwebviewAudio').muted=true;"
    webView.evaluateJavaScript(jsInject, completionHandler: nil)
}

private func forceIgnoreSilentHardwareSwitch(_ webView: WKWebView, initialSetup: Bool) {
    //after some trial and error this seems to be minimal silence sound that still plays
    let silenceMono56kbps100msBase64Mp3 = "data:audio/mp3;base64,//tAxAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAFAAAESAAzMzMzMzMzMzMzMzMzMzMzMzMzZmZmZmZmZmZmZmZmZmZmZmZmZmaZmZmZmZmZmZmZmZmZmZmZmZmZmczMzMzMzMzMzMzMzMzMzMzMzMzM//////////////////////////8AAAA5TEFNRTMuMTAwAZYAAAAAAAAAABQ4JAMGQgAAOAAABEhNIZS0AAAAAAD/+0DEAAPH3Yz0AAR8CPqyIEABp6AxjG/4x/XiInE4lfQDFwIIRE+uBgZoW4RL0OLMDFn6E5v+/u5ehf76bu7/6bu5+gAiIQGAABQIUJ0QolFghEn/9PhZQpcUTpXMjo0OGzRCZXyKxoIQzB2KhCtGobpT9TRVj/3Pmfp+f8X7Pu1B04sTnc3s0XhOlXoGVCMNo9X//9/r6a10TZEY5DsxqvO7mO5qFvpFCmKIjhpSItGsUYcRO//7QsQRgEiljQIAgLFJAbIhNBCa+JmorCbOi5q9nVd2dKnusTMQg4MFUlD6DQ4OFijwGAijRMfLbHG4nLVTjydyPlJTj8pfPflf9/5GD950A5e+jsrmNZSjSirjs1R7hnkia8vr//l/7Nb+crvr9Ok5ZJOylUKRxf/P9Zn0j2P4pJYXyKkeuy5wUYtdmOu6uobEtFqhIJViLEKIjGxchGev/L3Y0O3bwrIOszTBAZ7Ih28EUaSOZf/7QsQfg8fpjQIADN0JHbGgQBAZ8T//y//t/7d/2+f5m7MdCeo/9tdkMtGLbt1tqnabRroO1Qfvh20yEbei8nfDXP7btW7f9/uO9tbe5IvHQbLlxpf3DkAk0ojYcv///5/u3/7PTfGjPEPUvt5D6f+/3Lea4lz4tc4TnM/mFPrmalWbboeNiNyeyr+vufttZuvrVrt/WYv3T74JFo8qEDiJqJrmDTs///v99xDku2xG02jjunrICP/7QsQtA8kpkQAAgNMA/7FgQAGnobgfghgqA+uXwWQ3XFmGimSbe2X3ksY//KzK1a2k6cnNWOPJnPWUsYbKqkh8RJzrVf///P///////4vyhLKHLrCb5nIrYIUss4cthigL1lQ1wwNAc6C1pf1TIKRSkt+a//z+yLVcwlXKSqeSuCVQFLng2h4AFAFgTkH+Z/8jTX/zr//zsJV/5f//5UX/0ZNCNCCaf5lTCTRkaEdhNP//n/KUjf/7QsQ5AEhdiwAAjN7I6jGddBCO+WGTQ1mXrYatSAgaykxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg=="
    //Plays 100ms silence once the web page has loaded through HTML5 Audio element (through Javascript)
    //which as a side effect will switch WKWebView AudioSession to AVAudioSessionCategoryPlayback

    var jsInject: String
    if initialSetup {
       jsInject =
            "var s=new Audio('\(silenceMono56kbps100msBase64Mp3)');" +
            "s.id='wkwebviewAudio';" +
            "s.play();" +
            "s.loop=true;" +
            "document.body.appendChild(s);"
    } else {
        //Restore sound hack
        jsInject = "document.getElementById('wkwebviewAudio').muted=false;"
    }
    webView.evaluateJavaScript(jsInject, completionHandler: nil)
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    //As a result the WKWebView ignores the silent switch
    forceIgnoreSilentHardwareSwitch(webView, initialSetup: true)
}

Interestingly a related Safari problem is mentioned here: IOS WebAudio only works on headphones where @Spencer Evans workaround looks very similar to mine.

However when I tried to apply his shorter base64 silence sound it didn't work for WKWebView, so I'm providing my own minimal silence sound tested on iOS12.

Why it works?

Playing an <audio> or <video> element (which in the workaround happens to be non audible silence) changes WKWebView audio session category from AVAudioSessionCategoryAmbient to AVAudioSessionCategoryPlayback. This will be valid until next load request resets it.

It's all great till the app is backgrounded. But upon subsequent foregrounding things will break in 2 possible ways:

  • user needs to tap for the sounds to reappear
  • rarely no user input will help and the WKWebView lands in semi frozen state

To counter that^ the hack is reverted with disableIgnoreSilentSwitch(wkWebView) and later reenabled with forceIgnoreSilentHardwareSwitch(wkWebView, initialSetup: false)

Since WKWebView core runs in an external process it cannot be accessed the way UIWebView shared (with our app) AVAudioSession can be.

Verified for:
iOS 11.4
iOS 12.4.1
iOS 13.3
iOS 14.1
iOS 14.5.1
iOS 14.8
iOS 15.0
iOS 16.0
iOS 17.0

iOS 14 update
Situation got pretty bad in iOS 14 where obsolete audio tag .src=null trick stopped working. Technically .src=null does work for a very short window of time (one can revert the hack using .src during initial setup). However once the silence loop is playing it becomes useless.

The new trick relies on .mute which miraculously works across all iOS versions including iOS14 (but only when accessing documentById directly not a var). No mediacenter when locking the screen neither. It took a lot of research, but we got it.

Linsang answered 5/6, 2019 at 12:21 Comment(7)
I do not manage to implement your code. Where this part should go ? let configuration = WKWebViewConfiguration() configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] let localWebView = WKWebView(frame: .zero, configuration: configuration)Nil
@Nil you can put it in viewDidLoad, depends on your needs.Linsang
ok thx and why let localWebView = WKWebView(frame: .zero, configuration: configuration) ? and also how use the webView func ?...sorry beginner..Nil
These are separate questions way beyond this one. If you have trouble using WKWebView please check existing questions on SO and maybe some tutorials. If that does not help ask a new question on SO describing your particular problem/challenge in details.Linsang
This is amazing (even though little hacky) solution! Thanks!Polarimeter
Its so hacky but it works.. no need of AVAudioSession. Thanks!Pontine
This is still working on iOS17. Much appreciated!!!Pitchstone
E
0

For anybody that couldn't get this working on ios 17. You can just use this javascript api (https://w3c.github.io/audio-session/#introduction) to directly change the webprocess' audioSession by navigator.audioSession.type = "playback";. tested on 17.4 but should be compatible with 16.4-18 according to this source (https://caniuse.com/mdn-api_navigator_audiosession).

Eliseoelish answered 13/8 at 22:47 Comment(0)
L
-1

You can try calling the setupAudio method inside the didFinishLaunchingWithOptions method.

private func setupAudio() {
    do {
        let audioSession = AVAudioSession.sharedInstance()
        try audioSession.setActive(false)
        try audioSession.setCategory(.playback, options: [])
    } catch (let error) {
        print("Audio error: \(error.localizedDescription)")
    }
}
Lifesize answered 26/8, 2022 at 22:45 Comment(1)
Oh I did try this 3 years ago. It won’t work because Webkit of WKWebView is running as an external process, hence you cannot affect its AVAudioSession because it’s not the same instance as the one belonging to your app. That’s the very reason why the js trick was invented. Your approach would work for deprecated UIWebViewLinsang

© 2022 - 2024 — McMap. All rights reserved.