- 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>
516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
import type { DirectoryListing, FileEntry } from '@/types'
|
|
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
|
|
initialPath: string
|
|
}
|
|
|
|
type ModalState =
|
|
| { 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
|
|
|
|
export default function MixedView({ libraryId, initialPath }: Props) {
|
|
const [currentPath, setCurrentPath] = useState(initialPath)
|
|
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [modal, setModal] = useState<ModalState>(null)
|
|
const [tagPanel, setTagPanel] = useState<TagPanelState>(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 [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) => {
|
|
const next = new Set(prev)
|
|
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
|
return next
|
|
})
|
|
|
|
const loadPath = useCallback(
|
|
(path: string) => {
|
|
setLoading(true)
|
|
setError(null)
|
|
fetch(
|
|
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(path)}`
|
|
)
|
|
.then((r) => r.json())
|
|
.then((data: DirectoryListing) => {
|
|
setListing(data)
|
|
setCurrentPath(path)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to load directory')
|
|
setLoading(false)
|
|
})
|
|
},
|
|
[libraryId]
|
|
)
|
|
|
|
useEffect(() => {
|
|
loadPath(initialPath)
|
|
}, [loadPath, initialPath])
|
|
|
|
const fetchAssignments = useCallback(() => {
|
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
|
.then((r) => r.json())
|
|
.then(setAssignments)
|
|
.catch(() => {})
|
|
}, [libraryId])
|
|
|
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
|
|
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
|
|
|
const fetchRecursive = useCallback(() => {
|
|
if (recursiveLoaded || recursiveLoading) return
|
|
setRecursiveLoading(true)
|
|
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`)
|
|
.then((r) => r.json())
|
|
.then((data: DirectoryListing) => {
|
|
setRecursiveEntries(data.entries)
|
|
setRecursiveLoaded(true)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setRecursiveLoading(false))
|
|
}, [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
|
|
if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}`
|
|
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
|
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
|
|
loadPath(newPath)
|
|
return
|
|
}
|
|
if (!entry.url) return
|
|
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')
|
|
}
|
|
}
|
|
|
|
const handleTagEntry = (entry: FileEntry) => {
|
|
setTagPanel({ entry, mediaKey: mediaKeyFor(entry) })
|
|
}
|
|
|
|
const navigateUp = () => {
|
|
const parts = currentPath.split('/').filter(Boolean)
|
|
parts.pop()
|
|
loadPath(parts.join('/'))
|
|
}
|
|
|
|
// Build breadcrumb segments
|
|
const breadcrumbs = currentPath
|
|
? currentPath.split('/').filter(Boolean)
|
|
: []
|
|
|
|
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
|
|
}
|
|
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)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
style={{
|
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
|
>
|
|
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 && (
|
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
|
<FilterPanel
|
|
libraryId={libraryId}
|
|
assignments={assignments}
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
selectedTagIds={selectedTagIds}
|
|
onTagToggle={toggleTag}
|
|
refreshKey={filterRefreshKey}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Breadcrumb */}
|
|
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
|
|
<button
|
|
onClick={() => loadPath('')}
|
|
className="transition-colors"
|
|
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
|
|
>
|
|
Root
|
|
</button>
|
|
{breadcrumbs.map((segment, i) => {
|
|
const isLast = i === breadcrumbs.length - 1
|
|
const pathTo = breadcrumbs.slice(0, i + 1).join('/')
|
|
return (
|
|
<span key={i} className="flex items-center gap-1">
|
|
<span style={{ color: 'var(--border)' }}>/</span>
|
|
<button
|
|
onClick={() => !isLast && loadPath(pathTo)}
|
|
className="transition-colors"
|
|
style={{
|
|
color: isLast ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
cursor: isLast ? 'default' : 'pointer',
|
|
}}
|
|
>
|
|
{segment}
|
|
</button>
|
|
</span>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{(loading || recursiveLoading) && <LoadingSkeleton />}
|
|
{error && (
|
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !recursiveLoading && !error && (filtersActive || listing) && (
|
|
<>
|
|
{filteredEntries.length === 0 ? (
|
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
{filtersActive ? 'No results found.' : 'This folder is empty.'}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{/* Up button — hidden during recursive search */}
|
|
{!filtersActive && breadcrumbs.length > 0 && (
|
|
<button
|
|
onClick={navigateUp}
|
|
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"
|
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', aspectRatio: '1 / 1' }}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
>
|
|
<span className="text-2xl">↑</span>
|
|
<span>Up</span>
|
|
</button>
|
|
)}
|
|
{filteredEntries.map((entry) => (
|
|
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{modal?.type === 'video' && (
|
|
<VideoPlayerModal
|
|
url={modal.url}
|
|
name={modal.name}
|
|
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' && (
|
|
<ImageLightbox
|
|
url={modal.url}
|
|
name={modal.name}
|
|
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}
|
|
/>
|
|
)}
|
|
|
|
{/* Tag panel */}
|
|
{tagPanel && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
|
>
|
|
<div
|
|
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
>
|
|
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
Tags
|
|
</p>
|
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
{tagPanel.entry.name}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setTagPanel(null)}
|
|
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<TagSelector
|
|
mediaKey={tagPanel.mediaKey}
|
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void }) {
|
|
type ImgState = 'loading' | 'loaded' | 'error'
|
|
const [imgState, setImgState] = useState<ImgState>(
|
|
entry.thumbnailUrl ? 'loading' : 'error'
|
|
)
|
|
// Reset image state when the entry changes (e.g. navigating to a new folder)
|
|
const prevUrl = useRef(entry.thumbnailUrl)
|
|
if (prevUrl.current !== entry.thumbnailUrl) {
|
|
prevUrl.current = entry.thumbnailUrl
|
|
if (entry.thumbnailUrl) setImgState('loading')
|
|
else setImgState('error')
|
|
}
|
|
|
|
const isDir = entry.type === 'directory'
|
|
const isVideo = entry.mediaType === 'video'
|
|
const showThumbnail = !isDir && imgState !== 'error' && entry.thumbnailUrl
|
|
|
|
// Icon shown when no thumbnail available or while loading
|
|
const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄'
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onOpen(entry)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
|
|
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer"
|
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
|
onMouseEnter={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
|
}}
|
|
>
|
|
{/* Thumbnail image — hidden until loaded */}
|
|
{entry.thumbnailUrl && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={entry.thumbnailUrl}
|
|
alt=""
|
|
aria-hidden
|
|
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
|
|
style={{ opacity: imgState === 'loaded' ? 1 : 0 }}
|
|
onLoad={() => setImgState('loaded')}
|
|
onError={() => setImgState('error')}
|
|
/>
|
|
)}
|
|
|
|
{/* Skeleton pulse shown while image is loading */}
|
|
{imgState === 'loading' && (
|
|
<div
|
|
className="absolute inset-0 animate-pulse"
|
|
style={{ backgroundColor: 'var(--border)' }}
|
|
/>
|
|
)}
|
|
|
|
{/* Icon fallback — shown for dirs, other files, and failed thumbnails */}
|
|
{!showThumbnail && imgState !== 'loading' && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-3xl"
|
|
style={isVideo && imgState === 'error' ? { color: 'var(--accent)' } : undefined}>
|
|
{icon}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom label — always shown */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 px-2 py-1.5"
|
|
style={{
|
|
background: showThumbnail
|
|
? 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 100%)'
|
|
: undefined,
|
|
}}
|
|
>
|
|
<span
|
|
className="block w-full truncate"
|
|
style={{ color: showThumbnail ? '#fff' : 'var(--text-primary)' }}
|
|
title={entry.name}
|
|
>
|
|
{entry.name}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Video play badge — top-right overlay */}
|
|
{isVideo && imgState === 'loaded' && (
|
|
<div
|
|
className="absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
|
>
|
|
▶
|
|
</div>
|
|
)}
|
|
|
|
{/* Tag button — top-left, shown on hover */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onTag(entry) }}
|
|
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
|
aria-label={`Tag ${entry.name}`}
|
|
title="Tags"
|
|
>
|
|
🏷
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LoadingSkeleton() {
|
|
return (
|
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-xl animate-pulse"
|
|
style={{ backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|