'use client' import { useEffect, useRef, useState, useCallback } from 'react' import { useUserSettings } from '@/hooks/useUserSettings' export interface DoomScrollItem { url: string name: string mediaType: 'video' | 'image' itemKey?: string } interface Props { items: DoomScrollItem[] videoContext?: 'mixed' | 'movies' | 'tv' onClose: () => void onViewInLibrary?: (item: DoomScrollItem) => void } const HISTORY_CAP = 100 function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem { const excludeCount = Math.min(excludeRecent.length, items.length - 1) const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url)) const candidates = items.filter((i) => !recentUrls.has(i.url)) const pool = candidates.length > 0 ? candidates : items return pool[Math.floor(Math.random() * pool.length)] } export default function DoomScrollView({ items, videoContext = 'mixed', onClose, onViewInLibrary }: Props) { const settings = useUserSettings() const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted const [history, setHistory] = useState(() => { if (items.length === 0) return [] return [pickRandom(items, [])] }) const [historyIndex, setHistoryIndex] = useState(0) const [localMuted, setLocalMuted] = useState(settingsMuted) const [isPaused, setIsPaused] = useState(false) const [autoPlayEnabled, setAutoPlayEnabled] = useState(false) const [autoPlaySeconds, setAutoPlaySeconds] = useState(5) // Tools overlay visibility const [showToolsOverlay, setShowToolsOverlay] = useState(false) // Rating state const [userRating, setUserRatingState] = useState(null) const [ratingHover, setRatingHover] = useState(null) const [savingRating, setSavingRating] = useState(false) // Text overlay state const [extractedText, setExtractedText] = useState(null) const [editedExtractedText, setEditedExtractedText] = useState('') const [savingText, setSavingText] = useState(false) const [translatedText, setTranslatedText] = useState(null) const [showTextOverlay, setShowTextOverlay] = useState(false) const [showOriginal, setShowOriginal] = useState(false) const [extracting, setExtracting] = useState(false) const [extractError, setExtractError] = useState(null) const [extractPending, setExtractPending] = useState(false) const [retranslating, setRetranslating] = useState(false) const [translatePending, setTranslatePending] = useState(false) const [ocrLanguageInput, setOcrLanguageInput] = useState('') const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng') const [sourceLanguage, setSourceLanguage] = useState('') const videoRef = useRef(null) const extractPollRef = useRef | null>(null) const cooldownRef = useRef(false) const touchStartY = useRef(null) const current = history[historyIndex] ?? null const isVideo = current?.mediaType === 'video' const backCount = history.length - 1 - historyIndex // Derived: what text to display in the overlay const displayText = (translatedText && !showOriginal) ? translatedText : extractedText const goNext = useCallback(() => { if (items.length === 0) return setHistoryIndex((idx) => { if (idx < history.length - 1) { return idx + 1 } const next = pickRandom(items, history) setHistory((h) => { const updated = [...h, next] return updated.length > HISTORY_CAP ? updated.slice(-HISTORY_CAP) : updated }) return Math.min(idx + 1, HISTORY_CAP - 1) }) }, [items, history]) const goPrev = useCallback(() => { setHistoryIndex((idx) => Math.max(0, idx - 1)) }, []) const navigate = useCallback((dir: 'next' | 'prev') => { if (cooldownRef.current) return cooldownRef.current = true if (dir === 'next') goNext() else goPrev() setTimeout(() => { cooldownRef.current = false }, 300) }, [goNext, goPrev]) // On navigation to a new item: reset pause state and start playing. // Merging the reset + play() into one effect prevents the old isPaused=true // value from calling pause() on the freshly-mounted video element before the // reset fires. If autoplay is blocked by browser policy (common when unmuted), // fall back to muted and retry — the user can unmute manually afterward. useEffect(() => { setIsPaused(false) if (!videoRef.current) return videoRef.current.play().catch(() => { if (!videoRef.current) return videoRef.current.muted = true setLocalMuted(true) videoRef.current.play().catch(() => {}) }) }, [current?.url]) // Sync muted imperatively — React's muted prop is not reliable useEffect(() => { if (videoRef.current) videoRef.current.muted = localMuted }, [localMuted, current?.url]) // Sync play/pause imperatively for user-initiated pause/unpause only. // current?.url is intentionally excluded: navigation is handled above. useEffect(() => { if (!videoRef.current) return if (isPaused) { videoRef.current.pause() } else { videoRef.current.play().catch(() => {}) } }, [isPaused]) // Auto-play timer — resets on each new item, pause, enable/disable, or interval change useEffect(() => { if (!autoPlayEnabled || isPaused) return const id = setTimeout(() => goNext(), autoPlaySeconds * 1000) return () => clearTimeout(id) }, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) // Fetch OCR settings once on mount useEffect(() => { fetch('/api/ai-settings/ocr') .then((r) => r.json()) .then((d: { ocrMode: string; ocrLanguages: string }) => { setDefaultOcrLanguages(d.ocrLanguages) }) .catch(() => {}) }, []) // Fetch extracted text + rating for current item; clear any in-flight poll on item change useEffect(() => { if (extractPollRef.current) { clearInterval(extractPollRef.current) extractPollRef.current = null } setExtractedText(null) setEditedExtractedText('') setTranslatedText(null) setShowTextOverlay(false) setShowOriginal(false) setExtracting(false) setExtractError(null) setExtractPending(false) setRetranslating(false) setTranslatePending(false) setUserRatingState(null) setRatingHover(null) if (!current?.itemKey) return const key = current.itemKey fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`) .then((r) => r.json()) .then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { setExtractedText(data.extractedText) setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) }) .catch(() => {}) fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`) .then((r) => r.json()) .then((data: { userRating: number | null }) => { setUserRatingState(data.userRating) }) .catch(() => {}) }, [current?.itemKey]) // Clean up poll on unmount useEffect(() => { return () => { if (extractPollRef.current) clearInterval(extractPollRef.current) } }, []) useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); return } if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') } if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') } if (e.key === 't' || e.key === 'T') { if (extractedText) setShowTextOverlay((v) => !v) } } const handleWheel = (e: WheelEvent) => { e.preventDefault() navigate(e.deltaY > 0 ? 'next' : 'prev') } const handleTouchStart = (e: TouchEvent) => { touchStartY.current = e.touches[0].clientY } const handleTouchEnd = (e: TouchEvent) => { if (touchStartY.current === null) return const delta = touchStartY.current - e.changedTouches[0].clientY if (Math.abs(delta) > 50) navigate(delta > 0 ? 'next' : 'prev') touchStartY.current = null } document.addEventListener('keydown', handleKey) document.addEventListener('wheel', handleWheel, { passive: false }) document.addEventListener('touchstart', handleTouchStart, { passive: true }) document.addEventListener('touchend', handleTouchEnd, { passive: true }) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.removeEventListener('wheel', handleWheel) document.removeEventListener('touchstart', handleTouchStart) document.removeEventListener('touchend', handleTouchEnd) document.body.style.overflow = '' } }, [navigate, onClose, extractedText]) // ── Polling helper ────────────────────────────────────────────────────────── const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => { if (!current?.itemKey) return const itemKey = current.itemKey if (extractPollRef.current) clearInterval(extractPollRef.current) const deadline = Date.now() + 5 * 60 * 1000 extractPollRef.current = setInterval(async () => { if (Date.now() > deadline) { clearInterval(extractPollRef.current!) extractPollRef.current = null setExtractPending(false) setTranslatePending(false) return } try { const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() const textChanged = data.extractedText !== snapshotText const translationChanged = data.extractedTextTranslated !== snapshotTranslated if (textChanged || translationChanged) { clearInterval(extractPollRef.current!) extractPollRef.current = null setExtractedText(data.extractedText) setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) setExtractPending(false) setTranslatePending(false) if (data.extractedText) setShowTextOverlay(true) } } catch { /* ignore */ } }, 2000) }, [current?.itemKey]) // ── Rating actions ─────────────────────────────────────────────────────────── const handleSetRating = useCallback(async (star: number) => { if (!current?.itemKey) return const next = userRating === star ? null : star setSavingRating(true) try { const res = await fetch('/api/ratings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemKey: current.itemKey, userRating: next }), }) if (res.ok) setUserRatingState(next) } finally { setSavingRating(false) } }, [current?.itemKey, userRating]) // ── Text extraction ────────────────────────────────────────────────────────── const callExtract = useCallback(async (modeOverride: string) => { if (!current?.itemKey) return const itemKey = current.itemKey setExtracting(true) setExtractError(null) setExtractPending(false) try { const res = await fetch('/api/ai-tagging/extract-text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemKey, ocrMode: modeOverride, ...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }), }), }) if (res.status === 202) { setExtractPending(true) startPolling(extractedText, translatedText) return } if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error((data as { error?: string }).error ?? 'Extraction failed') } const result = await res.json() const newText: string | null = result.extractedText || null const newTranslated: string | null = result.translatedText || null setExtractedText(newText) setEditedExtractedText(newText ?? '') setTranslatedText(newTranslated) if (newText) setShowTextOverlay(true) } catch (err) { setExtractError(err instanceof Error ? err.message : 'Extraction failed') setTimeout(() => setExtractError(null), 4000) } finally { setExtracting(false) } }, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling]) // ── Save edited extracted text ─────────────────────────────────────────────── const handleSaveExtractedText = useCallback(async () => { if (!current?.itemKey) return setSavingText(true) try { await fetch('/api/ai-tagging/fields', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }), }) setExtractedText(editedExtractedText) } finally { setSavingText(false) } }, [current?.itemKey, editedExtractedText]) // ── Translation ────────────────────────────────────────────────────────────── const handleTranslate = useCallback(async () => { if (!current?.itemKey) return setRetranslating(true) setTranslatePending(false) try { const res = await fetch('/api/ai-tagging/translate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemKey: current.itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }), }), }) if (res.status === 202) { setTranslatePending(true) startPolling(extractedText, translatedText) return } if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error((data as { error?: string }).error ?? 'Translation failed') } const result = await res.json() setTranslatedText(result.translatedText || null) } catch { // ignore } finally { setRetranslating(false) } }, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling]) return (
{/* Keyframe for auto-play progress bar */} {/* Top bar */}
{backCount > 0 ? `← ${backCount}` : 'Doom Scroll'} {/* Auto-play controls */}
{autoPlayEnabled && (
{autoPlaySeconds}s
)}
{/* Media */}
{isVideo && current ? (
{/* Tools overlay — anchored lower-left, above the bottom bar */} {showToolsOverlay && current?.itemKey && (
e.stopPropagation()} > {/* ── Rating ──────────────────────────────────────────── */}

Rating

setRatingHover(null)}> {[1, 2, 3, 4, 5].map((star) => { const filled = (ratingHover ?? userRating ?? 0) >= star return ( ) })}
{/* ── Text Extraction (images only) ───────────────────── */} {current.mediaType === 'image' && (

Text Extraction

setOcrLanguageInput(e.target.value)} placeholder={defaultOcrLanguages} className="text-xs px-2 py-0.5 rounded-full outline-none" style={{ backgroundColor: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.85)', width: 120, }} title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default." />
{extractError && (

{extractError}

)} {/* Extracted text editor */} {extractedText !== null && (

Extracted Text