Universal Audio Device Selector

Allows you to select audio output device on any sites (except iframe)

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name        Universal Audio Device Selector
// @name:zh-cn  音频输出切换器
// @name:ja     ユニバーサル音声切替器
// @namespace   Violentmonkey Scripts
// @match       *://*/*
// @grant       none
// @version     1.2.1
// @author      tiamed
// @license     MIT
// @homepageURL https://github.com/tiamed/universal-audio-device-selector
// @description Allows you to select audio output device on any sites (except iframe)
// @description:zh-cn   可在任意网站切换音视频的音频输出设备(iframe除外)
// @description:ja     「あらゆるウェブサイトで音声出力デバイスの選択を可能にするスクリプト ※iframe内のコンテンツは除外」
// @run-at      document-end
// ==/UserScript==

(function () {
    'use strict';
    if (window.self !== window.top) return;

    const STORAGE_KEY = 'audioDeviceSettings';
    const LOCALE_KEYS = {
        BUTTON_TITLE: 'BUTTON_TITLE',
        MENU_TITLE_POSTFIX: 'DEVICE_NAME',
    };
    const LOCALE_ZH = {
        [LOCALE_KEYS.BUTTON_TITLE]: '音频输出切换器',
        [LOCALE_KEYS.MENU_TITLE_POSTFIX]: '的设备',
    };
    const LOCALE_JA = {
        [LOCALE_KEYS.BUTTON_TITLE]: '音声切替器',
        [LOCALE_KEYS.MENU_TITLE_POSTFIX]: 'のデバイス',
    };
    const LOCALE_EN = {
        [LOCALE_KEYS.BUTTON_TITLE]: 'audio device selector',
        [LOCALE_KEYS.MENU_TITLE_POSTFIX]: "'s devices",
    };
    const UI_STYLE = {
        button: {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            width: '24px',
            height: '24px',
            background: '#444',
            color: '#fff',
            borderRadius: '50%',
            cursor: 'pointer',
            zIndex: 999999,
            boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
            transition: 'all 0.2s',
            userSelect: 'none',
            WebkitUserSelect: 'none',
            display: 'grid',
            placeItems: 'center',
            opacity: '0.5',
        },
        menu: {
            position: 'fixed',
            bottom: '60px',
            right: '20px',
            background: '#333',
            color: '#fff',
            padding: '10px',
            borderRadius: '5px',
            zIndex: 999999,
            display: 'none',
            maxHeight: '60vh',
            overflowY: 'auto',
            boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
            minWidth: '200px',
            userSelect: 'none',
            WebkitUserSelect: 'none',
        },
        item: {
            activeBg: '#444',
            defaultBg: 'transparent',
        },
    };

    let locale = LOCALE_EN;
    let devices = [];
    let currentDevice = null;
    let isInitialized = false;
    let observer;

    // 存储管理
    const storage = {
        get() {
            try {
                return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')[
                    location.hostname
                ];
            } catch {
                return null;
            }
        },
        set(deviceId) {
            const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
            data[location.hostname] = deviceId;
            localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
        },
    };

    // 主入口
    async function main() {
        setupLocale();
        const { button, menu } = createUI();
        document.body.append(button, menu);

        // 自动应用已有设置
        const hasSetting = await tryAutoApply();
        updateButtonState(button, hasSetting);

        setupMutationObserver();
        setupEventListeners(button, menu);
    }

    function setupLocale() {
        if (window.navigator.language.includes('zh')) {
            locale = LOCALE_ZH;
        }
        if (window.navigator.language.includes('ja')) {
            locale = LOCALE_JA;
        }
    }

    // 创建UI元素
    function createUI() {
        const button = document.createElement('div');
        Object.assign(button.style, UI_STYLE.button);

        // 创建 SVG 元素
        const svg = document.createElementNS(
            'http://www.w3.org/2000/svg',
            'svg',
        );
        svg.setAttribute('width', '16');
        svg.setAttribute('height', '16');
        svg.setAttribute('viewBox', '0 0 20 20');
        svg.style.verticalAlign = 'middle';

        // 创建路径元素
        const path = document.createElementNS(
            'http://www.w3.org/2000/svg',
            'path',
        );
        path.setAttribute(
            'd',
            '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',
        );
        path.setAttribute('fill', 'currentColor');

        // 组装 SVG
        svg.appendChild(path);
        button.appendChild(svg);

        const menu = document.createElement('div');
        Object.assign(menu.style, UI_STYLE.menu);

        button.title = locale[LOCALE_KEYS.BUTTON_TITLE]; // 添加鼠标悬停提示

        return {
            button,
            menu,
        };
    }

    // 尝试自动应用设置
    async function tryAutoApply() {
        const savedId = storage.get();
        if (!savedId) return false;

        try {
            await initDevices(true);
            await updateDeviceList();
            currentDevice = devices.find((d) => d.deviceId === savedId);
            if (currentDevice) {
                await applyToAllMedia();
                return true;
            }
        } catch (e) {
            console.warn('Auto apply failed:', e);
        }
        return false;
    }

    // 设备初始化
    async function initDevices(silent = false) {
        if (isInitialized) return true;

        try {
            const stream = await navigator.mediaDevices.getUserMedia({
                audio: true,
            });
            stream.getTracks().forEach((t) => t.stop());
            isInitialized = true;
            return true;
        } catch (e) {
            if (!silent) console.error('Permission required:', e);
            return false;
        }
    }

    // 更新设备列表
    async function updateDeviceList() {
        devices = (await navigator.mediaDevices.enumerateDevices()).filter(
            (d) => d.kind === 'audiooutput' && d.deviceId !== 'default',
        );
    }

    // 应用到所有媒体元素
    async function applyToAllMedia() {
        const mediaElements = document.querySelectorAll('video, audio');
        for (const media of mediaElements) {
            if (currentDevice?.deviceId && media.setSinkId) {
                try {
                    await media.setSinkId(currentDevice.deviceId);
                } catch (e) {
                    console.warn('Switch failed:', media.src, e);
                }
            }
        }
    }

    // 更新按钮状态
    function updateButtonState(button, isActive) {
        button.style.background = isActive
            ? '#00ADB5'
            : UI_STYLE.button.background;
    }

    // 设置DOM监听
    function setupMutationObserver() {
        observer = new MutationObserver((mutations) => {
            const hasMedia = mutations.some((mutation) =>
                [...mutation.addedNodes].some(
                    (n) =>
                        n.nodeType === Node.ELEMENT_NODE &&
                        (n.tagName === 'VIDEO' || n.tagName === 'AUDIO'),
                ),
            );
            if (hasMedia) applyToAllMedia();
        });
        observer.observe(document, {
            subtree: true,
            childList: true,
        });
    }

    // 设置事件监听
    function setupEventListeners(button, menu) {
        // 按钮点击
        button.addEventListener('click', async () => {
            if (!isInitialized && !(await initDevices())) return;

            await updateDeviceList();
            refreshDeviceList(menu, button);
            menu.style.display =
                menu.style.display === 'block' ? 'none' : 'block';
        });

        // 全局点击关闭菜单
        document.addEventListener('click', (e) => {
            if (!menu.contains(e.target) && e.target !== button) {
                menu.style.display = 'none';
            }
        });
    }

    // 刷新设备列表
    function refreshDeviceList(menu, button) {
        // 清空原有内容
        while (menu.firstChild) {
            menu.removeChild(menu.firstChild);
        }

        // 创建标题
        const title = document.createElement('div');
        title.textContent = `${location.hostname} ${
            locale[LOCALE_KEYS.MENU_TITLE_POSTFIX]
        }`;
        title.style.cssText =
            'margin-bottom:10px; font-weight: bold; padding: 0 5px';
        menu.appendChild(title);

        // 动态创建设备项
        devices.forEach((d) => {
            const item = document.createElement('div');
            item.className = 'device-item';
            item.dataset.id = d.deviceId;
            item.textContent = d.label; // 使用 textContent 防止 XSS

            // 设置内联样式
            item.style.cssText = `
        padding: 8px 12px;
        cursor: pointer;
        background: ${
            d.deviceId === currentDevice?.deviceId
                ? UI_STYLE.item.activeBg
                : UI_STYLE.item.defaultBg
        };
        border-radius: 4px;
        margin: 2px 0;
        transition: background 0.2s;
    `;

            // 绑定点击事件
            item.addEventListener('click', async () => {
                currentDevice = devices.find(
                    (device) => device.deviceId === d.deviceId,
                );
                storage.set(currentDevice.deviceId);
                await applyToAllMedia();
                refreshDeviceList(menu, button);
                updateButtonState(button, true);
                menu.style.display = 'none';
            });

            menu.appendChild(item);
        });
    }

    window.addEventListener('load', main);
})();