音频输出切换器

可在任意网站切换音视频的音频输出设备(iframe除外)

  1. // ==UserScript==
  2. // @name Universal Audio Device Selector
  3. // @name:zh-cn 音频输出切换器
  4. // @name:ja ユニバーサル音声切替器
  5. // @namespace Violentmonkey Scripts
  6. // @match *://*/*
  7. // @grant none
  8. // @version 1.2.1
  9. // @author tiamed
  10. // @license MIT
  11. // @homepageURL https://github.com/tiamed/universal-audio-device-selector
  12. // @description Allows you to select audio output device on any sites (except iframe)
  13. // @description:zh-cn 可在任意网站切换音视频的音频输出设备(iframe除外)
  14. // @description:ja 「あらゆるウェブサイトで音声出力デバイスの選択を可能にするスクリプト ※iframe内のコンテンツは除外」
  15. // @run-at document-end
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20. if (window.self !== window.top) return;
  21.  
  22. const STORAGE_KEY = 'audioDeviceSettings';
  23. const LOCALE_KEYS = {
  24. BUTTON_TITLE: 'BUTTON_TITLE',
  25. MENU_TITLE_POSTFIX: 'DEVICE_NAME',
  26. };
  27. const LOCALE_ZH = {
  28. [LOCALE_KEYS.BUTTON_TITLE]: '音频输出切换器',
  29. [LOCALE_KEYS.MENU_TITLE_POSTFIX]: '的设备',
  30. };
  31. const LOCALE_JA = {
  32. [LOCALE_KEYS.BUTTON_TITLE]: '音声切替器',
  33. [LOCALE_KEYS.MENU_TITLE_POSTFIX]: 'のデバイス',
  34. };
  35. const LOCALE_EN = {
  36. [LOCALE_KEYS.BUTTON_TITLE]: 'audio device selector',
  37. [LOCALE_KEYS.MENU_TITLE_POSTFIX]: "'s devices",
  38. };
  39. const UI_STYLE = {
  40. button: {
  41. position: 'fixed',
  42. bottom: '20px',
  43. right: '20px',
  44. width: '24px',
  45. height: '24px',
  46. background: '#444',
  47. color: '#fff',
  48. borderRadius: '50%',
  49. cursor: 'pointer',
  50. zIndex: 999999,
  51. boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
  52. transition: 'all 0.2s',
  53. userSelect: 'none',
  54. WebkitUserSelect: 'none',
  55. display: 'grid',
  56. placeItems: 'center',
  57. opacity: '0.5',
  58. },
  59. menu: {
  60. position: 'fixed',
  61. bottom: '60px',
  62. right: '20px',
  63. background: '#333',
  64. color: '#fff',
  65. padding: '10px',
  66. borderRadius: '5px',
  67. zIndex: 999999,
  68. display: 'none',
  69. maxHeight: '60vh',
  70. overflowY: 'auto',
  71. boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
  72. minWidth: '200px',
  73. userSelect: 'none',
  74. WebkitUserSelect: 'none',
  75. },
  76. item: {
  77. activeBg: '#444',
  78. defaultBg: 'transparent',
  79. },
  80. };
  81.  
  82. let locale = LOCALE_EN;
  83. let devices = [];
  84. let currentDevice = null;
  85. let isInitialized = false;
  86. let observer;
  87.  
  88. // 存储管理
  89. const storage = {
  90. get() {
  91. try {
  92. return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')[
  93. location.hostname
  94. ];
  95. } catch {
  96. return null;
  97. }
  98. },
  99. set(deviceId) {
  100. const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
  101. data[location.hostname] = deviceId;
  102. localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
  103. },
  104. };
  105.  
  106. // 主入口
  107. async function main() {
  108. setupLocale();
  109. const { button, menu } = createUI();
  110. document.body.append(button, menu);
  111.  
  112. // 自动应用已有设置
  113. const hasSetting = await tryAutoApply();
  114. updateButtonState(button, hasSetting);
  115.  
  116. setupMutationObserver();
  117. setupEventListeners(button, menu);
  118. }
  119.  
  120. function setupLocale() {
  121. if (window.navigator.language.includes('zh')) {
  122. locale = LOCALE_ZH;
  123. }
  124. if (window.navigator.language.includes('ja')) {
  125. locale = LOCALE_JA;
  126. }
  127. }
  128.  
  129. // 创建UI元素
  130. function createUI() {
  131. const button = document.createElement('div');
  132. Object.assign(button.style, UI_STYLE.button);
  133.  
  134. // 创建 SVG 元素
  135. const svg = document.createElementNS(
  136. 'http://www.w3.org/2000/svg',
  137. 'svg',
  138. );
  139. svg.setAttribute('width', '16');
  140. svg.setAttribute('height', '16');
  141. svg.setAttribute('viewBox', '0 0 20 20');
  142. svg.style.verticalAlign = 'middle';
  143.  
  144. // 创建路径元素
  145. const path = document.createElementNS(
  146. 'http://www.w3.org/2000/svg',
  147. 'path',
  148. );
  149. path.setAttribute(
  150. 'd',
  151. 'M12 3.006c0-.873-1.04-1.327-1.68-.733L6.448 5.866a.5.5 0 0 1-.34.134H3.5A1.5 1.5 0 0 0 2 7.5v5A1.5 1.5 0 0 0 3.5 14h2.607a.5.5 0 0 1 .34.133l3.873 3.594c.64.593 1.68.14 1.68-.733V3.006z',
  152. );
  153. path.setAttribute('fill', 'currentColor');
  154.  
  155. // 组装 SVG
  156. svg.appendChild(path);
  157. button.appendChild(svg);
  158.  
  159. const menu = document.createElement('div');
  160. Object.assign(menu.style, UI_STYLE.menu);
  161.  
  162. button.title = locale[LOCALE_KEYS.BUTTON_TITLE]; // 添加鼠标悬停提示
  163.  
  164. return {
  165. button,
  166. menu,
  167. };
  168. }
  169.  
  170. // 尝试自动应用设置
  171. async function tryAutoApply() {
  172. const savedId = storage.get();
  173. if (!savedId) return false;
  174.  
  175. try {
  176. await initDevices(true);
  177. await updateDeviceList();
  178. currentDevice = devices.find((d) => d.deviceId === savedId);
  179. if (currentDevice) {
  180. await applyToAllMedia();
  181. return true;
  182. }
  183. } catch (e) {
  184. console.warn('Auto apply failed:', e);
  185. }
  186. return false;
  187. }
  188.  
  189. // 设备初始化
  190. async function initDevices(silent = false) {
  191. if (isInitialized) return true;
  192.  
  193. try {
  194. const stream = await navigator.mediaDevices.getUserMedia({
  195. audio: true,
  196. });
  197. stream.getTracks().forEach((t) => t.stop());
  198. isInitialized = true;
  199. return true;
  200. } catch (e) {
  201. if (!silent) console.error('Permission required:', e);
  202. return false;
  203. }
  204. }
  205.  
  206. // 更新设备列表
  207. async function updateDeviceList() {
  208. devices = (await navigator.mediaDevices.enumerateDevices()).filter(
  209. (d) => d.kind === 'audiooutput' && d.deviceId !== 'default',
  210. );
  211. }
  212.  
  213. // 应用到所有媒体元素
  214. async function applyToAllMedia() {
  215. const mediaElements = document.querySelectorAll('video, audio');
  216. for (const media of mediaElements) {
  217. if (currentDevice?.deviceId && media.setSinkId) {
  218. try {
  219. await media.setSinkId(currentDevice.deviceId);
  220. } catch (e) {
  221. console.warn('Switch failed:', media.src, e);
  222. }
  223. }
  224. }
  225. }
  226.  
  227. // 更新按钮状态
  228. function updateButtonState(button, isActive) {
  229. button.style.background = isActive
  230. ? '#00ADB5'
  231. : UI_STYLE.button.background;
  232. }
  233.  
  234. // 设置DOM监听
  235. function setupMutationObserver() {
  236. observer = new MutationObserver((mutations) => {
  237. const hasMedia = mutations.some((mutation) =>
  238. [...mutation.addedNodes].some(
  239. (n) =>
  240. n.nodeType === Node.ELEMENT_NODE &&
  241. (n.tagName === 'VIDEO' || n.tagName === 'AUDIO'),
  242. ),
  243. );
  244. if (hasMedia) applyToAllMedia();
  245. });
  246. observer.observe(document, {
  247. subtree: true,
  248. childList: true,
  249. });
  250. }
  251.  
  252. // 设置事件监听
  253. function setupEventListeners(button, menu) {
  254. // 按钮点击
  255. button.addEventListener('click', async () => {
  256. if (!isInitialized && !(await initDevices())) return;
  257.  
  258. await updateDeviceList();
  259. refreshDeviceList(menu, button);
  260. menu.style.display =
  261. menu.style.display === 'block' ? 'none' : 'block';
  262. });
  263.  
  264. // 全局点击关闭菜单
  265. document.addEventListener('click', (e) => {
  266. if (!menu.contains(e.target) && e.target !== button) {
  267. menu.style.display = 'none';
  268. }
  269. });
  270. }
  271.  
  272. // 刷新设备列表
  273. function refreshDeviceList(menu, button) {
  274. // 清空原有内容
  275. while (menu.firstChild) {
  276. menu.removeChild(menu.firstChild);
  277. }
  278.  
  279. // 创建标题
  280. const title = document.createElement('div');
  281. title.textContent = `${location.hostname} ${
  282. locale[LOCALE_KEYS.MENU_TITLE_POSTFIX]
  283. }`;
  284. title.style.cssText =
  285. 'margin-bottom:10px; font-weight: bold; padding: 0 5px';
  286. menu.appendChild(title);
  287.  
  288. // 动态创建设备项
  289. devices.forEach((d) => {
  290. const item = document.createElement('div');
  291. item.className = 'device-item';
  292. item.dataset.id = d.deviceId;
  293. item.textContent = d.label; // 使用 textContent 防止 XSS
  294.  
  295. // 设置内联样式
  296. item.style.cssText = `
  297. padding: 8px 12px;
  298. cursor: pointer;
  299. background: ${
  300. d.deviceId === currentDevice?.deviceId
  301. ? UI_STYLE.item.activeBg
  302. : UI_STYLE.item.defaultBg
  303. };
  304. border-radius: 4px;
  305. margin: 2px 0;
  306. transition: background 0.2s;
  307. `;
  308.  
  309. // 绑定点击事件
  310. item.addEventListener('click', async () => {
  311. currentDevice = devices.find(
  312. (device) => device.deviceId === d.deviceId,
  313. );
  314. storage.set(currentDevice.deviceId);
  315. await applyToAllMedia();
  316. refreshDeviceList(menu, button);
  317. updateButtonState(button, true);
  318. menu.style.display = 'none';
  319. });
  320.  
  321. menu.appendChild(item);
  322. });
  323. }
  324.  
  325. window.addEventListener('load', main);
  326. })();