niconiconi / Unblock iOS Background WebAudio

// ==UserScript==
// @name          Unblock iOS Background WebAudio
// @description   This script intercepts Web Audio contexts and shows a pop-up window to enable autoplay, allowing you to play Web games on iOS with sound that are otherwise incompatible. Background audio autoplay is disallowed on iOS, users are not even allowed to add exceptions. Thus, many existing Web apps, games, WebAssembly apps in particular, are incompatible with iOS without modifications.
// @version       1
// @license       0BSD
// @run-at        document-start
// @match         http://*/*
// @match         https://*/*
// @grant         none
//
// Note: By default, this script matches ALL websites, since the iOS
//       "Userscript" extension has no way to apply scripts to user-
//       defined websites. Thus, make sure to enable this script only
//       when it's needed, and disable it otherwise.
// ==/UserScript==

function monkeyPatch() {
  // monkey-patch window.AudioContext before page load
  let realAudioContext = window.AudioContext;
  let createdAudioContextList = [];
  
  window.AudioContext = function(options) {
    let context = new realAudioContext(options);
    createdAudioContextList.push(context);
    injectDialog("This website has just created a Web Audio context.");

    return context;
  }
  
  // Allow a Web Audio context to play background audio.
  //
  // This function must be called in a callback as a result
  // or user interaction, such as onclick.
  function unblockWebAudioContext(audioContext) {
    if (audioContext.state === "suspended") {
      audioContext.resume();
    }
  }

  // Inject a dialog into DOM to ask the user whether to allow
  // Web Audio.
  function injectDialog(reason) {
    if (document.getElementById("UnblockiOSWebAudioDialog")) {
      // a previous dialog is still visible
      return;
    }
    
    let dialog = document.createElement('dialog');
    dialog.id = "UnblockiOSWebAudioDialog";
    dialog.style = "padding-top: 5px;";
    dialog.innerHTML = `
      <div>
        <p>
          ${reason}<br />
          Allow audio playback in the background?
        </p>
        <div style="display: grid; grid-auto-flow: column; grid-column-gap: 60px;">
          <button id="UnblockiOSWebAudioNo">No</button>
          <button id="UnblockiOSWebAudioYes">Yes</button>
        </div>
      </div>`;

    document.body.appendChild(dialog);

    dialog.showModal();
    
    let buttonYes = document.getElementById("UnblockiOSWebAudioYes");
    buttonYes.onclick = () => {
      for (const audioContext of createdAudioContextList) {
        unblockWebAudioContext(audioContext);

        // at this point, suspended -> running
        // but the state may change again in the future.
        audioContext.onstatechange = () => {
          if (audioContext.state === "interrupted") {
            // running -> interrupted
            // Previously allowed, we can resume it without user interaction.
            audioContext.resume();
          }
          else if (audioContext.state === "suspended") {
            // running -> suspended
            // Is this transition even possible? Likely no, but just in case...
            injectDialog("This website's audio was suspended.");
          }
        }
      }
      dialog.close();
      document.body.removeChild(dialog);
    }

    let buttonNo = document.getElementById("UnblockiOSWebAudioNo");
    buttonNo.onclick = () => {
      dialog.close();
      document.body.removeChild(dialog);
    }
  }
}

// inject the monkey patch into DOM
var script = document.createElement('script');
script.appendChild(document.createTextNode('('+ monkeyPatch +')();'));
(document.body || document.head || document.documentElement).appendChild(script);