feat: 實現 TTS 播放功能 + 改進詞彙選擇 UX 流程
## TTS 播放功能 (BluePlayButton) - ✅ 實現瀏覽器內建 TTS 語音播放 - ✅ 添加瀏覽器支援檢測和錯誤處理 - ✅ 支援語速、音調、音量調整參數 - ✅ 改進播放/停止狀態管理 - ✅ 優化視覺回饋和無障礙體驗 ## FlipMemory 組件整合 - ✅ 在單詞展示區添加播放按鈕 - ✅ 在例句區塊添加播放按鈕 - ✅ 防止播放觸發翻卡動作 ## VocabChoiceQuiz UX 改進 - ✅ 移除自動跳頁邏輯,改為手動「下一題」 - ✅ 答題後顯示「下一題」按鈕取代「跳過」 - ✅ 在答案解析中添加單詞和例句播放功能 - ✅ 提供更好的學習體驗,讓用戶有時間查看解析 ## 技術改進 - 🎵 使用 Web Speech API 實現 TTS - 📱 響應式設計,支援多種按鈕尺寸 - 🛡️ 完善的錯誤處理和記憶體管理 - ⚡ 即時回應,無網路延遲 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3ff3b7f0a1
commit
fde7d1209b
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { CardState } from '@/lib/data/reviewSimpleData'
|
import { CardState } from '@/lib/data/reviewSimpleData'
|
||||||
import { QuizHeader } from '../ui/QuizHeader'
|
import { QuizHeader } from '../ui/QuizHeader'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface SimpleFlipCardProps {
|
interface SimpleFlipCardProps {
|
||||||
card: CardState
|
card: CardState
|
||||||
|
|
@ -120,6 +121,15 @@ export function FlipMemory({ card, onAnswer, onSkip }: SimpleFlipCardProps) {
|
||||||
{card.pronunciation && (
|
{card.pronunciation && (
|
||||||
<span className="text-lg text-gray-500">{card.pronunciation}</span>
|
<span className="text-lg text-gray-500">{card.pronunciation}</span>
|
||||||
)}
|
)}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<BluePlayButton
|
||||||
|
text={card.word}
|
||||||
|
size="sm"
|
||||||
|
title="播放單詞發音"
|
||||||
|
rate={0.8}
|
||||||
|
lang="en-US"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,6 +164,15 @@ export function FlipMemory({ card, onAnswer, onSkip }: SimpleFlipCardProps) {
|
||||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3>
|
<h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<p className="text-gray-700 italic mb-2 text-left pr-12">"{card.example}"</p>
|
<p className="text-gray-700 italic mb-2 text-left pr-12">"{card.example}"</p>
|
||||||
|
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<BluePlayButton
|
||||||
|
text={card.example}
|
||||||
|
size="sm"
|
||||||
|
title="播放例句"
|
||||||
|
rate={0.7}
|
||||||
|
lang="en-US"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 text-sm text-left">{card.exampleTranslation}</p>
|
<p className="text-gray-600 text-sm text-left">{card.exampleTranslation}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { CardState } from '@/lib/data/reviewSimpleData'
|
import { CardState } from '@/lib/data/reviewSimpleData'
|
||||||
import { QuizHeader } from '../ui/QuizHeader'
|
import { QuizHeader } from '../ui/QuizHeader'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
|
|
||||||
interface VocabChoiceTestProps {
|
interface VocabChoiceTestProps {
|
||||||
card: CardState
|
card: CardState
|
||||||
|
|
@ -14,30 +16,35 @@ interface VocabChoiceTestProps {
|
||||||
export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoiceTestProps) {
|
export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoiceTestProps) {
|
||||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
|
const [hasAnswered, setHasAnswered] = useState(false)
|
||||||
|
|
||||||
const handleAnswerSelect = useCallback((answer: string) => {
|
const handleAnswerSelect = useCallback((answer: string) => {
|
||||||
if (showResult) return
|
if (showResult || hasAnswered) return
|
||||||
|
|
||||||
setSelectedAnswer(answer)
|
setSelectedAnswer(answer)
|
||||||
setShowResult(true)
|
setShowResult(true)
|
||||||
|
setHasAnswered(true)
|
||||||
// 判斷答案是否正確,正確給3分,錯誤給1分
|
}, [showResult, hasAnswered])
|
||||||
const isCorrect = answer === card.word
|
|
||||||
const confidence = isCorrect ? 2 : 0
|
|
||||||
|
|
||||||
// 延遲一點再調用回調,讓用戶看到選擇結果
|
|
||||||
setTimeout(() => {
|
|
||||||
onAnswer(confidence)
|
|
||||||
// 重置狀態為下一題準備
|
|
||||||
setSelectedAnswer(null)
|
|
||||||
setShowResult(false)
|
|
||||||
}, 1500)
|
|
||||||
}, [showResult, card.word, onAnswer])
|
|
||||||
|
|
||||||
const handleSkipClick = useCallback(() => {
|
const handleSkipClick = useCallback(() => {
|
||||||
onSkip()
|
onSkip()
|
||||||
}, [onSkip])
|
}, [onSkip])
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (!hasAnswered || !selectedAnswer) return
|
||||||
|
|
||||||
|
// 判斷答案是否正確,正確給2分,錯誤給0分
|
||||||
|
const isCorrect = selectedAnswer === card.word
|
||||||
|
const confidence = isCorrect ? 2 : 0
|
||||||
|
|
||||||
|
onAnswer(confidence)
|
||||||
|
|
||||||
|
// 重置狀態為下一題準備
|
||||||
|
setSelectedAnswer(null)
|
||||||
|
setShowResult(false)
|
||||||
|
setHasAnswered(false)
|
||||||
|
}, [hasAnswered, selectedAnswer, card.word, onAnswer])
|
||||||
|
|
||||||
const isCorrect = selectedAnswer === card.word
|
const isCorrect = selectedAnswer === card.word
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,7 +98,7 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => handleAnswerSelect(option)}
|
onClick={() => handleAnswerSelect(option)}
|
||||||
disabled={showResult}
|
disabled={hasAnswered}
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
|
|
@ -119,10 +126,28 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
|
||||||
<strong>正確答案:</strong> {card.word}
|
<strong>正確答案:</strong> {card.word}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
<strong>發音:</strong> {card.pronunciation}
|
<strong>發音:</strong> {card.pronunciation}
|
||||||
|
<span className='ml-2' onClick={(e) => e.stopPropagation()}>
|
||||||
|
<BluePlayButton
|
||||||
|
text={card.word}
|
||||||
|
size="sm"
|
||||||
|
title="播放單詞發音"
|
||||||
|
rate={0.8}
|
||||||
|
lang="en-US"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
<strong>例句:</strong> "{card.example}"
|
<strong>例句:</strong> "{card.example}"
|
||||||
|
<span className='ml-2' onClick={(e) => e.stopPropagation()}>
|
||||||
|
<BluePlayButton
|
||||||
|
text={card.example}
|
||||||
|
size="sm"
|
||||||
|
title="播放單詞發音"
|
||||||
|
rate={0.8}
|
||||||
|
lang="en-US"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600 text-sm">
|
<p className="text-gray-600 text-sm">
|
||||||
{card.exampleTranslation}
|
{card.exampleTranslation}
|
||||||
|
|
@ -133,17 +158,26 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 跳過按鈕 - 移到卡片外面 */}
|
{/* 按鈕區域 - 根據答題狀態顯示不同按鈕 */}
|
||||||
{!showResult && (
|
<div className="mt-6">
|
||||||
<div className="mt-6">
|
{!hasAnswered ? (
|
||||||
|
// 未答題時顯示跳過按鈕
|
||||||
<button
|
<button
|
||||||
onClick={handleSkipClick}
|
onClick={handleSkipClick}
|
||||||
className="w-full border border-gray-300 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
className="w-full border border-gray-300 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
跳過
|
跳過
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
)}
|
// 已答題時顯示下一題按鈕
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
下一題
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
interface BluePlayButtonProps {
|
interface BluePlayButtonProps {
|
||||||
text?: string
|
text?: string
|
||||||
|
|
@ -7,8 +7,12 @@ interface BluePlayButtonProps {
|
||||||
className?: string
|
className?: string
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
title?: string
|
title?: string
|
||||||
|
rate?: number
|
||||||
|
pitch?: number
|
||||||
|
volume?: number
|
||||||
onPlayStart?: () => void
|
onPlayStart?: () => void
|
||||||
onPlayEnd?: () => void
|
onPlayEnd?: () => void
|
||||||
|
onError?: (error: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
|
|
@ -18,10 +22,16 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
title,
|
title,
|
||||||
|
rate = 0.9,
|
||||||
|
pitch = 1.0,
|
||||||
|
volume = 1.0,
|
||||||
onPlayStart,
|
onPlayStart,
|
||||||
onPlayEnd
|
onPlayEnd,
|
||||||
|
onError
|
||||||
}) => {
|
}) => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [isSupported, setIsSupported] = useState(true)
|
||||||
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'w-8 h-8',
|
sm: 'w-8 h-8',
|
||||||
|
|
@ -35,59 +45,134 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
lg: 'w-6 h-6'
|
lg: 'w-6 h-6'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 內建 TTS 邏輯
|
// 檢查瀏覽器支援
|
||||||
|
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 = () => {
|
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) {
|
if (isPlaying) {
|
||||||
speechSynthesis.cancel()
|
stopSpeech()
|
||||||
setIsPlaying(false)
|
|
||||||
if (onPlayEnd) onPlayEnd()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 開始播放邏輯
|
// 開始播放邏輯
|
||||||
if (onPlayStart) {
|
if (onPlayStart && !text) {
|
||||||
// 自定義播放場景(如錄音回放)
|
// 自定義播放場景(如錄音回放)
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
onPlayStart()
|
onPlayStart()
|
||||||
|
// 3秒後自動停止(可調整)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
if (onPlayEnd) onPlayEnd()
|
if (onPlayEnd) onPlayEnd()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} else if (text) {
|
} else if (text) {
|
||||||
// 標準 TTS 播放
|
// 標準 TTS 播放
|
||||||
const utterance = new SpeechSynthesisUtterance(text)
|
startSpeech(text)
|
||||||
utterance.lang = lang
|
} else {
|
||||||
utterance.rate = 0.8
|
const errorMessage = 'No text provided for speech synthesis'
|
||||||
|
console.warn(errorMessage)
|
||||||
utterance.onstart = () => {
|
if (onError) onError(errorMessage)
|
||||||
setIsPlaying(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
utterance.onend = () => {
|
|
||||||
setIsPlaying(false)
|
|
||||||
if (onPlayEnd) onPlayEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
utterance.onerror = () => {
|
|
||||||
setIsPlaying(false)
|
|
||||||
if (onPlayEnd) onPlayEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
speechSynthesis.speak(utterance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 計算按鈕狀態
|
||||||
|
const isDisabled = disabled || !isSupported
|
||||||
|
const buttonTitle = title ||
|
||||||
|
(!isSupported ? "此瀏覽器不支援語音播放" :
|
||||||
|
isPlaying ? "點擊停止播放" :
|
||||||
|
text ? "點擊播放發音" : "點擊播放")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
|
title={buttonTitle}
|
||||||
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
|
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
|
||||||
${isPlaying
|
${!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-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'
|
: 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${className}
|
} ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* 播放中波紋效果 */}
|
{/* 播放中波紋效果 */}
|
||||||
|
|
@ -97,11 +182,18 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
|
|
||||||
{/* 按鈕圖標 */}
|
{/* 按鈕圖標 */}
|
||||||
<div className="relative z-10 flex items-center justify-center w-full h-full">
|
<div className="relative z-10 flex items-center justify-center w-full h-full">
|
||||||
{isPlaying ? (
|
{!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">
|
<svg className={`${iconSizes[size]} text-white`} fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
|
// 播放圖標
|
||||||
<svg className={`${iconSizes[size]} text-white group-hover:scale-110 transition-transform`} fill="currentColor" viewBox="0 0 24 24">
|
<svg className={`${iconSizes[size]} text-white group-hover:scale-110 transition-transform`} fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -109,7 +201,7 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 懸停提示光環 */}
|
{/* 懸停提示光環 */}
|
||||||
{!isPlaying && !disabled && (
|
{!isPlaying && !isDisabled && isSupported && (
|
||||||
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
|
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue