Audio Output Picker

Pick a preferred audio output device for HTML5 audio and video elements.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Audio Output Picker
// @namespace    https://greasyfork.org/en/users/670188-hacker09?sort=daily_installs
// @version      5
// @description  Pick a preferred audio output device for HTML5 audio and video elements.
// @author       hacker09
// @include      *
// @icon         https://i.imgur.com/RHFAjq3.png
// @grant        GM_registerMenuCommand
// @grant        GM_deleteValue
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(async()=>{
  'use strict';
  const findMediaElement = async ()=>{//Finds the active media element, whether it's a <video> or an <audio> tag.
    while (true) {
      let mediaElement = document.querySelector('video, audio, .video-stream');//Look for the standard video player OR any audio element first.
      if (mediaElement) {return mediaElement;}
      await new Promise(resolve => setTimeout(resolve, 150));//If nothing is found, wait and retry.
    }
  };

  const getDevicesWithPermission = async () => {//Get permissions and devices.
    try { await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) {}//Prevent crash when tab is in background and cannot prompt for permission.
    return (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === "audiooutput");
  };

  const applySavedDevice = async () => {
    if(applySavedDevice.busy) return;//Prevent AbortErrors from simultaneous event triggers.
    applySavedDevice.busy = true;
    const savedLabel = GM_getValue(location.href) || GM_getValue(location.hostname);//Retrieve saved Name/Label instead of ID.
    if (!savedLabel) { applySavedDevice.busy = false; return; }//Only act if a saved setting exists.

    for (let i = 0; i < 5; i++) {//Try for 5 seconds to give Windows and the browser time to sync the BT connection.
      const devices = await getDevicesWithPermission();
      const target = devices.find(d => d.label === savedLabel);//Find the current ID for the saved Name.

      if (target) {
        const mediaElement = await findMediaElement();//Re-fetch the video element in case YouTube destroyed and recreated it in the background.
        try {
          await mediaElement.setSinkId('');//Force release of the dead audio pipeline before setting the new one.
          await mediaElement.setSinkId(target.deviceId);
          if(!mediaElement.paused) mediaElement.currentTime += 0.01;//Force audio buffer reset to fix Chromium silent stream bug.
        } catch(e){}//Catch silent promise rejections.
        break;//Stop trying once connected.
      }
      await new Promise(r => setTimeout(r, 1000));//Wait 1 second before checking the hardware list again.
    }
    applySavedDevice.busy = false;
  };

  const selectAudioDevice = async (scope) => {
    const devices = await getDevicesWithPermission();//Ask for mic permission.
    if (!devices || devices.length === 0) return;//Exit if mic permission denied or no devices.

    const choice = parseInt(prompt(devices.map((d,i) => `${i+1}: ${d.label||`Device ${i+1}`}`).join('\n')), 10)-1;
    if(devices[choice]) {
      const key = scope === 'URL' ? location.href : location.hostname;
      GM_setValue(key, devices[choice].label);//Save the label (Name) to persist across ID changes.
      const mediaElement = await findMediaElement();
      try { mediaElement.setSinkId(devices[choice].deviceId); } catch(e){}
      location.reload();//Reload to apply the setting and update the menu text from "Save" to "DELETE".
    }
  };

  ['URL', 'DOMAIN'].forEach(scope => {
    const key = scope === 'URL' ? location.href : location.hostname;
    if (GM_getValue(key)) {
      GM_registerMenuCommand(`DELETE ${scope}`, () => {
        GM_deleteValue(key);
        location.reload();
      });
    } else {
      GM_registerMenuCommand(`Save ${scope}`, () => selectAudioDevice(scope));
    }
  });

  navigator.mediaDevices.addEventListener('devicechange', applySavedDevice);//Reconnects when a device is turned on or off.
  document.addEventListener('visibilitychange', () => { if (!document.hidden) applySavedDevice(); });//Trigger reconnection when returning to a throttled background tab.
  window.addEventListener('focus', applySavedDevice);//Trigger reconnection when the tab is focused again.

  const initialMedia = await findMediaElement();
  initialMedia.addEventListener('loadedmetadata', applySavedDevice);
  initialMedia.addEventListener('play', applySavedDevice);//Trigger reconnection when media starts playing.
  applySavedDevice();//Attempt to apply immediately in case metadata is already loaded.
})();