From a8c5a86e88f1e66105f6216d4df5ecaa0b5dcffb Mon Sep 17 00:00:00 2001 From: Tuhin Srivastava Date: Wed, 14 Dec 2022 00:10:23 -0800 Subject: [PATCH] unmute tone context on ios --- .gitignore | 2 +- pages/index.tsx | 5 + public/unmute.js | 339 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 public/unmute.js diff --git a/.gitignore b/.gitignore index c87c9b3..3185eae 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /node_modules /.pnp .pnp.js - +.env # testing /coverage diff --git a/pages/index.tsx b/pages/index.tsx index f8a50e9..3cf31d9 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,4 +1,5 @@ import { useRouter } from "next/router"; +import { unmute } from "../public/unmute"; import { useCallback, useEffect, useState } from "react"; import * as Tone from "tone"; @@ -244,6 +245,10 @@ export default function Home() { // setNowPlayingResult(null); }; + useEffect(() => { + unmute(Tone.context._context, false, false); + }, []); + return ( <> diff --git a/public/unmute.js b/public/unmute.js new file mode 100644 index 0000000..e15a2eb --- /dev/null +++ b/public/unmute.js @@ -0,0 +1,339 @@ +"use strict"; +/** + * @file unmute.ts + * @author Spencer Evans evans.spencer@gmail.com + * + * unmute is a disgusting hack that helps.. + * 1) automatically resume web audio contexts on user interaction + * 2) automatically pause and resume web audio when the page is hidden. + * 3) ios only: web audio play on the media channel rather than the ringer channel + * 4) ios only: disable the media playback widget and airplay when: + * + * WebAudio is automatically resumed by checking context state and resuming whenever possible. + * + * WebAudio pausing is accomplished by watching the page visilibility API. When on iOS, page focus + * is also used to determine if the page is in the foreground because Apple's page vis api implementation is buggy. + * + * iOS Only: Forcing WebAudio onto the media channel (instead of the ringer channel) works by playing + * a short, high-quality, silent html audio track continuously when web audio is playing. + * + * iOS Only: Hiding the media playback widgets on iOS is accomplished by completely nuking the silent + * html audio track whenever the app isn't in the foreground. + * + * iOS detection is done by looking at the user agent for iPhone, iPod, iPad. This detects the phones fine, but + * apple likes to pretend their new iPads are computers (lol right..). Newer iPads are detected by finding + * mac osx in the user agent and then checking for touch support by testing navigator.maxTouchPoints > 0. + * + * This is all really gross and apple should really fix their janky browser. + * This code isn't optimized in any fashion, it is just whipped up to help someone out on stack overflow, its just meant as an example. + */ +/** + * Enables unmute. + * @param context A reference to the web audio context to "unmute". + * @param allowBackgroundPlayback Optional. Default false. Allows audio to continue to play in the background. This is not recommended because it will burn battery and display playback controls on the iOS lockscreen. + * @param forceIOSBehavior Optional. Default false. Forces behavior to that which is on iOS. This *could* be used in the event the iOS detection fails (which it shouldn't). It is strongly recommended NOT to use this. + * @returns An object containing a dispose function which can be used to dispose of the unmute controller. + */ +export function unmute(context, allowBackgroundPlayback, forceIOSBehavior) { + if (allowBackgroundPlayback === void 0) { + allowBackgroundPlayback = false; + } + if (forceIOSBehavior === void 0) { + forceIOSBehavior = false; + } + //#region Helpers + // Determine the page visibility api + var pageVisibilityAPI; + if (document.hidden !== undefined) + pageVisibilityAPI = { + hidden: "hidden", + visibilitychange: "visibilitychange", + }; + else if (document.webkitHidden !== undefined) + pageVisibilityAPI = { + hidden: "webkitHidden", + visibilitychange: "webkitvisibilitychange", + }; + else if (document.mozHidden !== undefined) + pageVisibilityAPI = { + hidden: "mozHidden", + visibilitychange: "mozvisibilitychange", + }; + else if (document.msHidden !== undefined) + pageVisibilityAPI = { + hidden: "msHidden", + visibilitychange: "msvisibilitychange", + }; + // Helpers to add/remove a bunch of event listeners + function addEventListeners(target, events, handler, capture, passive) { + for (var i = 0; i < events.length; ++i) + target.addEventListener(events[i], handler, { + capture: capture, + passive: passive, + }); + } + function removeEventListeners(target, events, handler, capture, passive) { + for (var i = 0; i < events.length; ++i) + target.removeEventListener(events[i], handler, { + capture: capture, + passive: passive, + }); + } + /** + * Helper no-operation function to ignore promises safely + */ + function noop() {} + //#endregion + //#region iOS Detection + var ua = navigator.userAgent.toLowerCase(); + var isIOS = + forceIOSBehavior || + (ua.indexOf("iphone") >= 0 && ua.indexOf("like iphone") < 0) || + (ua.indexOf("ipad") >= 0 && ua.indexOf("like ipad") < 0) || + (ua.indexOf("ipod") >= 0 && ua.indexOf("like ipod") < 0) || + (ua.indexOf("mac os x") >= 0 && navigator.maxTouchPoints > 0); // New ipads show up as macs in user agent, but they have a touch screen + //#endregion + //#region Playback Allowed State + /** Indicates if audio should be allowed to play. */ + var allowPlayback = true; // Assume page is visible and focused by default + /** + * Updates playback state. + */ + function updatePlaybackState() { + // Check if should be active + var shouldAllowPlayback = + allowBackgroundPlayback || // always be active if noPause is indicated + ((!pageVisibilityAPI || !document[pageVisibilityAPI.hidden]) && // can be active if no page vis api, or page not hidden + (!isIOS || document.hasFocus())) // if ios, then document must also be focused because their page vis api is buggy + ? true + : false; + // Change state + if (shouldAllowPlayback !== allowPlayback) { + allowPlayback = shouldAllowPlayback; + // Update the channel state + updateChannelState(false); + // The playback allowed state has changed, update the context state to suspend / resume accordingly + updateContextState(); + } + } + /** + * Handle visibility api events. + */ + function doc_visChange() { + updatePlaybackState(); + } + if (pageVisibilityAPI) + addEventListeners( + document, + [pageVisibilityAPI.visibilitychange], + doc_visChange, + true, + true + ); + /** + * Handles blur events (only used on iOS because it doesn't dispatch vis change events properly). + */ + function win_focusChange(evt) { + if (evt && evt.target !== window) return; // ignore bubbles + updatePlaybackState(); + } + if (isIOS) + addEventListeners(window, ["focus", "blur"], win_focusChange, true, true); + //#endregion + //#region WebAudio Context State + /** + * Updates the context state. + * NOTE: apple supports (and poorly at that) the proposed "interrupted" state spec, just ignore that for now. + */ + function updateContextState() { + if (allowPlayback) { + // Want to be running, so try resuming if necessary + if (context.state !== "running" && context.state !== "closed") { + // do nothing if the context was closed to avoid errors... can't check for the suspended state because of apple's crappy interrupted implementation + // Can only resume after a media playback (input) event has occurred + if (hasMediaPlaybackEventOccurred) { + var p = context.resume(); + if (p) p.then(noop, noop).catch(noop); + } + } + } else { + // Want to be suspended, so try suspending + if (context.state === "running") { + var p = context.suspend(); + if (p) p.then(noop, noop).catch(noop); + } + } + } + /** + * Handles context statechange events. + * @param evt The event. + */ + function context_statechange(evt) { + // Check if the event was already handled since we're listening for it both ways + if (!evt || !evt.unmute_handled) { + // Mark handled + evt.unmute_handled = true; + // The context may have auto changed to some undesired state, so immediately check again if we want to change it + updateContextState(); + } + } + addEventListeners(context, ["statechange"], context_statechange, true, true); // NOTE: IIRC some devices don't support the onstatechange event callback, so handle it both ways + if (!context.onstatechange) context.onstatechange = context_statechange; // NOTE: IIRC older androids don't support the statechange event via addeventlistener, so handle it both ways + //#endregion + //#region HTML Audio Channel State + /** The html audio element that forces web audio playback onto the media channel instead of the ringer channel. */ + var channelTag = null; + /** + * A utility function for decompressing the base64 silence string. A poor-mans implementation of huffman decoding. + * @param count The number of times the string is repeated in the string segment. + * @param repeatStr The string to repeat. + * @returns The + */ + function huffman(count, repeatStr) { + var e = repeatStr; + for (; count > 1; count--) e += repeatStr; + return e; + } + /** + * A very short bit of silence to be played with