Pick a preferred audio output device for HTML5 audio and video elements.
// ==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.
})();