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