270 lines
6.3 KiB
TypeScript
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
|
|
}
|
|
} |