Skip to content

Commit 7f5ba79

Browse files
committed
feat(player): immersive fullscreen player with in-video audio, quality, and subtitles
Redesigns playback as a pure immersive surface — the video fills the viewport on black with every control inside the stage, and the player best-effort enters fullscreen and locks landscape on open (silently falling back to the tab-fill layout where the browser disallows it). The old modal chrome (header, title row, side episode list) is gone; EpisodeList is deleted and the episode switcher now lives in the player as a searchable overlay panel. New in-video capabilities: - Settings panel with Quality / Speed / Audio / Subtitles tabs. Quality and Audio lead with hub-based entries derived from the movie's linkList — one entry per advertised resolution (480p/720p/1080p) or per audio language (multi-language WEB-DLs) — and fall back to manifest levels/tracks when the stream is a plain HLS rendition. Switching re-resolves the stream from that hub with failure memory, so a dead link is skipped on retry; the winning hub is matched against the original stream to preselect the active option. Original language is tagged and leads the Audio menu. - External caption registration: providers attach subtitle tracks (vtt/srt/ass/json) to stream sources; subtitleTracksFrom normalizes the wildly varying provider shapes and registers them as Vidstack text tracks, with a dedicated Subtitles shortcut button and no-stacking track switching. - Transport-cluster prev/next episode buttons (disabled at list boundaries) plus Shift+P / Shift+N keyboard shortcuts. - Safe-area aware top/bottom bars (env(safe-area-inset-*)) and a portrait rotate hint that auto-dismisses. Wiring and state: - App resolves and carries audioLanguages/hubQualities through the player/loading -> playing -> error cycle so menus stay populated while a switch is in flight, and re-derives them when a fallback hub takes over after sources are exhausted. - Reducer player states carry the new optional audio/quality fields. - DetailModal drops its old quality/season picker (superseded by the in-player settings) and gains dialog semantics: role=dialog, aria-modal, labelled by title, focus moved inside on open, Escape to close.
1 parent 0f3a471 commit 7f5ba79

9 files changed

Lines changed: 927 additions & 264 deletions

File tree

src/components/features/player/DetailModal.tsx

Lines changed: 24 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
import { Bookmark, Check, Film, Globe, Play, Star, X } from 'lucide-react';
44
import { motion } from 'motion/react';
55
import Image from 'next/image';
6-
import { useEffect, useMemo, useState } from 'react';
6+
import { useEffect, useMemo, useRef, useState } from 'react';
77
import { DURATIONS, EASE, SPRING_SOFT } from '@/components/motion/transitions';
88
import { Button } from '@/components/ui/button';
9-
import { type Media, type Meta, sortLinkListByQuality, titleFor } from '@/lib/api/client';
9+
import { type Media, type Meta, titleFor } from '@/lib/api/client';
1010
import { useScrollLock } from '@/lib/hooks/useScrollLock';
1111
import { imageUrl } from '@/lib/media/images';
12-
import { cn } from '@/lib/utils';
1312

1413
// Hoisted: created once instead of on every DetailModal render.
1514
const RATING_PATTERN = /\s*\/\s*10$/i;
@@ -19,7 +18,6 @@ type Props = {
1918
item: Media;
2019
meta?: Meta;
2120
inLibrary: boolean;
22-
excludedQualities?: string[];
2321
onClose: () => void;
2422
onPlay: (item: Media, hub?: string) => void;
2523
onToggleLibrary: (item: Media) => void;
@@ -31,7 +29,6 @@ export function DetailModal({
3129
item,
3230
meta,
3331
inLibrary,
34-
excludedQualities = [],
3532
onClose,
3633
onPlay,
3734
onToggleLibrary,
@@ -48,34 +45,12 @@ export function DetailModal({
4845
const year =
4946
(meta?.title || title).match(YEAR_PATTERN)?.[1] ??
5047
meta?.tags?.find((tag) => /^\d{4}$/.test(String(tag)));
51-
// Memoized: the auto-select effect watches `entries`, so a fresh array per
52-
// render would re-run it (and the modal's other effects) on every paint.
53-
const entries = useMemo(
54-
() =>
55-
sortLinkListByQuality(meta?.linkList).filter((entry) => {
56-
if (excludedQualities.length === 0) return true;
57-
const q = (entry.quality || entry.title || '').toLowerCase();
58-
return !excludedQualities.some((excluded) => q.includes(excluded.toLowerCase()));
59-
}),
60-
[meta?.linkList, excludedQualities],
61-
);
6248
const metadata = useMemo(
6349
() => [...(meta?.tags ?? [])].filter(Boolean).map(String).slice(0, 6),
6450
[meta?.tags],
6551
);
6652
const [logoFailed, setLogoFailed] = useState(false);
6753
const [readMore, setReadMore] = useState(false);
68-
const [activeHub, setActiveHub] = useState<string | undefined>(undefined);
69-
70-
// Once meta arrives, default the selection to the best available quality.
71-
// The modal mounts before meta resolves (onOpen fetches it async), so the
72-
// picker can't pick a default from state initializers.
73-
useEffect(() => {
74-
if (activeHub) return;
75-
const entry = entries[0];
76-
const hub = entry?.directLinks?.[0]?.link ?? entry?.episodesLink;
77-
if (hub) setActiveHub(hub);
78-
}, [entries, activeHub]);
7954

8055
// biome-ignore lint/correctness/useExhaustiveDependencies: re-runs on purpose so a new logo resets the failure flag.
8156
useEffect(() => {
@@ -87,6 +62,20 @@ export function DetailModal({
8762
// nested modal unmount can't leave the body permanently locked.
8863
useScrollLock();
8964

65+
const rootRef = useRef<HTMLDivElement | null>(null);
66+
67+
// Move keyboard focus into the modal on open so tabbing starts inside it,
68+
// and let Escape close it (mirrors the player and the native dialog
69+
// behaviour users expect).
70+
useEffect(() => {
71+
rootRef.current?.focus({ preventScroll: true });
72+
const onKey = (event: KeyboardEvent) => {
73+
if (event.key === 'Escape') onClose();
74+
};
75+
window.addEventListener('keydown', onKey);
76+
return () => window.removeEventListener('keydown', onKey);
77+
}, [onClose]);
78+
9079
const synopsisText =
9180
synopsis.length > 240 && !readMore ? `${synopsis.slice(0, 240)}...` : synopsis;
9281

@@ -95,7 +84,12 @@ export function DetailModal({
9584
return (
9685
// AnimatePresence in App drives the enter/exit; transform/opacity only.
9786
<motion.div
98-
className="fixed inset-0 z-40 flex flex-col bg-background pt-safe sm:grid sm:place-items-center sm:bg-background/80 sm:p-4 sm:backdrop-blur-sm sm:pt-0"
87+
ref={rootRef}
88+
role="dialog"
89+
aria-modal="true"
90+
aria-label={title}
91+
tabIndex={-1}
92+
className="fixed inset-0 z-40 flex flex-col bg-background pt-safe outline-hidden sm:grid sm:place-items-center sm:bg-background/80 sm:p-4 sm:backdrop-blur-sm sm:pt-0"
9993
initial={{ opacity: 0 }}
10094
animate={{ opacity: 1 }}
10195
exit={{ opacity: 0 }}
@@ -190,14 +184,14 @@ export function DetailModal({
190184
</div>
191185
</div>
192186

193-
<div className="flex min-w-0 flex-1 flex-col gap-5 overflow-y-auto p-5 sm:gap-6 sm:p-8">
187+
<div className="flex min-w-0 flex-1 flex-col gap-5 overflow-y-auto p-5 pb-12 sm:gap-6 sm:p-8">
194188
{/* Primary actions lead the content — watch is one tap from the
195189
artwork, with save/web secondary and tertiary. */}
196190
<div className="flex items-center gap-2">
197191
<Button
198192
size="lg"
199193
className="touch-target flex-1 justify-center transition-transform duration-200 ease-out hover:scale-[1.02] active:scale-[0.97] motion-reduce:transition-none motion-reduce:hover:scale-100 sm:flex-none sm:px-8"
200-
onClick={() => onPlay(item, activeHub)}
194+
onClick={() => onPlay(item)}
201195
onMouseEnter={onPreloadPlay}
202196
onFocus={onPreloadPlay}
203197
>
@@ -256,44 +250,6 @@ export function DetailModal({
256250
)}
257251
</div>
258252

259-
{entries.length > 0 && (
260-
<div>
261-
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
262-
{entries.some((e) => e.directLinks?.length) ? 'Quality' : 'Season'}
263-
</p>
264-
<div className="flex flex-wrap gap-2">
265-
{entries.map((entry) => {
266-
const hub = entry.directLinks?.[0]?.link ?? entry.episodesLink;
267-
const label = entry.title || entry.quality || 'Source';
268-
const selected = hub === activeHub;
269-
return (
270-
<button
271-
key={hub || label}
272-
type="button"
273-
onClick={() => hub && setActiveHub(hub)}
274-
aria-pressed={selected}
275-
className={cn(
276-
'touch-target relative rounded-full px-4 py-2 text-sm font-medium transition-colors',
277-
selected
278-
? 'text-background'
279-
: 'text-muted-foreground hover:text-foreground',
280-
)}
281-
>
282-
{selected && (
283-
<motion.span
284-
layoutId="detail-quality-pill"
285-
className="absolute inset-0 rounded-full bg-foreground"
286-
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
287-
/>
288-
)}
289-
<span className="relative z-10">{label}</span>
290-
</button>
291-
);
292-
})}
293-
</div>
294-
</div>
295-
)}
296-
297253
{meta?.cast && meta.cast.length > 0 && (
298254
<div>
299255
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">

src/components/features/player/EpisodeList.tsx

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)