Files
reader/components/tts-player.tsx
T
virtus 75ed8e233b Add moderation APIs and admin UI
Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
2026-03-06 17:30:56 +07:00

417 lines
15 KiB
TypeScript

"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<SpeechSynthesisVoice[]>([])
const [selectedVoiceURI, setSelectedVoiceURI] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const [autoNextChapter, setAutoNextChapter] = useState(true)
const [isSupported, setIsSupported] = useState(false)
const wakeLockRef = useRef<WakeLockSentinel | null>(null)
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(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()
// First try to find any Vietnamese voice ('vi-VN', 'Google Tiếng Việt', etc.)
const viVoices = available.filter(
(v) => v.lang.startsWith("vi") || v.name.toLowerCase().includes("vietnam") || v.name.toLowerCase().includes("tiếng việt")
)
// Filter out overly robotic generic fallbacks if we have good ones
const goodViVoices = viVoices.filter(v => v.name.includes("Google") || v.name.includes("Microsoft") || v.name.includes("Natural"))
const preferredViVoices = goodViVoices.length > 0 ? goodViVoices : viVoices
// If we still have NO vi voices, fallback to ALL voices so the user isn't stuck with an empty list
const allUsable = preferredViVoices.length > 0 ? preferredViVoices : available
setVoices(allUsable)
if (allUsable.length > 0 && !selectedVoiceURI) {
// Automatically default to the first Vietnamese voice if available, otherwise just the first system voice
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) => {
console.error("TTS Playback Error:", e.error, e)
if (e.error !== "canceled" && e.error !== "interrupted") {
setIsPlaying(false)
releaseWakeLock()
if (e.error === "synthesis-failed" || e.error === "network") {
// Toast notification is tricky here without importing sonner, let's just log and stop cleanly without crashing
console.warn("Trình duyệt không hỗ trợ đọc giọng nói này hoặc bị lỗi kết nối.")
}
}
}
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 */}
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-lg">
{/* Progress bar */}
<div className="h-0.5 w-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mx-auto max-w-3xl px-4 py-2">
{/* Compact bar */}
<div className="flex items-center gap-3">
{/* Play controls */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevParagraph} disabled={currentParagraphIndex <= 0}>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="icon"
className="h-9 w-9 rounded-full"
onClick={handlePlay}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextParagraph} disabled={currentParagraphIndex >= paragraphs.length - 1}>
<SkipForward className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleStop}>
<Square className="h-3.5 w-3.5" />
</Button>
</div>
{/* Info */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{chapterTitle}
</span>
<span className="text-[10px] text-muted-foreground">
{currentParagraphIndex + 1}/{paragraphs.length} doan
</span>
</div>
{/* Speed control */}
<div className="hidden items-center gap-1 sm:flex">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => setRate(Math.max(0.5, rate - 0.25))}
disabled={rate <= 0.5}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-12 text-center text-xs font-medium text-muted-foreground">
{rate.toFixed(2)}x
</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => setRate(Math.min(3, rate + 0.25))}
disabled={rate >= 3}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Expand/Collapse */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
</div>
{/* Expanded settings */}
{isExpanded && (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
{/* Speed on mobile */}
<div className="flex items-center gap-3 sm:hidden">
<span className="text-xs text-muted-foreground w-16 shrink-0">Toc do:</span>
<Slider
value={[rate]}
min={0.5}
max={3}
step={0.25}
onValueChange={([v]) => setRate(v)}
className="flex-1"
/>
<span className="w-10 text-right text-xs font-medium">{rate.toFixed(2)}x</span>
</div>
{/* Voice selector */}
{voices.length > 1 && (
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<select
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={selectedVoiceURI}
onChange={(e) => setSelectedVoiceURI(e.target.value)}
>
{voices.map((v) => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>
))}
</select>
</div>
)}
{/* Auto next chapter toggle */}
<label className="flex cursor-pointer items-center gap-3">
<div
role="switch"
aria-checked={autoNextChapter}
tabIndex={0}
className={cn(
"relative h-5 w-9 shrink-0 rounded-full transition-colors",
autoNextChapter ? "bg-primary" : "bg-muted-foreground/30"
)}
onClick={() => setAutoNextChapter(!autoNextChapter)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setAutoNextChapter(!autoNextChapter) }}
>
<span
className={cn(
"absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-background shadow transition-transform",
autoNextChapter && "translate-x-4"
)}
/>
</div>
<span className="text-xs text-muted-foreground">
Tu dong chuyen chuong {currentChapter < maxChapter ? `(chuong ${currentChapter + 1})` : "(da la chuong cuoi)"}
</span>
</label>
</div>
)}
</div>
</div>
{/* Spacer so content isn't hidden behind the player bar */}
<div className={cn("h-16", isExpanded && "h-44")} />
{/* TTS highlight styles */}
<style>{`
.tts-active-paragraph {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
margin-left: -0.75rem;
margin-right: -0.75rem;
transition: background 0.3s ease, color 0.3s ease;
}
`}</style>
</>
)
}