"use client" import { useState, useEffect, useRef, useCallback } from "react" import { useRouter } from "next/navigation" import { Play, Pause, Square, SkipForward, SkipBack, Volume2, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react" import { Button } from "@/components/ui/button" import { Slider } from "@/components/ui/slider" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { cn } from "@/lib/utils" interface TTSPlayerProps { paragraphs: string[] novelSlug: string currentChapter: number maxChapter: number chapterTitle: string } export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, chapterTitle }: TTSPlayerProps) { const router = useRouter() const [isPlaying, setIsPlaying] = useState(false) const [currentParagraphIndex, setCurrentParagraphIndex] = useState(0) const [rate, setRate] = useState(1.0) const [voices, setVoices] = useState([]) const [selectedVoiceURI, setSelectedVoiceURI] = useState("") const [isExpanded, setIsExpanded] = useState(false) const [autoNextChapter, setAutoNextChapter] = useState(true) const [isSupported, setIsSupported] = useState(false) const wakeLockRef = useRef(null) const utteranceRef = useRef(null) const isPlayingRef = useRef(false) const currentIndexRef = useRef(0) // Keep refs in sync useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying]) useEffect(() => { currentIndexRef.current = currentParagraphIndex }, [currentParagraphIndex]) // Check TTS support useEffect(() => { if (typeof window !== "undefined" && "speechSynthesis" in window) { setIsSupported(true) } }, []) // Load voices useEffect(() => { if (!isSupported) return const loadVoices = () => { const available = speechSynthesis.getVoices() const viVoices = available.filter( (v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam") ) const allUsable = viVoices.length > 0 ? viVoices : available.slice(0, 10) setVoices(allUsable) if (allUsable.length > 0 && !selectedVoiceURI) { setSelectedVoiceURI(allUsable[0].voiceURI) } } loadVoices() speechSynthesis.addEventListener("voiceschanged", loadVoices) return () => { speechSynthesis.removeEventListener("voiceschanged", loadVoices) } }, [isSupported, selectedVoiceURI]) // Acquire Wake Lock for background playback on mobile const acquireWakeLock = useCallback(async () => { if ("wakeLock" in navigator) { try { wakeLockRef.current = await navigator.wakeLock.request("screen") } catch { // Wake Lock not available or denied } } }, []) const releaseWakeLock = useCallback(async () => { if (wakeLockRef.current) { try { await wakeLockRef.current.release() wakeLockRef.current = null } catch { // ignore } } }, []) // Cleanup on unmount useEffect(() => { return () => { speechSynthesis.cancel() releaseWakeLock() } }, [releaseWakeLock]) // Highlight current paragraph in the DOM useEffect(() => { if (!isPlaying) return const articleEl = document.querySelector(".chapter-content") if (!articleEl) return const pElements = articleEl.querySelectorAll("p[data-p-index]") pElements.forEach((el) => { el.classList.remove("tts-active-paragraph") }) const activeEl = articleEl.querySelector(`p[data-p-index="${currentParagraphIndex}"]`) if (activeEl) { activeEl.classList.add("tts-active-paragraph") activeEl.scrollIntoView({ behavior: "smooth", block: "center" }) } }, [currentParagraphIndex, isPlaying]) // Clean highlights when stopped useEffect(() => { if (!isPlaying) { const articleEl = document.querySelector(".chapter-content") if (articleEl) { articleEl.querySelectorAll("p[data-p-index]").forEach((el) => { el.classList.remove("tts-active-paragraph") }) } } }, [isPlaying]) const speakParagraph = useCallback( (index: number) => { if (index >= paragraphs.length) { // Chapter finished setIsPlaying(false) releaseWakeLock() if (autoNextChapter && currentChapter < maxChapter) { // Auto navigate to next chapter router.push(`/truyen/${novelSlug}/${currentChapter + 1}?tts=auto`) } return } speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(paragraphs[index]) utterance.rate = rate utterance.lang = "vi-VN" const voice = voices.find((v) => v.voiceURI === selectedVoiceURI) if (voice) { utterance.voice = voice } utterance.onend = () => { if (isPlayingRef.current) { const nextIndex = currentIndexRef.current + 1 setCurrentParagraphIndex(nextIndex) speakParagraph(nextIndex) } } utterance.onerror = (e) => { if (e.error !== "canceled" && e.error !== "interrupted") { setIsPlaying(false) releaseWakeLock() } } utteranceRef.current = utterance setCurrentParagraphIndex(index) speechSynthesis.speak(utterance) }, [paragraphs, rate, voices, selectedVoiceURI, autoNextChapter, currentChapter, maxChapter, novelSlug, router, releaseWakeLock] ) const handlePlay = useCallback(() => { if (!isSupported) return if (isPlaying) { // Pause speechSynthesis.cancel() setIsPlaying(false) releaseWakeLock() } else { // Play / Resume setIsPlaying(true) acquireWakeLock() speakParagraph(currentParagraphIndex) } }, [isSupported, isPlaying, currentParagraphIndex, speakParagraph, acquireWakeLock, releaseWakeLock]) const handleStop = useCallback(() => { speechSynthesis.cancel() setIsPlaying(false) setCurrentParagraphIndex(0) releaseWakeLock() }, [releaseWakeLock]) const handlePrevParagraph = useCallback(() => { const newIndex = Math.max(0, currentParagraphIndex - 1) setCurrentParagraphIndex(newIndex) if (isPlaying) { speechSynthesis.cancel() speakParagraph(newIndex) } }, [currentParagraphIndex, isPlaying, speakParagraph]) const handleNextParagraph = useCallback(() => { const newIndex = Math.min(paragraphs.length - 1, currentParagraphIndex + 1) setCurrentParagraphIndex(newIndex) if (isPlaying) { speechSynthesis.cancel() speakParagraph(newIndex) } }, [currentParagraphIndex, paragraphs.length, isPlaying, speakParagraph]) // Auto-play TTS when coming from previous chapter auto-advance useEffect(() => { if (!isSupported) return const params = new URLSearchParams(window.location.search) if (params.get("tts") === "auto") { // Small delay to let the page render const timer = setTimeout(() => { setIsPlaying(true) acquireWakeLock() speakParagraph(0) }, 500) return () => clearTimeout(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSupported]) if (!isSupported) return null const progress = paragraphs.length > 0 ? ((currentParagraphIndex + 1) / paragraphs.length) * 100 : 0 return ( <> {/* Floating TTS bar */}
{/* Progress bar */}
{/* Compact bar */}
{/* Play controls */}
{/* Info */}
{chapterTitle} {currentParagraphIndex + 1}/{paragraphs.length} doan
{/* Speed control */}
{rate.toFixed(2)}x
{/* Expand/Collapse */}
{/* Expanded settings */} {isExpanded && (
{/* Speed on mobile */}
Toc do: setRate(v)} className="flex-1" /> {rate.toFixed(2)}x
{/* Voice selector */} {voices.length > 1 && (
)} {/* Auto next chapter toggle */}
)}
{/* Spacer so content isn't hidden behind the player bar */}
{/* TTS highlight styles */} ) }