821 lines
35 KiB
TypeScript
821 lines
35 KiB
TypeScript
'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<DoomScrollItem[]>(() => {
|
||
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<number | null>(null)
|
||
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
||
const [savingRating, setSavingRating] = useState(false)
|
||
|
||
// Text overlay state
|
||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||
const [savingText, setSavingText] = useState(false)
|
||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||
const [showOriginal, setShowOriginal] = useState(false)
|
||
const [extracting, setExtracting] = useState(false)
|
||
const [extractError, setExtractError] = useState<string | null>(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<HTMLVideoElement>(null)
|
||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
const cooldownRef = useRef(false)
|
||
const touchStartY = useRef<number | null>(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 (
|
||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||
{/* Keyframe for auto-play progress bar */}
|
||
<style>{`@keyframes doom-progress { from { width: 0% } to { width: 100% } }`}</style>
|
||
|
||
{/* Top bar */}
|
||
<div className="absolute top-0 left-0 right-0 flex items-center gap-2 p-3 z-10">
|
||
<span className="text-xs px-2 py-1 rounded flex-shrink-0" style={{ color: 'rgba(255,255,255,0.5)', backgroundColor: 'rgba(0,0,0,0.4)' }}>
|
||
{backCount > 0 ? `← ${backCount}` : 'Doom Scroll'}
|
||
</span>
|
||
|
||
{/* Auto-play controls */}
|
||
<div className="flex-1 flex items-center justify-center gap-2">
|
||
<button
|
||
onClick={() => setAutoPlayEnabled((v) => !v)}
|
||
className="px-3 py-1 rounded-full text-xs font-medium transition-colors flex-shrink-0"
|
||
style={{
|
||
backgroundColor: autoPlayEnabled ? 'var(--accent)' : 'rgba(0,0,0,0.5)',
|
||
color: '#fff',
|
||
}}
|
||
aria-label={autoPlayEnabled ? 'Disable auto-play' : 'Enable auto-play'}
|
||
>
|
||
Auto
|
||
</button>
|
||
{autoPlayEnabled && (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setAutoPlaySeconds((s) => Math.max(1, s - 1))}
|
||
className="w-6 h-6 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label="Decrease interval"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="text-xs text-center flex-shrink-0" style={{ color: 'rgba(255,255,255,0.8)', minWidth: '2.25rem' }}>
|
||
{autoPlaySeconds}s
|
||
</span>
|
||
<button
|
||
onClick={() => setAutoPlaySeconds((s) => Math.min(60, s + 1))}
|
||
className="w-6 h-6 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label="Increase interval"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={onClose}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-opacity hover:opacity-100 opacity-80"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label="Close doom scroll"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Media */}
|
||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||
{isVideo && current ? (
|
||
<video
|
||
ref={videoRef}
|
||
key={current.url}
|
||
src={current.url}
|
||
autoPlay
|
||
loop={!autoPlayEnabled}
|
||
muted={localMuted}
|
||
playsInline
|
||
className="max-w-full max-h-full object-contain cursor-pointer"
|
||
style={{ backgroundColor: '#000' }}
|
||
onClick={() => setIsPaused((v) => !v)}
|
||
/>
|
||
) : current?.mediaType === 'image' ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
key={current.url}
|
||
src={current.url}
|
||
alt={current.name}
|
||
className="max-w-full max-h-full object-contain"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Tools overlay — anchored lower-left, above the bottom bar */}
|
||
{showToolsOverlay && current?.itemKey && (
|
||
<div
|
||
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
|
||
style={{
|
||
backgroundColor: 'rgba(10,10,10,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
width: 'min(320px, calc(100vw - 2rem))',
|
||
maxHeight: 'calc(100vh - 8rem)',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* ── Rating ──────────────────────────────────────────── */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||
Rating
|
||
</p>
|
||
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
||
{[1, 2, 3, 4, 5].map((star) => {
|
||
const filled = (ratingHover ?? userRating ?? 0) >= star
|
||
return (
|
||
<button
|
||
key={star}
|
||
onClick={() => handleSetRating(star)}
|
||
onMouseEnter={() => setRatingHover(star)}
|
||
disabled={savingRating}
|
||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||
style={{
|
||
fontSize: '1.4rem',
|
||
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
|
||
background: 'none',
|
||
border: 'none',
|
||
padding: '0 2px',
|
||
cursor: savingRating ? 'wait' : 'pointer',
|
||
transition: 'color 0.1s',
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
★
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Text Extraction (images only) ───────────────────── */}
|
||
{current.mediaType === 'image' && (
|
||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||
Text Extraction
|
||
</p>
|
||
<button
|
||
onClick={() => callExtract('llm')}
|
||
disabled={extracting || extractPending}
|
||
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
|
||
style={{
|
||
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||
fontSize: '0.95rem',
|
||
}}
|
||
aria-label="Extract with AI"
|
||
title="Extract with AI (skips OCR)"
|
||
>
|
||
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
onClick={() => callExtract('tesseract')}
|
||
disabled={extracting || extractPending}
|
||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
|
||
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
|
||
>
|
||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||
</button>
|
||
<input
|
||
type="text"
|
||
value={ocrLanguageInput}
|
||
onChange={(e) => 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."
|
||
/>
|
||
</div>
|
||
|
||
{extractError && (
|
||
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
|
||
)}
|
||
|
||
{/* Extracted text editor */}
|
||
{extractedText !== null && (
|
||
<div className="flex flex-col gap-1 mt-2">
|
||
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
|
||
<textarea
|
||
value={editedExtractedText}
|
||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||
className="text-xs rounded-lg p-2 w-full resize-y 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.9)',
|
||
minHeight: '3.5rem',
|
||
maxHeight: '8rem',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
{editedExtractedText !== extractedText && (
|
||
<button
|
||
onClick={handleSaveExtractedText}
|
||
disabled={savingText}
|
||
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
>
|
||
{savingText ? '⟳ Saving…' : 'Save'}
|
||
</button>
|
||
)}
|
||
|
||
{/* Translation display */}
|
||
{translatedText && (
|
||
<div className="mt-1">
|
||
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
|
||
<pre
|
||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
|
||
style={{
|
||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||
border: '1px solid rgba(255,255,255,0.15)',
|
||
color: 'rgba(255,255,255,0.9)',
|
||
}}
|
||
>
|
||
{translatedText}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{/* Original / translation toggle */}
|
||
{extractedText && translatedText && (
|
||
<button
|
||
onClick={() => setShowOriginal((v) => !v)}
|
||
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
|
||
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
|
||
>
|
||
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
|
||
</button>
|
||
)}
|
||
|
||
{/* Translate / re-translate */}
|
||
<div className="flex items-center gap-1.5 flex-wrap mt-1">
|
||
<input
|
||
type="text"
|
||
value={sourceLanguage}
|
||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||
placeholder="Source lang…"
|
||
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: 100,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleTranslate}
|
||
disabled={retranslating || translatePending}
|
||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
|
||
style={{
|
||
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||
}}
|
||
>
|
||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Text overlay */}
|
||
{showTextOverlay && displayText && (
|
||
<div
|
||
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{extractedText && translatedText && (
|
||
<div className="flex justify-end mb-2">
|
||
<button
|
||
onClick={() => setShowOriginal((v) => !v)}
|
||
className="text-xs px-2 py-0.5 rounded-full"
|
||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||
>
|
||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||
{displayText}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom bar: [mute + tools] | filename | action buttons */}
|
||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{isVideo && (
|
||
<button
|
||
onClick={() => setLocalMuted((v) => !v)}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
||
>
|
||
{localMuted ? (
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||
<line x1="23" y1="9" x2="17" y2="15"/>
|
||
<line x1="17" y1="9" x2="23" y2="15"/>
|
||
</svg>
|
||
) : (
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
)}
|
||
{current?.itemKey && (
|
||
<button
|
||
onClick={() => setShowToolsOverlay((v) => !v)}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||
style={{
|
||
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||
color: '#fff',
|
||
}}
|
||
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
|
||
title="Rating & text tools"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||
<line x1="12" y1="2" x2="12" y2="5"/>
|
||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||
<line x1="2" y1="12" x2="5" y2="12"/>
|
||
<line x1="19" y1="12" x2="22" y2="12"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||
{current?.name}
|
||
</span>
|
||
<div className="flex-shrink-0 flex items-center gap-1">
|
||
{extractedText ? (
|
||
<button
|
||
onClick={() => setShowTextOverlay((v) => !v)}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||
style={{
|
||
backgroundColor: showTextOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||
color: '#fff',
|
||
}}
|
||
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||
<line x1="3" y1="12" x2="15" y2="12"/>
|
||
<line x1="3" y1="18" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||
<button
|
||
onClick={() => callExtract('tesseract')}
|
||
disabled={extracting || extractPending}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||
style={{
|
||
backgroundColor: extractPending
|
||
? 'var(--accent)'
|
||
: extractError
|
||
? 'rgba(127,29,29,0.8)'
|
||
: 'rgba(0,0,0,0.5)',
|
||
color: extractError ? '#fca5a5' : '#fff',
|
||
}}
|
||
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||
>
|
||
{extracting || extractPending ? (
|
||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||
<polyline points="10 9 9 9 8 9"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
) : null}
|
||
{onViewInLibrary && current?.itemKey && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onViewInLibrary(current) }}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label="View in library"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-play progress bar — key on current URL restarts animation on each new item */}
|
||
{autoPlayEnabled && !isPaused && (
|
||
<div
|
||
key={current?.url}
|
||
className="absolute bottom-0 left-0 h-0.5 z-20"
|
||
style={{
|
||
backgroundColor: 'var(--accent)',
|
||
animationName: 'doom-progress',
|
||
animationDuration: `${autoPlaySeconds}s`,
|
||
animationTimingFunction: 'linear',
|
||
animationFillMode: 'forwards',
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Prev / Next hint arrows */}
|
||
{historyIndex > 0 && (
|
||
<button
|
||
onClick={() => navigate('prev')}
|
||
className="absolute left-1/2 top-16 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center text-xl transition-opacity hover:opacity-100 opacity-50 z-10"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Previous"
|
||
>
|
||
↑
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => navigate('next')}
|
||
className="absolute left-1/2 bottom-14 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center text-xl transition-opacity hover:opacity-100 opacity-50 z-10"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Next"
|
||
>
|
||
↓
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|