navigation #9
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>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,13 @@ interface Props {
|
||||
url: string
|
||||
name: string
|
||||
onClose: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
mediaKey?: string
|
||||
onTagsChanged?: () => void
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
|
||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, mediaKey, onTagsChanged }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const [showTags, setShowTags] = useState(
|
||||
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
||||
@@ -20,6 +22,8 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') onPrev?.()
|
||||
if (e.key === 'ArrowRight') onNext?.()
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -27,7 +31,7 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose])
|
||||
}, [onClose, onPrev, onNext])
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
@@ -83,7 +87,7 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
{showTags ? (
|
||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
|
||||
{/* Image */}
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen">
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
@@ -91,6 +95,26 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
className="object-contain rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Tag panel */}
|
||||
<div
|
||||
@@ -105,7 +129,7 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
@@ -113,6 +137,26 @@ export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChan
|
||||
className="max-w-full max-h-full object-contain rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import VideoPlayerModal from './VideoPlayerModal'
|
||||
import ImageLightbox from './ImageLightbox'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
@@ -13,8 +14,8 @@ interface Props {
|
||||
}
|
||||
|
||||
type ModalState =
|
||||
| { type: 'video'; url: string; name: string; mediaKey: string }
|
||||
| { type: 'image'; url: string; name: string; mediaKey: string }
|
||||
| { type: 'video'; url: string; name: string; mediaKey: string; mediaIndex: number }
|
||||
| { type: 'image'; url: string; name: string; mediaKey: string; mediaIndex: number }
|
||||
| null
|
||||
|
||||
type TagPanelState = { entry: FileEntry; mediaKey: string } | null
|
||||
@@ -34,6 +35,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
||||
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
||||
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
@@ -78,9 +81,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||
|
||||
// Fetch the full recursive listing the first time any filter becomes active
|
||||
useEffect(() => {
|
||||
if (!filtersActive || recursiveLoaded || recursiveLoading) return
|
||||
const fetchRecursive = useCallback(() => {
|
||||
if (recursiveLoaded || recursiveLoading) return
|
||||
setRecursiveLoading(true)
|
||||
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`)
|
||||
.then((r) => r.json())
|
||||
@@ -90,7 +92,13 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setRecursiveLoading(false))
|
||||
}, [filtersActive, libraryId, recursiveLoaded, recursiveLoading])
|
||||
}, [libraryId, recursiveLoaded, recursiveLoading])
|
||||
|
||||
// Fetch the full recursive listing the first time any filter becomes active
|
||||
useEffect(() => {
|
||||
if (!filtersActive) return
|
||||
fetchRecursive()
|
||||
}, [filtersActive, fetchRecursive])
|
||||
|
||||
const mediaKeyFor = (entry: FileEntry) => {
|
||||
// In recursive mode entry.name is already the full relative path from the library root
|
||||
@@ -99,6 +107,38 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
return `${libraryId}:${encodeURIComponent(rel)}`
|
||||
}
|
||||
|
||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||
|
||||
const filteredEntries = sourceEntries.filter((entry) => {
|
||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||
const entryTags = assignments[mediaKeyFor(entry)] ?? []
|
||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const mediaEntries = filteredEntries.filter(
|
||||
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
||||
)
|
||||
|
||||
const openMediaEntry = (entry: FileEntry, idx: number) => {
|
||||
if (!entry.url) return
|
||||
const mediaKey = mediaKeyFor(entry)
|
||||
if (entry.mediaType === 'video') {
|
||||
setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx })
|
||||
} else if (entry.mediaType === 'image') {
|
||||
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx })
|
||||
}
|
||||
}
|
||||
|
||||
const navigateModal = (delta: -1 | 1) => {
|
||||
if (!modal) return
|
||||
const newIdx = Math.max(0, Math.min(mediaEntries.length - 1, modal.mediaIndex + delta))
|
||||
if (newIdx === modal.mediaIndex) return
|
||||
openMediaEntry(mediaEntries[newIdx], newIdx)
|
||||
}
|
||||
|
||||
const handleEntry = (entry: FileEntry) => {
|
||||
if (entry.type === 'directory') {
|
||||
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
||||
@@ -106,10 +146,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
return
|
||||
}
|
||||
if (!entry.url) return
|
||||
if (entry.mediaType === 'video') {
|
||||
setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
|
||||
} else if (entry.mediaType === 'image') {
|
||||
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
|
||||
if (entry.mediaType === 'video' || entry.mediaType === 'image') {
|
||||
const idx = mediaEntries.findIndex((e) => e.name === entry.name && e.url === entry.url)
|
||||
openMediaEntry(entry, idx)
|
||||
} else {
|
||||
window.open(entry.url, '_blank')
|
||||
}
|
||||
@@ -130,19 +169,50 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
? currentPath.split('/').filter(Boolean)
|
||||
: []
|
||||
|
||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||
|
||||
const filteredEntries = sourceEntries.filter((entry) => {
|
||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||
const entryTags = assignments[mediaKeyFor(entry)] ?? []
|
||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
||||
const handleDoomScroll = () => {
|
||||
if (filtersActive) {
|
||||
// filteredEntries already reflects the active filters; just ensure recursive data is loaded
|
||||
if (recursiveLoaded) {
|
||||
setDoomScrollActive(true)
|
||||
return
|
||||
}
|
||||
// Recursive fetch was triggered by the filter becoming active; wait for it
|
||||
setDoomScrollLoading(true)
|
||||
fetchRecursive()
|
||||
return
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (recursiveLoaded) {
|
||||
setDoomScrollActive(true)
|
||||
return
|
||||
}
|
||||
setDoomScrollLoading(true)
|
||||
fetchRecursive()
|
||||
}
|
||||
|
||||
// Activate doom scroll once the recursive listing finishes loading (when triggered by button)
|
||||
useEffect(() => {
|
||||
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) {
|
||||
setDoomScrollLoading(false)
|
||||
setDoomScrollActive(true)
|
||||
}
|
||||
}, [doomScrollLoading, recursiveLoading, recursiveLoaded])
|
||||
|
||||
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
||||
// When no filters, doom scroll uses the full recursiveEntries.
|
||||
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
|
||||
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url)
|
||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
|
||||
|
||||
return (
|
||||
<>
|
||||
{doomScrollActive && doomScrollItems.length > 0 && (
|
||||
<DoomScrollView
|
||||
items={doomScrollItems}
|
||||
videoContext="mixed"
|
||||
onClose={() => setDoomScrollActive(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
@@ -156,6 +226,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
>
|
||||
Filters{filtersActive ? ' ●' : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDoomScroll}
|
||||
disabled={doomScrollLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!doomScrollLoading) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
|
||||
>
|
||||
{doomScrollLoading ? 'Loading…' : 'Doom Scroll'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||
{showFilters && (
|
||||
@@ -245,6 +329,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
mediaKey={modal.mediaKey}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onClose={() => setModal(null)}
|
||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||
/>
|
||||
)}
|
||||
{modal?.type === 'image' && (
|
||||
@@ -254,6 +340,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
mediaKey={modal.mediaKey}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onClose={() => setModal(null)}
|
||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ interface Props {
|
||||
url: string
|
||||
name: string
|
||||
onClose: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
mediaKey?: string
|
||||
onTagsChanged?: () => void
|
||||
context?: 'mixed' | 'movies' | 'tv'
|
||||
}
|
||||
|
||||
export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsChanged, context = 'mixed' }: Props) {
|
||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, mediaKey, onTagsChanged, context = 'mixed' }: Props) {
|
||||
const settings = useUserSettings()
|
||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
||||
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||
@@ -26,6 +28,8 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') onPrev?.()
|
||||
if (e.key === 'ArrowRight') onNext?.()
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -33,7 +37,7 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose])
|
||||
}, [onClose, onPrev, onNext])
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
@@ -88,8 +92,9 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
{showTags ? (
|
||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
|
||||
{/* Video */}
|
||||
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full">
|
||||
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
|
||||
<video
|
||||
key={url}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={autoPlay}
|
||||
@@ -99,6 +104,26 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
style={{ backgroundColor: '#000' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Tag panel */}
|
||||
<div
|
||||
@@ -113,8 +138,9 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
||||
<video
|
||||
key={url}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={autoPlay}
|
||||
@@ -124,6 +150,26 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
|
||||
style={{ backgroundColor: '#000' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,13 @@ interface Props {
|
||||
movie: Movie
|
||||
libraryId: string
|
||||
onClose: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
onTagsChanged?: () => void
|
||||
onDeleted: (movieId: string) => void
|
||||
}
|
||||
|
||||
export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) {
|
||||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
@@ -72,6 +74,8 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
|
||||
mediaKey={`${libraryId}:${movie.id}`}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onClose={() => setPlaying(false)}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
context="movies"
|
||||
/>
|
||||
)
|
||||
@@ -102,6 +106,32 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Prev / Next buttons on the detail card */}
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={onPrev}
|
||||
className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||
aria-label="Previous movie"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="absolute top-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)', right: onPrev ? '3rem' : undefined }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||
aria-label="Next movie"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hero image */}
|
||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{heroUrl ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import type { Movie } from '@/types'
|
||||
import MovieDetailModal from './MovieDetailModal'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
@@ -13,12 +14,14 @@ export default function MoviesView({ libraryId }: Props) {
|
||||
const [movies, setMovies] = useState<Movie[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Movie | null>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||
const [showFilters, setShowFilters] = useState(true)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
@@ -60,15 +63,36 @@ export default function MoviesView({ libraryId }: Props) {
|
||||
return true
|
||||
})
|
||||
|
||||
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
||||
|
||||
const handleDeleted = (movieId: string) => {
|
||||
setSelected(null)
|
||||
setSelectedIndex(null)
|
||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||
}
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||
|
||||
const handleDoomScroll = () => {
|
||||
// Use filtered movies — respects any active search/tag filters automatically
|
||||
const items: DoomScrollItem[] = filtered.map((m) => ({
|
||||
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`,
|
||||
name: m.title,
|
||||
mediaType: 'video' as const,
|
||||
}))
|
||||
setDoomScrollItems(items)
|
||||
setDoomScrollActive(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{doomScrollActive && doomScrollItems.length > 0 && (
|
||||
<DoomScrollView
|
||||
items={doomScrollItems}
|
||||
videoContext="movies"
|
||||
onClose={() => setDoomScrollActive(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
@@ -82,6 +106,19 @@ export default function MoviesView({ libraryId }: Props) {
|
||||
>
|
||||
Filters{filtersActive ? ' ●' : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDoomScroll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
|
||||
>
|
||||
Doom Scroll
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||
{showFilters && (
|
||||
@@ -111,10 +148,10 @@ export default function MoviesView({ libraryId }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{filtered.map((movie) => (
|
||||
{filtered.map((movie, idx) => (
|
||||
<button
|
||||
key={movie.id}
|
||||
onClick={() => setSelected(movie)}
|
||||
onClick={() => setSelectedIndex(idx)}
|
||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -159,11 +196,13 @@ export default function MoviesView({ libraryId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
{selected && selectedIndex !== null && (
|
||||
<MovieDetailModal
|
||||
movie={selected}
|
||||
libraryId={libraryId}
|
||||
onClose={() => setSelected(null)}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||
import type { DirectoryListing } from '@/types'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import EpisodeCard from './EpisodeCard'
|
||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
@@ -20,7 +22,7 @@ export default function TvView({ libraryId }: Props) {
|
||||
const [episodes, setEpisodes] = useState<TvEpisode[]>([])
|
||||
const [selectedSeries, setSelectedSeries] = useState<TvSeries | null>(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState<TvSeason | null>(null)
|
||||
const [playingEpisode, setPlayingEpisode] = useState<TvEpisode | null>(null)
|
||||
const [playingEpisodeIndex, setPlayingEpisodeIndex] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -32,6 +34,9 @@ export default function TvView({ libraryId }: Props) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
@@ -126,6 +131,50 @@ export default function TvView({ libraryId }: Props) {
|
||||
.catch(() => setDeleting(false))
|
||||
}
|
||||
|
||||
const handleDoomScroll = async () => {
|
||||
setDoomScrollLoading(true)
|
||||
try {
|
||||
let items: DoomScrollItem[]
|
||||
if (filtersActive && filteredSeries.length < series.length) {
|
||||
// Fetch episodes only from the filtered series
|
||||
const episodeLists = await Promise.all(
|
||||
filteredSeries.map(async (s) => {
|
||||
const seasons: TvSeason[] = await fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`
|
||||
).then((r) => r.json())
|
||||
const seasonEps = await Promise.all(
|
||||
seasons.map((season) =>
|
||||
fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}`
|
||||
).then((r) => r.json() as Promise<TvEpisode[]>)
|
||||
)
|
||||
)
|
||||
return seasonEps.flat()
|
||||
})
|
||||
)
|
||||
items = episodeLists.flat().map((ep) => ({
|
||||
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
|
||||
name: ep.title,
|
||||
mediaType: 'video' as const,
|
||||
}))
|
||||
} else {
|
||||
// No filters — use full recursive browse
|
||||
const data: DirectoryListing = await fetch(
|
||||
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`
|
||||
).then((r) => r.json())
|
||||
items = data.entries
|
||||
.filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url)
|
||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const }))
|
||||
}
|
||||
setDoomScrollItems(items)
|
||||
setDoomScrollActive(true)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDoomScrollLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||
|
||||
const filteredSeries = series.filter((s) => {
|
||||
@@ -137,7 +186,9 @@ export default function TvView({ libraryId }: Props) {
|
||||
return true
|
||||
})
|
||||
|
||||
if (playingEpisode) {
|
||||
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
|
||||
|
||||
if (playingEpisode && playingEpisodeIndex !== null) {
|
||||
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(playingEpisode.videoPath)}`
|
||||
return (
|
||||
<VideoPlayerModal
|
||||
@@ -145,7 +196,9 @@ export default function TvView({ libraryId }: Props) {
|
||||
name={playingEpisode.title}
|
||||
mediaKey={`${libraryId}:${playingEpisode.id}`}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onClose={() => setPlayingEpisode(null)}
|
||||
onClose={() => setPlayingEpisodeIndex(null)}
|
||||
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||
context="tv"
|
||||
/>
|
||||
)
|
||||
@@ -153,6 +206,14 @@ export default function TvView({ libraryId }: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{doomScrollActive && doomScrollItems.length > 0 && (
|
||||
<DoomScrollView
|
||||
items={doomScrollItems}
|
||||
videoContext="tv"
|
||||
onClose={() => setDoomScrollActive(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-6 text-sm flex-wrap">
|
||||
{view !== 'series' ? (
|
||||
@@ -201,6 +262,20 @@ export default function TvView({ libraryId }: Props) {
|
||||
>
|
||||
Filters{filtersActive ? ' ●' : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDoomScroll}
|
||||
disabled={doomScrollLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!doomScrollLoading) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
|
||||
>
|
||||
{doomScrollLoading ? 'Loading…' : 'Doom Scroll'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||
{showFilters && (
|
||||
@@ -427,11 +502,11 @@ export default function TvView({ libraryId }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{episodes.map((ep) => (
|
||||
{episodes.map((ep, idx) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
episode={ep}
|
||||
onClick={() => setPlayingEpisode(ep)}
|
||||
onClick={() => setPlayingEpisodeIndex(idx)}
|
||||
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user