Selective HTML5 Media Audio Output for Chrome

Allows users to select specific audio output device for HTML5 audio / video in a per site basis.

  1. // ==UserScript==
  2. // @name Selective HTML5 Media Audio Output for Chrome
  3. // @namespace https://greasyfork.org/en/users/85671-jcunews
  4. // @version 1.1.2
  5. // @license AGPLv3
  6. // @author jcunews
  7. // @description Allows users to select specific audio output device for HTML5 audio / video in a per site basis.
  8. // @match *://*/*
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM_registerMenuCommand
  12. // @grant unsafeWindow
  13. // @run-at document-start
  14. // ==/UserScript==
  15.  
  16. /*
  17. Audio output selection can be accessed by clicking on the "Select audio output device for this site" menu
  18. of this script's entry on the Tampermonkey/Violentmonkey/Greasemonkey browser extension's popup menu.
  19.  
  20. Alternatively, the audio selection menu can be provided as a bookmarklet and be added onto the browser's
  21. bookmark toolbar. The bookmark URL should be specified as:
  22.  
  23. javascript:shmao_ujs()
  24.  
  25. Because of the microphone permission issue noted below, this script provides additional layer of privacy
  26. protection to guard against access to microphone (and optionally the camera). The microphone permission
  27. setting can be accessed from the "Microphone permission for this site" menu of this script's entry on the
  28. Tampermonkey/Violentmonkey/Greasemonkey browser extension's popup menu.
  29.  
  30. Alternatively, the microphone permission menu can be provided as a bookmarklet and be added onto the
  31. browser's bookmark toolbar. The bookmark URL should be specified as:
  32.  
  33. javascript:shmaop_ujs()
  34.  
  35. Notes:
  36.  
  37. - HTML5 audio output selection is currently supported only on Chrome browser and its clones, and only when
  38. the HTML page is accessed using "http://" or "https://" URLs.
  39.  
  40. - Due to either unclear Web API specifications, or incorrect browser implementation, permission to access
  41. microphone is required for selecting audio output device. In current browser implementation, the
  42. microphone permission covers both the audio input and output devices, regardless of the device type such
  43. as microphone or line-in.
  44.  
  45. - This script has not been tested under platforms other than Windows.
  46. */
  47.  
  48. (async (u, gum, sl, sps, ds, ps, si, err, ec, pc, eas) => {
  49. function errSet(e) {
  50. alert(`Failed to set audio output device to "${sl}".\n${e}`);
  51. }
  52. function setOutput(el, single) {
  53. if (si && (ps.state === "granted")) {
  54. if (single) ec = null;
  55. el.setSinkId(si).catch(e => ec = e).finally(() => (single || !--pc) && ec && errSet(ec));
  56. }
  57. }
  58. function setAll(es) {
  59. pc = (es = document.querySelectorAll('audio,video')).length;
  60. ec = null;
  61. es.forEach(e => setOutput(e));
  62. }
  63. function devLabel(d) {
  64. return (/[0-9a-f]{64}/).test(d.deviceId) ? d.label : d.deviceId;
  65. }
  66. async function refDevInfo() {
  67. return gum.call(navigator.mediaDevices, {audio: true}).then(() => {
  68. return navigator.mediaDevices.enumerateDevices().then((dis, ns, k) => {
  69. if (dis.length && !dis[0].label) return err = true;
  70. ns = {};
  71. k = "";
  72. ds = dis.reduce((r, di, i, s) => {
  73. if (di.kind === "audiooutput") {
  74. if (ns[di.label] && (ns[di.label] !== di.groupId)) {
  75. for (i = 2; i < 99; i++) {
  76. s = di.label + " #" + i;
  77. if (!ns[s]) {
  78. di = Object.keys(di).reduce((r, k) => {
  79. r[k] = di[k];
  80. return r;
  81. }, {});
  82. di.label = s;
  83. break;
  84. }
  85. }
  86. }
  87. ns[di.label] = di.groupId;
  88. if (sl && (devLabel(di) === sl)) k = di.deviceId;
  89. r.push(di);
  90. }
  91. return r;
  92. }, []);
  93. if (!ds.length) return;
  94. si = k ? k : "default";
  95. setAll();
  96. return err = false;
  97. }, () => err = true);
  98. }, () => err = true);
  99. }
  100. function getSetting(key) {
  101. if (sps[u]) return sps[u][key];
  102. }
  103. function setSetting(key, v) {
  104. sps[u] = sps[u] || {};
  105. sps[u][key] = v;
  106. GM_setValue("siteSettings", JSON.stringify(sps));
  107. }
  108. function delSetting(key) {
  109. if (!sps[u]) return;
  110. delete sps[u][key];
  111. if (!Object.keys(sps[u]).length) delete sps[u];
  112. GM_setValue("siteSettings", JSON.stringify(sps));
  113. }
  114.  
  115. if (!(u = location.href.match(/^https?:[/]*[^/]+/)) || !document.documentElement) {
  116. unsafeWindow.shmaop_ujs = unsafeWindow.shmao_ujs = () => {};
  117. return;
  118. }
  119.  
  120. u = u[0];
  121. gum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
  122. navigator.mediaDevices.getUserMedia = (function(cs) {
  123. var a;
  124. if (cs.audio) {
  125. if ((!sps[u] || !("microphone" in sps[u]))) {
  126. a = prompt(`${u} wants permission to access microphone and other audio devices.\nDo you want to allow it?\n
  127. Enter "yes" and press "OK" to allow.
  128. Enter "no" and press "OK" to block.
  129. Enter anything else and press "OK" to allow this time only.
  130. Press "Cancel" to block this time only.`);
  131. if (a !== null) {
  132. if (a = a.match(/^\s*(yes|no)\s*$/i)) {
  133. if (a[1] === "no") {
  134. setSetting("microphone", false);
  135. a = false;
  136. } else if (a = a[1] === "yes") {
  137. setSetting("microphone", true);
  138. }
  139. } else a = true;
  140. }
  141. } else a = sps[u].microphone;
  142. }
  143. if (a) {
  144. return gum.apply(navigator.mediaDevices, arguments);
  145. } else {
  146. return new Promise(function(res, rej) {
  147. rej(new DOMException("Permission denied.", "NotAllowedError"));
  148. });
  149. }
  150. }).bind(navigator.mediaDevices);
  151. GM_registerMenuCommand("Select audio output device for this site", unsafeWindow.shmao_ujs = (function(a, j, k, l, s, t) {
  152. if (err) {
  153. alert("Audio output device selection permission has not been granted.");
  154. return;
  155. }
  156. l = ds.map((d, i) => {
  157. if ((sl && (devLabel(d) === sl)) || (!sl && (d.deviceId === "default"))) j = i + 1;
  158. if (d.deviceId === "default") k = i + 1;
  159. return `[${i + 1}] ${d.label}`;
  160. }).join("\n") + "\n[0] <<Clear user selection (use the default device)>>";
  161. if (isNaN(j)) {
  162. s = `User selected audio output device is no longer installed:\n ${sl}\n\n`;
  163. t = `[${k}] ${ds[k - 1].label}`;
  164. } else {
  165. s = "";
  166. t = `[${j}] ${ds[j - 1].label}`;
  167. }
  168. a = prompt(`${s}Currently selected audio output device:\n ${t}\n\nPlease enter a device number to use:\n\n${l}`);
  169. if ((a === null) || !(a = a.replace(/^\s+|\s+$/g, "")) || isNaN(a = parseInt(a)) || (a < 0) || (a > ds.length)) return;
  170. if (a) {
  171. sl = devLabel(ds[a - 1]);
  172. setSetting("audioOutput", sl);
  173. si = ds[a - 1].deviceId;
  174. } else {
  175. sl = "";
  176. si = "default";
  177. delSetting("audioOutput");
  178. }
  179. setAll();
  180. }).bind(unsafeWindow));
  181. GM_registerMenuCommand("Microphone permission for this site", unsafeWindow.shmaop_ujs = (function(a) {
  182. if (err) {
  183. alert("Audio output device selection permission has not been granted.\nThis script's microphone permission is not yet applicable.");
  184. return;
  185. }
  186. if (sps[u] && ("microphone" in sps[u])) {
  187. a = sps[u].microphone ? "Allow" : "Block";
  188. } else a = "Ask";
  189. a = prompt(`Microphone and other audio devices permission for\n${u} is currently set to ${a}.\n
  190. Do you want to set it to Allowed?\n
  191. Enter "yes" and press "OK" to set it to Allow.
  192. Enter "no" and press "OK" to set it to Block.
  193. Enter anything else and press "OK" to set it to Ask.`);
  194. if (a === null) return;
  195. if (a = a.match(/^\s*(yes|no)\s*$/i)) {
  196. setSetting("microphone", a[1] === "yes");
  197. } else delSetting("microphone");
  198. }).bind(unsafeWindow));
  199. sps = JSON.parse(GM_getValue("siteSettings", "{}"));
  200. sl = getSetting("audioOutput", "");
  201. ds = [];
  202. await navigator.permissions.query({name: "microphone"}).then(s => {
  203. (ps = s).addEventListener("change", () => {
  204. if (!(err = (s.state !== "granted"))) refDevInfo();
  205. })
  206. });
  207. if (err === undefined) await refDevInfo();
  208. navigator.mediaDevices.addEventListener("devicechange", refDevInfo);
  209. (new MutationObserver(
  210. recs => recs.forEach(
  211. rec => rec.addedNodes.forEach(node => {
  212. if (["AUDIO", "VIDEO"].indexOf(node.tagName) >= 0) {
  213. setOutput(node, true);
  214. node.addEventListener("play", function() {
  215. setOutput(this, true);
  216. });
  217. }
  218. })
  219. )
  220. )).observe(document.documentElement, {childList: true, subtree: true});
  221. })();