diff --git a/src/components/player/BookmarksPanel.tsx b/src/components/player/BookmarksPanel.tsx new file mode 100644 index 0000000..d165e15 --- /dev/null +++ b/src/components/player/BookmarksPanel.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Plus } from '../../lib/icons' +import { + loadBookmarks, + removeBookmark, + updateBookmark, + type Bookmark, +} from '../../lib/bookmarks' + +interface Props { + open: boolean + onClose: () => void + itemId: string + currentTime: number + onJump: (timeSec: number) => void + onAdd: () => void + /** Bumped when the parent adds a bookmark so the panel re-reads storage. */ + refreshKey: number +} + +function formatSec(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return '0:00' + const h = Math.floor(sec / 3600) + const m = Math.floor((sec % 3600) / 60) + const s = Math.floor(sec % 60) + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + return `${m}:${s.toString().padStart(2, '0')}` +} + +export default function BookmarksPanel({ + open, + onClose, + itemId, + onJump, + onAdd, + refreshKey, +}: Props) { + const [bookmarks, setBookmarks] = useState([]) + const [editingId, setEditingId] = useState(null) + const [draftNote, setDraftNote] = useState('') + + useEffect(() => { + if (!itemId) return + setBookmarks(loadBookmarks(itemId)) + }, [itemId, refreshKey]) + + function commitNote(bm: Bookmark) { + updateBookmark(itemId, bm.id, { note: draftNote.trim() || undefined }) + setBookmarks(loadBookmarks(itemId)) + setEditingId(null) + } + + function deleteBookmark(bm: Bookmark) { + removeBookmark(itemId, bm.id) + setBookmarks(loadBookmarks(itemId)) + } + + return ( + + {open && ( + <> + + +
+

+ Bookmarks +

+
+ + +
+
+ +
+ {bookmarks.length === 0 && ( +
+

No bookmarks yet

+

+ Press B while + watching to mark a moment. +

+
+ )} + {bookmarks.map(bm => ( +
+
+ + +
+ {editingId === bm.id ? ( + setDraftNote(e.target.value)} + onBlur={() => commitNote(bm)} + onKeyDown={e => { + if (e.key === 'Enter') commitNote(bm) + if (e.key === 'Escape') setEditingId(null) + }} + placeholder="Add a note..." + className="w-full bg-void/60 border border-border rounded px-2 py-1 text-[12px] text-text-1 placeholder:text-text-4 focus:outline-none focus:border-accent/50" + /> + ) : ( + + )} +
+ ))} +
+
+ + )} +
+ ) +} diff --git a/src/components/player/ChapterTicks.tsx b/src/components/player/ChapterTicks.tsx new file mode 100644 index 0000000..1a4c953 --- /dev/null +++ b/src/components/player/ChapterTicks.tsx @@ -0,0 +1,35 @@ +import { ticksToSeconds } from '../../lib/format' + +interface Chapter { + StartPositionTicks?: number | null + Name?: string | null +} + +interface Props { + chapters?: Chapter[] | null + duration: number + onJump?: (seconds: number) => void +} + +export default function ChapterTicks({ chapters, duration, onJump }: Props) { + if (!chapters?.length || !duration) return null + + return ( +
+ {chapters.map((c, i) => { + const start = ticksToSeconds(c.StartPositionTicks) + if (start <= 0 || start >= duration) return null + const left = (start / duration) * 100 + return ( +
+ ) +} diff --git a/src/components/player/ChaptersPanel.tsx b/src/components/player/ChaptersPanel.tsx new file mode 100644 index 0000000..2d9bdb0 --- /dev/null +++ b/src/components/player/ChaptersPanel.tsx @@ -0,0 +1,148 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { X } from '../../lib/icons' +import { ticksToSeconds, formatTimecode } from '../../lib/format' + +interface Chapter { + Name?: string | null + StartPositionTicks?: number | null + ImageTag?: string | null +} + +interface Props { + open: boolean + onClose: () => void + chapters: Chapter[] + itemId: string + serverUrl: string + currentTime: number + onJump: (timeSec: number) => void +} + +/** + * Slide-in panel listing chapters with thumbnail + name + timecode. Same + * pattern as EpisodesPanel: 380px from the right, glass chrome, escape / + * backdrop click to close. The current chapter is highlighted with the + * accent rail. + */ +export default function ChaptersPanel({ + open, + onClose, + chapters, + itemId, + serverUrl, + currentTime, + onJump, +}: Props) { + const currentIdx = (() => { + let idx = -1 + for (let i = 0; i < chapters.length; i++) { + const t = ticksToSeconds(chapters[i].StartPositionTicks) + if (t <= currentTime) idx = i + else break + } + return idx + })() + + return ( + + {open && ( + <> + + +
+

+ Chapters +

+ +
+ +
+ {chapters.length === 0 && ( +

+ No chapters in this item. +

+ )} + {chapters.map((c, i) => { + const startSec = ticksToSeconds(c.StartPositionTicks) + const isCurrent = i === currentIdx + const thumb = c.ImageTag + ? `${serverUrl}/Items/${itemId}/Images/Chapter/${i}?tag=${c.ImageTag}&maxWidth=240` + : null + return ( + + ) + })} +
+
+ + )} +
+ ) +} diff --git a/src/components/player/EndOfVideoCard.tsx b/src/components/player/EndOfVideoCard.tsx new file mode 100644 index 0000000..a9332bf --- /dev/null +++ b/src/components/player/EndOfVideoCard.tsx @@ -0,0 +1,322 @@ +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 }) { + if (!itemId || String(itemId).startsWith('tmdb-')) return null + const [hoverRating, setHoverRating] = useState(0) + const personal = usePersonalData(s => s.entries[itemId]) + const setRating = usePersonalData(s => s.setRating) + const addDiary = useDiary(s => s.add) + const current = personal?.rating || 0 + const [logged, setLogged] = useState(false) + + 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 as any)._tmdbPoster as string | undefined + const inLibrary = (it as any)._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 ( + + ) + })} +
+
+ ) +} + diff --git a/src/components/player/EpisodesPanel.tsx b/src/components/player/EpisodesPanel.tsx new file mode 100644 index 0000000..60a3d04 --- /dev/null +++ b/src/components/player/EpisodesPanel.tsx @@ -0,0 +1,313 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useNavigate } from 'react-router-dom' +import { X, Check, Play } from '../../lib/icons' +import { useEpisodes, useItemDetails, useSeasons } from '../../hooks/use-jellyfin' +import { useCinemeta } from '../../hooks/use-external' +import { getBestImage } from '../../api/jellyfin' +import { formatRuntime } from '../../lib/format' +import { buildCinemetaEpisodeMap } from '../../api/cinemeta' +import { useAnimeFiller, classifyEpisode } from '../../lib/anime-filler' +import { EpisodeMetaProvider } from '../../lib/episode-meta-context' +import EpisodeRatingChip from '../detail/EpisodeRatingChip' +import FillerChip from '../detail/FillerChip' +import { usePreferencesStore } from '../../stores/preferences-store' +import Select, { type SelectOption } from '../ui/Select' + +/** + * Netflix-style in-player episode browser. Slides in from the right when the + * user clicks the episodes button in the player chrome. Shows the current + * season's episodes with thumbnails, runtimes, watch progress, and a season + * dropdown at the top. Clicking an episode navigates the player to that + * episode's id. + * + * Mounted at the player's root level so it can sit above the click-capture + * layer and the controls bars without fighting their pointer events. + */ +interface Props { + open: boolean + onClose: () => void + seriesId: string + currentItemId: string + /** Falls back to the season currently in the URL if the current item lives elsewhere. */ + initialSeasonId?: string + serverUrl: string +} + +export default function EpisodesPanel({ + open, + onClose, + seriesId, + currentItemId, + initialSeasonId, + serverUrl, +}: Props) { + const navigate = useNavigate() + const panelRef = useRef(null) + const { data: seasons = [] } = useSeasons(seriesId) + const [seasonId, setSeasonId] = useState(initialSeasonId) + + // Pick a sensible default season the first time data lands. + useEffect(() => { + if (seasonId) return + if (initialSeasonId) { + setSeasonId(initialSeasonId) + return + } + if (seasons.length > 0) setSeasonId(seasons[0].Id || undefined) + }, [seasons, initialSeasonId, seasonId]) + + const { data: episodes = [] } = useEpisodes(seriesId, seasonId) + // Pull series IMDB / TMDB ids so we can attach rating + filler chips to + // the episode rows the same way the detail page does. + const { data: seriesItem } = useItemDetails(seriesId) + const seriesImdbId = seriesItem?.ProviderIds?.Imdb || null + const seriesTmdbId = seriesItem?.ProviderIds?.Tmdb ? Number(seriesItem.ProviderIds.Tmdb) : null + const { data: cinemetaSeriesData } = useCinemeta(seriesImdbId, 'series') + const cinemetaMap = useMemo( + () => buildCinemetaEpisodeMap(cinemetaSeriesData), + [cinemetaSeriesData], + ) + const fillerData = useAnimeFiller(seriesTmdbId) + const seasonStarts = useMemo(() => { + const m = new Map() + if (!seasons) return m + let running = 0 + const ordered = [...seasons] + .filter(s => (s.IndexNumber ?? 0) > 0) + .sort((a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)) + for (const s of ordered) { + const idx = s.IndexNumber ?? 0 + m.set(idx, running) + running += s.ChildCount ?? 0 + } + return m + }, [seasons]) + const fillerOf = useMemo(() => { + return (season: number | null | undefined, episode: number | null | undefined) => { + if (!fillerData || season == null || episode == null) return null + const start = seasonStarts.get(season) + if (start == null) return null + return classifyEpisode(start + episode, fillerData) + } + }, [fillerData, seasonStarts]) + const spoilerBlur = usePreferencesStore(s => s.episode.show.spoilerBlur) + + // Specials always last to match the rest of the app. + const orderedEpisodes = useMemo(() => { + return [...episodes].sort((a: any, b: any) => { + const aSpecial = a.ParentIndexNumber === 0 + const bSpecial = b.ParentIndexNumber === 0 + if (aSpecial && !bSpecial) return 1 + if (!aSpecial && bSpecial) return -1 + return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0) + }) + }, [episodes]) + + // Close on Escape so the user can dismiss without reaching for the mouse. + useEffect(() => { + if (!open) return + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [open, onClose]) + + const seasonOptions: SelectOption[] = seasons + .filter(s => !!s.Id) + .map(s => ({ + value: s.Id as string, + label: s.IndexNumber === 0 ? 'Specials' : s.Name || `Season ${s.IndexNumber ?? '?'}`, + })) + + return ( + + + {open && ( + <> + {/* Backdrop - dims the player area, dismisses on click */} + + + {/* Panel */} + + {/* Header */} +
+
+

+ Episodes +

+ +
+ {seasonOptions.length > 0 && ( + setVolume(Number(e.target.value))} + className="slider w-22 ml-1" + aria-label="Volume" + /> + + + + + + + + )} + + ) +} + +function TransportButton({ + active, + children, + ...props +}: React.ButtonHTMLAttributes & { active?: boolean }) { + return ( + + ) +} + +function Scrubber({ + playedPercent, + onSeek, +}: { + playedPercent: number + onSeek: (pct: number) => void +}) { + function handleClick(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(pct) + } + + return ( +
+
+
+
+
+
+ ) +} + +function ProgressSliver({ + playedPercent, + onSeek, + duration, +}: { + playedPercent: number + onSeek: (time: number) => void + duration: number +}) { + function handleClick(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(pct * duration) + } + return ( +
+
+
+
+
+ ) +} diff --git a/src/components/player/NowPlaying.tsx b/src/components/player/NowPlaying.tsx new file mode 100644 index 0000000..c4d8334 --- /dev/null +++ b/src/components/player/NowPlaying.tsx @@ -0,0 +1,391 @@ +import { useState } from 'react' +import { + Play, + Pause, + SkipBack, + SkipForward, + Shuffle, + Repeat, + Repeat1, + X, + ListMusic, + Heart, + Volume2, + VolumeX, + ChevronDown, +} from '../../lib/icons' +import { motion, AnimatePresence } from 'framer-motion' +import { useMusicStore } from '../../stores/music-store' +import { getBestImage, getStoredServerUrl } from '../../api/jellyfin' + +interface NowPlayingProps { + isOpen: boolean + onClose: () => void +} + +export default function NowPlaying({ isOpen, onClose }: NowPlayingProps) { + const [showQueue, setShowQueue] = useState(false) + const { + currentTrack, + isPlaying, + currentTime, + duration, + shuffle, + repeat, + queue, + queueIndex, + volume, + isMuted, + pause, + resume, + nextTrack, + prevTrack, + toggleShuffle, + cycleRepeat, + seekTo, + toggleMute, + setVolume, + } = useMusicStore() + + const serverUrl = getStoredServerUrl() + + const imageUrl = currentTrack + ? getBestImage(serverUrl, currentTrack, 'primary', 800) + : '' + const bgImageUrl = currentTrack + ? getBestImage(serverUrl, currentTrack, 'primary', 320) + : '' + + const title = currentTrack?.Name || 'Unknown track' + const artist = currentTrack?.AlbumArtist || currentTrack?.Artists?.[0] || '' + const album = currentTrack?.Album || '' + + const RepeatIcon = repeat === 'one' ? Repeat1 : Repeat + const VolIcon = isMuted || volume === 0 ? VolumeX : Volume2 + + function formatTime(s: number): string { + if (!s || !isFinite(s)) return '0:00' + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + return `${m}:${sec.toString().padStart(2, '0')}` + } + + const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0 + + function handleScrubClick(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + seekTo(pct * duration) + } + + return ( + + {isOpen && currentTrack && ( + + {/* ── Cinematic background ────────────────────────────── */} +
+ {bgImageUrl && ( + <> + {/* Heavy blurred album art as ambient backdrop */} + + {/* Color wash */} +
+
+ + )} +
+ + {/* ── Header ────────────────────────────────────────── */} +
+ + + + Now playing + + + +
+ + {/* ── Main content ─────────────────────────────────── */} +
+ {/* Album art */} + + {/* Glow halo */} +
+ {/* Reflection-like shadow */} +
+ +
+ {imageUrl ? ( + {title} + ) : ( +
+ +
+ )} +
+ + + {/* Track info */} + +

+ {title} +

+

+ {artist} + {album && ( + <> + · + {album} + + )} +

+
+ + {/* Scrubber */} + +
+
+
+
+
+
+
+ {formatTime(currentTime)} + -{formatTime(Math.max(0, duration - currentTime))} +
+ + + {/* Transport */} + + + + + + + + + + + + + + + + + + + {/* Secondary controls */} + + + + + + {/* Volume */} +
+ + setVolume(Number(e.target.value))} + className="slider w-24" + aria-label="Volume" + /> +
+ + +
+
+ + {/* ── Queue panel ───────────────────────────────────── */} + + {showQueue && ( + +
+

Up next

+

+ {queue.length} {queue.length === 1 ? 'track' : 'tracks'} +

+
+
+ {queue.map((track, i) => { + const isActive = i === queueIndex + const thumbUrl = getBestImage(serverUrl, track, 'primary', 100) + return ( +
+
+ {thumbUrl ? ( + + ) : ( +
+ +
+ )} + {isActive && isPlaying && } +
+
+

+ {track.Name || 'Unknown'} +

+

+ {track.AlbumArtist || track.Artists?.[0] || 'Unknown artist'} +

+
+ + {track.RunTimeTicks + ? formatTime(Number(track.RunTimeTicks) / 10000000) + : ''} + +
+ ) + })} +
+
+ )} +
+ + )} + + ) +} + +function NPButton({ + active, + children, + ...props +}: React.ButtonHTMLAttributes & { active?: boolean }) { + return ( + + ) +} + +function Equalizer() { + return ( +
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+ ) +} diff --git a/src/components/player/PictureMenu.tsx b/src/components/player/PictureMenu.tsx new file mode 100644 index 0000000..2afd564 --- /dev/null +++ b/src/components/player/PictureMenu.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Palette, RotateCcw } from '../../lib/icons' + +interface Props { + brightness: number + contrast: number + saturation: number + onChange: (key: 'brightness' | 'contrast' | 'saturation', value: number) => void + onReset: () => void +} + +/** + * Picture-tuning menu with three sliders. Values are 0.5..1.5 (50%..150%). + * Applied via CSS `filter` on the underlying