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:
@@ -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