Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 187 additions & 217 deletions CHANGELOG.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion inertia/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ createInertiaApp({
title: (title) => `${title} - ${appName}`,

resolve: (name) => {
return resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/!(*.test|*.spec).tsx'))
return resolvePageComponent(
`./pages/${name}.tsx`,
import.meta.glob('./pages/**/!(*.test|*.spec).tsx')
)
},

setup({ el, App, props }) {
Expand Down
7 changes: 6 additions & 1 deletion inertia/components/library/delete-media-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ describe('DeleteMediaDialog', () => {
const user = userEvent.setup()
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(
<DeleteMediaDialog {...defaultProps} mode="deleteFile" hasFile={true} onConfirm={onConfirm} />
<DeleteMediaDialog
{...defaultProps}
mode="deleteFile"
hasFile={true}
onConfirm={onConfirm}
/>
)

await user.click(screen.getByRole('button', { name: 'Delete File' }))
Expand Down
25 changes: 16 additions & 9 deletions inertia/components/library/download-progress-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,28 @@ function formatEta(seconds: number | null): string {

function DownloadItem({ download }: { download: ActiveDownloadInfo }) {
const isImporting = download.status === 'importing'
const downloaded = download.size && download.remaining !== null
? download.size - download.remaining
: null
const downloaded =
download.size && download.remaining !== null ? download.size - download.remaining : null

return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<HugeiconsIcon
icon={Download01Icon}
className={cn('h-4 w-4 flex-shrink-0', isImporting ? 'text-purple-500' : 'text-blue-500')}
className={cn(
'h-4 w-4 flex-shrink-0',
isImporting ? 'text-purple-500' : 'text-blue-500'
)}
/>
<span className="text-sm font-medium truncate">{download.title}</span>
</div>
<span className={cn('text-sm font-medium flex-shrink-0', isImporting ? 'text-purple-500' : 'text-blue-500')}>
<span
className={cn(
'text-sm font-medium flex-shrink-0',
isImporting ? 'text-purple-500' : 'text-blue-500'
)}
>
{isImporting ? 'Importing' : `${Math.round(download.progress)}%`}
</span>
</div>
Expand All @@ -55,7 +62,9 @@ function DownloadItem({ download }: { download: ActiveDownloadInfo }) {
{downloaded !== null && download.size && (
<div className="flex items-center gap-1">
<HugeiconsIcon icon={HardDriveIcon} className="h-3 w-3" />
<span>{formatFileSize(downloaded)} / {formatFileSize(download.size)}</span>
<span>
{formatFileSize(downloaded)} / {formatFileSize(download.size)}
</span>
</div>
)}
{!isImporting && download.eta !== null && download.eta > 0 && (
Expand All @@ -64,9 +73,7 @@ function DownloadItem({ download }: { download: ActiveDownloadInfo }) {
<span>{formatEta(download.eta)}</span>
</div>
)}
{download.downloadClient && (
<span className="ml-auto">{download.downloadClient}</span>
)}
{download.downloadClient && <span className="ml-auto">{download.downloadClient}</span>}
</div>
</div>
)
Expand Down
10 changes: 9 additions & 1 deletion inertia/components/library/media-image.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,15 @@ export const SmallIconSize: Story = {
name: 'Small Icon Size',
decorators: [
(Story) => (
<div style={{ width: '80px', height: '80px', borderRadius: '8px', overflow: 'hidden', background: 'var(--muted, #f3f4f6)' }}>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--muted, #f3f4f6)',
}}
>
<Story />
</div>
),
Expand Down
4 changes: 1 addition & 3 deletions inertia/components/library/media-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ describe('MediaImage', () => {
})

it('applies custom iconClassName to fallback', () => {
render(
<MediaImage src={null} alt="No poster" mediaType="books" iconClassName="h-24 w-24" />
)
render(<MediaImage src={null} alt="No poster" mediaType="books" iconClassName="h-24 w-24" />)
const icon = screen.getByTestId('fallback-icon')
expect(icon).toHaveClass('h-24', 'w-24')
})
Expand Down
20 changes: 4 additions & 16 deletions inertia/components/library/media-status-badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,14 @@ describe('getMediaItemStatus', () => {
})

it('returns downloaded even with active download', () => {
const result = getMediaItemStatus(
{ hasFile: true },
{ progress: 50, status: 'downloading' }
)
const result = getMediaItemStatus({ hasFile: true }, { progress: 50, status: 'downloading' })
expect(result).toEqual({ status: 'downloaded', progress: 100 })
})
})

describe('downloading status', () => {
it('returns downloading with progress when active download exists', () => {
const result = getMediaItemStatus(
{ hasFile: false },
{ progress: 45, status: 'downloading' }
)
const result = getMediaItemStatus({ hasFile: false }, { progress: 45, status: 'downloading' })
expect(result).toEqual({ status: 'downloading', progress: 45 })
})

Expand All @@ -43,10 +37,7 @@ describe('getMediaItemStatus', () => {

describe('importing status', () => {
it('returns importing when active download has importing status', () => {
const result = getMediaItemStatus(
{ hasFile: false },
{ progress: 100, status: 'importing' }
)
const result = getMediaItemStatus({ hasFile: false }, { progress: 100, status: 'importing' })
expect(result).toEqual({ status: 'importing', progress: 100 })
})

Expand Down Expand Up @@ -113,10 +104,7 @@ describe('getMediaItemStatus', () => {
})

it('importing download takes priority over requested', () => {
const result = getMediaItemStatus(
{ requested: true },
{ progress: 100, status: 'importing' }
)
const result = getMediaItemStatus({ requested: true }, { progress: 100, status: 'importing' })
expect(result.status).toBe('importing')
})
})
Expand Down
46 changes: 38 additions & 8 deletions inertia/components/library/media-teaser.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,16 @@ export const WithStreamingProviders: Story = {
mediaType: 'movie',
status: 'downloaded',
streamingProviders: [
{ id: 8, name: 'Netflix', logoUrl: 'https://image.tmdb.org/t/p/original/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg' },
{ id: 337, name: 'Disney+', logoUrl: 'https://image.tmdb.org/t/p/original/7rwgEs15tFwyR9NPQ5vpzxTj19Q.jpg' },
{
id: 8,
name: 'Netflix',
logoUrl: 'https://image.tmdb.org/t/p/original/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg',
},
{
id: 337,
name: 'Disney+',
logoUrl: 'https://image.tmdb.org/t/p/original/7rwgEs15tFwyR9NPQ5vpzxTj19Q.jpg',
},
],
},
}
Expand All @@ -249,11 +257,31 @@ export const ManyStreamingProviders: Story = {
mediaType: 'movie',
status: 'none',
streamingProviders: [
{ id: 8, name: 'Netflix', logoUrl: 'https://image.tmdb.org/t/p/original/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg' },
{ id: 337, name: 'Disney+', logoUrl: 'https://image.tmdb.org/t/p/original/7rwgEs15tFwyR9NPQ5vpzxTj19Q.jpg' },
{ id: 9, name: 'Prime Video', logoUrl: 'https://image.tmdb.org/t/p/original/emthp39XA2YScoYL1p0sdbAH2WA.jpg' },
{ id: 350, name: 'Apple TV+', logoUrl: 'https://image.tmdb.org/t/p/original/6uhKBfmtzFqOcLousHwZuzcrScK.jpg' },
{ id: 531, name: 'Paramount+', logoUrl: 'https://image.tmdb.org/t/p/original/xbhHHa1YgtpwhC8lb1NQ3ACVcLd.jpg' },
{
id: 8,
name: 'Netflix',
logoUrl: 'https://image.tmdb.org/t/p/original/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg',
},
{
id: 337,
name: 'Disney+',
logoUrl: 'https://image.tmdb.org/t/p/original/7rwgEs15tFwyR9NPQ5vpzxTj19Q.jpg',
},
{
id: 9,
name: 'Prime Video',
logoUrl: 'https://image.tmdb.org/t/p/original/emthp39XA2YScoYL1p0sdbAH2WA.jpg',
},
{
id: 350,
name: 'Apple TV+',
logoUrl: 'https://image.tmdb.org/t/p/original/6uhKBfmtzFqOcLousHwZuzcrScK.jpg',
},
{
id: 531,
name: 'Paramount+',
logoUrl: 'https://image.tmdb.org/t/p/original/xbhHHa1YgtpwhC8lb1NQ3ACVcLd.jpg',
},
],
},
}
Expand Down Expand Up @@ -397,7 +425,9 @@ export const StreamingLoaderBadge: StoryObj = {
render: () => (
<>
<StreamingProviderLoader />
<span style={{ color: '#888', fontSize: 12 }}>← Matrix loading badge at actual size (20×20)</span>
<span style={{ color: '#888', fontSize: 12 }}>
← Matrix loading badge at actual size (20×20)
</span>
</>
),
}
Expand Down
30 changes: 15 additions & 15 deletions inertia/components/library/media-teaser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export function MediaTeaser({
const wasVisibleRef = useRef(false)
const loadingRef = useRef(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [transition, setTransition] = useState<'pending' | 'loading' | 'fading-out' | 'fading-in' | 'idle'>('idle')
const [transition, setTransition] = useState<
'pending' | 'loading' | 'fading-out' | 'fading-in' | 'idle'
>('idle')

useEffect(() => {
if (isLoadingProviders) {
Expand All @@ -114,7 +116,9 @@ export function MediaTeaser({
wasVisibleRef.current = true
setTransition('loading')
}, 400)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}

if (!loadingRef.current) return
Expand All @@ -138,7 +142,10 @@ export function MediaTeaser({
const t2 = setTimeout(() => {
setTransition('idle')
}, 500)
return () => { clearTimeout(t1); clearTimeout(t2) }
return () => {
clearTimeout(t1)
clearTimeout(t2)
}
}

// Loader was never shown (fast fetch) — go straight to fade-in or idle
Expand All @@ -153,14 +160,10 @@ export function MediaTeaser({

const IconComponent = mediaType === 'movie' ? Film01Icon : Tv01Icon

const widthClass =
size === 'lane' ? 'w-[150px]' : size === 'small' ? 'w-32' : ''
const iconSize =
size === 'small' ? 'h-8 w-8' : 'h-12 w-12'
const titleSize =
size === 'small' ? 'text-xs' : 'text-sm'
const yearSize =
size === 'small' ? 'text-[10px]' : 'text-xs'
const widthClass = size === 'lane' ? 'w-[150px]' : size === 'small' ? 'w-32' : ''
const iconSize = size === 'small' ? 'h-8 w-8' : 'h-12 w-12'
const titleSize = size === 'small' ? 'text-xs' : 'text-sm'
const yearSize = size === 'small' ? 'text-[10px]' : 'text-xs'

const maxProviders = 3
const visibleProviders = streamingProviders?.slice(0, maxProviders) ?? []
Expand Down Expand Up @@ -200,10 +203,7 @@ export function MediaTeaser({
{/* Hover gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
{/* Status badge */}
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
<CardStatusBadge
status={status}
size="tiny"
Expand Down
18 changes: 8 additions & 10 deletions inertia/components/library/similar-lane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ vi.mock('@/contexts/media_preview_context', () => ({
}))

vi.mock('@/hooks/use_visible_watch_providers', () => ({
useVisibleWatchProviders: () => ({ providers: {}, loadingIds: new Set(), observerRef: () => () => {} }),
useVisibleWatchProviders: () => ({
providers: {},
loadingIds: new Set(),
observerRef: () => () => {},
}),
}))

vi.mock('@/components/ui/skeleton', () => ({
Expand Down Expand Up @@ -103,9 +107,7 @@ describe('SimilarLane', () => {
})

it('renders nothing when tmdbId is null', () => {
const { container } = render(
<SimilarLane mediaType="movies" tmdbId={null} />
)
const { container } = render(<SimilarLane mediaType="movies" tmdbId={null} />)
expect(container.innerHTML).toBe('')
})

Expand All @@ -121,9 +123,7 @@ describe('SimilarLane', () => {
it('renders nothing when fetch returns empty results', async () => {
mockFetchSuccess({ results: [] })

const { container } = render(
<SimilarLane mediaType="movies" tmdbId="123" />
)
const { container } = render(<SimilarLane mediaType="movies" tmdbId="123" />)

await waitFor(() => {
expect(container.querySelector('[data-testid="skeleton"]')).not.toBeInTheDocument()
Expand All @@ -134,9 +134,7 @@ describe('SimilarLane', () => {
it('renders nothing when fetch fails', async () => {
mockFetchFailure()

const { container } = render(
<SimilarLane mediaType="movies" tmdbId="123" />
)
const { container } = render(<SimilarLane mediaType="movies" tmdbId="123" />)

await waitFor(() => {
expect(container.querySelector('[data-testid="skeleton"]')).not.toBeInTheDocument()
Expand Down
6 changes: 5 additions & 1 deletion inertia/components/library/similar-lane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export function SimilarLane({ mediaType, mediaId, tmdbId }: SimilarLaneProps) {
const [loading, setLoading] = useState(true)
const { openMoviePreview, openTvShowPreview } = useMediaPreview()

const { providers: watchProviders, loadingIds: watchProviderLoading, observerRef: watchProviderRef } = useVisibleWatchProviders(mediaType === 'movies' ? 'movie' : 'tv')
const {
providers: watchProviders,
loadingIds: watchProviderLoading,
observerRef: watchProviderRef,
} = useVisibleWatchProviders(mediaType === 'movies' ? 'movie' : 'tv')

useEffect(() => {
if (!tmdbId) {
Expand Down
22 changes: 17 additions & 5 deletions inertia/components/media-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ function extractYouTubeKey(embedUrl: string): string | null {
return match ? match[1] : null
}

export function MediaGallery({ trailerUrl, images, title, className, children }: MediaGalleryProps) {
export function MediaGallery({
trailerUrl,
images,
title,
className,
children,
}: MediaGalleryProps) {
const [playingTrailer, setPlayingTrailer] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const scrollRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -68,17 +74,23 @@ export function MediaGallery({ trailerUrl, images, title, className, children }:
}

const goToSlide = (direction: 'prev' | 'next') => {
const next = direction === 'next'
? (activeIndex + 1) % totalSlides
: (activeIndex - 1 + totalSlides) % totalSlides
const next =
direction === 'next'
? (activeIndex + 1) % totalSlides
: (activeIndex - 1 + totalSlides) % totalSlides
scrollToSlide(next)
}

// Mouse drag handlers
const onMouseDown = (e: MouseEvent) => {
const el = scrollRef.current
if (!el) return
dragState.current = { isDown: true, startX: e.pageX - el.offsetLeft, scrollLeft: el.scrollLeft, dragged: false }
dragState.current = {
isDown: true,
startX: e.pageX - el.offsetLeft,
scrollLeft: el.scrollLeft,
dragged: false,
}
el.style.cursor = 'grabbing'
el.style.scrollSnapType = 'none'
}
Expand Down
Loading
Loading