基于Vue3中實(shí)現(xiàn)耳機(jī)和揚(yáng)聲器切換的完整方案
功能概述
在 Web 應(yīng)用中實(shí)現(xiàn)音頻輸出設(shè)備的動(dòng)態(tài)切換,允許用戶(hù)在揚(yáng)聲器、耳機(jī)、藍(lán)牙設(shè)備等不同音頻輸出設(shè)備之間自由切換。
應(yīng)用場(chǎng)景
- 在線(xiàn)教育平臺(tái)的語(yǔ)音通話(huà)
- 視頻會(huì)議系統(tǒng)
- 在線(xiàn)音樂(lè)播放器
- 語(yǔ)音聊天應(yīng)用
核心功能
- 枚舉所有可用的音頻輸出設(shè)備
- 動(dòng)態(tài)切換音頻輸出設(shè)備
- 智能識(shí)別設(shè)備類(lèi)型(揚(yáng)聲器/耳機(jī)/藍(lán)牙)
- 跨平臺(tái)支持(H5 + 微信小程序)
技術(shù)原理
1. Web Audio API 架構(gòu)
音頻源 → AudioBufferSourceNode → MediaStreamDestination → <audio> 元素 → 音頻輸出設(shè)備
↓
setSinkId(deviceId)
2. 關(guān)鍵 API
2.1 枚舉音頻設(shè)備
navigator.mediaDevices.enumerateDevices();
返回所有媒體設(shè)備,包括:
audioinput:音頻輸入設(shè)備(麥克風(fēng))audiooutput:音頻輸出設(shè)備(揚(yáng)聲器/耳機(jī))videoinput:視頻輸入設(shè)備(攝像頭)
2.2 切換音頻輸出
audioElement.setSinkId(deviceId);
將 <audio> 元素的輸出切換到指定設(shè)備。
2.3 創(chuàng)建音頻流
const destination = audioContext.createMediaStreamDestination(); audioElement.srcObject = destination.stream;
3. 為什么需要 MediaStreamDestination?
直接使用 AudioContext.destination 無(wú)法切換設(shè)備,因?yàn)樗苯虞敵龅侥J(rèn)設(shè)備。通過(guò) MediaStreamDestination 創(chuàng)建一個(gè)中間流,再連接到 <audio> 元素,就可以使用 setSinkId 切換設(shè)備。
實(shí)現(xiàn)步驟
步驟 1:創(chuàng)建音頻播放器類(lèi)
class AudioStreamPlayer {
private audioContext: AudioContext;
private mediaStreamDestination: MediaStreamAudioDestinationNode;
private audioElement: HTMLAudioElement;
private currentSinkId: string = "";
constructor() {
this.initWebAudio();
}
private initWebAudio() {
// 創(chuàng)建 AudioContext
this.audioContext = new AudioContext({
sampleRate: 16000,
latencyHint: "interactive",
});
// 創(chuàng)建 MediaStreamDestination
this.mediaStreamDestination = this.audioContext.createMediaStreamDestination();
// 創(chuàng)建 audio 元素并連接到流
this.audioElement = document.createElement("audio");
this.audioElement.autoplay = true;
this.audioElement.srcObject = this.mediaStreamDestination.stream;
// 啟動(dòng)播放
this.audioElement.play().catch((err) => {
console.warn("自動(dòng)播放被阻止,需要用戶(hù)交互:", err);
});
}
// 播放音頻數(shù)據(jù)
playAudio(audioBuffer: AudioBuffer) {
const sourceNode = this.audioContext.createBufferSource();
sourceNode.buffer = audioBuffer;
// 連接到 MediaStreamDestination(關(guān)鍵!)
sourceNode.connect(this.mediaStreamDestination);
sourceNode.start();
}
// 切換音頻輸出設(shè)備
async setSinkId(deviceId: string): Promise<boolean> {
if (!this.audioElement) {
console.warn("audio 元素未初始化");
return false;
}
if (typeof this.audioElement.setSinkId !== "function") {
console.warn("瀏覽器不支持 setSinkId");
return false;
}
try {
await this.audioElement.setSinkId(deviceId);
this.currentSinkId = deviceId;
// 確保 audio 元素仍在播放
if (this.audioElement.paused) {
await this.audioElement.play();
}
console.log("? 音頻輸出設(shè)備已切換:", deviceId);
return true;
} catch (error) {
console.error("? 切換音頻輸出設(shè)備失敗:", error);
return false;
}
}
}
步驟 2:創(chuàng)建設(shè)備管理 Hook
// useSpeaker.ts
import { ref, onMounted } from "vue";
export enum SpeakerMode {
Speaker = "speaker", // 揚(yáng)聲器
Earphone = "earphone", // 耳機(jī)
Receiver = "receiver", // 聽(tīng)筒(僅移動(dòng)端)
}
export function useSpeaker(setSinkIdCallback?: (sinkId: string) => Promise<boolean>) {
const audioDevices = ref<MediaDeviceInfo[]>([]);
const currentSpeaker = ref<SpeakerMode>(SpeakerMode.Speaker);
// 獲取所有音頻輸出設(shè)備
const getAudioDevices = async () => {
try {
// 先請(qǐng)求麥克風(fēng)權(quán)限,以獲取完整的設(shè)備標(biāo)簽
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
} catch (err) {
console.warn("無(wú)法獲取麥克風(fēng)權(quán)限:", err);
}
// 枚舉所有設(shè)備
const devices = await navigator.mediaDevices.enumerateDevices();
audioDevices.value = devices.filter((device) => device.kind === "audiooutput");
console.log("可用的音頻輸出設(shè)備:", audioDevices.value);
} catch (error) {
console.error("獲取音頻設(shè)備失敗:", error);
}
};
// 選擇揚(yáng)聲器模式
const selectSpeaker = async (mode: SpeakerMode) => {
currentSpeaker.value = mode;
// 刷新設(shè)備列表
await getAudioDevices();
if (audioDevices.value.length === 0) {
console.warn("未找到可用的音頻輸出設(shè)備");
return;
}
let targetDevice: MediaDeviceInfo | undefined;
if (mode === SpeakerMode.Speaker) {
// 揚(yáng)聲器:優(yōu)先選擇 default 設(shè)備
targetDevice = audioDevices.value.find((device) => device.deviceId === "default");
if (!targetDevice) {
// 如果沒(méi)有 default,找第一個(gè)非耳機(jī)設(shè)備
targetDevice = audioDevices.value.find((device) => !device.label.toLowerCase().includes("headphone") && !device.label.toLowerCase().includes("headset") && !device.label.toLowerCase().includes("耳機(jī)") && !device.label.toLowerCase().includes("bluetooth"));
}
if (!targetDevice && audioDevices.value.length > 0) {
targetDevice = audioDevices.value[0];
}
} else if (mode === SpeakerMode.Earphone) {
// 耳機(jī):選擇包含 headphone/headset/耳機(jī) 的設(shè)備
targetDevice = audioDevices.value.find((device) => device.label.toLowerCase().includes("headphone") || device.label.toLowerCase().includes("headset") || device.label.toLowerCase().includes("耳機(jī)"));
if (!targetDevice) {
console.warn("未檢測(cè)到耳機(jī)設(shè)備");
return;
}
}
if (!targetDevice) {
console.warn("未找到目標(biāo)設(shè)備");
return;
}
console.log("目標(biāo)設(shè)備:", targetDevice.label, targetDevice.deviceId);
// 調(diào)用切換回調(diào)
if (setSinkIdCallback) {
const success = await setSinkIdCallback(targetDevice.deviceId);
if (success) {
console.log(`? 已切換到: ${targetDevice.label}`);
}
}
};
// 初始化時(shí)獲取設(shè)備列表
onMounted(() => {
getAudioDevices();
});
return {
audioDevices,
currentSpeaker,
getAudioDevices,
selectSpeaker,
};
}
步驟 3:在 Vue 組件中使用
<template>
<div class="audio-player">
<!-- 揚(yáng)聲器切換按鈕 -->
<button @click="showDeviceSelector = true">切換音頻設(shè)備</button>
<!-- 設(shè)備選擇彈窗 -->
<div v-if="showDeviceSelector" class="device-selector">
<div v-for="device in audioDevices" :key="device.deviceId" @click="switchDevice(device.deviceId)" class="device-item">
{{ device.label }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useSpeaker, SpeakerMode } from "./useSpeaker";
const showDeviceSelector = ref(false);
const audioPlayer = new AudioStreamPlayer();
// 使用設(shè)備管理 Hook
const { audioDevices, selectSpeaker } = useSpeaker((sinkId) => audioPlayer.setSinkId(sinkId));
// 切換設(shè)備
const switchDevice = async (deviceId: string) => {
const success = await audioPlayer.setSinkId(deviceId);
if (success) {
showDeviceSelector.value = false;
}
};
</script>
完整代碼
1. AudioStreamPlayer 完整實(shí)現(xiàn)
/**
* 音頻流播放器
* 支持動(dòng)態(tài)切換音頻輸出設(shè)備
*/
export class AudioStreamPlayer {
private audioContext: AudioContext | null = null;
private audioQueue: ArrayBuffer[] = [];
private isPlaying = false;
private currentSinkId = "";
private audioElement: HTMLAudioElement | null = null;
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
private sourceNodes: AudioBufferSourceNode[] = [];
private nextPlayTime = 0;
constructor() {
this.initWebAudio();
}
/**
* 初始化 Web Audio API
*/
private initWebAudio() {
this.audioContext = new AudioContext({
sampleRate: 16000,
latencyHint: "interactive",
});
// 創(chuàng)建 MediaStreamDestination 和 audio 元素以支持設(shè)備切換
this.mediaStreamDestination = this.audioContext.createMediaStreamDestination();
this.audioElement = document.createElement("audio");
this.audioElement.autoplay = true;
this.audioElement.srcObject = this.mediaStreamDestination.stream;
// 嘗試播放
const tryPlay = () => {
if (this.audioElement) {
this.audioElement
.play()
.then(() => {
console.log("? audio 元素已開(kāi)始播放");
})
.catch((err) => {
console.warn("?? 自動(dòng)播放被阻止,需要用戶(hù)交互:", err);
// 監(jiān)聽(tīng)用戶(hù)交互后重試
const retryPlay = () => {
if (this.audioElement) {
this.audioElement.play();
}
};
document.addEventListener("click", retryPlay, { once: true });
});
}
};
tryPlay();
console.log("? 已創(chuàng)建 audio 元素用于設(shè)備切換");
}
/**
* 設(shè)置音頻輸出設(shè)備
*/
async setSinkId(sinkId: string): Promise<boolean> {
if (!this.audioElement) {
console.warn("?? audio 元素未初始化");
return false;
}
if (typeof (this.audioElement as any).setSinkId !== "function") {
console.warn("?? 瀏覽器不支持 setSinkId");
return false;
}
try {
console.log("?? 當(dāng)前 sinkId:", (this.audioElement as any).sinkId);
console.log("?? 準(zhǔn)備切換到設(shè)備:", sinkId);
await (this.audioElement as any).setSinkId(sinkId);
this.currentSinkId = sinkId;
console.log("? 音頻輸出設(shè)備已切換:", sinkId);
// 確保 audio 元素仍在播放
if (this.audioElement.paused) {
await this.audioElement.play();
}
return true;
} catch (error) {
console.error("? 切換音頻輸出設(shè)備失敗:", error);
return false;
}
}
/**
* 添加音頻數(shù)據(jù)塊
*/
addAudioChunk(arrayBuffer: ArrayBuffer): void {
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
return;
}
this.audioQueue.push(arrayBuffer);
this.processQueue();
}
/**
* 處理音頻隊(duì)列
*/
private processQueue() {
if (this.audioQueue.length === 0 || !this.audioContext) {
return;
}
const chunk = this.audioQueue.shift();
if (!chunk) return;
// 解碼音頻數(shù)據(jù)
this.audioContext
.decodeAudioData(chunk.slice(0))
.then((decodedData) => {
this.playAudio(decodedData);
})
.catch((error) => {
console.error("音頻解碼失敗:", error);
})
.finally(() => {
this.processQueue();
});
}
/**
* 播放音頻
*/
private playAudio(audioBuffer: AudioBuffer) {
if (!this.audioContext || !this.mediaStreamDestination) return;
const sourceNode = this.audioContext.createBufferSource();
sourceNode.buffer = audioBuffer;
// ?? 關(guān)鍵:連接到 mediaStreamDestination
sourceNode.connect(this.mediaStreamDestination);
// 計(jì)算播放時(shí)間
if (this.nextPlayTime === 0 || this.nextPlayTime < this.audioContext.currentTime) {
this.nextPlayTime = this.audioContext.currentTime + 0.05;
}
sourceNode.start(this.nextPlayTime);
this.nextPlayTime += audioBuffer.duration;
this.sourceNodes.push(sourceNode);
sourceNode.onended = () => {
sourceNode.disconnect();
this.sourceNodes = this.sourceNodes.filter((n) => n !== sourceNode);
if (this.sourceNodes.length === 0 && this.audioQueue.length === 0) {
this.nextPlayTime = 0;
this.isPlaying = false;
}
};
if (!this.isPlaying) {
this.isPlaying = true;
}
}
/**
* 清空播放隊(duì)列
*/
clear(): void {
this.audioQueue = [];
this.sourceNodes.forEach((node) => {
try {
node.stop();
node.disconnect();
} catch (e) {
// ignore
}
});
this.sourceNodes = [];
this.nextPlayTime = 0;
this.isPlaying = false;
}
/**
* 關(guān)閉音頻上下文
*/
close(): void {
this.clear();
if (this.audioContext && this.audioContext.state !== "closed") {
this.audioContext.close();
this.audioContext = null;
}
}
}
2. useSpeaker Hook 完整實(shí)現(xiàn)
import { ref, onMounted } from "vue";
export enum SpeakerMode {
Speaker = "speaker",
Earphone = "earphone",
Receiver = "receiver",
}
export interface SpeakerOption {
label: string;
value: SpeakerMode;
icon: string;
}
export function useSpeaker(setSinkIdCallback?: (sinkId: string) => Promise<boolean>) {
const showSpeakerPopup = ref(false);
const currentSpeaker = ref<SpeakerMode>(SpeakerMode.Speaker);
const audioDevices = ref<MediaDeviceInfo[]>([]);
const speakerOptions: SpeakerOption[] = [
{ label: "揚(yáng)聲器", value: SpeakerMode.Speaker, icon: "speaker" },
{ label: "耳機(jī)", value: SpeakerMode.Earphone, icon: "headset" },
{ label: "聽(tīng)筒", value: SpeakerMode.Receiver, icon: "phone" },
];
/**
* 獲取可用的音頻輸出設(shè)備
*/
const getAudioDevices = async () => {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
console.warn("瀏覽器不支持枚舉設(shè)備");
return;
}
// 先請(qǐng)求麥克風(fēng)權(quán)限,以獲取完整的設(shè)備標(biāo)簽
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
stream.getTracks().forEach((track) => track.stop());
console.log("? 已獲取麥克風(fēng)權(quán)限");
} catch (err) {
console.warn("?? 無(wú)法獲取麥克風(fēng)權(quán)限:", err);
}
const devices = await navigator.mediaDevices.enumerateDevices();
audioDevices.value = devices.filter((device) => device.kind === "audiooutput");
console.log("?? 可用的音頻輸出設(shè)備:", audioDevices.value);
audioDevices.value.forEach((device, index) => {
console.log(` ${index + 1}. ${device.label} (${device.deviceId})`);
});
} catch (error) {
console.error("? 獲取音頻設(shè)備失敗:", error);
}
};
/**
* 切換彈窗顯示
*/
const toggleSpeakerPopup = () => {
showSpeakerPopup.value = !showSpeakerPopup.value;
};
/**
* 選擇揚(yáng)聲器模式
*/
const selectSpeaker = async (mode: SpeakerMode) => {
currentSpeaker.value = mode;
showSpeakerPopup.value = false;
// 檢查瀏覽器是否支持 setSinkId
const testAudio = document.createElement("audio");
if (typeof (testAudio as any).setSinkId !== "function") {
console.warn("瀏覽器不支持 setSinkId API");
return;
}
// 刷新設(shè)備列表
await getAudioDevices();
if (audioDevices.value.length === 0) {
console.warn("未找到可用的音頻輸出設(shè)備");
return;
}
let targetDevice: MediaDeviceInfo | undefined;
if (mode === SpeakerMode.Speaker) {
// 揚(yáng)聲器:優(yōu)先選擇 default 設(shè)備
targetDevice = audioDevices.value.find((device) => device.deviceId === "default");
if (!targetDevice) {
// 找第一個(gè)非耳機(jī)設(shè)備
targetDevice = audioDevices.value.find((device) => !device.label.toLowerCase().includes("headphone") && !device.label.toLowerCase().includes("headset") && !device.label.toLowerCase().includes("耳機(jī)") && !device.label.toLowerCase().includes("bluetooth"));
}
if (!targetDevice && audioDevices.value.length > 0) {
targetDevice = audioDevices.value[0];
}
} else if (mode === SpeakerMode.Earphone) {
// 耳機(jī):選擇包含 headphone/headset/耳機(jī) 的設(shè)備
targetDevice = audioDevices.value.find((device) => device.label.toLowerCase().includes("headphone") || device.label.toLowerCase().includes("headset") || device.label.toLowerCase().includes("耳機(jī)"));
if (!targetDevice) {
console.warn("未檢測(cè)到耳機(jī)設(shè)備");
return;
}
} else if (mode === SpeakerMode.Receiver) {
// 聽(tīng)筒:PC 不支持
console.warn("PC 不支持聽(tīng)筒模式");
return;
}
if (!targetDevice) {
console.warn("未找到目標(biāo)設(shè)備");
return;
}
console.log("?? 目標(biāo)設(shè)備:", targetDevice.label, targetDevice.deviceId);
// 切換音頻輸出
if (setSinkIdCallback) {
const success = await setSinkIdCallback(targetDevice.deviceId);
if (success) {
console.log(`? 已切換到: ${targetDevice.label}`);
}
}
};
// 初始化時(shí)獲取設(shè)備列表
onMounted(() => {
getAudioDevices();
});
return {
showSpeakerPopup,
currentSpeaker,
audioDevices,
speakerOptions,
toggleSpeakerPopup,
selectSpeaker,
getAudioDevices,
};
}
常見(jiàn)問(wèn)題
Q1: 為什么藍(lán)牙耳機(jī)可以切換,有線(xiàn)耳機(jī)不行?
A: 這是硬件和驅(qū)動(dòng)的設(shè)計(jì)導(dǎo)致的:
- 藍(lán)牙耳機(jī):通過(guò)藍(lán)牙適配器連接,被識(shí)別為獨(dú)立的音頻輸出設(shè)備,有獨(dú)立的設(shè)備 ID
- 有線(xiàn)耳機(jī):插入 3.5mm 或 Type-C 接口后,與內(nèi)置揚(yáng)聲器共享同一個(gè)音頻芯片,系統(tǒng)只看到一個(gè)設(shè)備
當(dāng)插入有線(xiàn)耳機(jī)時(shí),音頻芯片會(huì)在硬件層面自動(dòng)切換輸出到耳機(jī),對(duì)操作系統(tǒng)和瀏覽器來(lái)說(shuō)仍然是同一個(gè)設(shè)備,因此無(wú)法通過(guò)軟件切換。
Q2: 為什么需要請(qǐng)求麥克風(fēng)權(quán)限?
A: 出于隱私保護(hù),瀏覽器在未獲得權(quán)限時(shí),enumerateDevices() 返回的設(shè)備標(biāo)簽(label)會(huì)是空的或通用名稱(chēng)。請(qǐng)求麥克風(fēng)權(quán)限后,才能獲取完整的設(shè)備名稱(chēng),便于識(shí)別設(shè)備類(lèi)型。
Q3: 如何判斷瀏覽器是否支持設(shè)備切換?
A: 檢查 HTMLMediaElement.setSinkId 方法是否存在:
const audio = document.createElement("audio");
if (typeof audio.setSinkId === "function") {
console.log("? 瀏覽器支持設(shè)備切換");
} else {
console.log("? 瀏覽器不支持設(shè)備切換");
}
Q4: 為什么自動(dòng)播放會(huì)失???
A: 現(xiàn)代瀏覽器的自動(dòng)播放策略要求:
- 用戶(hù)必須與頁(yè)面有過(guò)交互(點(diǎn)擊、觸摸等)
- 或者音頻是靜音的
解決方案:
- 在用戶(hù)交互后再初始化音頻
- 監(jiān)聽(tīng)用戶(hù)交互事件,重試播放
- 提示用戶(hù)點(diǎn)擊頁(yè)面以啟用音頻
Q5: 如何在微信小程序中實(shí)現(xiàn)設(shè)備切換?
A: 微信小程序使用不同的 API:
// 切換到揚(yáng)聲器
uni.setInnerAudioOption({
obeyMuteSwitch: false,
speakerOn: true,
});
// 切換到聽(tīng)筒
uni.setInnerAudioOption({
obeyMuteSwitch: true,
speakerOn: false,
});
瀏覽器兼容性
setSinkId API 支持情況
| 瀏覽器 | 版本 | 支持情況 |
|---|---|---|
| Chrome | 49+ | ? 完全支持 |
| Edge | 79+ | ? 完全支持 |
| Firefox | 116+ | ? 完全支持 |
| Safari | ? | ? 不支持 |
| Opera | 36+ | ? 完全支持 |
兼容性檢測(cè)
function checkAudioDeviceSwitchSupport() {
// 檢查 enumerateDevices
if (!navigator.mediaDevices?.enumerateDevices) {
return {
supported: false,
reason: "瀏覽器不支持枚舉設(shè)備",
};
}
// 檢查 setSinkId
const audio = document.createElement("audio");
if (typeof audio.setSinkId !== "function") {
return {
supported: false,
reason: "瀏覽器不支持 setSinkId API",
};
}
return {
supported: true,
reason: "瀏覽器完全支持音頻設(shè)備切換",
};
}
// 使用
const result = checkAudioDeviceSwitchSupport();
console.log(result);
最佳實(shí)踐
1. 錯(cuò)誤處理
async function switchAudioDevice(deviceId: string) {
try {
// 檢查瀏覽器支持
if (typeof audioElement.setSinkId !== 'function') {
throw new Error('瀏覽器不支持設(shè)備切換');
}
// 切換設(shè)備
await audioElement.setSinkId(deviceId);
// 確保播放狀態(tài)
if (audioElement.paused) {
await audioElement.play();
}
// 用戶(hù)反饋
showToast('設(shè)備切換成功');
} catch (error) {
console.error('設(shè)備切換失敗:', error);
showToast('設(shè)備切換失敗,請(qǐng)重試');
}
}
2. 用戶(hù)體驗(yàn)優(yōu)化
// 1. 記住用戶(hù)選擇
localStorage.setItem("preferredAudioDevice", deviceId);
// 2. 自動(dòng)恢復(fù)上次選擇
const savedDeviceId = localStorage.getItem("preferredAudioDevice");
if (savedDeviceId) {
await switchAudioDevice(savedDeviceId);
}
// 3. 監(jiān)聽(tīng)設(shè)備變化
navigator.mediaDevices.addEventListener("devicechange", () => {
console.log("設(shè)備列表已變化,刷新設(shè)備列表");
getAudioDevices();
});
3. 性能優(yōu)化
// 避免頻繁枚舉設(shè)備
let deviceListCache: MediaDeviceInfo[] = [];
let lastEnumerateTime = 0;
const CACHE_DURATION = 5000; // 5秒緩存
async function getAudioDevices() {
const now = Date.now();
if (now - lastEnumerateTime < CACHE_DURATION) {
return deviceListCache;
}
const devices = await navigator.mediaDevices.enumerateDevices();
deviceListCache = devices.filter(d => d.kind === 'audiooutput');
lastEnumerateTime = now;
return deviceListCache;
}
總結(jié)
通過(guò) Web Audio API 的 MediaStreamDestination 和 HTMLMediaElement.setSinkId,我們可以實(shí)現(xiàn)靈活的音頻輸出設(shè)備切換功能。關(guān)鍵點(diǎn):
- 使用
MediaStreamDestination創(chuàng)建音頻流 - 將音頻流連接到
<audio>元素 - 使用
setSinkId切換輸出設(shè)備 - 處理瀏覽器兼容性和自動(dòng)播放策略
- 提供良好的用戶(hù)體驗(yàn)和錯(cuò)誤處理
這個(gè)方案已在生產(chǎn)環(huán)境中驗(yàn)證,適用于在線(xiàn)教育、視頻會(huì)議等場(chǎng)景。
以上就是基于Vue3中實(shí)現(xiàn)耳機(jī)和揚(yáng)聲器切換的完整方案的詳細(xì)內(nèi)容,更多關(guān)于Vue3耳機(jī)和揚(yáng)聲器切換的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
在Vue3中創(chuàng)建和使用全局組件的實(shí)現(xiàn)方式
在前端開(kāi)發(fā)中,Vue.js 是一個(gè)廣泛使用的框架,因其靈活性和強(qiáng)大的功能,得到許多開(kāi)發(fā)者的喜愛(ài),Vue 3 的發(fā)布為這一框架帶來(lái)了很多新的特性和改進(jìn),在本文中,我們將詳細(xì)討論如何在 Vue 3 中創(chuàng)建和使用全局組件,并通過(guò)示例代碼展示具體實(shí)現(xiàn)方式,需要的朋友可以參考下2024-07-07
vue+element UI實(shí)現(xiàn)樹(shù)形表格
這篇文章主要為大家詳細(xì)介紹了vue+element UI實(shí)現(xiàn)樹(shù)形表格,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-12-12
cesium開(kāi)發(fā)之如何在vue項(xiàng)目中使用cesium,使用離線(xiàn)地圖資源
這篇文章主要介紹了cesium開(kāi)發(fā)之如何在vue項(xiàng)目中使用cesium,使用離線(xiàn)地圖資源問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-04-04
vuejs+element UI table表格中實(shí)現(xiàn)禁用部分復(fù)選框的方法
今天小編就為大家分享一篇vuejs+element UI table表格中實(shí)現(xiàn)禁用部分復(fù)選框的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2019-09-09
FastApi+Vue+LayUI實(shí)現(xiàn)前后端分離的示例代碼
本文主要介紹了FastApi+Vue+LayUI實(shí)現(xiàn)前后端分離的示例代碼,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11
在Nginx上部署前端Vue項(xiàng)目的詳細(xì)步驟(超級(jí)簡(jiǎn)單!)
這篇文章主要介紹了在Nginx上部署前端Vue項(xiàng)目的詳細(xì)步驟,Nginx是一款高效的HTTP和反向代理Web服務(wù)器,作為開(kāi)源軟件,Nginx以其高性能、可擴(kuò)展性和靈活性廣泛應(yīng)用于Web架構(gòu)中,文中將步驟介紹的非常詳細(xì),需要的朋友可以參考下2024-10-10
vue+elementUI實(shí)現(xiàn)表單和圖片上傳及驗(yàn)證功能示例
這篇文章主要介紹了vue+elementUI實(shí)現(xiàn)表單和圖片上傳及驗(yàn)證功能,結(jié)合實(shí)例形式分析了vue+elementUI表單相關(guān)操作技巧,需要的朋友可以參考下2019-05-05
vue使用advanced-mark.js實(shí)現(xiàn)高亮文字效果
在日常項(xiàng)目中我們往往會(huì)有搜索高亮的需求,下面這篇文章主要介紹了vue使用advanced-mark.js實(shí)現(xiàn)高亮文字效果的相關(guān)資料,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-08-08
vue路由對(duì)不同界面進(jìn)行傳參及跳轉(zhuǎn)的總結(jié)
這篇文章主要介紹了vue路由對(duì)不同界面進(jìn)行傳參及跳轉(zhuǎn)的總結(jié),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-04-04

