import { useMemo, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useNavigate } from 'react-router-dom' import { RotateCw, ListDetails, ArrowLeft, Star, Book, ChevronRight } from '../../lib/icons' import { useTmdbMovie, useTmdbTvShow, useTmdbDiscoverMovies } from '../../hooks/use-tmdb' import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' import { mapTmdbToJf } from '../../lib/tmdb-mapping' import { tmdbMovieGenreId } from '../../lib/tmdb-genres' import { usePreferencesStore } from '../../stores/preferences-store' import { usePersonalData } from '../../stores/personal-data-store' import { useDiary } from '../../stores/diary-store' import type { BaseItemDto } from '../../api/types' interface Props { open: boolean hasEpisodes: boolean /** The just-finished item, used to source same-mood recommendations. */ item?: BaseItemDto | null /** Next item in queue / series, if any. */ nextItem?: BaseItemDto | null onReplay: () => void onEpisodes: () => void onBack: () => void onPlayNext?: () => void } /** * Card shown when a video ends and there's no auto-advance target. * Three direct actions (Replay / Episodes / Back) plus two scrollable * recommendation rows: "More like this" sourced from TMDB * recommendations, and "Different vibe" pulling top-rated picks from * a deliberately opposite genre. */ export default function EndOfVideoCard({ open, hasEpisodes, item, nextItem, onReplay, onEpisodes, onBack, onPlayNext, }: Props) { return ( {open && (

Finished

{item?.Name || "What's next?"}

{item && } {nextItem && onPlayNext && (

Up next

{nextItem.Name}

)}
{hasEpisodes && ( )}
{item && }
)}
) } function RateAndLogRow({ itemId, itemName }: { itemId?: string; itemName: string }) { const [hoverRating, setHoverRating] = useState(0) const personal = usePersonalData(s => itemId ? s.entries[itemId] : undefined) const setRating = usePersonalData(s => s.setRating) const addDiary = useDiary(s => s.add) const current = personal?.rating || 0 const [logged, setLogged] = useState(false) if (!itemId || String(itemId).startsWith('tmdb-')) return null return (
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(star => ( ))}
{hoverRating || current || '-'}
) } function EndOfVideoExtras({ item }: { item: BaseItemDto }) { const showMoreLikeThis = usePreferencesStore(s => s.endVideo.show.moreLikeThis) const showAntiRec = usePreferencesStore(s => s.endVideo.show.antiRec) if (!showMoreLikeThis && !showAntiRec) return null return (
{showMoreLikeThis && } {showAntiRec && }
) } /* ────────────────────────────────────────────────────────────── */ /* Recommendation rows */ /* ────────────────────────────────────────────────────────────── */ /** * "More like this" - pulls TMDB recommendations on the just-watched * item. For episodes, sources from the parent series since per-episode * recommendations don't exist. */ function MoreLikeThis({ item }: { item: BaseItemDto }) { // Episodes: fall back to series TMDB id when present (passed via item.SeriesId // would require an extra fetch; the player already has it in the parent's // ProviderIds when we navigate from a series detail page, but for safety // we use the item's own ProviderIds or null). const tmdbId = item.ProviderIds?.Tmdb ? Number(item.ProviderIds.Tmdb) : null const isSeries = item.Type === 'Series' || item.Type === 'Episode' const movieFull = useTmdbMovie(!isSeries ? tmdbId : null) const tvFull = useTmdbTvShow(isSeries ? tmdbId : null) const libraryByTmdbId = useLibraryByTmdbId() const items = useMemo(() => { const recs = isSeries ? tvFull.data?.recommendations?.results : movieFull.data?.recommendations?.results if (!recs) return [] return mapTmdbToJf(recs.slice(0, 12), libraryByTmdbId.data) }, [movieFull.data, tvFull.data, isSeries, libraryByTmdbId.data]) if (!tmdbId || items.length === 0) return null return } const VIBE_FLIP: Record = { Horror: 35, // → Comedy Thriller: 35, Crime: 35, Drama: 12, // → Adventure Documentary: 28, // → Action Action: 18, // → Drama Adventure: 18, Comedy: 27, // → Horror Family: 53, // → Thriller Animation: 80, // → Crime Romance: 878, // → Sci-Fi 'Science Fiction': 10749, // → Romance } /** * "Different vibe" - top-rated picks from the genre roughly opposite * the just-watched item. Useful when the user wants a palate cleanser * after a heavy watch. */ function DifferentVibe({ item }: { item: BaseItemDto }) { const oppositeGenreId = useMemo(() => { const genres = item.Genres || [] for (const g of genres) { if (VIBE_FLIP[g] != null) return VIBE_FLIP[g] } // Fall back: if the source genre is in our movie-genre map, swap to // the most distant entry (Documentary) so we always emit something. return tmdbMovieGenreId('Documentary') }, [item.Genres]) const discover = useTmdbDiscoverMovies( oppositeGenreId ? { with_genres: String(oppositeGenreId), 'vote_count.gte': '500', sort_by: 'vote_average.desc', } : ({} as Record), ) const libraryByTmdbId = useLibraryByTmdbId() const items = useMemo(() => { const raw = (discover.data?.results || []).slice(0, 10).map(m => ({ ...m, media_type: 'movie' })) return mapTmdbToJf(raw, libraryByTmdbId.data) }, [discover.data, libraryByTmdbId.data]) if (items.length === 0) return null return } function RowStrip({ title, items }: { title: string; items: BaseItemDto[] }) { const navigate = useNavigate() return (

{title}

{items.map(it => { const tmdbPoster = it._tmdbPoster const inLibrary = it._inLibrary === true const poster = tmdbPoster || (it.ImageTags?.Primary && it.Id ? null // Local items go through real Jellyfin URL on click; we skip the thumb here : null) return ( ) })}
) }