add viewer navigation and doom scroll mode
- Add prev/next arrow buttons and ArrowLeft/ArrowRight keyboard shortcuts to ImageLightbox and VideoPlayerModal - Wire prev/next navigation in MixedView (through filtered media entries), TvView (through season episodes), and MoviesView/MovieDetailModal (through filtered movie list) - Add new DoomScrollView component: fullscreen random-media mode with scroll/swipe/keyboard navigation, 100-item back-history, and per-library mute settings - Add Doom Scroll button to mixed, movies, and TV library views - Doom scroll respects active filters: mixed uses filtered entries, movies uses filtered movie list, TV fetches episodes from matching series only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
174
src/components/DoomScrollView.tsx
Normal file
174
src/components/DoomScrollView.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||
|
||||
export interface DoomScrollItem {
|
||||
url: string
|
||||
name: string
|
||||
mediaType: 'video' | 'image'
|
||||
mediaKey?: 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 muted = 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 cooldownRef = useRef(false)
|
||||
const touchStartY = useRef<number | null>(null)
|
||||
|
||||
const current = history[historyIndex] ?? null
|
||||
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
const backCount = history.length - 1 - historyIndex
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||||
{/* Top bar */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between p-3 z-10">
|
||||
<span className="text-xs px-2 py-1 rounded" style={{ color: 'rgba(255,255,255,0.5)', backgroundColor: 'rgba(0,0,0,0.4)' }}>
|
||||
{backCount > 0 ? `← ${backCount} back` : 'Doom Scroll'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm 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">
|
||||
{current?.mediaType === 'video' ? (
|
||||
<video
|
||||
key={current.url}
|
||||
src={current.url}
|
||||
autoPlay
|
||||
loop
|
||||
muted={muted}
|
||||
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 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center p-4 z-10">
|
||||
<span className="text-xs truncate max-w-sm text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||
{current?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Prev / Next hint arrows */}
|
||||
{historyIndex > 0 && (
|
||||
<button
|
||||
onClick={() => navigate('prev')}
|
||||
className="absolute left-1/2 top-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"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('next')}
|
||||
className="absolute left-1/2 bottom-12 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center text-xl transition-opacity hover:opacity-100 opacity-50"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user