Compare commits

...

2 Commits

Author SHA1 Message Date
Garret Patti
6c2443fa2c Filter non-browser-playable formats from Doom Scroll
Formats like .mkv, .avi, .wmv, .ts, .m2ts and .tiff are not natively
supported by browsers and would stall silently in Doom Scroll mode.

Add src/lib/browser-media.ts with BROWSER_VIDEO_EXTENSIONS (.mp4,
.webm, .mov, .m4v), BROWSER_IMAGE_EXTENSIONS (.jpg, .jpeg, .png, .gif,
.webp, .bmp), and an isBrowserPlayable() helper that extracts the
extension without importing Node's path module.

Filter doomScrollItems in MixedView, MoviesView, and TvView using this
helper so only natively renderable files are passed to DoomScrollView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:29:17 -04:00
Garret Patti
5d4d11512d Fix DoomScrollView going blank after 100 items
When history hit the 100-entry cap, setHistory sliced the array back to
indices 0–99 but setHistoryIndex still returned idx + 1 = 100, making
current = history[100] = undefined. Nothing rendered and no API calls
were made until the user went back (decrementing to index 99, which
held the newly-picked item).

Fix: cap the returned historyIndex at HISTORY_CAP - 1 so it always
points to a valid entry in the sliced array. Extract HISTORY_CAP = 100
as a named constant so the slice and the index cap stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:06 -04:00
5 changed files with 30 additions and 6 deletions

View File

@@ -16,6 +16,8 @@ interface Props {
onClose: () => void onClose: () => void
} }
const HISTORY_CAP = 100
function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem { function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem {
const excludeCount = Math.min(excludeRecent.length, items.length - 1) const excludeCount = Math.min(excludeRecent.length, items.length - 1)
const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url)) const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url))
@@ -55,9 +57,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
const next = pickRandom(items, history) const next = pickRandom(items, history)
setHistory((h) => { setHistory((h) => {
const updated = [...h, next] const updated = [...h, next]
return updated.length > 100 ? updated.slice(-100) : updated return updated.length > HISTORY_CAP ? updated.slice(-HISTORY_CAP) : updated
}) })
return idx + 1 return Math.min(idx + 1, HISTORY_CAP - 1)
}) })
}, [items, history]) }, [items, history])

View File

@@ -7,6 +7,7 @@ import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -200,7 +201,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries. // When no filters, doom scroll uses the full recursiveEntries.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries) const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url) .filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' })) .map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
return ( return (

View File

@@ -5,6 +5,7 @@ import type { Movie } from '@/types'
import MovieDetailModal from './MovieDetailModal' import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -74,7 +75,7 @@ export default function MoviesView({ libraryId }: Props) {
const handleDoomScroll = () => { const handleDoomScroll = () => {
// Use filtered movies — respects any active search/tag filters automatically // Use filtered movies — respects any active search/tag filters automatically
const items: DoomScrollItem[] = filtered.map((m) => ({ const items: DoomScrollItem[] = filtered.filter((m) => isBrowserPlayable(m.videoPath)).map((m) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`,
name: m.title, name: m.title,
mediaType: 'video' as const, mediaType: 'video' as const,

View File

@@ -8,6 +8,7 @@ import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -184,7 +185,7 @@ export default function TvView({ libraryId }: Props) {
return seasonEps.flat() return seasonEps.flat()
}) })
) )
items = episodeLists.flat().map((ep) => ({ items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title, name: ep.title,
mediaType: 'video' as const, mediaType: 'video' as const,
@@ -209,7 +210,7 @@ export default function TvView({ libraryId }: Props) {
return seasonEps.flat() return seasonEps.flat()
}) })
) )
items = episodeLists.flat().map((ep) => ({ items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title, name: ep.title,
mediaType: 'video' as const, mediaType: 'video' as const,

19
src/lib/browser-media.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Browser-native media formats safe for use in <video> and <img> elements.
* Kept separate from the broader scanner extension sets (media-utils.ts, files.ts)
* which include server-side-only formats like .mkv, .avi, .tiff, etc.
*/
export const BROWSER_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v'])
export const BROWSER_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
/**
* Returns true if the file at `filename` (or path) has a browser-playable extension.
* Uses lastIndexOf to avoid importing the Node `path` module in client components.
*/
export function isBrowserPlayable(filename: string): boolean {
const dot = filename.lastIndexOf('.')
if (dot === -1) return false
const ext = filename.slice(dot).toLowerCase()
return BROWSER_VIDEO_EXTENSIONS.has(ext) || BROWSER_IMAGE_EXTENSIONS.has(ext)
}