dramaling-app/apps/web/src/composables/useAudio.ts

270 lines
6.3 KiB
TypeScript

import { ref, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
export interface AudioOptions {
playbackRate?: number
volume?: number
loop?: boolean
preload?: boolean
}
export function useAudio() {
const $q = useQuasar()
// 狀態管理
const isPlaying = ref(false)
const isLoading = ref(false)
const duration = ref(0)
const currentTime = ref(0)
const volume = ref(1)
const playbackRate = ref(1)
const error = ref<string | null>(null)
// Web Audio API 支援
let audioContext: AudioContext | null = null
let currentAudioSource: AudioBufferSourceNode | null = null
let gainNode: GainNode | null = null
let audioBuffer: AudioBuffer | null = null
// HTML5 Audio fallback
let htmlAudio: HTMLAudioElement | null = null
// 初始化音頻上下文
const initAudioContext = async (): Promise<boolean> => {
if (audioContext) return true
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
gainNode = audioContext.createGain()
gainNode.connect(audioContext.destination)
return true
} catch (err) {
console.warn('Web Audio API 不支援,使用 HTML5 Audio fallback:', err)
return false
}
}
// 載入音頻文件
const loadAudio = async (url: string): Promise<boolean> => {
error.value = null
isLoading.value = true
try {
const useWebAudio = await initAudioContext()
if (useWebAudio && audioContext) {
// 使用 Web Audio API
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const arrayBuffer = await response.arrayBuffer()
audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
duration.value = audioBuffer.duration
} else {
// 使用 HTML5 Audio fallback
htmlAudio = new Audio()
htmlAudio.preload = 'auto'
htmlAudio.src = url
return new Promise((resolve, reject) => {
if (!htmlAudio) {
reject(new Error('無法創建 Audio 元素'))
return
}
htmlAudio.onloadedmetadata = () => {
duration.value = htmlAudio!.duration
resolve(true)
}
htmlAudio.onerror = () => {
reject(new Error('音頻載入失敗'))
}
})
}
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '載入音頻失敗'
console.error('載入音頻失敗:', err)
return false
} finally {
isLoading.value = false
}
}
// 播放音頻
const play = async (options?: AudioOptions) => {
if (isPlaying.value) {
stop()
}
try {
if (audioBuffer && audioContext && gainNode) {
// 使用 Web Audio API 播放
currentAudioSource = audioContext.createBufferSource()
currentAudioSource.buffer = audioBuffer
currentAudioSource.playbackRate.value = options?.playbackRate || playbackRate.value
gainNode.gain.value = options?.volume || volume.value
currentAudioSource.connect(gainNode)
currentAudioSource.start(0)
currentAudioSource.onended = () => {
isPlaying.value = false
currentTime.value = 0
}
} else if (htmlAudio) {
// 使用 HTML5 Audio 播放
htmlAudio.volume = options?.volume || volume.value
htmlAudio.playbackRate = options?.playbackRate || playbackRate.value
htmlAudio.loop = options?.loop || false
htmlAudio.ontimeupdate = () => {
currentTime.value = htmlAudio!.currentTime
}
htmlAudio.onended = () => {
isPlaying.value = false
currentTime.value = 0
}
await htmlAudio.play()
} else {
throw new Error('沒有可用的音頻資源')
}
isPlaying.value = true
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : '播放失敗'
isPlaying.value = false
$q.notify({
type: 'negative',
message: error.value
})
}
}
// 暫停音頻
const pause = () => {
if (currentAudioSource) {
currentAudioSource.stop()
currentAudioSource = null
}
if (htmlAudio) {
htmlAudio.pause()
}
isPlaying.value = false
}
// 停止音頻
const stop = () => {
pause()
currentTime.value = 0
if (htmlAudio) {
htmlAudio.currentTime = 0
}
}
// 設置音量
const setVolume = (newVolume: number) => {
volume.value = Math.max(0, Math.min(1, newVolume))
if (gainNode) {
gainNode.gain.value = volume.value
}
if (htmlAudio) {
htmlAudio.volume = volume.value
}
}
// 設置播放速度
const setPlaybackRate = (rate: number) => {
playbackRate.value = Math.max(0.25, Math.min(4, rate))
if (currentAudioSource) {
currentAudioSource.playbackRate.value = playbackRate.value
}
if (htmlAudio) {
htmlAudio.playbackRate = playbackRate.value
}
}
// 跳轉到指定時間
const seekTo = (time: number) => {
if (htmlAudio) {
htmlAudio.currentTime = Math.max(0, Math.min(duration.value, time))
currentTime.value = htmlAudio.currentTime
}
}
// 快速播放功能(用於詞彙學習)
const quickPlay = async (url: string, options?: AudioOptions) => {
const success = await loadAudio(url)
if (success) {
await play(options)
}
return success
}
// 銷毀資源
const cleanup = () => {
stop()
if (audioBuffer) {
audioBuffer = null
}
if (htmlAudio) {
htmlAudio.remove()
htmlAudio = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
audioContext = null
}
gainNode = null
currentAudioSource = null
}
// 組件卸載時清理資源
onUnmounted(() => {
cleanup()
})
return {
// 狀態
isPlaying,
isLoading,
duration,
currentTime,
volume,
playbackRate,
error,
// 方法
loadAudio,
play,
pause,
stop,
setVolume,
setPlaybackRate,
seekTo,
quickPlay,
cleanup
}
}