This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/components/DoomScrollView.tsx
2026-04-21 11:17:43 -04:00

821 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 &amp; 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>
)
}