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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user