player components

This commit is contained in:
2026-03-29 06:40:45 +03:00
parent 02d65fbeeb
commit 9a4f5a4bf5
25 changed files with 4795 additions and 0 deletions
+163
View File
@@ -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<Bookmark[]>([])
const [editingId, setEditingId] = useState<string | null>(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 (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
role="dialog"
aria-label="Bookmarks"
>
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
Bookmarks
</h2>
<div className="flex items-center gap-1">
<button
onClick={onAdd}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent/15 hover:bg-accent/25 text-accent text-[11.5px] font-semibold transition-colors focus-ring"
>
<Plus size={13} stroke={2.25} />
Mark
</button>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1.5">
{bookmarks.length === 0 && (
<div className="text-center py-12 px-4">
<p className="text-[13px] text-white/75 font-medium mb-1">No bookmarks yet</p>
<p className="text-[11.5px] text-white/45">
Press <kbd className="px-1 py-0.5 rounded bg-white/10 font-mono text-[10.5px]">B</kbd> while
watching to mark a moment.
</p>
</div>
)}
{bookmarks.map(bm => (
<div
key={bm.id}
className="rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors p-3 group"
>
<div className="flex items-baseline gap-3 mb-1.5">
<button
onClick={() => onJump(bm.positionSec)}
className="text-[13px] font-mono font-semibold tabular-nums text-accent hover:text-accent-hover transition-colors focus-ring rounded"
>
{formatSec(bm.positionSec)}
</button>
<button
onClick={() => deleteBookmark(bm)}
className="ml-auto text-[10.5px] text-white/40 hover:text-error transition-colors opacity-0 group-hover:opacity-100"
>
Delete
</button>
</div>
{editingId === bm.id ? (
<input
autoFocus
value={draftNote}
onChange={e => 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"
/>
) : (
<button
onClick={() => {
setEditingId(bm.id)
setDraftNote(bm.note || '')
}}
className="text-left w-full text-[12px] text-white/75 hover:text-white transition-colors"
>
{bm.note || <span className="text-white/35 italic">Add a note...</span>}
</button>
)}
</div>
))}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}
+35
View File
@@ -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 (
<div className="absolute inset-0 pointer-events-none">
{chapters.map((c, i) => {
const start = ticksToSeconds(c.StartPositionTicks)
if (start <= 0 || start >= duration) return null
const left = (start / duration) * 100
return (
<button
key={i}
onClick={() => onJump?.(start)}
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-2 bg-white/45 hover:bg-white hover:scale-y-150 rounded-sm transition-all duration-150 pointer-events-auto"
style={{ left: `${left}%` }}
aria-label={c.Name || `Chapter ${i + 1}`}
/>
)
})}
</div>
)
}
+148
View File
@@ -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 (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
role="dialog"
aria-label="Chapters"
>
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
Chapters
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</header>
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1.5">
{chapters.length === 0 && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
No chapters in this item.
</p>
)}
{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 (
<button
key={i}
onClick={() => {
onClose()
onJump(startSec)
}}
className={`w-full text-left rounded-lg overflow-hidden border transition-colors duration-150 group ${
isCurrent
? 'border-accent/70 bg-accent/8'
: 'border-white/10 hover:border-white/25 hover:bg-white/5 focus-ring'
}`}
>
<div className="flex gap-3 p-2">
<div className="relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-black">
{thumb ? (
<img src={thumb} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-elevated grid place-items-center text-text-4 font-mono text-[11px] tabular-nums">
{String(i + 1).padStart(2, '0')}
</div>
)}
{isCurrent && (
<div className="absolute inset-0 grid place-items-center bg-black/45">
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent">
Now
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 py-0.5">
<p
className={`text-[10.5px] uppercase tracking-[0.14em] font-semibold mb-0.5 ${
isCurrent ? 'text-accent' : 'text-white/55'
}`}
>
{String(i + 1).padStart(2, '0')} · {formatTimecode(c.StartPositionTicks)}
</p>
<p
className={`text-[13px] font-medium leading-tight tracking-tight line-clamp-2 ${
isCurrent ? 'text-white' : 'text-white/90'
}`}
>
{c.Name || `Chapter ${i + 1}`}
</p>
</div>
</div>
</button>
)
})}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}
+322
View File
@@ -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 (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
className="absolute inset-0 z-30 grid place-items-center bg-black/65 backdrop-blur-sm overflow-y-auto"
>
<motion.div
initial={{ y: 20, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 20, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-7 text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] w-[min(960px,92vw)] my-8"
>
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-2">
Finished
</p>
<h2 className="font-display text-2xl font-bold text-white mb-4 tracking-tight">
{item?.Name || "What's next?"}
</h2>
{item && <RateAndLogRow itemId={item.Id} itemName={item.Name || ''} />}
{nextItem && onPlayNext && (
<motion.button
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
onClick={onPlayNext}
className="mb-5 w-full max-w-md mx-auto flex items-center gap-3 p-3 rounded-xl bg-white/8 hover:bg-white/14 border border-white/14 hover:border-white/25 transition text-left focus-ring"
>
<div className="w-10 h-10 rounded-lg bg-elevated grid place-items-center shrink-0">
<ChevronRight size={16} className="text-accent" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55">
Up next
</p>
<p className="text-[13px] text-white font-medium truncate tracking-tight">
{nextItem.Name}
</p>
</div>
</motion.button>
)}
<div className="flex items-center gap-2.5 justify-center flex-wrap mb-2">
<button
onClick={onReplay}
autoFocus
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
>
<RotateCw size={14} stroke={2.25} />
Replay
</button>
{hasEpisodes && (
<button
onClick={onEpisodes}
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out active:scale-[0.97] focus-ring"
>
<ListDetails size={14} />
Episodes
</button>
)}
<button
onClick={onBack}
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out active:scale-[0.97] focus-ring"
>
<ArrowLeft size={14} stroke={2} />
Back
</button>
</div>
{item && <EndOfVideoExtras item={item} />}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
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 (
<div className="flex items-center justify-center gap-3 mb-5">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(star => (
<button
key={star}
type="button"
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => setRating(itemId, star)}
className="w-6 h-6 grid place-items-center focus-ring rounded"
aria-label={`Rate ${star} out of 10`}
>
<Star
size={14}
className={
(hoverRating ? star <= hoverRating : star <= current)
? 'text-accent'
: 'text-white/25'
}
fill={(hoverRating ? star <= hoverRating : star <= current) ? 'currentColor' : 'none'}
stroke={2}
/>
</button>
))}
</div>
<span className="text-[11px] text-white/40 tabular-nums w-6 text-left">
{hoverRating || current || '-'}
</span>
<button
onClick={() => {
if (logged) return
addDiary({ itemId, itemName, watchedAt: new Date().toISOString() })
setLogged(true)
}}
disabled={logged}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-white/8 hover:bg-white/14 border border-white/14 text-[11.5px] text-white/80 hover:text-white transition disabled:opacity-40 disabled:cursor-default focus-ring"
>
<Book size={12} />
{logged ? 'Logged' : 'Add to diary'}
</button>
</div>
)
}
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 (
<div className="mt-6 text-left">
{showMoreLikeThis && <MoreLikeThis item={item} />}
{showAntiRec && <DifferentVibe item={item} />}
</div>
)
}
/* ────────────────────────────────────────────────────────────── */
/* 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 <RowStrip title="More like this" items={items} />
}
const VIBE_FLIP: Record<string, number> = {
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<string, string>),
)
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 <RowStrip title="Want a different vibe?" items={items} />
}
function RowStrip({ title, items }: { title: string; items: BaseItemDto[] }) {
const navigate = useNavigate()
return (
<section className="mb-5 last:mb-0">
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-white/55 mb-2.5 px-1">
{title}
</p>
<div className="flex gap-2 overflow-x-auto hide-scrollbar -mx-1 px-1 pb-1.5">
{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 (
<button
key={it.Id}
onClick={() => it.Id && navigate(`/item/${it.Id}`)}
className="shrink-0 w-[112px] text-left focus-ring rounded-md group"
>
<div className={`relative aspect-[2/3] rounded-md overflow-hidden bg-elevated/60 ring-1 transition ${
inLibrary ? 'ring-accent/60' : 'ring-white/10 group-hover:ring-white/30'
}`}>
{poster && (
<img src={poster} alt="" loading="lazy" className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-300" />
)}
{!poster && (
<div className="absolute inset-0 grid place-items-center text-white/45 text-2xl font-display font-semibold">
{it.Name?.[0]?.toUpperCase() || '?'}
</div>
)}
</div>
<p className="text-[11.5px] text-white/85 font-medium truncate mt-1.5 leading-tight">
{it.Name}
</p>
{it.ProductionYear && (
<p className="text-[10px] text-white/45 tabular-nums">
{it.ProductionYear}
</p>
)}
</button>
)
})}
</div>
</section>
)
}
+313
View File
@@ -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<HTMLElement>(null)
const { data: seasons = [] } = useSeasons(seriesId)
const [seasonId, setSeasonId] = useState<string | undefined>(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<number, number>()
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<string>[] = seasons
.filter(s => !!s.Id)
.map(s => ({
value: s.Id as string,
label: s.IndexNumber === 0 ? 'Specials' : s.Name || `Season ${s.IndexNumber ?? '?'}`,
}))
return (
<EpisodeMetaProvider cinemetaMap={cinemetaMap} fillerOf={fillerOf}>
<AnimatePresence>
{open && (
<>
{/* Backdrop - dims the player area, dismisses on click */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
{/* Panel */}
<motion.aside
ref={panelRef as any}
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
data-episodes-panel
role="dialog"
aria-label="Episodes"
>
{/* Header */}
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
Episodes
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{seasonOptions.length > 0 && (
<Select
value={seasonId || ''}
onChange={v => setSeasonId(v)}
options={seasonOptions}
width="w-full"
ariaLabel="Season"
portalContainer={panelRef.current}
/>
)}
</header>
{/* Episode list */}
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-2">
{orderedEpisodes.length === 0 && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
No episodes found in this season.
</p>
)}
{orderedEpisodes.map(ep => {
const epId = ep.Id || ''
const isCurrent = epId === currentItemId
const watched = ep.UserData?.Played === true
const progressTicks = Number(ep.UserData?.PlaybackPositionTicks ?? 0)
const runtimeTicks = Number(ep.RunTimeTicks ?? 0)
const progressPct =
progressTicks > 0 && runtimeTicks > 0
? Math.min(100, (progressTicks / runtimeTicks) * 100)
: 0
const epNum = ep.IndexNumber
const seasonNum = ep.ParentIndexNumber
const thumb = getBestImage(serverUrl, ep, 'thumb', 360)
return (
<button
key={epId}
onClick={() => {
if (epId && !isCurrent) {
onClose()
navigate(`/play/${epId}`)
}
}}
disabled={isCurrent}
className={`w-full text-left rounded-lg overflow-hidden border transition-colors duration-150 group ${
isCurrent
? 'border-accent/70 bg-accent/8 cursor-default'
: 'border-white/10 hover:border-white/25 hover:bg-white/5 focus-ring'
}`}
>
<div className="flex gap-3 p-2">
<div className="relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-black">
{thumb ? (
<img
src={thumb}
alt=""
className={`w-full h-full object-cover transition-[filter,transform] duration-300 ${
isCurrent ? '' : 'group-hover:scale-[1.04]'
} ${
spoilerBlur && !watched && !isCurrent
? 'blur-[10px] saturate-[0.6] group-hover:blur-0 group-hover:saturate-100'
: ''
}`}
/>
) : (
<div className="w-full h-full bg-elevated" />
)}
{/* Play overlay on hover (skipped for current episode) */}
{!isCurrent && (
<div className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="w-9 h-9 rounded-full bg-white/95 grid place-items-center text-void">
<Play size={14} fill="currentColor" className="translate-x-px" />
</span>
</div>
)}
{watched && !isCurrent && (
<span className="absolute top-1 right-1 w-5 h-5 rounded-full bg-accent text-void grid place-items-center">
<Check size={11} strokeWidth={3} />
</span>
)}
<span className="absolute top-1 left-1 flex gap-1">
<FillerChip season={seasonNum} episode={epNum} />
</span>
{progressPct > 0 && progressPct < 95 && !watched && (
<div className="absolute left-0 right-0 bottom-0 h-1 bg-black/45">
<div
className="h-full bg-accent"
style={{ width: `${progressPct}%` }}
/>
</div>
)}
{isCurrent && (
<div className="absolute inset-0 grid place-items-center bg-black/45">
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent">
Now playing
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 py-0.5">
<p
className={`text-[10.5px] uppercase tracking-[0.14em] font-semibold mb-0.5 ${
isCurrent ? 'text-accent' : 'text-white/55'
}`}
>
{seasonNum != null && epNum != null
? `S${seasonNum} · E${epNum}`
: `Episode ${epNum ?? '?'}`}
{runtimeTicks > 0 && (
<span className="text-white/40 normal-case tracking-normal font-normal ml-1.5">
· {formatRuntime(runtimeTicks)}
</span>
)}
<span className="ml-1.5 inline-flex">
<EpisodeRatingChip season={seasonNum} episode={epNum} />
</span>
</p>
<p
className={`text-[13px] font-medium leading-tight tracking-tight line-clamp-2 ${
isCurrent ? 'text-white' : 'text-white/90'
}`}
>
{ep.Name || `Episode ${epNum ?? ''}`}
</p>
{ep.Overview && (
<p
className={`text-[11.5px] text-white/55 leading-snug mt-1 line-clamp-2 transition-[filter,color] duration-200 ${
spoilerBlur && !watched && !isCurrent
? 'blur-[5px] text-white/35 hover:blur-0 hover:text-white/55'
: ''
}`}
>
{ep.Overview}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
</EpisodeMetaProvider>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from '../../lib/icons'
import { SHORTCUTS, type ShortcutCategory } from '../../lib/player-shortcuts'
import { usePreferencesStore } from '../../stores/preferences-store'
interface Props {
open: boolean
onClose: () => void
}
const CATEGORY_LABELS: Record<ShortcutCategory, string> = {
playback: 'Playback',
audio: 'Audio',
subtitles: 'Subtitles',
tools: 'Tools',
view: 'View',
navigation: 'Navigation',
}
const CATEGORY_ORDER: ShortcutCategory[] = [
'playback',
'audio',
'subtitles',
'view',
'tools',
'navigation',
]
function prettyKey(binding: string): string[] {
return binding.split('+').map(p => {
if (p === ' ') return 'Space'
if (p === 'ArrowLeft') return '←'
if (p === 'ArrowRight') return '→'
if (p === 'ArrowUp') return '↑'
if (p === 'ArrowDown') return '↓'
if (p === 'Escape') return 'Esc'
return p
})
}
export default function KeyboardHints({ open, onClose }: Props) {
const overrides = usePreferencesStore(s => s.keyboardShortcuts)
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [open, onClose])
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="absolute inset-0 z-toast grid place-items-center bg-black/70 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ y: 20, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 20, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="bg-[#0c0a08]/96 backdrop-blur-2xl border border-white/14 rounded-2xl w-[640px] max-w-[88vw] max-h-[80vh] overflow-y-auto content-scroll shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
role="dialog"
aria-label="Keyboard shortcuts"
>
<header className="sticky top-0 px-6 py-4 bg-[#0c0a08]/96 backdrop-blur-2xl border-b border-white/8 flex items-center justify-between z-10">
<h2 className="font-display text-xl font-bold text-white tracking-tight">
Keyboard shortcuts
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</header>
<div className="px-6 py-5 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
{CATEGORY_ORDER.map(cat => {
const items = SHORTCUTS.filter(s => s.category === cat)
if (items.length === 0) return null
return (
<div key={cat}>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3 leading-none">
{CATEGORY_LABELS[cat]}
</p>
<ul className="flex flex-col gap-2">
{items.map(sc => {
const keys = overrides[sc.id]?.length ? overrides[sc.id] : sc.keys
return (
<li key={sc.id} className="flex items-baseline gap-3">
<span className="text-[12.5px] text-text-2 leading-tight flex-1">
{sc.description}
</span>
<span className="flex items-center gap-1 shrink-0">
{prettyKey(keys[0]).map((k, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[22px] h-6 px-1.5 rounded text-[10.5px] font-mono font-medium text-text-1 bg-elevated border border-border tabular-nums"
>
{k}
</kbd>
))}
</span>
</li>
)
})}
</ul>
</div>
)
})}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef, type RefObject } from 'react'
import type { MediaPlayerInstance } from '@vidstack/react'
/**
* Renders ASS / SSA subtitles via libass-wasm (SubtitlesOctopus). The
* library spawns a Web Worker that compiles libass to WASM and renders
* each frame to an offscreen canvas, which it then transfers to the
* onscreen canvas we mount over the video.
*
* Why this exists: Jellyfin can transcode ASS to VTT for browsers that
* don't render ASS natively, but VTT loses the things that matter most
* about ASS - positioning, fonts, karaoke, overlapping cues, styled
* effects. For anime / fansub libraries this is the difference between
* "subtitles work" and "subtitles look right".
*
* The component takes a direct ASS URL (we construct it via
* getSubtitleUrl(..., 'ass') so Jellyfin returns the original) and the
* underlying <video> element. Mounts a fixed canvas overlay sized to
* match the player.
*/
interface Props {
playerRef: RefObject<MediaPlayerInstance | null>
/** URL pointing to a Jellyfin ASS/SSA stream. Pass null to disable. */
subtitleUrl: string | null
}
const WORKER_URL = '/libass/subtitles-octopus-worker.js'
const LEGACY_WORKER_URL = '/libass/subtitles-octopus-worker-legacy.js'
export default function LibAssRenderer({ playerRef, subtitleUrl }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const octopusRef = useRef<any>(null)
useEffect(() => {
let cancelled = false
if (!subtitleUrl) return
function getVideo(): HTMLVideoElement | null {
const player = playerRef.current
const el = (player as any)?.el as HTMLElement | undefined
return (el?.querySelector('video') as HTMLVideoElement | null) || null
}
async function init() {
const video = getVideo()
const canvas = canvasRef.current
if (!video || !canvas) return
try {
// SubtitlesOctopus is exposed as a global by the dist bundle. We
// dynamic-import the npm package so the wasm worker isn't pulled
// into the main bundle for users who never see ASS subs.
const mod: any = await import('libass-wasm')
const SubtitlesOctopus = mod.default || mod.SubtitlesOctopus || (window as any).SubtitlesOctopus
if (!SubtitlesOctopus || cancelled) return
if (octopusRef.current) {
try { octopusRef.current.dispose() } catch { /* noop */ }
octopusRef.current = null
}
octopusRef.current = new SubtitlesOctopus({
video,
canvas,
subUrl: subtitleUrl,
workerUrl: WORKER_URL,
legacyWorkerUrl: LEGACY_WORKER_URL,
// Render at 1.5x for sharper text on high-density displays
targetFps: 24,
})
} catch (err) {
console.warn('[libass] failed to init', err)
}
}
init()
return () => {
cancelled = true
if (octopusRef.current) {
try { octopusRef.current.dispose() } catch { /* noop */ }
octopusRef.current = null
}
}
}, [playerRef, subtitleUrl])
if (!subtitleUrl) return null
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none z-30"
aria-hidden
/>
)
}
+286
View File
@@ -0,0 +1,286 @@
import {
Play,
Pause,
SkipBack,
SkipForward,
Shuffle,
Repeat,
Repeat1,
ListMusic,
ChevronUp,
Volume2,
VolumeX,
Heart,
} from '../../lib/icons'
import { motion, AnimatePresence } from 'framer-motion'
import { useMusicStore } from '../../stores/music-store'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
interface MiniPlayerProps {
onExpand: () => void
}
export default function MiniPlayer({ onExpand }: MiniPlayerProps) {
const {
currentTrack,
isPlaying,
currentTime,
duration,
shuffle,
repeat,
volume,
isMuted,
pause,
resume,
nextTrack,
prevTrack,
toggleShuffle,
cycleRepeat,
toggleMute,
setVolume,
seekTo,
} = useMusicStore()
const serverUrl = getStoredServerUrl()
const imageUrl = currentTrack
? getBestImage(serverUrl, currentTrack, 'primary', 120)
: ''
const title = currentTrack?.Name || 'Unknown track'
const artist = currentTrack?.AlbumArtist || currentTrack?.Artists?.[0] || ''
const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
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 RepeatIcon = repeat === 'one' ? Repeat1 : Repeat
const VolIcon = isMuted || volume === 0 ? VolumeX : Volume2
return (
<AnimatePresence>
{currentTrack && (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="relative shrink-0 z-mini-player"
>
{/* Top progress sliver - clickable */}
<ProgressSliver
playedPercent={playedPercent}
onSeek={seekTo}
duration={duration}
/>
<div className="h-[72px] glass-strong border-t border-border flex items-center px-5 gap-4">
{/* Left: Art + Info */}
<button
onClick={onExpand}
className="group flex items-center gap-3 min-w-0 w-[260px] focus-ring rounded-lg p-1 -m-1"
>
<div className="relative w-12 h-12 shrink-0">
<div className="absolute inset-0 rounded-md overflow-hidden bg-elevated ring-1 ring-border">
{imageUrl ? (
<img src={imageUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full grid place-items-center text-text-3">
<ListMusic size={18} />
</div>
)}
</div>
{/* Hover affordance */}
<div className="absolute inset-0 rounded-md grid place-items-center bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<ChevronUp size={16} className="text-white" />
</div>
</div>
<div className="min-w-0 text-left">
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{title}</p>
<p className="text-[11.5px] text-text-3 truncate">{artist || 'Unknown artist'}</p>
</div>
</button>
{/* Center: Transport */}
<div className="flex-1 flex flex-col items-center max-w-2xl gap-1">
<div className="flex items-center gap-2.5">
<TransportButton
onClick={toggleShuffle}
active={shuffle}
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle size={14} />
</TransportButton>
<TransportButton onClick={prevTrack} aria-label="Previous track">
<SkipBack size={16} fill="currentColor" />
</TransportButton>
{/* Play/Pause */}
<button
onClick={isPlaying ? pause : resume}
className="relative w-9 h-9 rounded-full bg-text-1 text-void grid place-items-center transition-all duration-150 hover:scale-105 active:scale-95 focus-ring"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause size={15} fill="currentColor" />
) : (
<Play size={15} fill="currentColor" className="translate-x-0.5" />
)}
</button>
<TransportButton onClick={nextTrack} aria-label="Next track">
<SkipForward size={16} fill="currentColor" />
</TransportButton>
<TransportButton
onClick={cycleRepeat}
active={repeat !== 'off'}
aria-label={`Repeat: ${repeat}`}
>
<RepeatIcon size={14} />
</TransportButton>
</div>
{/* Inline scrubber */}
<div className="w-full flex items-center gap-2.5 text-[10px] text-text-3 tabular-nums">
<span className="w-9 text-right">{formatTime(currentTime)}</span>
<Scrubber
playedPercent={playedPercent}
onSeek={(pct) => seekTo(pct * duration)}
/>
<span className="w-9">{formatTime(duration)}</span>
</div>
</div>
{/* Right: Volume + Queue */}
<div className="flex items-center gap-1 shrink-0">
<TransportButton
onClick={() => {
if (currentTrack && currentTrack.UserData) {
// local optimistic - UI only
}
}}
aria-label="Favorite"
>
<Heart
size={15}
className={currentTrack?.UserData?.IsFavorite ? 'text-accent' : ''}
fill={currentTrack?.UserData?.IsFavorite ? 'currentColor' : 'none'}
/>
</TransportButton>
{/* Volume control */}
<div className="group/vol flex items-center">
<TransportButton onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
<VolIcon size={15} />
</TransportButton>
<div className="w-0 group-hover/vol:w-24 transition-all duration-300 ease-out overflow-hidden">
<input
type="range"
min={0}
max={1}
step={0.01}
value={isMuted ? 0 : volume}
onChange={e => setVolume(Number(e.target.value))}
className="slider w-22 ml-1"
aria-label="Volume"
/>
</div>
</div>
<button
onClick={onExpand}
className="ml-1 w-9 h-9 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
aria-label="Expand player"
>
<ChevronUp size={17} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
function TransportButton({
active,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
{...props}
className={`w-8 h-8 grid place-items-center rounded-md transition-all duration-150 hover:bg-glass-light focus-ring ${
active ? 'text-accent' : 'text-text-2 hover:text-text-1'
}`}
>
{children}
</button>
)
}
function Scrubber({
playedPercent,
onSeek,
}: {
playedPercent: number
onSeek: (pct: number) => void
}) {
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeek(pct)
}
return (
<div
onClick={handleClick}
className="relative flex-1 h-3 cursor-pointer group/scrub"
>
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-[3px] group-hover/scrub:h-1.5 rounded-full bg-white/10 transition-all duration-150 overflow-hidden">
<div
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
style={{ width: `${playedPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)] opacity-0 group-hover/scrub:opacity-100 transition-opacity duration-150 pointer-events-none"
style={{ left: `${playedPercent}%` }}
/>
</div>
)
}
function ProgressSliver({
playedPercent,
onSeek,
duration,
}: {
playedPercent: number
onSeek: (time: number) => void
duration: number
}) {
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeek(pct * duration)
}
return (
<div
onClick={handleClick}
className="absolute left-0 right-0 -top-px h-0.5 cursor-pointer group/sliver hover:h-1 transition-all duration-150"
>
<div className="w-full h-full bg-white/5">
<div
className="h-full bg-accent transition-all duration-100"
style={{ width: `${playedPercent}%` }}
/>
</div>
</div>
)
}
+391
View File
@@ -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<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
seekTo(pct * duration)
}
return (
<AnimatePresence>
{isOpen && currentTrack && (
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="fixed inset-0 z-fullscreen flex"
>
{/* ── Cinematic background ────────────────────────────── */}
<div className="absolute inset-0 overflow-hidden">
{bgImageUrl && (
<>
{/* Heavy blurred album art as ambient backdrop */}
<motion.div
initial={{ scale: 1.04 }}
animate={{ scale: 1.12 }}
transition={{ duration: 30, ease: 'linear', repeat: Infinity, repeatType: 'reverse' }}
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${bgImageUrl})`,
filter: 'blur(80px) saturate(140%)',
opacity: 0.55,
}}
/>
{/* Color wash */}
<div className="absolute inset-0 bg-void/65" />
<div className="absolute inset-0 bg-gradient-to-b from-void/40 via-transparent to-void/80" />
</>
)}
</div>
{/* ── Header ────────────────────────────────────────── */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-6 pt-5">
<button
onClick={onClose}
className="group flex items-center gap-2 text-white/70 hover:text-white transition-colors px-2 py-1 rounded-md focus-ring"
>
<ChevronDown size={20} strokeWidth={2.25} className="transition-transform duration-200 group-hover:translate-y-0.5" />
<span className="text-[12px] font-medium uppercase tracking-[0.14em]">Hide</span>
</button>
<span className="text-[11px] uppercase tracking-[0.18em] text-white/55 font-semibold">
Now playing
</span>
<button
onClick={onClose}
className="w-9 h-9 rounded-full bg-white/8 hover:bg-white/15 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-all duration-200 focus-ring"
aria-label="Close"
>
<X size={17} />
</button>
</div>
{/* ── Main content ─────────────────────────────────── */}
<div className="relative flex-1 flex flex-col items-center justify-center px-8 pt-14 pb-10">
{/* Album art */}
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
className="relative mb-9"
>
{/* Glow halo */}
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-3xl scale-110" />
{/* Reflection-like shadow */}
<div className="absolute -bottom-6 left-4 right-4 h-12 bg-black/60 blur-2xl rounded-full opacity-70" />
<div className="relative w-[300px] h-[300px] md:w-[340px] md:h-[340px] rounded-2xl overflow-hidden ring-1 ring-white/10 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.8)] bg-elevated">
{imageUrl ? (
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
<ListMusic size={72} className="text-text-3" />
</div>
)}
</div>
</motion.div>
{/* Track info */}
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.3 }}
className="text-center mb-7 max-w-2xl"
>
<h2 className="font-display text-3xl md:text-4xl font-bold text-white tracking-tight mb-2 line-clamp-2">
{title}
</h2>
<p className="text-[15px] text-white/70 truncate">
{artist}
{album && (
<>
<span className="text-white/30 mx-2">·</span>
<span className="text-white/55">{album}</span>
</>
)}
</p>
</motion.div>
{/* Scrubber */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
className="w-full max-w-md mb-6"
>
<div
onClick={handleScrubClick}
className="relative h-5 cursor-pointer group/scrub"
>
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 group-hover/scrub:h-1.5 rounded-full bg-white/15 overflow-hidden transition-all duration-150">
<div
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
style={{ width: `${playedPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-accent shadow-[0_0_12px_rgba(245,182,66,0.6)] pointer-events-none transition-opacity duration-150 opacity-0 group-hover/scrub:opacity-100"
style={{ left: `${playedPercent}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-white/55 mt-1.5 tabular-nums font-medium">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
</motion.div>
{/* Transport */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.5 }}
className="flex items-center gap-5 mb-6"
>
<NPButton onClick={toggleShuffle} active={shuffle} aria-label="Shuffle">
<Shuffle size={18} />
</NPButton>
<NPButton onClick={prevTrack} aria-label="Previous">
<SkipBack size={22} fill="currentColor" />
</NPButton>
<button
onClick={isPlaying ? pause : resume}
className="relative w-16 h-16 rounded-full bg-white text-void grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 shadow-[0_8px_24px_-6px_rgba(0,0,0,0.6)] focus-ring"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
<div className="absolute inset-0 rounded-full bg-white/30 blur-2xl scale-110 opacity-0 hover:opacity-100 transition-opacity" />
{isPlaying ? (
<Pause size={26} fill="currentColor" />
) : (
<Play size={26} fill="currentColor" className="translate-x-0.5" />
)}
</button>
<NPButton onClick={nextTrack} aria-label="Next">
<SkipForward size={22} fill="currentColor" />
</NPButton>
<NPButton onClick={cycleRepeat} active={repeat !== 'off'} aria-label="Repeat">
<RepeatIcon size={18} />
</NPButton>
</motion.div>
{/* Secondary controls */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="flex items-center gap-2"
>
<NPButton aria-label="Favorite">
<Heart
size={16}
className={currentTrack.UserData?.IsFavorite ? 'text-accent' : ''}
fill={currentTrack.UserData?.IsFavorite ? 'currentColor' : 'none'}
/>
</NPButton>
{/* Volume */}
<div className="flex items-center gap-2 px-3 h-9 rounded-full bg-white/8 hover:bg-white/12 transition-colors">
<button onClick={toggleMute} className="text-white/70 hover:text-white transition-colors">
<VolIcon size={14} />
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={isMuted ? 0 : volume}
onChange={e => setVolume(Number(e.target.value))}
className="slider w-24"
aria-label="Volume"
/>
</div>
<button
onClick={() => setShowQueue(!showQueue)}
className={`flex items-center gap-2 h-9 px-3 rounded-full transition-all duration-200 text-[12px] font-medium focus-ring ${
showQueue
? 'bg-accent/15 text-accent border border-accent/25'
: 'bg-white/8 text-white/70 hover:bg-white/12 hover:text-white border border-transparent'
}`}
>
<ListMusic size={14} />
Queue
<span className="text-white/40">·</span>
<span className="tabular-nums">{queue.length}</span>
</button>
</motion.div>
</div>
{/* ── Queue panel ───────────────────────────────────── */}
<AnimatePresence>
{showQueue && (
<motion.div
initial={{ x: 360, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 360, opacity: 0 }}
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[340px] glass-strong border-l border-border overflow-y-auto content-scroll"
>
<div className="sticky top-0 px-6 py-4 bg-glass-strong backdrop-blur-xl border-b border-border z-10">
<p className="text-[10px] font-semibold text-text-2 uppercase tracking-[0.14em]">Up next</p>
<h3 className="text-[15px] font-semibold text-text-1 tracking-tight mt-0.5">
{queue.length} {queue.length === 1 ? 'track' : 'tracks'}
</h3>
</div>
<div className="p-3 space-y-1">
{queue.map((track, i) => {
const isActive = i === queueIndex
const thumbUrl = getBestImage(serverUrl, track, 'primary', 100)
return (
<div
key={`${track.Id}-${i}`}
className={`group flex items-center gap-3 p-2 rounded-lg transition-all duration-150 ${
isActive ? 'bg-accent/12 ring-1 ring-accent/25' : 'hover:bg-glass-light'
}`}
>
<div className="relative w-10 h-10 rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border">
{thumbUrl ? (
<img src={thumbUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full grid place-items-center text-text-3">
<ListMusic size={14} />
</div>
)}
{isActive && isPlaying && <Equalizer />}
</div>
<div className="min-w-0 flex-1">
<p className={`text-[13px] truncate font-medium tracking-tight ${isActive ? 'text-accent' : 'text-text-1'}`}>
{track.Name || 'Unknown'}
</p>
<p className="text-[11.5px] text-text-3 truncate">
{track.AlbumArtist || track.Artists?.[0] || 'Unknown artist'}
</p>
</div>
<span className="text-[10px] text-text-4 tabular-nums shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{track.RunTimeTicks
? formatTime(Number(track.RunTimeTicks) / 10000000)
: ''}
</span>
</div>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
)
}
function NPButton({
active,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
{...props}
className={`w-10 h-10 grid place-items-center rounded-full transition-all duration-200 hover:bg-white/12 focus-ring ${
active ? 'text-accent bg-accent/12' : 'text-white/80 hover:text-white'
}`}
>
{children}
</button>
)
}
function Equalizer() {
return (
<div className="absolute inset-0 grid place-items-center bg-black/55 backdrop-blur-sm">
<div className="flex items-end gap-[2px] h-3.5">
{[0, 1, 2].map(i => (
<div
key={i}
className="w-[3px] bg-accent rounded-full origin-bottom"
style={{
animation: `equalizer-bar 0.9s ease-in-out infinite`,
animationDelay: `${i * 0.15}s`,
}}
/>
))}
</div>
</div>
)
}
+128
View File
@@ -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 <video> element (handled by
* the parent so libass canvas overlay isn't tinted along with it).
*/
export default function PictureMenu({
brightness,
contrast,
saturation,
onChange,
onReset,
}: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) {
window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc)
}
}, [open])
const isModified =
Math.abs(brightness - 1) > 0.001 ||
Math.abs(contrast - 1) > 0.001 ||
Math.abs(saturation - 1) > 0.001
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={`w-9 h-9 rounded-full grid place-items-center transition-colors focus-ring ${
isModified
? 'text-accent bg-accent/15'
: open
? 'bg-white/15 text-white'
: 'text-white/85 hover:text-white hover:bg-white/10'
}`}
aria-haspopup="dialog"
aria-expanded={open}
aria-label="Picture"
title="Picture"
>
<Palette size={17} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
role="dialog"
className="absolute right-0 top-full mt-2 w-[280px] bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
>
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10 flex items-center justify-between">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
Picture
</p>
{isModified && (
<button
onClick={onReset}
className="inline-flex items-center gap-1 text-[10.5px] text-white/65 hover:text-white transition-colors focus-ring rounded"
>
<RotateCcw size={11} />
Reset
</button>
)}
</div>
<div className="p-4 space-y-4">
<Slider label="Brightness" value={brightness} onChange={v => onChange('brightness', v)} />
<Slider label="Contrast" value={contrast} onChange={v => onChange('contrast', v)} />
<Slider label="Saturation" value={saturation} onChange={v => onChange('saturation', v)} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function Slider({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (v: number) => void
}) {
return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-[11.5px] font-medium text-white/80">{label}</span>
<span className="text-[11px] font-mono tabular-nums text-white/55">
{Math.round(value * 100)}%
</span>
</div>
<input
type="range"
min={0.5}
max={1.5}
step={0.01}
value={value}
onChange={e => onChange(Number(e.target.value))}
onDoubleClick={() => onChange(1)}
className="slider w-full"
aria-label={label}
/>
</div>
)
}
+308
View File
@@ -0,0 +1,308 @@
import { motion } from 'framer-motion'
import type { ButtonHTMLAttributes, PointerEvent as ReactPointerEvent } from 'react'
import {
Play,
Pause,
SkipBack,
SkipForward,
RotateCcw,
RotateCw,
Volume1,
Volume2,
VolumeX,
Maximize,
Minimize,
} from '../../lib/icons'
import ChapterTicks from './ChapterTicks'
import TrickplayThumbnail from './TrickplayThumbnail'
import { loadBookmarks } from '../../lib/bookmarks'
import type { BaseItemDto } from '../../api/types'
interface Props {
itemId: string | undefined
item: BaseItemDto | null | undefined
serverUrl: string
token: string
duration: number
currentTime: number
bufferedPercent: number
playedPercent: number
scrubPercent: number | null
isPaused: boolean
isMuted: boolean
isFullscreen: boolean
volume: number
loopA: number | null
loopB: number | null
chapters: { StartPositionTicks: number; Name?: string | null; ImageTag?: string | null }[]
bookmarksRefreshKey: number
previousItem: BaseItemDto | null | undefined
nextItem: BaseItemDto | null | undefined
itemType: string | null | undefined
queueActive: boolean
onScrubPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => void
onScrubPointerLeave: () => void
onScrubPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void
onScrubPointerUp: (e: ReactPointerEvent<HTMLDivElement>) => void
onChapterJump: (seconds: number) => void
onTogglePlay: () => void
onPrevious: () => void
onNext: () => void
onBack10: () => void
onForward10: () => void
onToggleMute: () => void
onVolumeChange: (v: number) => void
onToggleFullscreen: () => void
}
export default function PlayerBottomBar({
itemId,
item,
serverUrl,
token,
duration,
currentTime,
bufferedPercent,
playedPercent,
scrubPercent,
isPaused,
isMuted,
isFullscreen,
volume,
loopA,
loopB,
chapters,
bookmarksRefreshKey,
previousItem,
nextItem,
itemType,
queueActive,
onScrubPointerMove,
onScrubPointerLeave,
onScrubPointerDown,
onScrubPointerUp,
onChapterJump,
onTogglePlay,
onPrevious,
onNext,
onBack10,
onForward10,
onToggleMute,
onVolumeChange,
onToggleFullscreen,
}: Props) {
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2
const isEpisode = itemType === 'Episode'
const showPrev = !!(previousItem || queueActive || isEpisode)
const showNext = !!(nextItem || queueActive || isEpisode)
const trickplay = (item as { Trickplay?: unknown } | null | undefined)?.Trickplay
return (
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
data-player-bottom-bar
className="bg-gradient-to-t from-black/85 via-black/40 to-transparent pt-12 pb-5 px-7 pointer-events-auto"
>
{/* Scrubber */}
<div
className="relative h-5 mb-1 cursor-pointer group/scrub touch-none"
onPointerMove={onScrubPointerMove}
onPointerLeave={onScrubPointerLeave}
onPointerDown={onScrubPointerDown}
onPointerUp={onScrubPointerUp}
onPointerCancel={onScrubPointerUp}
>
{/* Track */}
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 group-hover/scrub:h-1.5 transition-all duration-150 rounded-full bg-white/15 overflow-hidden">
<div
className="absolute left-0 top-0 bottom-0 bg-white/25 rounded-full transition-all duration-300"
style={{ width: `${bufferedPercent}%` }}
/>
<div
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
style={{ width: `${playedPercent}%` }}
/>
{scrubPercent !== null && (
<div
className="absolute left-0 top-0 bottom-0 bg-white/15 rounded-full pointer-events-none"
style={{ width: `${scrubPercent * 100}%` }}
/>
)}
{duration > 0 && loopA != null && loopB != null && loopB > loopA && (
<div
className="absolute top-0 bottom-0 bg-accent/40 ring-1 ring-accent/60 pointer-events-none"
style={{
left: `${(loopA / duration) * 100}%`,
width: `${((loopB - loopA) / duration) * 100}%`,
}}
/>
)}
</div>
{/* Bookmark dots */}
{itemId && duration > 0 && loadBookmarks(itemId).map(bm => {
void bookmarksRefreshKey
const left = (bm.positionSec / duration) * 100
if (left < 0 || left > 100) return null
return (
<span
key={bm.id}
title={bm.note || `Bookmark @ ${bm.positionSec.toFixed(0)}s`}
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-cool ring-2 ring-cool/40 pointer-events-none"
style={{ left: `${left}%` }}
/>
)
})}
{/* Chapter ticks */}
{itemId && (
<ChapterTicks
chapters={chapters}
duration={duration}
onJump={onChapterJump}
/>
)}
{/* Thumb */}
<motion.div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-accent shadow-[0_0_12px_rgba(245,182,66,0.7)] opacity-0 group-hover/scrub:opacity-100 transition-opacity duration-150 pointer-events-none"
style={{ left: `${playedPercent}%` }}
/>
{/* Time tooltip + trickplay */}
{scrubPercent !== null && (
<div
className="absolute bottom-6 -translate-x-1/2 flex flex-col items-center gap-1 pointer-events-none"
style={{ left: `${scrubPercent * 100}%` }}
>
{!!trickplay && item && (
<TrickplayThumbnail
item={item}
serverUrl={serverUrl}
token={token}
timeSec={scrubPercent * duration}
displayWidth={200}
/>
)}
<div className="px-2 py-1 rounded-md bg-black/85 text-white text-[11px] font-medium tabular-nums whitespace-nowrap">
{formatTime(scrubPercent * duration)}
</div>
</div>
)}
</div>
{/* Controls row */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-1">
{showPrev && (
<button
onClick={onPrevious}
disabled={!previousItem?.Id}
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring disabled:opacity-30 disabled:hover:bg-transparent"
aria-label="Previous"
title={previousItem ? `Previous: ${previousItem.Name || ''}` : 'No previous'}
>
<SkipBack size={16} fill="currentColor" />
</button>
)}
<button
onClick={onTogglePlay}
className="w-11 h-11 rounded-full grid place-items-center text-white hover:bg-white/10 transition-all duration-150 focus-ring"
aria-label={isPaused ? 'Play' : 'Pause'}
>
{isPaused ? (
<Play size={20} fill="currentColor" className="translate-x-0.5" />
) : (
<Pause size={20} fill="currentColor" />
)}
</button>
{showNext && (
<button
onClick={onNext}
disabled={!nextItem?.Id || nextItem.Id === item?.Id}
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring disabled:opacity-30 disabled:hover:bg-transparent"
aria-label="Next"
title={nextItem ? `Next: ${nextItem.Name || ''}` : 'No next'}
>
<SkipForward size={16} fill="currentColor" />
</button>
)}
<button
onClick={onBack10}
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring relative"
aria-label="Back 10 seconds"
>
<RotateCcw size={17} />
<span className="absolute text-[8px] font-bold tabular-nums">10</span>
</button>
<button
onClick={onForward10}
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring relative"
aria-label="Forward 10 seconds"
>
<RotateCw size={17} />
<span className="absolute text-[8px] font-bold tabular-nums">10</span>
</button>
{/* Volume cluster */}
<div className="group/vol flex items-center ml-1">
<button
onClick={onToggleMute}
className="w-9 h-9 grid place-items-center text-white/85 hover:text-white rounded-full hover:bg-white/10 transition-colors focus-ring"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
<VolumeIcon size={17} />
</button>
<div className="w-0 group-hover/vol:w-24 overflow-x-clip overflow-y-visible transition-all duration-300 ease-out">
<input
type="range"
min={0}
max={1}
step={0.01}
value={isMuted ? 0 : volume}
onChange={e => onVolumeChange(Number(e.target.value))}
className="slider w-22 ml-1.5"
/>
</div>
</div>
{/* Time */}
<div className="ml-3 text-[12px] text-white/85 font-medium tabular-nums">
{formatTime(currentTime)}
<span className="text-white/40 mx-1.5">/</span>
<span className="text-white/55">{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center gap-1">
<ChromeButton onClick={onToggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
</ChromeButton>
</div>
</div>
</motion.div>
)
}
function ChromeButton({
children,
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${className}`}
>
{children}
</button>
)
}
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
+247
View File
@@ -0,0 +1,247 @@
import { motion, AnimatePresence } from 'framer-motion'
import UpNext from './UpNext'
import ResumePrompt from './ResumePrompt'
import RecapCard from './RecapCard'
import ChaptersPanel from './ChaptersPanel'
import BookmarksPanel from './BookmarksPanel'
import EndOfVideoCard from './EndOfVideoCard'
import KeyboardHints from './KeyboardHints'
import SubtitleSearchPanel from './SubtitleSearchPanel'
import SyncPlayPanel from './SyncPlayPanel'
import type { BaseItemDto } from '../../api/types'
export interface ChapterMarker {
StartPositionTicks: number
Name?: string | null
ImageTag?: string | null
}
interface UpNextProps {
item: BaseItemDto | null
visible: boolean
countdown: number
onSkip: () => void
onDismiss: () => void
}
interface ResumeProps {
open: boolean
positionTicks: number
lastPlayedDate?: string | null
onResume: () => void
onRestart: () => void
}
interface RecapProps {
open: boolean
previousEpisodes: BaseItemDto[]
daysSinceLastWatch: number | null
onDismiss: () => void
}
interface ChaptersProps {
open: boolean
itemId: string
chapters: ChapterMarker[]
serverUrl: string
currentTime: number
onClose: () => void
onJump: (t: number) => void
}
interface BookmarksProps {
open: boolean
itemId: string
currentTime: number
refreshKey: number
onClose: () => void
onAdd: () => void
onJump: (t: number) => void
}
interface EndCardProps {
open: boolean
hasEpisodes: boolean
item: BaseItemDto | null | undefined
nextItem?: BaseItemDto | null | undefined
onReplay: () => void
onEpisodes: () => void
onBack: () => void
onPlayNext?: () => void
}
interface SkipPromptProps {
seriesId: string | null
onAccept: () => void
onNotNow: () => void
onDismiss: () => void
}
interface HintsProps {
open: boolean
onClose: () => void
}
interface SubSearchProps {
open: boolean
subtitleUrl: string | null
onClose: () => void
onJump: (t: number) => void
}
interface SyncPlayProps {
open: boolean
onClose: () => void
currentItemId: string | null
currentPositionTicks: number
}
interface Props {
upNext: UpNextProps
resume: ResumeProps
recap: RecapProps
chapters: ChaptersProps | null
bookmarks: BookmarksProps | null
endCard: EndCardProps
nextItem?: BaseItemDto | null
onPlayNext?: () => void
skipPrompt: SkipPromptProps
hints: HintsProps
subSearch: SubSearchProps
syncPlay: SyncPlayProps
}
/**
* All the modal / panel / card overlays that float above the video. Kept in
* one component so PlayerPage doesn't have to render 9 sibling overlays
* inline. State lives in PlayerPage; this is pure presentation.
*/
export default function PlayerOverlays({
upNext,
resume,
recap,
chapters,
bookmarks,
endCard,
nextItem,
onPlayNext,
skipPrompt,
hints,
subSearch,
syncPlay,
}: Props) {
return (
<>
<UpNext
nextItem={upNext.item}
visible={upNext.visible}
countdownSeconds={upNext.countdown}
onSkip={upNext.onSkip}
onDismiss={upNext.onDismiss}
/>
<ResumePrompt
open={resume.open}
positionTicks={resume.positionTicks}
lastPlayedDate={resume.lastPlayedDate}
onResume={resume.onResume}
onRestart={resume.onRestart}
/>
<RecapCard
open={recap.open}
previousEpisodes={recap.previousEpisodes}
daysSinceLastWatch={recap.daysSinceLastWatch}
onDismiss={recap.onDismiss}
/>
{chapters && (
<ChaptersPanel
open={chapters.open}
onClose={chapters.onClose}
chapters={chapters.chapters}
itemId={chapters.itemId}
serverUrl={chapters.serverUrl}
currentTime={chapters.currentTime}
onJump={chapters.onJump}
/>
)}
{bookmarks && (
<BookmarksPanel
open={bookmarks.open}
onClose={bookmarks.onClose}
itemId={bookmarks.itemId}
currentTime={bookmarks.currentTime}
onJump={bookmarks.onJump}
onAdd={bookmarks.onAdd}
refreshKey={bookmarks.refreshKey}
/>
)}
<EndOfVideoCard
open={endCard.open}
hasEpisodes={endCard.hasEpisodes}
item={endCard.item ?? undefined}
nextItem={endCard.nextItem ?? nextItem ?? undefined}
onReplay={endCard.onReplay}
onEpisodes={endCard.onEpisodes}
onBack={endCard.onBack}
onPlayNext={endCard.onPlayNext ?? onPlayNext}
/>
{/* Smart skip prompt - third manual skip on the same series triggers
a one-shot offer to auto-skip from now on. */}
<AnimatePresence>
{skipPrompt.seriesId && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
className="absolute bottom-32 left-1/2 -translate-x-1/2 z-toast inline-flex items-center gap-2 h-11 pl-4 pr-2 rounded-full bg-black/90 backdrop-blur-xl border border-white/12 shadow-2xl"
role="alert"
>
<span className="text-[12px] text-white tracking-tight">
Auto-skip intros on this show?
</span>
<button
onClick={skipPrompt.onAccept}
className="h-8 px-3 rounded-full bg-accent text-void text-[11.5px] font-semibold hover:bg-accent-hover transition-colors focus-ring"
>
Yes
</button>
<button
onClick={skipPrompt.onNotNow}
className="h-8 px-3 rounded-full text-[11.5px] text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
>
Not now
</button>
<button
onClick={skipPrompt.onDismiss}
className="h-8 px-3 rounded-full text-[11.5px] text-white/55 hover:text-white hover:bg-white/10 transition-colors focus-ring"
>
Don't ask
</button>
</motion.div>
)}
</AnimatePresence>
<KeyboardHints open={hints.open} onClose={hints.onClose} />
<SubtitleSearchPanel
open={subSearch.open}
onClose={subSearch.onClose}
subtitleUrl={subSearch.subtitleUrl}
onJump={subSearch.onJump}
/>
<SyncPlayPanel
open={syncPlay.open}
onClose={syncPlay.onClose}
currentItemId={syncPlay.currentItemId}
currentPositionTicks={syncPlay.currentPositionTicks}
/>
</>
)
}
+274
View File
@@ -0,0 +1,274 @@
import { motion } from 'framer-motion'
import type { ButtonHTMLAttributes } from 'react'
import {
ArrowLeft,
AudioLines,
Bookmark,
Download,
Info,
List,
ListDetails,
Moon,
PictureInPicture2,
Search,
Subtitles,
Users,
} from '../../lib/icons'
import SpeedMenu from './SpeedMenu'
import QualityMenu, { type QualityOption } from './QualityMenu'
import TrackMenu from './TrackMenu'
import SubtitleStyleMenu from './SubtitleStyleMenu'
import PictureMenu from './PictureMenu'
import type { BaseItemDto } from '../../api/types'
interface TrackInfo {
Index?: number
Codec?: string | null
Profile?: string | null
Language?: string | null
Title?: string | null
Channels?: number | null
ChannelLayout?: string | null
IsDefault?: boolean | null
IsForced?: boolean | null
IsHearingImpaired?: boolean | null
IsExternal?: boolean | null
AudioSpatialFormat?: string | null
Type?: string
}
interface Props {
item: BaseItemDto | null | undefined
playbackRate: number
onPlaybackRateChange: (rate: number) => void
qualityKey: string
onQualitySelect: (q: QualityOption) => void
onPictureInPicture: () => void
audioTracks: TrackInfo[]
audioIndex: number | null
onAudioSelect: (i: number | null) => void
subtitleTracks: TrackInfo[]
subtitleIndex: number | null
onSubtitleSelect: (i: number | null) => void
hasSeries: boolean
episodesOpen: boolean
onToggleEpisodes: () => void
hasChapters: boolean
chaptersOpen: boolean
onToggleChapters: () => void
bookmarksOpen: boolean
onToggleBookmarks: () => void
subSearchOpen: boolean
onToggleSubSearch: () => void
syncPlayOpen: boolean
onToggleSyncPlay: () => void
syncPlayActive: boolean
videoBrightness: number
videoContrast: number
videoSaturation: number
onPictureChange: (key: 'brightness' | 'contrast' | 'saturation', value: number) => void
onPictureReset: () => void
streamInfoOpen: boolean
onToggleStreamInfo: () => void
onBack: () => void
sleepRemainingSec?: number
onDownload?: () => void
isDownloaded?: boolean
}
export default function PlayerTopBar({
item,
playbackRate,
onPlaybackRateChange,
qualityKey,
onQualitySelect,
onPictureInPicture,
audioTracks,
audioIndex,
onAudioSelect,
subtitleTracks,
subtitleIndex,
onSubtitleSelect,
hasSeries,
episodesOpen,
onToggleEpisodes,
hasChapters,
chaptersOpen,
onToggleChapters,
bookmarksOpen,
onToggleBookmarks,
subSearchOpen,
onToggleSubSearch,
syncPlayOpen,
onToggleSyncPlay,
syncPlayActive,
videoBrightness,
videoContrast,
videoSaturation,
onPictureChange,
onPictureReset,
streamInfoOpen,
onToggleStreamInfo,
onBack,
sleepRemainingSec,
onDownload,
isDownloaded,
}: Props) {
return (
<motion.div
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
data-player-top-bar
className="bg-gradient-to-b from-black/75 via-black/30 to-transparent pt-5 pb-12 px-7 flex items-start justify-between pointer-events-auto"
>
<button
onClick={onBack}
className="group flex items-center gap-3 text-white/85 hover:text-white transition-colors focus-ring rounded-md p-1 -m-1"
>
<span className="w-9 h-9 rounded-full bg-black/40 backdrop-blur grid place-items-center group-hover:bg-black/60 transition-colors">
<ArrowLeft size={17} strokeWidth={2.25} />
</span>
<span className="hidden sm:flex flex-col items-start leading-tight">
<span className="text-[10px] uppercase tracking-[0.14em] text-white/55 font-semibold">
Now playing
</span>
<span className="text-[14px] font-medium tracking-tight">{item?.Name || 'Untitled'}</span>
</span>
</button>
<div className="flex items-center gap-1.5">
<SpeedMenu speed={playbackRate} onChange={onPlaybackRateChange} />
<QualityMenu selectedKey={qualityKey} onSelect={onQualitySelect} />
{onDownload && (
<ChromeButton
onClick={onDownload}
aria-label={isDownloaded ? 'Downloaded' : 'Download'}
title={isDownloaded ? 'Downloaded' : 'Download'}
className={isDownloaded ? 'text-accent' : ''}
>
<Download size={16} />
</ChromeButton>
)}
{sleepRemainingSec != null && sleepRemainingSec > 0 && (
<span
className="hidden sm:inline-flex items-center gap-1 h-7 px-2 rounded-full bg-black/40 backdrop-blur text-[10.5px] text-white/70 font-mono tabular-nums"
title="Sleep timer active"
>
<Moon size={12} />
{formatSleep(sleepRemainingSec)}
</span>
)}
<ChromeButton onClick={onPictureInPicture} aria-label="Picture in picture">
<PictureInPicture2 size={17} />
</ChromeButton>
<TrackMenu
trigger={<AudioLines size={17} />}
title="Audio"
tracks={audioTracks}
selectedIndex={audioIndex ?? (audioTracks.find(t => t.IsDefault)?.Index ?? null)}
onSelect={onAudioSelect}
variant="audio"
/>
<TrackMenu
trigger={<Subtitles size={17} />}
title="Subtitles"
tracks={subtitleTracks}
selectedIndex={subtitleIndex}
onSelect={onSubtitleSelect}
variant="subtitle"
/>
<SubtitleStyleMenu />
{subtitleIndex != null && (
<ChromeButton
onClick={onToggleSubSearch}
aria-label="Search subtitles"
aria-pressed={subSearchOpen}
className={subSearchOpen ? 'bg-white/15 text-white' : ''}
>
<Search size={17} />
</ChromeButton>
)}
{hasSeries && (
<ChromeButton
onClick={onToggleEpisodes}
aria-label="Episodes"
aria-pressed={episodesOpen}
className={episodesOpen ? 'bg-white/15 text-white' : ''}
>
<ListDetails size={17} />
</ChromeButton>
)}
{hasChapters && (
<ChromeButton
onClick={onToggleChapters}
aria-label="Chapters"
aria-pressed={chaptersOpen}
className={chaptersOpen ? 'bg-white/15 text-white' : ''}
>
<List size={17} />
</ChromeButton>
)}
<ChromeButton
onClick={onToggleBookmarks}
aria-label="Bookmarks"
aria-pressed={bookmarksOpen}
className={bookmarksOpen ? 'bg-white/15 text-white' : ''}
>
<Bookmark size={17} />
</ChromeButton>
<ChromeButton
onClick={onToggleSyncPlay}
aria-label="Watch party"
aria-pressed={syncPlayOpen}
className={syncPlayOpen || syncPlayActive ? 'bg-white/15 text-white' : ''}
>
<span className="relative">
<Users size={17} />
{syncPlayActive && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</span>
</ChromeButton>
<PictureMenu
brightness={videoBrightness}
contrast={videoContrast}
saturation={videoSaturation}
onChange={onPictureChange}
onReset={onPictureReset}
/>
<ChromeButton
onClick={onToggleStreamInfo}
aria-label="Stream info"
aria-pressed={streamInfoOpen}
className={streamInfoOpen ? 'bg-white/15 text-white' : ''}
>
<Info size={17} />
</ChromeButton>
</div>
</motion.div>
)
}
function ChromeButton({
children,
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={`w-9 h-9 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/85 hover:text-white transition-colors focus-ring ${className}`}
>
{children}
</button>
)
}
function formatSleep(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
if (m >= 60) return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Adjustments, Check } from '../../lib/icons'
export interface QualityOption {
/** Stable key for the menu */
key: string
/** Bitrate cap in bps to send as MaxStreamingBitrate. 0 = "unlimited" (Original). */
bitrate: number
label: string
/** Optional sub-text shown smaller next to the label */
hint?: string
}
const QUALITIES: QualityOption[] = [
{ key: 'auto', bitrate: 120_000_000, label: 'Auto', hint: 'Best for connection' },
{ key: 'original', bitrate: 0, label: 'Original', hint: 'Direct stream' },
{ key: '4k', bitrate: 20_000_000, label: '4K', hint: '20 Mbps' },
{ key: '1080', bitrate: 10_000_000, label: '1080p', hint: '10 Mbps' },
{ key: '720', bitrate: 4_000_000, label: '720p', hint: '4 Mbps' },
{ key: '480', bitrate: 2_000_000, label: '480p', hint: '2 Mbps' },
{ key: '360', bitrate: 800_000, label: '360p', hint: '800 Kbps' },
]
interface Props {
selectedKey: string
onSelect: (q: QualityOption) => void
}
export default function QualityMenu({ selectedKey, onSelect }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) {
window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc)
}
}, [open])
const selected = QUALITIES.find(q => q.key === selectedKey) || QUALITIES[0]
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${
open ? 'bg-white/15 text-white' : ''
}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Quality: ${selected.label}`}
title={`Quality: ${selected.label}`}
>
<Adjustments size={17} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
role="listbox"
className="absolute right-0 top-full mt-2 w-56 bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
>
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
Quality
</p>
</div>
<div className="p-1">
{QUALITIES.map(q => {
const sel = q.key === selectedKey
return (
<button
key={q.key}
onClick={() => {
onSelect(q)
setOpen(false)
}}
role="option"
aria-selected={sel}
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
sel ? 'bg-accent/15' : 'hover:bg-white/8'
}`}
>
<span
className={`w-4 h-4 grid place-items-center shrink-0 ${
sel ? 'text-accent' : 'text-transparent'
}`}
>
<Check size={13} strokeWidth={2.5} />
</span>
<span className="flex-1 min-w-0">
<span
className={`block text-[12.5px] font-medium ${
sel ? 'text-accent' : 'text-white'
}`}
>
{q.label}
</span>
{q.hint && (
<span className="block text-[10.5px] text-white/55 truncate">{q.hint}</span>
)}
</span>
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export { QUALITIES }
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, X } from '../../lib/icons'
import { getImageUrl } from '../../api/jellyfin'
import { jellyfinClient } from '../../api/jellyfin'
import type { BaseItemDto } from '../../api/types'
interface Props {
open: boolean
previousEpisodes: BaseItemDto[]
daysSinceLastWatch: number | null
onDismiss: () => void
}
const AUTO_DISMISS_SEC = 12
/**
* "Previously on..." card shown before playback starts when the user is
* returning to a series after a long break. Surfaces stills + summaries
* of the 1-2 prior episodes so they re-enter the story without rewatching.
*
* Auto-dismisses after 12 seconds; clicking anywhere outside the cards
* (or the X) dismisses early.
*/
export default function RecapCard({ open, previousEpisodes, daysSinceLastWatch, onDismiss }: Props) {
const [countdown, setCountdown] = useState(AUTO_DISMISS_SEC)
useEffect(() => {
if (!open) return
setCountdown(AUTO_DISMISS_SEC)
const id = setInterval(() => {
setCountdown(c => {
if (c <= 1) {
clearInterval(id)
onDismiss()
return 0
}
return c - 1
})
}, 1000)
return () => clearInterval(id)
}, [open, onDismiss])
const auth = jellyfinClient.getAuthState()
const serverUrl = auth?.serverUrl || ''
const gapLabel = (() => {
if (daysSinceLastWatch == null) return null
if (daysSinceLastWatch < 30) return `${Math.round(daysSinceLastWatch)} days`
const months = Math.round(daysSinceLastWatch / 30)
if (months < 12) return `${months} ${months === 1 ? 'month' : 'months'}`
const years = Math.round(daysSinceLastWatch / 365 * 10) / 10
return `${years} ${years === 1 ? 'year' : 'years'}`
})()
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
onClick={onDismiss}
className="absolute inset-0 z-30 grid place-items-center bg-black/65 backdrop-blur-sm"
>
<motion.div
initial={{ y: 24, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 24, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-7 w-[min(720px,92vw)] shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] relative"
>
<button
onClick={onDismiss}
aria-label="Dismiss recap"
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full text-white/60 hover:text-white hover:bg-white/10 transition focus-ring"
>
<X size={14} stroke={2} />
</button>
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-1">
Previously on
</p>
{gapLabel && (
<p className="text-[12px] text-white/45 mb-5 tracking-tight">
It has been {gapLabel} since you last watched
</p>
)}
<div className={`grid gap-3 mb-6 ${previousEpisodes.length > 1 ? 'sm:grid-cols-2' : ''}`}>
{previousEpisodes.map(ep => {
const tag = ep.ImageTags?.Primary
const thumb = tag
? getImageUrl(serverUrl, ep.Id!, 'Primary', 480, tag)
: null
const sLabel = ep.ParentIndexNumber != null && ep.IndexNumber != null
? `S${ep.ParentIndexNumber} · E${ep.IndexNumber}`
: ''
return (
<div key={ep.Id} className="rounded-xl overflow-hidden bg-black/30 ring-1 ring-white/10">
{thumb && (
<div className="aspect-video bg-black/40 overflow-hidden">
<img src={thumb} alt={ep.Name || ''} className="w-full h-full object-cover" />
</div>
)}
<div className="p-3.5">
{sLabel && (
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-accent/90 mb-1">
{sLabel}
</p>
)}
<p className="text-[13.5px] font-semibold text-white/95 mb-1.5 leading-tight tracking-tight">
{ep.Name}
</p>
{ep.Overview && (
<p className="text-[11.5px] text-white/65 leading-relaxed line-clamp-3">
{ep.Overview}
</p>
)}
</div>
</div>
)
})}
</div>
<div className="flex items-center justify-center gap-2.5 flex-wrap">
<button
onClick={onDismiss}
autoFocus
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
>
<Play size={14} fill="currentColor" />
Got it
<span className="text-void/65 tabular-nums text-[11.5px]">({countdown}s)</span>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, RotateCcw } from '../../lib/icons'
import { formatRuntime } from '../../lib/format'
interface Props {
open: boolean
positionTicks: number
/** ISO timestamp of the last play, if known. Renders "Last watched X
* ago" under the main message so the user remembers when they were
* on this item - useful when picking up after a long break or coming
* back from another device. */
lastPlayedDate?: string | null
onResume: () => void
onRestart: () => void
}
function formatRelativeAgo(iso: string): string {
const then = Date.parse(iso)
if (!Number.isFinite(then)) return ''
const ms = Date.now() - then
if (ms < 0) return ''
const minutes = Math.floor(ms / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
const weeks = Math.floor(days / 7)
if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 's'} ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
const years = Math.floor(days / 365)
return `${years} year${years === 1 ? '' : 's'} ago`
}
const AUTO_CONFIRM_SEC = 6
/**
* Lightweight overlay shown on player entry when a resume position exists
* and the user has the "ask before resuming" pref on. Two buttons -
* Resume from M:SS / Start over - with a 6-second auto-confirm on Resume
* so it doesn't feel like a wall.
*/
export default function ResumePrompt({ open, positionTicks, lastPlayedDate, onResume, onRestart }: Props) {
const [countdown, setCountdown] = useState(AUTO_CONFIRM_SEC)
const ago = lastPlayedDate ? formatRelativeAgo(lastPlayedDate) : ''
useEffect(() => {
if (!open) return
setCountdown(AUTO_CONFIRM_SEC)
const id = setInterval(() => {
setCountdown(c => {
if (c <= 1) {
clearInterval(id)
onResume()
return 0
}
return c - 1
})
}, 1000)
return () => clearInterval(id)
}, [open, onResume])
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
className="absolute inset-0 z-30 grid place-items-center bg-black/55 backdrop-blur-sm"
>
<motion.div
initial={{ y: 24, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 24, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-7 max-w-md text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
>
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-2">
Continue watching
</p>
<p className="text-[16px] text-white/90 mb-1 tracking-tight">
You left off at <span className="font-display font-bold text-accent tabular-nums">{formatRuntime(positionTicks)}</span>
</p>
{ago && (
<p className="text-[11.5px] text-white/55 mb-6 tracking-tight">
Last watched {ago}
</p>
)}
{!ago && <div className="mb-6" />}
<div className="flex items-center gap-2.5 justify-center flex-wrap">
<button
onClick={onResume}
autoFocus
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
>
<Play size={14} fill="currentColor" />
Resume
<span className="text-void/65 tabular-nums text-[11.5px]">({countdown}s)</span>
</button>
<button
onClick={onRestart}
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out active:scale-[0.97] focus-ring"
>
<RotateCcw size={14} stroke={2} />
Start over
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Check } from '../../lib/icons'
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
interface Props {
speed: number
onChange: (speed: number) => void
}
/**
* Playback-speed dropdown. Trigger shows the current speed badge in the
* top bar; clicking opens a small radio-style menu. Same chrome look as
* the existing TrackMenu so it slots in next to Audio / Subtitles.
*/
export default function SpeedMenu({ speed, onChange }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) {
window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc)
}
}, [open])
const display = speed === 1 ? '1×' : `${speed}×`
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={`min-w-[44px] h-9 px-2 rounded-full grid place-items-center text-[12px] font-mono font-semibold tabular-nums transition-colors focus-ring ${
speed !== 1 ? 'bg-accent/20 text-accent' : 'text-white/85 hover:text-white hover:bg-white/10'
}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Playback speed ${display}`}
title={`Playback speed: ${display}`}
>
{display}
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
role="listbox"
className="absolute right-0 top-full mt-2 w-44 bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
>
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
Playback speed
</p>
</div>
<div className="p-1">
{SPEEDS.map(s => {
const selected = Math.abs(speed - s) < 0.001
return (
<button
key={s}
onClick={() => {
onChange(s)
setOpen(false)
}}
role="option"
aria-selected={selected}
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
selected ? 'bg-accent/15' : 'hover:bg-white/8'
}`}
>
<span
className={`w-4 h-4 grid place-items-center shrink-0 ${
selected ? 'text-accent' : 'text-transparent'
}`}
>
<Check size={13} strokeWidth={2.5} />
</span>
<span
className={`flex-1 text-[12.5px] font-mono tabular-nums ${
selected ? 'text-accent font-semibold' : 'text-white'
}`}
>
{s === 1 ? 'Normal (1×)' : `${s}×`}
</span>
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import type { BaseItemDto } from '../../api/types'
import {
pickPrimarySource,
getVideoStream,
getAudioStreams,
getSubtitleStreams,
videoCodecLabel,
audioFormatLabel,
framerateLabel,
resolutionLabel,
videoRangeLabel,
} from '../../lib/jellyfin-meta'
import { formatBitrate } from '../../lib/format'
interface Props {
item?: BaseItemDto | null
visible: boolean
/** PlayMethod from PlaybackInfo so users see whether they're transcoding. */
playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode' | null
}
interface LiveStats {
bufferAheadSec: number | null
droppedFrames: number | null
totalFrames: number | null
currentFps: number | null
}
interface HwDecodeState {
supported: boolean | null
hwAccelerated: boolean | null
}
function readLiveStats(): LiveStats {
const video = document.querySelector('video') as HTMLVideoElement | null
if (!video) return { bufferAheadSec: null, droppedFrames: null, totalFrames: null, currentFps: null }
let bufferAhead: number | null = null
try {
const buf = video.buffered
if (buf && buf.length) {
const t = video.currentTime
for (let i = 0; i < buf.length; i++) {
if (t >= buf.start(i) && t <= buf.end(i)) {
bufferAhead = Math.max(0, buf.end(i) - t)
break
}
}
}
} catch { /* timeranges throws sometimes */ }
const q = (video as any).getVideoPlaybackQuality?.()
return {
bufferAheadSec: bufferAhead,
droppedFrames: q?.droppedVideoFrames ?? null,
totalFrames: q?.totalVideoFrames ?? null,
currentFps: null,
}
}
async function checkHwDecode(stream: { codec?: string | null; width?: number | null; height?: number | null; bitrate?: number | null } | null): Promise<HwDecodeState> {
if (!stream || typeof (navigator as any).mediaCapabilities?.decodingInfo !== 'function') {
return { supported: null, hwAccelerated: null }
}
const codec = (stream.codec || stream.Codec || '').toLowerCase()
const w = stream.width ?? stream.Width ?? 1920
const h = stream.height ?? stream.Height ?? 1080
const br = stream.bitrate ?? stream.BitRate ?? 8000000
// Map common Jellyfin codec names to MIME codec strings
const mimeCodec =
codec === 'h264' ? 'avc1.640033'
: codec === 'hevc' ? 'hev1.1.6.L150.90'
: codec === 'av1' ? 'av01.0.05M.08'
: codec === 'vp9' ? 'vp09.00.50.08'
: null
if (!mimeCodec) return { supported: null, hwAccelerated: null }
try {
const info = await (navigator as any).mediaCapabilities.decodingInfo({
type: 'media-source',
video: {
contentType: `video/mp4; codecs="${mimeCodec}"`,
width: w,
height: h,
bitrate: br,
framerate: 24,
},
})
return { supported: info.supported, hwAccelerated: info.powerEfficient }
} catch {
return { supported: null, hwAccelerated: null }
}
}
/**
* Infer why transcoding is happening by comparing the original source
* streams against what the browser natively supports and what the user
* has selected (audio track, subtitle track, max bitrate).
*/
function inferTranscodeReasons(
item: BaseItemDto | null | undefined,
playMethod: Props['playMethod'],
): string[] {
if (playMethod !== 'Transcode') return []
const reasons: string[] = []
const source = pickPrimarySource(item || {})
const v = getVideoStream(item || {})
const a = getAudioStreams(item || {})[0]
if (v) {
const codec = (v.Codec || '').toLowerCase()
if (codec === 'hevc' || codec === 'h265') reasons.push('HEVC not natively supported')
else if (codec === 'av1') reasons.push('AV1 not natively supported')
else if (codec === 'mpeg2video') reasons.push('MPEG-2 not natively supported')
else if (codec === 'vc1') reasons.push('VC-1 not natively supported')
}
if (a) {
const codec = (a.Codec || '').toLowerCase()
if (codec === 'truehd') reasons.push('Dolby TrueHD requires transcode')
else if (codec === 'dts' && (a.Profile || '').toLowerCase().includes('hd')) reasons.push('DTS-HD requires transcode')
else if (codec === 'dts') reasons.push('DTS requires transcode')
else if (codec === 'eac3' && (a.Profile || '').toLowerCase().includes('atmos')) {
// Atmos in EAC3 may pass through on some browsers; if transcoding, note it
}
}
// Check container
const container = (source?.Container || '').toLowerCase()
if (container === 'mkv' || container === 'avi' || container === 'wmv') {
reasons.push(`Container .${container} not natively supported`)
}
// Subtitle burn-in: image-based subtitles usually force transcode
const subStreams = getSubtitleStreams(item || {})
const imageSub = subStreams.find((s: any) => {
const c = (s.Codec || '').toLowerCase()
return c === 'pgssub' || c === 'dvdsub' || c === 'vobsub' || c === 'sup'
})
if (imageSub) reasons.push('Image subtitles require burn-in')
// If we couldn't infer anything specific, give a generic reason
if (reasons.length === 0) reasons.push('Codec or container incompatible with browser')
return reasons
}
export default function StreamInfo({ item, visible, playMethod }: Props) {
const [live, setLive] = useState<LiveStats>({
bufferAheadSec: null,
droppedFrames: null,
totalFrames: null,
currentFps: null,
})
const [hw, setHw] = useState<HwDecodeState>({ supported: null, hwAccelerated: null })
useEffect(() => {
if (!visible) return
const tick = () => setLive(readLiveStats())
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [visible])
useEffect(() => {
if (!visible) return
const v = getVideoStream(item || {})
checkHwDecode(v as any).then(setHw)
}, [visible, item?.Id])
if (!visible || !item) return null
const source = pickPrimarySource(item)
const v = getVideoStream(item)
const a = getAudioStreams(item)[0]
const rows: { label: string; value: string; accent?: boolean; warn?: boolean }[] = []
// Play method with color coding + hardware decode badge
if (playMethod) {
const hwLabel = hw.hwAccelerated === true ? ' · HW' : hw.hwAccelerated === false ? ' · SW' : ''
rows.push({
label: 'Method',
value: playMethod + hwLabel,
accent: playMethod === 'DirectPlay',
warn: playMethod === 'Transcode',
})
}
// Transcoding reasons
const transcodeReasons = inferTranscodeReasons(item, playMethod)
if (transcodeReasons.length > 0) {
rows.push({ label: 'Why transcoding', value: transcodeReasons.join(', '), warn: true })
}
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
if (res) rows.push({ label: 'Resolution', value: range ? `${res} · ${range}` : res })
if (v) {
rows.push({ label: 'Video', value: videoCodecLabel(v) })
const fps = framerateLabel(v)
if (fps) rows.push({ label: 'Framerate', value: fps })
if (v.BitRate) rows.push({ label: 'Video bitrate', value: formatBitrate(v.BitRate) })
}
if (a) {
rows.push({ label: 'Audio', value: audioFormatLabel(a) || '' })
if (a.BitRate) rows.push({ label: 'Audio bitrate', value: formatBitrate(a.BitRate) })
}
if (source?.Container) rows.push({ label: 'Container', value: source.Container.toUpperCase() })
if (source?.Bitrate) rows.push({ label: 'Total bitrate', value: formatBitrate(source.Bitrate) })
// Live block - only meaningful when there's a video element
const liveRows: { label: string; value: string }[] = []
if (live.bufferAheadSec != null) {
liveRows.push({ label: 'Buffer ahead', value: `${live.bufferAheadSec.toFixed(1)}s` })
}
if (live.droppedFrames != null && live.totalFrames != null) {
const pct = live.totalFrames > 0 ? (live.droppedFrames / live.totalFrames) * 100 : 0
liveRows.push({
label: 'Dropped frames',
value: `${live.droppedFrames} / ${live.totalFrames} (${pct.toFixed(2)}%)`,
})
}
if (!rows.length && !liveRows.length) return null
return (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18 }}
className="absolute top-20 right-6 z-20 w-80 rounded-lg bg-black/75 backdrop-blur-md border border-white/10 p-4 pointer-events-none"
>
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55 mb-3">
Stream info
</p>
<div className="space-y-1.5">
{rows.map(r => (
<div key={r.label} className="flex items-baseline justify-between gap-3 text-[11.5px]">
<span className="text-white/55 shrink-0">{r.label}</span>
<span
className={`font-mono tabular-nums text-right ${
r.accent ? 'text-success' : r.warn ? 'text-warning' : 'text-white'
}`}
>
{r.value}
</span>
</div>
))}
</div>
{liveRows.length > 0 && (
<>
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55 mt-4 mb-2">
Live
</p>
<div className="space-y-1.5">
{liveRows.map(r => (
<div key={r.label} className="flex items-baseline justify-between gap-3 text-[11.5px]">
<span className="text-white/55">{r.label}</span>
<span className="text-white font-mono tabular-nums">{r.value}</span>
</div>
))}
</div>
</>
)}
<p className="text-[9.5px] text-white/45 mt-3 pt-2 border-t border-white/8">Press i to dismiss</p>
</motion.div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
import type { MediaPlayerInstance } from '@vidstack/react'
import { useSubtitleStyles } from '../../lib/subtitle-style'
import { usePlayerRuntimeStore } from '../../stores/player-runtime-store'
import { usePreferencesStore } from '../../stores/preferences-store'
/**
* Custom subtitle renderer that bypasses both the browser's <track> machinery
* and vidstack's <Captions> component. We fetch the VTT ourselves, parse it
* into a flat list of cues, then poll the player's currentTime against that
* list to figure out what to draw.
*
* Why not use TextTrack.activeCues? Two reasons:
* 1. vidstack's wrapped TextTrackList and the underlying <video>.textTracks
* don't always agree on `mode`, so cues sometimes never load.
* 2. vidstack's TextTrack uses a 'cue-change' event name, the DOM standard
* uses 'cuechange'; subscribing to one misses the other.
*
* Going direct sidesteps both problems entirely.
*/
interface Cue {
start: number
end: number
text: string
}
interface Props {
playerRef: RefObject<MediaPlayerInstance | null>
subtitleUrl: string | null
}
function parseTimeStamp(h: string | undefined, m: string, s: string, ms: string): number {
const hh = h ? parseInt(h.replace(':', ''), 10) : 0
return hh * 3600 + parseInt(m, 10) * 60 + parseInt(s, 10) + parseInt(ms, 10) / 1000
}
function parseVTT(raw: string): Cue[] {
const text = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks = text.split(/\n\n+/)
const cues: Cue[] = []
for (const block of blocks) {
const lines = block.split('\n')
let tsLine = -1
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('-->')) {
tsLine = i
break
}
}
if (tsLine === -1) continue
// Accept both WebVTT dots and SRT commas for the milliseconds separator.
// Jellyfin sometimes serves SRT content even on the .vtt endpoint.
const m = lines[tsLine].match(
/(\d+:)?(\d+):(\d+)[\.,](\d+)\s+-->\s+(\d+:)?(\d+):(\d+)[\.,](\d+)/,
)
if (!m) continue
const start = parseTimeStamp(m[1], m[2], m[3], m[4])
const end = parseTimeStamp(m[5], m[6], m[7], m[8])
const body = lines
.slice(tsLine + 1)
.filter(l => l.trim() !== '')
.join('\n')
.replace(/<[^>]+>/g, '')
.trim()
if (body) cues.push({ start, end, text: body })
}
// Sort so the polling-loop break optimisation (and any out-of-order
// source files) behave correctly.
cues.sort((a, b) => a.start - b.start)
if (cues.length === 0 && text.trim().length > 100) {
console.warn('[subtitles] parseVTT produced zero cues from non-empty content. First 200 chars:', text.trim().slice(0, 200))
}
return cues
}
export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) {
const { className, style } = useSubtitleStyles()
const cuesRef = useRef<Cue[]>([])
const [activeCues, setActiveCues] = useState<string[]>([])
useEffect(() => {
cuesRef.current = []
setActiveCues([])
if (!subtitleUrl) return
let cancelled = false
fetch(subtitleUrl, { credentials: 'omit' })
.then(r => {
if (!r.ok) throw new Error(`vtt fetch ${r.status}`)
return r.text()
})
.then(text => {
if (cancelled) return
cuesRef.current = parseVTT(text)
})
.catch(err => {
if (!cancelled) {
console.warn('[subtitles] failed to load VTT', err)
}
})
return () => {
cancelled = true
}
}, [subtitleUrl])
useEffect(() => {
let lastSig = ''
let cancelled = false
function tick() {
if (cancelled) return
const player = playerRef.current
const cues = cuesRef.current
if (!player || cues.length === 0) {
if (lastSig !== '') {
lastSig = ''
setActiveCues([])
}
return
}
// Effective time = player time + (-offset). A positive offset means
// "subtitles later", which we implement by checking cues against an
// earlier time. Negative = "subtitles earlier" (cues against later).
const offsetSec = (usePreferencesStore.getState().subtitleDelayMs
+ usePlayerRuntimeStore.getState().subtitleOffsetMs) / 1000
const t = (player.currentTime ?? 0) - offsetSec
if (!Number.isFinite(t)) return
const matches: string[] = []
for (const c of cues) {
if (t >= c.start && t < c.end) matches.push(c.text)
}
const sig = matches.join('\x1f')
if (sig !== lastSig) {
lastSig = sig
setActiveCues(matches)
}
}
tick()
const interval = setInterval(tick, 100)
return () => {
cancelled = true
clearInterval(interval)
}
}, [playerRef, subtitleUrl])
if (activeCues.length === 0) return null
return (
<div className={`${className} z-30`} style={style}>
{activeCues.map((cue, ci) => (
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
<span data-cue className="whitespace-pre-line">
{cue}
</span>
</div>
))}
</div>
)
}
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Search } from '../../lib/icons'
interface Cue {
start: number
end: number
text: string
}
interface Props {
open: boolean
onClose: () => void
/** URL to a VTT or ASS subtitle stream. */
subtitleUrl: string | null
onJump: (timeSec: number) => void
}
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')}`
}
function parseTimestamp(s: string): number {
const m = s.match(/(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/)
if (!m) return 0
return (
Number(m[1] || 0) * 3600 +
Number(m[2]) * 60 +
Number(m[3]) +
Number(m[4]) / 1000
)
}
function parseVTT(text: string): Cue[] {
const out: Cue[] = []
const blocks = text.replace(/\r\n/g, '\n').split(/\n\n+/)
for (const block of blocks) {
const lines = block.split('\n').filter(l => l.trim() !== '')
if (lines.length < 2) continue
const tsLine = lines.find(l => l.includes('-->'))
if (!tsLine) continue
const [a, b] = tsLine.split('-->').map(s => s.trim().split(' ')[0])
const start = parseTimestamp(a)
const end = parseTimestamp(b)
const textLines = lines.slice(lines.indexOf(tsLine) + 1)
const txt = textLines.join('\n').replace(/<[^>]+>/g, '').trim()
if (txt && end > start) out.push({ start, end, text: txt })
}
return out
}
function parseASS(text: string): Cue[] {
const out: Cue[] = []
const lines = text.split('\n')
for (const line of lines) {
if (!line.startsWith('Dialogue:')) continue
// Format: Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello world
const fields = line.slice(9).split(',')
if (fields.length < 10) continue
const start = parseTimestamp(fields[1])
const end = parseTimestamp(fields[2])
// Text is the rest joined by comma; strip ASS override blocks {\\...}
const txt = fields.slice(9).join(',').replace(/\{[^}]*\}/g, '').replace(/\\N/gi, ' ').trim()
if (txt && end > start) out.push({ start, end, text: txt })
}
return out
}
export default function SubtitleSearchPanel({ open, onClose, subtitleUrl, onJump }: Props) {
const [cues, setCues] = useState<Cue[]>([])
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open || !subtitleUrl) return
setLoading(true)
let cancelled = false
fetch(subtitleUrl, { credentials: 'omit' })
.then(r => r.text())
.then(text => {
if (cancelled) return
const parsed = subtitleUrl.endsWith('.ass') || subtitleUrl.endsWith('.ssa')
? parseASS(text)
: parseVTT(text)
setCues(parsed)
setLoading(false)
})
.catch(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, subtitleUrl])
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
const results = useMemo(() => {
if (!query.trim()) return []
const q = query.toLowerCase()
const out: Cue[] = []
for (const c of cues) {
if (c.text.toLowerCase().includes(q)) out.push(c)
if (out.length >= 200) break
}
return out
}, [cues, query])
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[420px] max-w-[88vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
role="dialog"
aria-label="Search subtitles"
>
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
Search subtitles
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</div>
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-3 pointer-events-none" />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={subtitleUrl ? 'Search dialogue...' : 'No subtitle track active'}
disabled={!subtitleUrl}
className="w-full h-10 pl-9 pr-3 rounded-md bg-void/60 border border-border focus:border-accent/50 focus:ring-2 focus:ring-accent/15 text-[13px] text-text-1 placeholder:text-text-4 transition-all duration-150 focus:outline-none disabled:opacity-50"
/>
</div>
{!loading && cues.length > 0 && (
<p className="text-[10.5px] text-white/45 mt-2 tabular-nums">
{cues.length} cues loaded · {results.length} match
</p>
)}
</header>
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1">
{loading && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">Loading subtitles...</p>
)}
{!loading && !subtitleUrl && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
Pick a subtitle track first.
</p>
)}
{!loading && subtitleUrl && results.length === 0 && query.trim() && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">No matches.</p>
)}
{results.map((c, i) => (
<button
key={`${c.start}-${i}`}
onClick={() => {
onClose()
onJump(c.start)
}}
className="w-full text-left px-3 py-2.5 rounded-lg border border-white/8 hover:border-white/20 hover:bg-white/5 transition-colors focus-ring"
>
<p className="text-[10.5px] font-mono tabular-nums text-accent mb-1">
{formatSec(c.start)}
</p>
<p className="text-[12.5px] text-white/85 leading-snug">{c.text}</p>
</button>
))}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Type } from '../../lib/icons'
import { usePreferencesStore } from '../../stores/preferences-store'
import { usePlayerRuntimeStore } from '../../stores/player-runtime-store'
import { subtitleClasses } from '../../lib/subtitle-style'
import type { AppSettings } from '../../api/types'
/**
* Floating panel inside the player that exposes the same subtitle-styling
* controls as the Settings page, so users can fine-tune captions without
* leaving playback. Writes straight to the preferences store, so changes
* apply live everywhere the styling hook is consumed.
*/
export default function SubtitleStyleMenu() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const prefs = usePreferencesStore()
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) {
window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc)
}
}, [open])
const preview = subtitleClasses({
subtitleFontSize: prefs.subtitleFontSize,
subtitleFontFamily: prefs.subtitleFontFamily,
subtitleBackground: prefs.subtitleBackground,
subtitleEdge: prefs.subtitleEdge,
subtitlePosition: prefs.subtitlePosition,
subtitleColor: prefs.subtitleColor,
})
const previewCls = preview.className.replace(/\babsolute\b|\binset-x-0\b|\bbottom-32\b|\btop-24\b|\bpx-7\b/g, '')
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${
open ? 'bg-white/15 text-white' : ''
}`}
aria-haspopup="dialog"
aria-expanded={open}
aria-label="Subtitle appearance"
>
<Type size={17} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
role="dialog"
className="absolute right-0 top-full mt-2 w-[340px] max-h-[min(80vh,560px)] overflow-y-auto content-scroll bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
>
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
Subtitle appearance
</p>
</div>
<div className="p-3 space-y-3">
<Field label={`Size (${prefs.subtitleFontSize}px)`}>
<input
type="range"
min={12}
max={72}
step={1}
value={prefs.subtitleFontSize}
onChange={e => prefs.setPreference('subtitleFontSize', Number(e.target.value))}
className="subtitle-size-slider w-full h-7 appearance-none bg-transparent cursor-pointer"
/>
<div className="flex justify-between text-[9px] text-white/40 -mt-0.5 px-0.5">
<span>12px</span>
<span>72px</span>
</div>
</Field>
<Field label="Background">
<Seg
value={prefs.subtitleBackground}
onChange={v => prefs.setPreference('subtitleBackground', v as AppSettings['subtitleBackground'])}
options={[
{ value: 'none', label: 'None' },
{ value: 'subtle', label: 'Subtle' },
{ value: 'solid', label: 'Solid' },
]}
/>
</Field>
<Field label="Edge">
<Seg
value={prefs.subtitleEdge}
onChange={v => prefs.setPreference('subtitleEdge', v as AppSettings['subtitleEdge'])}
options={[
{ value: 'none', label: 'None' },
{ value: 'shadow', label: 'Shadow' },
{ value: 'outline', label: 'Outline' },
]}
/>
</Field>
<Field label="Position">
<Seg
value={prefs.subtitlePosition}
onChange={v => prefs.setPreference('subtitlePosition', v as AppSettings['subtitlePosition'])}
options={[
{ value: 'bottom', label: 'Bottom' },
{ value: 'top', label: 'Top' },
]}
/>
</Field>
<Field label="Colour">
<Seg
value={prefs.subtitleColor}
onChange={v => prefs.setPreference('subtitleColor', v as AppSettings['subtitleColor'])}
options={[
{ value: 'white', label: 'White' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'cyan', label: 'Cyan' },
]}
/>
</Field>
<Field label="Font">
<Seg
value={prefs.subtitleFontFamily}
onChange={v => prefs.setPreference('subtitleFontFamily', v as AppSettings['subtitleFontFamily'])}
options={[
{ value: 'sans', label: 'Sans' },
{ value: 'serif', label: 'Display' },
{ value: 'mono', label: 'Mono' },
]}
/>
</Field>
<SyncRow />
<div className="pt-1">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/45 mb-1.5">
Preview
</p>
<div className="relative h-20 rounded-md overflow-hidden bg-[radial-gradient(ellipse_at_30%_20%,rgba(245,182,66,0.15),transparent_55%),linear-gradient(180deg,#1a1610,#0c0a08)] grid place-items-center">
<div className={previewCls} style={preview.style}>
<span data-cue>Sample subtitle text</span>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function SyncRow() {
const offsetMs = usePlayerRuntimeStore(s => s.subtitleOffsetMs)
const bump = usePlayerRuntimeStore(s => s.bumpSubtitleOffsetMs)
const reset = usePlayerRuntimeStore(s => s.setSubtitleOffsetMs)
const display = `${offsetMs >= 0 ? '+' : ''}${(offsetMs / 1000).toFixed(2)}s`
return (
<Field label="Sync (this session)">
<div className="flex items-center gap-1.5">
<button
onClick={() => bump(-100)}
className="h-7 px-2.5 rounded bg-white/8 hover:bg-white/14 text-[11.5px] font-medium text-white/85 hover:text-white transition-colors focus-ring"
aria-label="Subtitles earlier 100ms"
>
100ms
</button>
<button
onClick={() => reset(0)}
className="flex-1 h-7 px-2.5 rounded text-[11.5px] font-mono font-semibold tabular-nums bg-void/60 border border-border text-text-1 hover:bg-void transition-colors focus-ring"
title="Click to reset"
>
{display}
</button>
<button
onClick={() => bump(100)}
className="h-7 px-2.5 rounded bg-white/8 hover:bg-white/14 text-[11.5px] font-medium text-white/85 hover:text-white transition-colors focus-ring"
aria-label="Subtitles later 100ms"
>
+100ms
</button>
</div>
</Field>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<p className="text-[10.5px] font-medium text-white/55 mb-1 tracking-wide uppercase">{label}</p>
{children}
</div>
)
}
function Seg<T extends string>({
value,
options,
onChange,
}: {
value: T
options: { value: T; label: string }[]
onChange: (v: T) => void
}) {
return (
<div className="inline-flex w-full p-0.5 bg-white/8 border border-white/10 rounded-md">
{options.map(o => {
const active = o.value === value
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={`flex-1 h-7 px-2 rounded text-[11.5px] font-medium tracking-tight transition-colors ${
active ? 'bg-white text-void' : 'text-white/75 hover:text-white hover:bg-white/8'
}`}
>
{o.label}
</button>
)
})}
</div>
)
}
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Users, Plus, Loader2, LogOut } from '../../lib/icons'
import { listGroups, createGroup, joinGroup, leaveGroup, setNewQueue, type SyncPlayGroup } from '../../lib/syncplay'
import { useSyncPlay } from '../../stores/syncplay-store'
import { toast } from '../../stores/toast-store'
interface Props {
open: boolean
onClose: () => void
/** The item the host is currently playing - used to seed the group's
* queue when they create a party so joiners get pushed the right
* item via the server's PlayQueue update. */
currentItemId: string | null
currentPositionTicks: number
}
/**
* Watch-party panel. Slides in from the right, shows the open SyncPlay
* groups on the server, and lets the user create/join/leave one. Once
* the user is in a group, PlayerPage starts mirroring pause/play/seek
* across the group via the SyncPlay REST + WebSocket bridge.
*/
export default function SyncPlayPanel({ open, onClose, currentItemId, currentPositionTicks }: Props) {
const active = useSyncPlay(s => s.active)
const setActive = useSyncPlay(s => s.setActive)
const [groups, setGroups] = useState<SyncPlayGroup[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [newName, setNewName] = useState('')
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
listGroups()
.then(g => {
if (!cancelled) setGroups(g)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, active])
async function onCreate() {
if (!newName.trim() || busy) return
setBusy('create')
const name = newName.trim()
try {
const created = await createGroup(name)
if (created) {
setActive({ groupId: created.groupId, groupName: created.groupName, memberCount: created.participantCount })
// Seed the group queue so joiners get pushed our item. If there's
// no playback yet (creating a party from an empty player) the
// seed is skipped - the host can set it later by starting any
// item, and the server will broadcast a PlayQueue update.
if (currentItemId) {
setNewQueue(currentItemId, currentPositionTicks).catch(() => {})
}
}
toast('Watch party created', 'success')
setNewName('')
setGroups(await listGroups())
} catch {
toast('Could not create watch party', 'error')
} finally {
setBusy(null)
}
}
async function onJoin(g: SyncPlayGroup) {
if (busy) return
setBusy(g.groupId)
try {
await joinGroup(g.groupId)
setActive({ groupId: g.groupId, groupName: g.groupName, memberCount: g.participantCount + 1 })
toast(`Joined ${g.groupName}`, 'success')
} catch {
toast('Could not join that party', 'error')
} finally {
setBusy(null)
}
}
async function onLeave() {
setBusy('leave')
try {
await leaveGroup()
setActive(null)
toast('Left the watch party', 'info')
} catch {
toast('Could not leave', 'error')
} finally {
setBusy(null)
}
}
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[420px] max-w-[88vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
role="dialog"
aria-label="Watch party"
>
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85 inline-flex items-center gap-2">
<Users size={14} />
Watch party
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</header>
<div className="flex-1 overflow-y-auto content-scroll px-4 py-4 space-y-4">
{active ? (
<section className="rounded-lg bg-accent/8 border border-accent/25 p-3.5">
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-accent mb-1">
In a party
</p>
<p className="text-[14px] font-semibold text-white tracking-tight">
{active.groupName}
</p>
<p className="text-[11.5px] text-white/65 mt-0.5">
{active.memberCount} {active.memberCount === 1 ? 'watcher' : 'watchers'}
</p>
<button
onClick={onLeave}
disabled={!!busy}
className="mt-3 inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-[11.5px] font-medium bg-white/10 hover:bg-white/15 text-white border border-white/15 transition-colors focus-ring disabled:opacity-50"
>
<LogOut size={12} stroke={2} />
Leave
</button>
</section>
) : (
<section className="rounded-lg bg-elevated/30 border border-border p-3.5">
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
Start a new party
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
onCreate()
}
}}
placeholder="Friday movie night"
className="flex-1 h-9 px-3 bg-void/55 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 transition-all"
/>
<button
onClick={onCreate}
disabled={!newName.trim() || !!busy}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-accent hover:bg-accent-hover text-void disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-ring"
>
{busy === 'create' ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} stroke={2} />}
Create
</button>
</div>
</section>
)}
<section>
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2.5">
Open parties
</p>
{loading && (
<div className="flex items-center gap-2 text-[12px] text-text-3 py-3 px-2">
<Loader2 size={13} className="animate-spin" />
Looking for parties...
</div>
)}
{!loading && groups.length === 0 && (
<p className="text-[12px] text-text-3 py-3 px-2 leading-snug">
No open watch parties. Start one above and tell a friend the group name.
</p>
)}
{!loading && groups.length > 0 && (
<ul className="space-y-1.5">
{groups.map(g => {
const isActive = active?.groupId === g.groupId
return (
<li key={g.groupId}>
<button
onClick={() => !isActive && onJoin(g)}
disabled={isActive || !!busy}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left focus-ring ${
isActive
? 'bg-accent/10 border-accent/30 cursor-default'
: 'bg-elevated/30 border-border hover:border-border-strong hover:bg-elevated/60'
}`}
>
<span className="w-8 h-8 grid place-items-center rounded-md bg-void/55 shrink-0">
<Users size={13} className="text-text-3" />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-medium text-text-1 truncate tracking-tight">
{g.groupName}
</span>
<span className="block text-[11px] text-text-3 truncate">
{g.participantCount} {g.participantCount === 1 ? 'watcher' : 'watchers'}
</span>
</span>
{busy === g.groupId && <Loader2 size={13} className="text-text-3 animate-spin" />}
</button>
</li>
)
})}
</ul>
)}
</section>
<p className="text-[10.5px] text-text-4 leading-relaxed">
Pause, resume, and seek will mirror to everyone in the party. The host needs to keep their player open for state to flow.
</p>
</div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Check } from '../../lib/icons'
import {
audioFormatLabel,
subtitleLabel,
} from '../../lib/jellyfin-meta'
import { languageLabel } from '../../lib/format'
interface Track {
Index?: number
Codec?: string | null
Profile?: string | null
Language?: string | null
Title?: string | null
Channels?: number | null
ChannelLayout?: string | null
IsDefault?: boolean | null
IsForced?: boolean | null
IsHearingImpaired?: boolean | null
IsExternal?: boolean | null
AudioSpatialFormat?: string | null
Type?: string
}
interface Props {
trigger: React.ReactNode
title: string
tracks: Track[]
selectedIndex: number | null
onSelect: (index: number | null) => void
variant: 'audio' | 'subtitle'
}
export default function TrackMenu({
trigger,
title,
tracks,
selectedIndex,
onSelect,
variant,
}: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) {
window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc)
}
}, [open])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={title}
>
{trigger}
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
role="listbox"
className="absolute right-0 top-full mt-2 w-72 max-h-[min(60vh,420px)] overflow-y-auto content-scroll bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
>
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
{title}
</p>
</div>
<div className="p-1">
{variant === 'subtitle' && (
<TrackOption
selected={selectedIndex === null}
onClick={() => {
onSelect(null)
setOpen(false)
}}
primary="Off"
/>
)}
{tracks.length === 0 && (
<p className="px-3 py-3 text-[12px] text-white/55">No tracks available</p>
)}
{tracks.map(t => {
const idx = t.Index ?? -1
const selected = selectedIndex === idx
const primary =
variant === 'audio'
? audioFormatLabel(t) || t.Title || 'Unknown'
: subtitleLabel(t)
const secondary =
variant === 'audio'
? [languageLabel(t.Language), t.IsDefault && 'Default']
.filter(Boolean)
.join(' · ')
: [t.Codec?.toUpperCase(), t.IsExternal && 'External']
.filter(Boolean)
.join(' · ')
return (
<TrackOption
key={idx}
selected={selected}
onClick={() => {
onSelect(idx)
setOpen(false)
}}
primary={primary}
secondary={secondary}
/>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function TrackOption({
selected,
onClick,
primary,
secondary,
}: {
selected: boolean
onClick: () => void
primary: string
secondary?: string
}) {
return (
<button
onClick={onClick}
role="option"
aria-selected={selected}
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
selected ? 'bg-accent/15' : 'hover:bg-white/8'
}`}
>
<span
className={`w-4 h-4 grid place-items-center shrink-0 ${
selected ? 'text-accent' : 'text-transparent'
}`}
>
<Check size={13} strokeWidth={2.5} />
</span>
<span className="flex-1 min-w-0">
<span
className={`block text-[12.5px] font-medium truncate ${
selected ? 'text-accent' : 'text-white'
}`}
>
{primary}
</span>
{secondary && <span className="block text-[10.5px] text-white/55 truncate">{secondary}</span>}
</span>
</button>
)
}
@@ -0,0 +1,101 @@
/**
* Renders a Jellyfin trickplay thumbnail for a given playback time. Jellyfin
* stores trickplay frames as a sprite sheet ("tile") - one image containing
* many small thumbnails in a grid. The tile's metadata (frame size, tile
* dimensions, interval, count) lives on `item.Trickplay`. We compute which
* tile + which cell within the tile corresponds to the requested second,
* then position a fixed-size box over the right cell with `background-image`
* + `background-position`.
*
* The component returns null silently if trickplay isn't generated for the
* source - that's the most common case when nothing was processed yet.
*/
interface TrickplayInfo {
Width: number
Height: number
TileWidth: number
TileHeight: number
ThumbnailCount: number
Interval: number // ms between frames
Bandwidth?: number
}
interface Props {
item: any
serverUrl: string
token: string
timeSec: number
/** Visible width on screen. Aspect ratio is preserved off the source frame. */
displayWidth?: number
}
/**
* Picks the highest-resolution trickplay variant the server has, across all
* media sources. Jellyfin keys them by MediaSourceId, then by source width
* in pixels (e.g. "320", "640"). Larger wins when present.
*/
function pickBestVariant(
trickplay: any,
): { mediaSourceId: string; info: TrickplayInfo; widthKey: string } | null {
if (!trickplay || typeof trickplay !== 'object') return null
let best: { mediaSourceId: string; info: TrickplayInfo; widthKey: string } | null = null
for (const mediaSourceId of Object.keys(trickplay)) {
const variants = trickplay[mediaSourceId]
if (!variants || typeof variants !== 'object') continue
for (const widthKey of Object.keys(variants)) {
const info = variants[widthKey] as TrickplayInfo
if (!info?.Width || !info?.Height || !info?.TileWidth || !info?.TileHeight) continue
const w = Number(widthKey)
if (!best || w > Number(best.widthKey)) {
best = { mediaSourceId, info, widthKey }
}
}
}
return best
}
export default function TrickplayThumbnail({
item,
serverUrl,
token,
timeSec,
displayWidth = 200,
}: Props) {
const variant = pickBestVariant(item?.Trickplay)
if (!variant || !item?.Id) return null
const { info, widthKey } = variant
const itemId = item.Id as string
const tilesPerSheet = info.TileWidth * info.TileHeight
const thumbIndex = Math.max(
0,
Math.min(info.ThumbnailCount - 1, Math.floor((timeSec * 1000) / info.Interval)),
)
const tileIndex = Math.floor(thumbIndex / tilesPerSheet)
const positionInTile = thumbIndex % tilesPerSheet
const col = positionInTile % info.TileWidth
const row = Math.floor(positionInTile / info.TileWidth)
const tileUrl = `${serverUrl}/Videos/${itemId}/Trickplay/${widthKey}/${tileIndex}.jpg?api_key=${encodeURIComponent(
token,
)}`
const aspectRatio = info.Width / info.Height
const displayHeight = displayWidth / aspectRatio
const scale = displayWidth / info.Width
return (
<div
className="rounded-md ring-1 ring-white/15 shadow-2xl bg-black"
style={{
width: `${displayWidth}px`,
height: `${displayHeight}px`,
backgroundImage: `url("${tileUrl}")`,
backgroundSize: `${info.Width * info.TileWidth * scale}px ${info.Height * info.TileHeight * scale}px`,
backgroundPosition: `-${col * info.Width * scale}px -${row * info.Height * scale}px`,
backgroundRepeat: 'no-repeat',
}}
/>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, X } from '../../lib/icons'
import type { BaseItemDto } from '../../api/types'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
import { formatRuntime } from '../../lib/format'
interface Props {
nextItem?: BaseItemDto | null
visible: boolean
countdownSeconds: number
onSkip: () => void
onDismiss: () => void
}
export default function UpNext({
nextItem,
visible,
countdownSeconds,
onSkip,
onDismiss,
}: Props) {
const navigate = useNavigate()
const serverUrl = getStoredServerUrl()
if (!nextItem) return null
const thumb = getBestImage(serverUrl, nextItem, 'thumb', 480)
const epLabel =
nextItem.ParentIndexNumber != null && nextItem.IndexNumber != null
? `S${nextItem.ParentIndexNumber} · E${nextItem.IndexNumber}`
: ''
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: 30, y: 0 }}
animate={{ opacity: 1, x: 0, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="absolute bottom-32 right-6 w-[360px] glass-strong rounded-xl border border-white/15 overflow-hidden shadow-2xl pointer-events-auto"
>
<button
onClick={onDismiss}
className="absolute top-2 right-2 z-10 w-7 h-7 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-colors focus-ring"
aria-label="Dismiss up next"
>
<X size={14} />
</button>
<div className="relative aspect-video bg-elevated">
{thumb && <img src={thumb} alt="" className="w-full h-full object-cover" />}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-[10px] uppercase tracking-[0.14em] text-accent font-semibold mb-0.5">
Up next in {countdownSeconds}s
</p>
{epLabel && (
<p className="text-[11px] text-white/65 font-mono mb-0.5">{epLabel}</p>
)}
<p className="text-[14px] text-white font-semibold tracking-tight line-clamp-1">
{nextItem.Name}
</p>
</div>
</div>
<div className="p-3 space-y-2">
{nextItem.Overview && (
<p className="text-[11.5px] text-white/65 line-clamp-2 leading-relaxed">{nextItem.Overview}</p>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={onSkip}
className="flex-1 h-9 px-3 bg-white text-void rounded-md flex items-center justify-center gap-1.5 text-[12.5px] font-semibold transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] focus-ring"
>
<Play size={13} fill="currentColor" />
Play now
</button>
<button
onClick={() => nextItem.Id && navigate(`/item/${nextItem.Id}`)}
className="h-9 px-3 bg-white/10 hover:bg-white/15 text-white border border-white/15 rounded-md text-[12.5px] font-medium transition-colors focus-ring"
>
Details
</button>
{nextItem.RunTimeTicks && (
<span className="text-[11px] text-white/55 tabular-nums">
{formatRuntime(nextItem.RunTimeTicks)}
</span>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}