From 4f54a7c888db9c392d0e9ff68d0119d4b192806e Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:34:32 -0400 Subject: [PATCH] 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 --- src/components/DoomScrollView.tsx | 174 +++++++++++++++++++++ src/components/mixed/ImageLightbox.tsx | 52 +++++- src/components/mixed/MixedView.tsx | 126 ++++++++++++--- src/components/mixed/VideoPlayerModal.tsx | 54 ++++++- src/components/movies/MovieDetailModal.tsx | 32 +++- src/components/movies/MoviesView.tsx | 51 +++++- src/components/tv/TvView.tsx | 85 +++++++++- 7 files changed, 535 insertions(+), 39 deletions(-) create mode 100644 src/components/DoomScrollView.tsx diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx new file mode 100644 index 0000000..7392127 --- /dev/null +++ b/src/components/DoomScrollView.tsx @@ -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(() => { + if (items.length === 0) return [] + return [pickRandom(items, [])] + }) + const [historyIndex, setHistoryIndex] = useState(0) + const cooldownRef = useRef(false) + const touchStartY = useRef(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 ( +
+ {/* Top bar */} +
+ + {backCount > 0 ? `← ${backCount} back` : 'Doom Scroll'} + + +
+ + {/* Media */} +
+ {current?.mediaType === 'video' ? ( +
+ + {/* Bottom bar */} +
+ + {current?.name} + +
+ + {/* Prev / Next hint arrows */} + {historyIndex > 0 && ( + + )} + +
+ ) +} diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index a474cc7..cf27d0a 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -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(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 ? (
{/* Image */} -
+
{/* eslint-disable-next-line @next/next/no-img-element */} e.stopPropagation()} /> + {onPrev && ( + + )} + {onNext && ( + + )}
{/* Tag panel */}
) : ( -
+
{/* eslint-disable-next-line @next/next/no-img-element */} e.stopPropagation()} /> + {onPrev && ( + + )} + {onNext && ( + + )}
)}
diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 7b839c9..3d92a37 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -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([]) 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 && ( + setDoomScrollActive(false)} + /> + )} +
+
{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} /> )} diff --git a/src/components/mixed/VideoPlayerModal.tsx b/src/components/mixed/VideoPlayerModal.tsx index 52fa1d3..eb36aa0 100644 --- a/src/components/mixed/VideoPlayerModal.tsx +++ b/src/components/mixed/VideoPlayerModal.tsx @@ -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 ? (
{/* Video */} -
+
{/* Tag panel */}
) : ( -
+
)}
diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index 1e8f481..a45dc2d 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -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(null) const menuRef = useRef(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 ✕ + {/* Prev / Next buttons on the detail card */} + {onPrev && ( + + )} + {onNext && ( + + )} + {/* Hero image */}
{heroUrl ? ( diff --git a/src/components/movies/MoviesView.tsx b/src/components/movies/MoviesView.tsx index 27739eb..ce57d45 100644 --- a/src/components/movies/MoviesView.tsx +++ b/src/components/movies/MoviesView.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [selected, setSelected] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(null) const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) + const [doomScrollActive, setDoomScrollActive] = useState(false) + const [doomScrollItems, setDoomScrollItems] = useState([]) 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 && ( + setDoomScrollActive(false)} + /> + )} +
+
{showFilters && ( @@ -111,10 +148,10 @@ export default function MoviesView({ libraryId }: Props) {
) : (
- {filtered.map((movie) => ( + {filtered.map((movie, idx) => (
)} - {selected && ( + {selected && selectedIndex !== null && ( 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} /> diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index b213ba1..78dad0c 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -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([]) const [selectedSeries, setSelectedSeries] = useState(null) const [selectedSeason, setSelectedSeason] = useState(null) - const [playingEpisode, setPlayingEpisode] = useState(null) + const [playingEpisodeIndex, setPlayingEpisodeIndex] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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([]) + const [doomScrollLoading, setDoomScrollLoading] = useState(false) const menuRef = useRef(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) + ) + ) + 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 ( { 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 (
+ {doomScrollActive && doomScrollItems.length > 0 && ( + setDoomScrollActive(false)} + /> + )} + {/* Breadcrumb */}
{view !== 'series' ? ( @@ -201,6 +262,20 @@ export default function TvView({ libraryId }: Props) { > Filters{filtersActive ? ' ●' : ''} +
{showFilters && ( @@ -427,11 +502,11 @@ export default function TvView({ libraryId }: Props) {
) : (
- {episodes.map((ep) => ( + {episodes.map((ep, idx) => ( setPlayingEpisode(ep)} + onClick={() => setPlayingEpisodeIndex(idx)} onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} /> ))}