import type { JellyItem } from './shared'; import { useEffect, useState } from 'react'; import { Link, useLocation, useSearchParams } from 'react-router'; import { Check, Heart, Loader2, Play, X } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { backdropUrl, closeHref, episodeLabel, formatBytes, formatRuntime, ITEM_PARAM, itemHref, posterUrl, progressPercent, ticksToSeconds, } from './shared'; import { useEpisodes, useItemFlags, useJellyfinItem, useSeasons } from './useJellyfinData'; // The detail sheet — an overlay ON the grid, opened by `?item=`. // // It is not a route because the grid behind it should stay mounted and scrolled: closing is dropping one // query parameter, and the back button does it for free. The same component covers a film, a series, a season // and an episode, because Jellyfin's item shape does — what changes is which extra list is worth showing // underneath, and that is decided by `Type`. const META = 'text-xs text-muted-foreground'; const Chip = ({ children }: { children: React.ReactNode }) => ( {children} ); /** The technical line — what is actually in the file, which is what predicts whether it will transcode. */ const StreamSummary = ({ item }: { item: JellyItem }) => { const source = item.MediaSources?.[0]; if (!source) return null; const video = source.MediaStreams?.find((s) => s.Type === 'Video'); const audio = source.MediaStreams?.find((s) => s.Type === 'Audio'); const parts = [ source.Container?.toUpperCase(), video?.Height ? `${video.Height}p` : null, video?.Codec?.toUpperCase(), audio?.Codec?.toUpperCase(), audio?.Channels ? `${audio.Channels}ch` : null, formatBytes(source.Size), ].filter(Boolean); return

{parts.join(' · ')}

; }; const SeriesEpisodes = ({ series }: { series: JellyItem }) => { const { data: seasonData } = useSeasons(series.Id); const seasons = seasonData?.Items ?? []; const [seasonId, setSeasonId] = useState(null); // Default to the first season only once the list arrives; picking one is then the owner's choice and is // not overwritten by a refetch. useEffect(() => { if (!seasonId && seasons.length) setSeasonId(seasons[0]?.Id ?? null); }, [seasonId, seasons]); const { data: episodeData, isLoading } = useEpisodes(series.Id, seasonId); const episodes = episodeData?.Items ?? []; if (!seasons.length) return null; return (
{seasons.map((season) => ( ))}
{isLoading ? ( ) : (
    {episodes.map((episode) => ( ))}
)}
); }; const EpisodeRow = ({ episode }: { episode: JellyItem }) => { const { pathname } = useLocation(); const [params] = useSearchParams(); const progress = progressPercent(episode); return (
  • {episodeLabel(episode)}

    {episode.Overview &&

    {episode.Overview}

    }
    {formatRuntime(episode.RunTimeTicks)} {episode.UserData?.Played && }
    {progress > 0 && progress < 100 && ( )}
  • ); }; export const ItemDetail = ({ id }: { id: string }) => { const { token } = useClient(); const { pathname } = useLocation(); const [params] = useSearchParams(); const { data: item, isLoading } = useJellyfinItem(id); const { setPlayed, setFavorite } = useItemFlags(); const close = closeHref(pathname, params, ITEM_PARAM); if (isLoading || !item) { return (
    ); } const backdrop = backdropUrl(item, token); const poster = posterUrl(item, token, 300); const resumeSeconds = ticksToSeconds(item.UserData?.PlaybackPositionTicks); const played = item.UserData?.Played === true; const favorite = item.UserData?.IsFavorite === true; // A series is not playable itself; its Next Up episode is. Sending the series id to the player would ask // Jellyfin to negotiate playback for a folder, which fails in a way that reads as a broken video. const playableId = item.Type === 'Series' ? null : item.Id; const meta = [ item.ProductionYear ? String(item.ProductionYear) : null, formatRuntime(item.RunTimeTicks), item.OfficialRating, item.CommunityRating ? `★ ${item.CommunityRating.toFixed(1)}` : null, ].filter(Boolean); return (
    {backdrop && (
    )}
    {poster && ( )}

    {item.Name}

    {item.Type === 'Episode' && item.SeriesName && (

    {item.SeriesName} · {episodeLabel(item)}

    )} {item.Taglines?.[0] &&

    {item.Taglines[0]}

    }

    {meta.join(' · ')}

    {item.Genres?.slice(0, 5).map((g) => ( {g} ))}
    {playableId && ( )}
    {item.Overview &&

    {item.Overview}

    } {item.Type === 'Series' && } {!!item.People?.length && (

    Cast

    {item.People.filter((person) => person.Type === 'Actor') .slice(0, 12) .map((person) => (

    {person.Name} {person.Role && as {person.Role}}

    ))}
    )}
    ); };