dramaling-vocab-learning/frontend/components/shared/BluePlayButton.tsx

209 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react'
interface BluePlayButtonProps {
text?: string
lang?: string
disabled?: boolean
className?: string
size?: 'sm' | 'md' | 'lg'
title?: string
rate?: number
pitch?: number
volume?: number
onPlayStart?: () => void
onPlayEnd?: () => void
onError?: (error: string) => void
}
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
text,
lang = 'en-US',
disabled = false,
className = '',
size = 'md',
title,
rate = 0.9,
pitch = 1.0,
volume = 1.0,
onPlayStart,
onPlayEnd,
onError
}) => {
const [isPlaying, setIsPlaying] = useState(false)
const [isSupported, setIsSupported] = useState(true)
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
}
// 檢查瀏覽器支援
useEffect(() => {
if (typeof window !== 'undefined') {
setIsSupported('speechSynthesis' in window)
}
}, [])
// 清理未完成的語音
useEffect(() => {
return () => {
if (utteranceRef.current) {
speechSynthesis.cancel()
}
}
}, [])
// 停止當前播放
const stopSpeech = () => {
if (utteranceRef.current) {
speechSynthesis.cancel()
utteranceRef.current = null
}
setIsPlaying(false)
if (onPlayEnd) onPlayEnd()
}
// 開始 TTS 播放
const startSpeech = (textToSpeak: string) => {
try {
// 停止任何正在進行的語音
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(textToSpeak)
utteranceRef.current = utterance
// 設置語音參數
utterance.lang = lang
utterance.rate = Math.max(0.1, Math.min(2.0, rate)) // 限制範圍 0.1-2.0
utterance.pitch = Math.max(0, Math.min(2, pitch)) // 限制範圍 0-2
utterance.volume = Math.max(0, Math.min(1, volume)) // 限制範圍 0-1
// 事件處理
utterance.onstart = () => {
setIsPlaying(true)
if (onPlayStart) onPlayStart()
}
utterance.onend = () => {
utteranceRef.current = null
setIsPlaying(false)
if (onPlayEnd) onPlayEnd()
}
utterance.onerror = (event) => {
utteranceRef.current = null
setIsPlaying(false)
const errorMessage = `Speech synthesis error: ${event.error}`
console.warn(errorMessage)
if (onError) onError(errorMessage)
if (onPlayEnd) onPlayEnd()
}
// 開始播放
speechSynthesis.speak(utterance)
} catch (error) {
const errorMessage = `Failed to start speech synthesis: ${error}`
console.error(errorMessage)
setIsPlaying(false)
if (onError) onError(errorMessage)
}
}
// 主處理函數
const handleToggle = () => {
// 如果不支援 TTS
if (!isSupported) {
const errorMessage = 'Text-to-speech is not supported in this browser'
console.warn(errorMessage)
if (onError) onError(errorMessage)
return
}
// 停止播放邏輯
if (isPlaying) {
stopSpeech()
return
}
// 開始播放邏輯
if (onPlayStart && !text) {
// 自定義播放場景(如錄音回放)
setIsPlaying(true)
onPlayStart()
// 3秒後自動停止可調整
setTimeout(() => {
setIsPlaying(false)
if (onPlayEnd) onPlayEnd()
}, 3000)
} else if (text) {
// 標準 TTS 播放
startSpeech(text)
} else {
const errorMessage = 'No text provided for speech synthesis'
console.warn(errorMessage)
if (onError) onError(errorMessage)
}
}
// 計算按鈕狀態
const isDisabled = disabled || !isSupported
const buttonTitle = title ||
(!isSupported ? "此瀏覽器不支援語音播放" :
isPlaying ? "點擊停止播放" :
text ? "點擊播放發音" : "點擊播放")
return (
<button
onClick={handleToggle}
disabled={isDisabled}
title={buttonTitle}
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
${!isSupported
? 'bg-gradient-to-br from-gray-400 to-gray-500 shadow-gray-200 cursor-not-allowed opacity-75'
: isPlaying
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105'
: 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
} ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${className}
`}
>
{/* 播放中波紋效果 */}
{isPlaying && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{!isSupported ? (
// 不支援圖標 - 禁用音頻
<svg className={`${iconSizes[size]} text-white`} fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : isPlaying ? (
// 暫停圖標
<svg className={`${iconSizes[size]} text-white`} fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
// 播放圖標
<svg className={`${iconSizes[size]} text-white group-hover:scale-110 transition-transform`} fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!isPlaying && !isDisabled && isSupported && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
)
}