- 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>
230 lines
8.9 KiB
TypeScript
230 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
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
|
|
}
|
|
|
|
export default function MoviesView({ libraryId }: Props) {
|
|
const [movies, setMovies] = useState<Movie[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | 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) => {
|
|
const next = new Set(prev)
|
|
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
|
return next
|
|
})
|
|
|
|
const fetchMovies = useCallback(() => {
|
|
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}`)
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
setMovies(data)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to load movies')
|
|
setLoading(false)
|
|
})
|
|
}, [libraryId])
|
|
|
|
useEffect(() => { fetchMovies() }, [fetchMovies])
|
|
|
|
const fetchAssignments = useCallback(() => {
|
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
|
.then((r) => r.json())
|
|
.then(setAssignments)
|
|
.catch(() => {})
|
|
}, [libraryId])
|
|
|
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
|
|
|
const filtered = movies.filter((movie) => {
|
|
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
|
if (selectedTagIds.size > 0) {
|
|
const movieTags = assignments[`${libraryId}:${movie.id}`] ?? []
|
|
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
|
|
|
const handleDeleted = (movieId: string) => {
|
|
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)}
|
|
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}
|
|
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 && (
|
|
<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">
|
|
{loading ? (
|
|
<LoadingGrid />
|
|
) : error ? (
|
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
{error}
|
|
</div>
|
|
) : movies.length === 0 ? (
|
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
<p className="text-lg mb-1">No movies found</p>
|
|
<p className="text-sm">Each movie should be a folder containing a video file.</p>
|
|
</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, idx) => (
|
|
<button
|
|
key={movie.id}
|
|
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) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
|
}}
|
|
>
|
|
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
|
{movie.posterUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={movie.posterUrl}
|
|
alt={movie.title}
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
|
🎬
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-2">
|
|
<p
|
|
className="text-xs font-medium truncate leading-tight"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
title={movie.title}
|
|
>
|
|
{movie.title}
|
|
</p>
|
|
{movie.year && (
|
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
{movie.year}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selected && selectedIndex !== null && (
|
|
<MovieDetailModal
|
|
movie={selected}
|
|
libraryId={libraryId}
|
|
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}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function LoadingGrid() {
|
|
return (
|
|
<div className="grid gap-4 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 overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
|
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
|
<div className="p-2">
|
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|