// ==UserScript==
// @name AudioDeviceSelect
// @namespace http://tampermonkey.net/
// @version 1
// @description by GitHub Copilot
// @author jayhuang
// @match https://*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @license MIT
// @grant none
// ==/UserScript==
(function() {
'use strict';
createDeviceModalElements();
// 不再於載入時自動取得裝置,改為點擊按鈕時觸發
})();
// 搜尋可用的音訊輸出裝置,並提供切換功能
async function listAudioOutputDevices() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
console.log('此瀏覽器不支援列舉裝置');
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioOutputs = devices.filter(device => device.kind === 'audiooutput');
console.log('可用的音訊輸出裝置:', audioOutputs);
const list = document.getElementById('audio-output-list');
if (list) {
while (list.firstChild) list.removeChild(list.firstChild);
if (audioOutputs.length === 0) {
const li = document.createElement('li');
li.textContent = '找不到音訊輸出裝置';
li.style.color = '#888';
li.style.padding = '8px';
list.appendChild(li);
} else {
audioOutputs.forEach((device, idx) => {
const li = document.createElement('li');
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.justifyContent = 'space-between';
li.style.padding = '8px 12px';
li.style.marginBottom = '6px';
li.style.border = '1px solid #ddd';
li.style.borderRadius = '6px';
li.style.background = idx % 2 === 0 ? '#f9f9f9' : '#f1f5fa';
const infoDiv = document.createElement('div');
infoDiv.style.flex = '1';
infoDiv.style.overflow = 'hidden';
infoDiv.style.whiteSpace = 'nowrap';
infoDiv.style.textOverflow = 'ellipsis';
// 安全建立內容
const strong = document.createElement('strong');
strong.textContent = device.label || '未命名裝置';
infoDiv.appendChild(strong);
infoDiv.appendChild(document.createElement('br'));
const span = document.createElement('span');
span.style.fontSize = '12px';
span.style.color = '#888';
span.textContent = `ID: ${device.deviceId}`;
infoDiv.appendChild(span);
const btn = document.createElement('button');
btn.textContent = '切換到此裝置';
btn.style.marginLeft = '16px';
btn.style.padding = '6px 14px';
btn.style.border = 'none';
btn.style.borderRadius = '4px';
btn.style.background = '#1976d2';
btn.style.color = '#fff';
btn.style.cursor = 'pointer';
btn.onmouseenter = () => btn.style.background = '#1565c0';
btn.onmouseleave = () => btn.style.background = '#1976d2';
btn.onclick = () => {
setOutputDeviceForAllMedia(device.deviceId);
closeDeviceModal();
};
li.appendChild(infoDiv);
li.appendChild(btn);
list.appendChild(li);
});
}
}
} catch (err) {
console.error('取得裝置時發生錯誤:', err);
}
}
// 將所有 audio/video 元素切換到指定輸出裝置
function setOutputDeviceForAllMedia(deviceId) {
const mediaEls = Array.from(document.querySelectorAll('audio, video'));
console.log("mediaEls", mediaEls, deviceId)
mediaEls.forEach(el => {
if (typeof el.setSinkId === 'function') {
el.setSinkId(deviceId)
.catch(err => {
console.warn('切換失敗:', err);
});
} else {
el.style.outline = '2px solid orange';
console.warn('此瀏覽器不支援 setSinkId');
}
});
}
// 全域彈窗開關函式
function openDeviceModal() {
const modal = document.getElementById('audio-device-modal');
if (modal) {
modal.style.display = 'flex';
setTimeout(() => { modal.style.opacity = '1'; }, 10);
}
}
function closeDeviceModal() {
const modal = document.getElementById('audio-device-modal');
if (modal) {
modal.style.opacity = '0';
setTimeout(() => { modal.style.display = 'none'; }, 200);
}
}
// ES6語法動態生成彈出視窗按鈕及列表,並確認注入
function createDeviceModalElements() {
console.log('建立音訊裝置選擇彈出視窗元素');
// 建立彈出視窗背景
const modal = document.createElement('div');
modal.id = 'audio-device-modal';
Object.assign(modal.style, {
display: 'none', position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.25)', zIndex: 1000, alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', transition: 'opacity 0.2s', opacity: 0
});
// 預設隱藏,等開啟時才顯示
modal.style.display = 'none';
// 內容區塊
const modalContent = document.createElement('div');
Object.assign(modalContent.style, {
background: '#fff', minWidth: '320px', maxWidth: '90vw', minHeight: '120px',
borderRadius: '10px', boxShadow: '0 4px 24px #0002', padding: '24px 18px 18px 18px', position: 'relative'
});
// 關閉按鈕
const closeBtn = document.createElement('button');
closeBtn.id = 'close-device-modal';
closeBtn.textContent = '\u00D7';
Object.assign(closeBtn.style, {
position: 'absolute', top: '10px', right: '10px', background: 'none', border: 'none',
fontSize: '20px', color: '#888', cursor: 'pointer'
});
closeBtn.onclick = closeDeviceModal;
// 標題
const h3 = document.createElement('h3');
h3.textContent = '可用音訊輸出裝置';
h3.style.marginTop = '0';
// 權限說明區塊
const permissionInfo = document.createElement('div');
permissionInfo.id = 'audio-permission-info';
permissionInfo.style.display = 'none';
permissionInfo.style.background = '#f8f9fa';
permissionInfo.style.color = '#444';
permissionInfo.style.border = '1px solid #e0e0e0';
permissionInfo.style.borderRadius = '6px';
permissionInfo.style.padding = '12px 10px';
permissionInfo.style.marginBottom = '12px';
permissionInfo.style.fontSize = '15px';
permissionInfo.textContent = '請授權麥克風權限以顯示可用音訊輸出裝置名稱。';
// 裝置列表
const ul = document.createElement('ul');
ul.id = 'audio-output-list';
Object.assign(ul.style, { listStyle: 'none', padding: 0, margin: 0 });
modalContent.append(closeBtn, h3, permissionInfo, ul);
modal.appendChild(modalContent);
document.body.appendChild(modal);
modal.addEventListener('click', e => {
if (e.target === modal) closeDeviceModal();
});
// 彈窗開啟 icon 按鈕
const showBtn = document.createElement('button');
showBtn.id = 'show-device-list-btn';
showBtn.title = '選擇音訊輸出裝置';
// 使用 createElementNS 建立 SVG,避免 TrustedHTML 問題
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('xmlns', svgNS);
const circle = document.createElementNS(svgNS, 'circle');
circle.setAttribute('cx', '12');
circle.setAttribute('cy', '12');
circle.setAttribute('r', '10');
circle.setAttribute('fill', '#1976d2');
svg.appendChild(circle);
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M8 15V9a4 4 0 1 1 8 0v6');
path.setAttribute('stroke', '#fff');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
const rect = document.createElementNS(svgNS, 'rect');
rect.setAttribute('x', '7');
rect.setAttribute('y', '15');
rect.setAttribute('width', '10');
rect.setAttribute('height', '2');
rect.setAttribute('rx', '1');
rect.setAttribute('fill', '#fff');
svg.appendChild(rect);
showBtn.appendChild(svg);
Object.assign(showBtn.style, {
position: 'fixed',
right: '24px',
bottom: '24px',
zIndex: 1100,
padding: '4px',
background: '#fff',
color: 'inherit',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px #0004, 0 1.5px 8px #1976d233',
margin: 0
});
showBtn.onmouseenter = () => { showBtn.style.background = '#e3eaf7'; }
showBtn.onmouseleave = () => { showBtn.style.background = '#fff'; }
showBtn.onclick = () => {
openDeviceModal();
const infoDiv = document.getElementById('audio-permission-info');
const list = document.getElementById('audio-output-list');
if (navigator.permissions && navigator.permissions.query) {
navigator.permissions.query({ name: 'microphone' }).then(result => {
if (result.state === 'granted') {
if (infoDiv) infoDiv.style.display = 'none';
if (list) list.style.display = '';
handlePermissionAndListDevices();
} else {
if (infoDiv) infoDiv.style.display = '';
if (list) list.style.display = 'none';
// 仍然主動詢問權限
handlePermissionAndListDevices();
}
}).catch(() => {
// 查詢失敗時,預設顯示說明並主動詢問權限
if (infoDiv) infoDiv.style.display = '';
if (list) list.style.display = 'none';
handlePermissionAndListDevices();
});
} else {
// 不支援 Permissions API 時,預設顯示說明並主動詢問權限
if (infoDiv) infoDiv.style.display = '';
if (list) list.style.display = 'none';
handlePermissionAndListDevices();
}
};
// 插入到 body
// 確保 body 已經 ready
if (document.body) {
document.body.insertBefore(showBtn, document.body.firstChild);
document.body.appendChild(modal);
} else {
window.addEventListener('DOMContentLoaded', () => {
document.body.insertBefore(showBtn, document.body.firstChild);
document.body.appendChild(modal);
});
}
// 驗證注入
setTimeout(() => {
if (!document.getElementById('show-device-list-btn') || !document.getElementById('audio-device-modal')) {
console.error('彈出視窗或按鈕注入失敗');
} else {
console.log('彈出視窗與按鈕已成功注入畫面');
}
}, 100);
}
// 處理權限與裝置列表顯示(首次打開時顯示說明)
let audioPermissionGranted = false;
async function handlePermissionAndListDevices() {
const infoDiv = document.getElementById('audio-permission-info');
const list = document.getElementById('audio-output-list');
// 檢查是否已取得權限
let permission;
if (navigator.permissions && navigator.permissions.query) {
try {
permission = await navigator.permissions.query({ name: 'microphone' });
} catch (e) {
permission = null;
}
}
// 若未取得權限,顯示說明,隱藏列表,並嘗試請求權限
if (permission && permission.state !== 'granted' && !audioPermissionGranted) {
if (infoDiv) infoDiv.style.display = '';
if (list) list.style.display = 'none';
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (stream && stream.getTracks) stream.getTracks().forEach(track => track.stop());
audioPermissionGranted = true;
if (infoDiv) infoDiv.style.display = 'none';
if (list) list.style.display = '';
listAudioOutputDevices();
} catch (err) {
if (infoDiv) infoDiv.style.display = '';
if (list) list.style.display = 'none';
}
} else if (permission && permission.state === 'granted') {
// 已取得權限,仍需每次都呼叫 getUserMedia 以取得 label
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (stream && stream.getTracks) stream.getTracks().forEach(track => track.stop());
} catch (err) {
// 若出錯仍繼續顯示列表(但 label 可能為空)
}
if (infoDiv) infoDiv.style.display = 'none';
if (list) list.style.display = '';
listAudioOutputDevices();
} else {
// 無法判斷權限狀態時,預設顯示列表
if (infoDiv) infoDiv.style.display = 'none';
if (list) list.style.display = '';
listAudioOutputDevices();
}
}