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
Garret Patti 6f86750a99 Unify media_key and item_key — use item_key everywhere
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>
2026-04-10 18:04:29 -04:00

302 lines
12 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
}
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>
)
}