media_key was a lossy shortening of item_key (libraryId:lastSegment) that introduced a real collision bug: two TV episodes from different series with the same filename would share the same media_key and each other's tags. - DB migration converts existing media_tags rows from short format to full item_key by joining against media_items; ambiguous/orphaned rows are dropped - media_tags column renamed media_key → item_key - Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes item_key directly to reKeyMediaItem - DB reader functions (tv, movies, games) now expose item_key on returned entities; frontend components use entity.item_key instead of constructing the short libraryId:id form - MixedView now constructs the full mixed_file: item_key format - Tag API renamed mediaKey param → itemKey throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 lines
12 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
|
||
}
|
||
|
||
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 }: 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)
|
||
|
||
const videoRef = useRef<HTMLVideoElement>(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
|
||
|
||
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 > 100 ? updated.slice(-100) : updated
|
||
})
|
||
return idx + 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])
|
||
|
||
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') }
|
||
}
|
||
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])
|
||
|
||
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"
|
||
style={{ backgroundColor: '#000' }}
|
||
/>
|
||
) : 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>
|
||
|
||
{/* Bottom bar: mute | filename | play-pause */}
|
||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||
<div className="w-9 flex-shrink-0">
|
||
{isVideo && (
|
||
<button
|
||
onClick={() => setLocalMuted((v) => !v)}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center text-base transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label={localMuted ? 'Unmute' : 'Mute'}
|
||
>
|
||
{localMuted ? '🔇' : '🔊'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||
{current?.name}
|
||
</span>
|
||
<div className="w-9 flex-shrink-0 flex justify-end">
|
||
{isVideo && (
|
||
<button
|
||
onClick={() => setIsPaused((v) => !v)}
|
||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||
aria-label={isPaused ? 'Play' : 'Pause'}
|
||
>
|
||
{isPaused ? '▶' : '⏸'}
|
||
</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>
|
||
)
|
||
}
|