mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-10 09:05:11 +00:00
refactor: split HeroSection.tsx into styles, small parts, and a trailer hook
HeroSection.tsx dropped from 997 to 345 lines. Style objects move to heroStyles.ts, NavArrow/HeroIconBtn/helpers move to HeroSectionParts.tsx, and all trailer resolve/playback/subtitle state moves to useHeroTrailer.
This commit is contained in:
parent
8caab8264a
commit
7ef4242151
4 changed files with 711 additions and 668 deletions
|
|
@ -1,12 +1,11 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Info, Maximize2, Play, Plus, Volume2, VolumeX } from 'lucide-react';
|
||||
import { seasonPosterUrl } from '../core/seasonPosters';
|
||||
import { youtubeVideoId } from './detail/TrailerCarousel';
|
||||
import { httpFetchText } from '../core/engine';
|
||||
import { resolveYoutubeTrailer, type YoutubeTrailerSubtitleTrack } from '../core/effectRunner';
|
||||
import { normalizeTrailerSubtitleUrl, parseTrailerSubtitleCues, selectTrailerSubtitle, type TrailerCue } from '../core/trailerSubtitles';
|
||||
import type { Meta } from '../core/types';
|
||||
import { t } from '../i18n';
|
||||
import { heroKeyframes, heroStyles as styles } from './heroStyles';
|
||||
import { HeroIconBtn, NavArrow, parseReleaseYear, readOptionalString } from './HeroSectionParts';
|
||||
import { useHeroTrailer } from './useHeroTrailer';
|
||||
|
||||
interface Props {
|
||||
meta: Meta;
|
||||
|
|
@ -23,22 +22,9 @@ interface Props {
|
|||
}
|
||||
|
||||
const DEFAULT_SLIDE_INTERVAL_MS = 6500;
|
||||
const STALL_TIMEOUT_MS = 7000;
|
||||
const PANEL_LEFT = '7.5rem';
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const keyframes = `
|
||||
@keyframes heroKenBurns {
|
||||
from { transform: scale(1); }
|
||||
to { transform: scale(1.06); }
|
||||
}
|
||||
@keyframes heroIndicatorFill {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
`;
|
||||
|
||||
export const HeroSection = React.memo(function HeroSection({
|
||||
meta,
|
||||
slides,
|
||||
|
|
@ -72,45 +58,17 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
|
||||
const activeMeta = items[activeIndex] ?? meta;
|
||||
const canSlide = items.length > 1;
|
||||
|
||||
const {
|
||||
trailerContainerRef, trailerVideoRef, trailerAudioRef,
|
||||
trailerStreamUrl, trailerAudioUrl, trailerReady, trailerActive, trailerPending, trailerProgress, trailerMuted, activeTrailerSubtitle,
|
||||
handleTrailerPlaying, handleTrailerTimeUpdate, handleTrailerStopped, toggleTrailerMute, fullscreenTrailer,
|
||||
} = useHeroTrailer({ activeMeta, isActive, autoplayTrailer, autoplayTrailerDelaySecs, preferredSubtitleLanguage, secondarySubtitleLanguage });
|
||||
|
||||
const slideIntervalMs = autoplayTrailer ? Math.max(DEFAULT_SLIDE_INTERVAL_MS, autoplayTrailerDelaySecs * 1000 + 3000) : DEFAULT_SLIDE_INTERVAL_MS;
|
||||
const imageUrl = (preferSeasonPosters ? seasonPosterUrl(activeMeta) : undefined) ?? activeMeta.background ?? activeMeta.poster;
|
||||
const bgUrl = !bgError ? imageUrl : null;
|
||||
const logoUrl = !logoError ? activeMeta.logo : null;
|
||||
const trailerVideoId = useMemo(() => {
|
||||
for (const trailer of activeMeta.trailers ?? []) {
|
||||
const id = youtubeVideoId(trailer.url);
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
}, [activeMeta.trailers]);
|
||||
const [trailerStreamUrl, setTrailerStreamUrl] = useState<string | null>(null);
|
||||
const [trailerAudioUrl, setTrailerAudioUrl] = useState<string | null>(null);
|
||||
const [trailerSubtitles, setTrailerSubtitles] = useState<YoutubeTrailerSubtitleTrack[]>([]);
|
||||
const [trailerSubtitleCues, setTrailerSubtitleCues] = useState<TrailerCue[]>([]);
|
||||
const [activeTrailerSubtitle, setActiveTrailerSubtitle] = useState('');
|
||||
const [trailerReady, setTrailerReady] = useState(false);
|
||||
const [trailerResolving, setTrailerResolving] = useState(false);
|
||||
const [trailerLoading, setTrailerLoading] = useState(false);
|
||||
const [trailerProgress, setTrailerProgress] = useState(0);
|
||||
const [trailerMuted, setTrailerMuted] = useState(true);
|
||||
const lastTrailerProgressAtRef = useRef(0);
|
||||
const trailerVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const trailerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const trailerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeTrailerSubtitleRef = useRef('');
|
||||
const trailerActive = !!trailerStreamUrl && trailerReady;
|
||||
const trailerPending = trailerResolving || trailerLoading || !!trailerStreamUrl;
|
||||
const [selectedTrailerSubtitle, setSelectedTrailerSubtitle] = useState<YoutubeTrailerSubtitleTrack | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
selectTrailerSubtitle(trailerSubtitles, preferredSubtitleLanguage, secondarySubtitleLanguage).then((track) => {
|
||||
if (!cancelled) setSelectedTrailerSubtitle(track);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trailerSubtitles, preferredSubtitleLanguage, secondarySubtitleLanguage]);
|
||||
|
||||
const imdbNum = activeMeta.imdbRating != null ? Number(activeMeta.imdbRating) : NaN;
|
||||
const releaseYear = activeMeta.year ?? parseReleaseYear(activeMeta.releaseInfo);
|
||||
|
|
@ -133,147 +91,6 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
setLogoError(false);
|
||||
}, [activeMeta.id, imageUrl, activeMeta.logo]);
|
||||
|
||||
useEffect(() => {
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerAudioUrl(null);
|
||||
setTrailerSubtitles([]);
|
||||
setTrailerSubtitleCues([]);
|
||||
setActiveTrailerSubtitle('');
|
||||
activeTrailerSubtitleRef.current = '';
|
||||
setTrailerReady(false);
|
||||
setTrailerProgress(0);
|
||||
setTrailerResolving(false);
|
||||
setTrailerLoading(false);
|
||||
setTrailerMuted(true);
|
||||
}, [activeMeta.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplayTrailer || !isActive || !trailerVideoId) return;
|
||||
let cancelled = false;
|
||||
let delayElapsed = autoplayTrailerDelaySecs <= 0;
|
||||
let resolvedTrailer: Awaited<ReturnType<typeof resolveYoutubeTrailer>> | null = null;
|
||||
let resolveFinished = false;
|
||||
setTrailerResolving(true);
|
||||
|
||||
const applyResolvedTrailer = () => {
|
||||
if (cancelled || !delayElapsed || !resolveFinished) return;
|
||||
if (resolvedTrailer?.streamUrl) {
|
||||
setTrailerSubtitles(resolvedTrailer.subtitles ?? []);
|
||||
setTrailerAudioUrl(resolvedTrailer.audioUrl ?? null);
|
||||
setTrailerReady(false);
|
||||
setTrailerLoading(true);
|
||||
setTrailerStreamUrl(resolvedTrailer.streamUrl);
|
||||
}
|
||||
setTrailerResolving(false);
|
||||
if (!resolvedTrailer?.streamUrl) setTrailerLoading(false);
|
||||
};
|
||||
|
||||
const delayId = window.setTimeout(() => {
|
||||
delayElapsed = true;
|
||||
if (!resolveFinished) setTrailerLoading(true);
|
||||
applyResolvedTrailer();
|
||||
}, autoplayTrailerDelaySecs * 1000);
|
||||
|
||||
resolveYoutubeTrailer(trailerVideoId).then((resolved) => {
|
||||
if (cancelled) return;
|
||||
resolvedTrailer = resolved;
|
||||
resolveFinished = true;
|
||||
applyResolvedTrailer();
|
||||
}).catch((err) => {
|
||||
console.error('resolveYoutubeTrailerUrl failed', err);
|
||||
resolveFinished = true;
|
||||
if (!cancelled) {
|
||||
setTrailerResolving(false);
|
||||
if (delayElapsed) setTrailerLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setTrailerResolving(false);
|
||||
window.clearTimeout(delayId);
|
||||
};
|
||||
}, [trailerVideoId, autoplayTrailer, autoplayTrailerDelaySecs, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTrailerSubtitleCues([]);
|
||||
setActiveTrailerSubtitle('');
|
||||
activeTrailerSubtitleRef.current = '';
|
||||
if (!selectedTrailerSubtitle?.url || !trailerStreamUrl) return;
|
||||
|
||||
normalizeTrailerSubtitleUrl(selectedTrailerSubtitle.url).then(httpFetchText).then(async (response) => {
|
||||
if (cancelled || response.statusCode < 200 || response.statusCode > 299 || !response.body.trim()) return;
|
||||
const cues = await parseTrailerSubtitleCues(response.body);
|
||||
if (cancelled) return;
|
||||
setTrailerSubtitleCues(cues);
|
||||
updateActiveTrailerSubtitle(trailerVideoRef.current?.currentTime ?? 0, cues);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedTrailerSubtitle?.url, trailerStreamUrl]);
|
||||
|
||||
function updateActiveTrailerSubtitle(time: number, cues = trailerSubtitleCues) {
|
||||
const text = cues.find((cue) => time >= cue.start && time <= cue.end)?.text ?? '';
|
||||
if (text !== activeTrailerSubtitleRef.current) {
|
||||
activeTrailerSubtitleRef.current = text;
|
||||
setActiveTrailerSubtitle(text);
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrailerAudio(shouldPlay = false) {
|
||||
if (!trailerAudioUrl) return;
|
||||
const video = trailerVideoRef.current;
|
||||
const audio = trailerAudioRef.current;
|
||||
if (!video || !audio) return;
|
||||
if (Number.isFinite(video.currentTime) && Math.abs(audio.currentTime - video.currentTime) > 0.35) {
|
||||
audio.currentTime = video.currentTime;
|
||||
}
|
||||
audio.muted = trailerMuted;
|
||||
audio.volume = trailerMuted ? 0 : 1;
|
||||
if (trailerMuted || video.paused || video.ended) {
|
||||
audio.pause();
|
||||
} else if (shouldPlay || audio.paused) {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!trailerStreamUrl) return;
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
const id = window.setInterval(() => {
|
||||
if (Date.now() - lastTrailerProgressAtRef.current > STALL_TIMEOUT_MS) {
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerLoading(false);
|
||||
}
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [trailerStreamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trailerStreamUrl) return;
|
||||
const el = trailerVideoRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && el.paused && !el.ended) {
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
}, { threshold: 0.05 });
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [trailerStreamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = trailerVideoRef.current;
|
||||
if (!el) return;
|
||||
el.muted = trailerMuted;
|
||||
el.volume = trailerMuted ? 0 : 1;
|
||||
syncTrailerAudio(!trailerMuted);
|
||||
}, [trailerMuted, trailerAudioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingRef.current) clearTimeout(pendingRef.current);
|
||||
|
|
@ -324,27 +141,13 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
else if (e.key === 'ArrowRight') { e.preventDefault(); goTo(activeIndex + 1); }
|
||||
};
|
||||
|
||||
const fullscreenTrailer = () => {
|
||||
const container = trailerContainerRef.current;
|
||||
if (!container) return;
|
||||
const fullscreenTarget = container as HTMLDivElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
const request = fullscreenTarget.requestFullscreen?.bind(fullscreenTarget)
|
||||
?? fullscreenTarget.webkitRequestFullscreen?.bind(fullscreenTarget);
|
||||
try {
|
||||
const result = request?.();
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={styles.hero}
|
||||
tabIndex={canSlide ? 0 : -1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<style>{keyframes}</style>
|
||||
<style>{heroKeyframes}</style>
|
||||
{bgUrl && (
|
||||
<img
|
||||
key={activeMeta.id || activeIndex}
|
||||
|
|
@ -373,36 +176,10 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
autoPlay
|
||||
muted={trailerMuted}
|
||||
playsInline
|
||||
onPlaying={() => {
|
||||
setTrailerReady(true);
|
||||
setTrailerLoading(false);
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
if (trailerVideoRef.current) {
|
||||
trailerVideoRef.current.muted = trailerMuted;
|
||||
trailerVideoRef.current.volume = trailerMuted ? 0 : 1;
|
||||
}
|
||||
syncTrailerAudio(true);
|
||||
updateActiveTrailerSubtitle(trailerVideoRef.current?.currentTime ?? 0);
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const el = e.currentTarget;
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
if (el.duration > 0) setTrailerProgress(el.currentTime / el.duration);
|
||||
syncTrailerAudio(false);
|
||||
updateActiveTrailerSubtitle(el.currentTime);
|
||||
}}
|
||||
onEnded={() => {
|
||||
trailerAudioRef.current?.pause();
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerAudioUrl(null);
|
||||
setTrailerLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
trailerAudioRef.current?.pause();
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerAudioUrl(null);
|
||||
setTrailerLoading(false);
|
||||
}}
|
||||
onPlaying={handleTrailerPlaying}
|
||||
onTimeUpdate={(e) => handleTrailerTimeUpdate(e.currentTarget)}
|
||||
onEnded={handleTrailerStopped}
|
||||
onError={handleTrailerStopped}
|
||||
/>
|
||||
)}
|
||||
{trailerAudioUrl && (
|
||||
|
|
@ -436,27 +213,7 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
|
||||
{trailerActive && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newMutedState = !trailerMuted;
|
||||
setTrailerMuted(newMutedState);
|
||||
if (trailerVideoRef.current) {
|
||||
trailerVideoRef.current.muted = newMutedState;
|
||||
trailerVideoRef.current.volume = newMutedState ? 0 : 1;
|
||||
if (!newMutedState && trailerVideoRef.current.paused) {
|
||||
trailerVideoRef.current.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (trailerAudioRef.current && trailerVideoRef.current) {
|
||||
trailerAudioRef.current.muted = newMutedState;
|
||||
trailerAudioRef.current.volume = newMutedState ? 0 : 1;
|
||||
if (newMutedState) {
|
||||
trailerAudioRef.current.pause();
|
||||
} else {
|
||||
trailerAudioRef.current.currentTime = trailerVideoRef.current.currentTime;
|
||||
trailerAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={toggleTrailerMute}
|
||||
style={styles.trailerMuteButton}
|
||||
aria-label={trailerMuted ? 'Unmute' : 'Mute'}
|
||||
onMouseEnter={(e) => {
|
||||
|
|
@ -586,412 +343,3 @@ export const HeroSection = React.memo(function HeroSection({
|
|||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function NavArrow({ direction, onClick }: { direction: 'left' | 'right'; onClick: () => void }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
aria-label={direction === 'left' ? 'Previous' : 'Next'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(29vh - 2.5rem)',
|
||||
...(direction === 'left' ? { left: '1.25rem' } : { right: '0.875rem' }),
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: hovered ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)',
|
||||
fontSize: '3rem',
|
||||
fontWeight: 300,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
width: '3.75rem',
|
||||
height: '4.375rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 15,
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.3s ease, transform 0.3s ease, text-shadow 0.3s ease',
|
||||
textShadow: hovered
|
||||
? '0 0 0.9375rem rgba(255,255,255,0.8), 0 0 1.5625rem rgba(255,255,255,0.5), 0.125rem 0.125rem 0.1875rem rgba(0,0,0,0.9)'
|
||||
: '0.125rem 0.125rem 0.25rem rgba(0,0,0,0.8)',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
>
|
||||
{direction === 'left' ? '‹' : '›'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroIconBtn({ onClick, title, ariaLabel, children }: { onClick?: () => void; title?: string; ariaLabel?: string; children: React.ReactNode }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
title={title}
|
||||
aria-label={ariaLabel ?? title}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
width: '2.625rem',
|
||||
height: '2.625rem',
|
||||
borderRadius: '50%',
|
||||
background: hovered ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.10)',
|
||||
border: '0.125rem solid rgba(255,255,255,0.55)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
boxShadow: '0 0.25rem 1rem rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function parseReleaseYear(releaseInfo?: string): number | null {
|
||||
const match = releaseInfo?.match(/\b(19|20)\d{2}\b/);
|
||||
return match ? Number(match[0]) : null;
|
||||
}
|
||||
|
||||
function readOptionalString(meta: Meta, keys: string[]): string | null {
|
||||
const record = meta as unknown as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
hero: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'var(--hero-height, clamp(38rem, 66vh, 54rem))' as unknown as number,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
background: '#040508',
|
||||
willChange: 'transform',
|
||||
},
|
||||
backdrop: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center 20%',
|
||||
display: 'block',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
transformOrigin: 'center 30%',
|
||||
},
|
||||
trailerContainer: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
},
|
||||
trailerFrame: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
border: 'none',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
trailerSubtitleOverlay: {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 'clamp(3.25rem, 8vh, 5.75rem)' as unknown as number,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 18,
|
||||
maxWidth: 'min(64rem, calc(100% - 12rem))',
|
||||
padding: '0.28rem 0.65rem',
|
||||
borderRadius: '0.25rem',
|
||||
background: 'rgba(0,0,0,0.58)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 'clamp(1.1rem, 2.05vw, 1.8rem)' as unknown as number,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.28,
|
||||
textAlign: 'center',
|
||||
textShadow: '0 0.125rem 0.25rem rgba(0,0,0,0.9)',
|
||||
whiteSpace: 'pre-line',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
gradientTop: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to bottom, rgba(4,5,8,0.55) 0%, rgba(4,5,8,0.20) 12%, rgba(4,5,8,0.00) 28%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
gradientLeft: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: [
|
||||
'linear-gradient(to right,',
|
||||
'rgba(4,5,8,1.00) 0%,',
|
||||
'rgba(4,5,8,0.99) 22%,',
|
||||
'rgba(4,5,8,0.96) 34%,',
|
||||
'rgba(4,5,8,0.88) 46%,',
|
||||
'rgba(4,5,8,0.72) 56%,',
|
||||
'rgba(4,5,8,0.40) 68%,',
|
||||
'rgba(4,5,8,0.10) 80%,',
|
||||
'rgba(4,5,8,0.00) 90%)',
|
||||
].join(' '),
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 72%, rgba(0,0,0,0.65) 86%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 72%, rgba(0,0,0,0.65) 86%, transparent 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
gradientBottom: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: [
|
||||
'linear-gradient(to bottom,',
|
||||
'rgba(4,5,8,0.00) 0%,',
|
||||
'rgba(4,5,8,0.00) 52%,',
|
||||
'rgba(4,5,8,0.30) 70%,',
|
||||
'rgba(4,5,8,0.76) 88%,',
|
||||
'#040508 100%)',
|
||||
].join(' '),
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
panel: {
|
||||
position: 'absolute',
|
||||
bottom: 'clamp(3rem, 7vh, 5rem)' as unknown as number,
|
||||
left: PANEL_LEFT,
|
||||
maxWidth: '36.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
logo: {
|
||||
height: 'clamp(5rem, 13vh, 12.5rem)' as unknown as number,
|
||||
maxWidth: '33.75rem',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'left center',
|
||||
filter: 'drop-shadow(0 0.25rem 0.75rem rgba(0,0,0,0.65)) drop-shadow(0 0 1px rgba(255,255,255,0.25))',
|
||||
userSelect: 'none',
|
||||
marginBottom: '1.375rem',
|
||||
transition: 'height 0.35s ease, max-width 0.35s ease, margin-bottom 0.35s ease',
|
||||
},
|
||||
logoTrailerActive: {
|
||||
height: 'clamp(3rem, 8vh, 6.5rem)' as unknown as number,
|
||||
maxWidth: '24rem',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 'clamp(2.4rem, 5vw, 5rem)' as unknown as number,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.0,
|
||||
margin: '0 0 1.375rem 0',
|
||||
fontFamily: "'Montserrat', sans-serif",
|
||||
textShadow: '0 0.25rem 0.5rem rgba(0,0,0,0.6)',
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
tagline: {
|
||||
color: 'rgba(255,255,255,0.88)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'italic',
|
||||
margin: '0 0 1.25rem 0',
|
||||
textShadow: '0 0.125rem 0.5rem rgba(0,0,0,0.7)',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
metaLine: {
|
||||
color: 'rgb(170, 170, 170)',
|
||||
fontSize: '0.875rem',
|
||||
margin: '0 0 1rem 0',
|
||||
fontWeight: 400,
|
||||
textShadow: '0 0.125rem 0.25rem rgba(0,0,0,0.8)',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
metaRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.125rem',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
imdbBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
flexShrink: 0,
|
||||
},
|
||||
imdbLogo: {
|
||||
height: '1rem',
|
||||
width: 'auto',
|
||||
display: 'block',
|
||||
borderRadius: '0.1875rem',
|
||||
userSelect: 'none',
|
||||
},
|
||||
imdbScore: {
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
certBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '1px 0.3125rem 0.125rem',
|
||||
border: '1px solid rgba(255,255,255,0.50)',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
borderRadius: '0.125rem',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.02em',
|
||||
flexShrink: 0,
|
||||
},
|
||||
genreText: {
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
description: {
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 0 0',
|
||||
maxWidth: '30rem',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
overflow: 'hidden',
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
awards: {
|
||||
color: 'rgba(255,255,255,0.65)',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.45,
|
||||
margin: '0.75rem 0 0',
|
||||
maxWidth: '30rem',
|
||||
fontWeight: 500,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
marginTop: '1.5rem',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
watchBtn: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5625rem',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '0.4375rem',
|
||||
padding: '0.5625rem 1.375rem',
|
||||
fontSize: '0.925rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'Montserrat', sans-serif",
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
boxShadow: '0 0.5rem 2rem rgba(0,0,0,0.3), 0 0.25rem 1rem rgba(0,0,0,0.1)',
|
||||
},
|
||||
indicators: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '0.375rem',
|
||||
zIndex: 10,
|
||||
padding: '0.625rem 1rem',
|
||||
},
|
||||
indicatorTrack: {
|
||||
width: '1.75rem',
|
||||
height: '0.1875rem',
|
||||
borderRadius: '62.4375rem',
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
indicatorFill: {
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
background: 'rgba(255,255,255,0.90)',
|
||||
borderRadius: '62.4375rem',
|
||||
},
|
||||
indicatorFillDone: {
|
||||
width: '100%',
|
||||
},
|
||||
trailerProgressTrack: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '13.75rem',
|
||||
height: '0.1875rem',
|
||||
borderRadius: '62.4375rem',
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
},
|
||||
trailerProgressFill: {
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
background: 'rgba(255,255,255,0.90)',
|
||||
borderRadius: '62.4375rem',
|
||||
},
|
||||
trailerMuteButton: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 20,
|
||||
transition: 'background 0.2s ease, border-color 0.2s ease',
|
||||
},
|
||||
trailerFullscreenButton: {
|
||||
position: 'absolute',
|
||||
top: '1.5rem',
|
||||
left: '1.5rem',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 20,
|
||||
transition: 'background 0.2s ease, border-color 0.2s ease',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
86
src/components/HeroSectionParts.tsx
Normal file
86
src/components/HeroSectionParts.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { Meta } from '../core/types';
|
||||
|
||||
export function NavArrow({ direction, onClick }: { direction: 'left' | 'right'; onClick: () => void }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
aria-label={direction === 'left' ? 'Previous' : 'Next'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(29vh - 2.5rem)',
|
||||
...(direction === 'left' ? { left: '1.25rem' } : { right: '0.875rem' }),
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: hovered ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)',
|
||||
fontSize: '3rem',
|
||||
fontWeight: 300,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
width: '3.75rem',
|
||||
height: '4.375rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 15,
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.3s ease, transform 0.3s ease, text-shadow 0.3s ease',
|
||||
textShadow: hovered
|
||||
? '0 0 0.9375rem rgba(255,255,255,0.8), 0 0 1.5625rem rgba(255,255,255,0.5), 0.125rem 0.125rem 0.1875rem rgba(0,0,0,0.9)'
|
||||
: '0.125rem 0.125rem 0.25rem rgba(0,0,0,0.8)',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
>
|
||||
{direction === 'left' ? '‹' : '›'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroIconBtn({ onClick, title, ariaLabel, children }: { onClick?: () => void; title?: string; ariaLabel?: string; children: React.ReactNode }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
title={title}
|
||||
aria-label={ariaLabel ?? title}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
width: '2.625rem',
|
||||
height: '2.625rem',
|
||||
borderRadius: '50%',
|
||||
background: hovered ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.10)',
|
||||
border: '0.125rem solid rgba(255,255,255,0.55)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
boxShadow: '0 0.25rem 1rem rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseReleaseYear(releaseInfo?: string): number | null {
|
||||
const match = releaseInfo?.match(/\b(19|20)\d{2}\b/);
|
||||
return match ? Number(match[0]) : null;
|
||||
}
|
||||
|
||||
export function readOptionalString(meta: Meta, keys: string[]): string | null {
|
||||
const record = meta as unknown as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
339
src/components/heroStyles.ts
Normal file
339
src/components/heroStyles.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import type React from 'react';
|
||||
|
||||
export const PANEL_LEFT = '7.5rem';
|
||||
|
||||
export const heroKeyframes = `
|
||||
@keyframes heroKenBurns {
|
||||
from { transform: scale(1); }
|
||||
to { transform: scale(1.06); }
|
||||
}
|
||||
@keyframes heroIndicatorFill {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
`;
|
||||
|
||||
export const heroStyles: Record<string, React.CSSProperties> = {
|
||||
hero: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'var(--hero-height, clamp(38rem, 66vh, 54rem))' as unknown as number,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
background: '#040508',
|
||||
willChange: 'transform',
|
||||
},
|
||||
backdrop: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center 20%',
|
||||
display: 'block',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
transformOrigin: 'center 30%',
|
||||
},
|
||||
trailerContainer: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
},
|
||||
trailerFrame: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
border: 'none',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
trailerSubtitleOverlay: {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 'clamp(3.25rem, 8vh, 5.75rem)' as unknown as number,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 18,
|
||||
maxWidth: 'min(64rem, calc(100% - 12rem))',
|
||||
padding: '0.28rem 0.65rem',
|
||||
borderRadius: '0.25rem',
|
||||
background: 'rgba(0,0,0,0.58)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 'clamp(1.1rem, 2.05vw, 1.8rem)' as unknown as number,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.28,
|
||||
textAlign: 'center',
|
||||
textShadow: '0 0.125rem 0.25rem rgba(0,0,0,0.9)',
|
||||
whiteSpace: 'pre-line',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
gradientTop: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to bottom, rgba(4,5,8,0.55) 0%, rgba(4,5,8,0.20) 12%, rgba(4,5,8,0.00) 28%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
gradientLeft: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: [
|
||||
'linear-gradient(to right,',
|
||||
'rgba(4,5,8,1.00) 0%,',
|
||||
'rgba(4,5,8,0.99) 22%,',
|
||||
'rgba(4,5,8,0.96) 34%,',
|
||||
'rgba(4,5,8,0.88) 46%,',
|
||||
'rgba(4,5,8,0.72) 56%,',
|
||||
'rgba(4,5,8,0.40) 68%,',
|
||||
'rgba(4,5,8,0.10) 80%,',
|
||||
'rgba(4,5,8,0.00) 90%)',
|
||||
].join(' '),
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 72%, rgba(0,0,0,0.65) 86%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 72%, rgba(0,0,0,0.65) 86%, transparent 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
gradientBottom: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: [
|
||||
'linear-gradient(to bottom,',
|
||||
'rgba(4,5,8,0.00) 0%,',
|
||||
'rgba(4,5,8,0.00) 52%,',
|
||||
'rgba(4,5,8,0.30) 70%,',
|
||||
'rgba(4,5,8,0.76) 88%,',
|
||||
'#040508 100%)',
|
||||
].join(' '),
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
panel: {
|
||||
position: 'absolute',
|
||||
bottom: 'clamp(3rem, 7vh, 5rem)' as unknown as number,
|
||||
left: PANEL_LEFT,
|
||||
maxWidth: '36.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
logo: {
|
||||
height: 'clamp(5rem, 13vh, 12.5rem)' as unknown as number,
|
||||
maxWidth: '33.75rem',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'left center',
|
||||
filter: 'drop-shadow(0 0.25rem 0.75rem rgba(0,0,0,0.65)) drop-shadow(0 0 1px rgba(255,255,255,0.25))',
|
||||
userSelect: 'none',
|
||||
marginBottom: '1.375rem',
|
||||
transition: 'height 0.35s ease, max-width 0.35s ease, margin-bottom 0.35s ease',
|
||||
},
|
||||
logoTrailerActive: {
|
||||
height: 'clamp(3rem, 8vh, 6.5rem)' as unknown as number,
|
||||
maxWidth: '24rem',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 'clamp(2.4rem, 5vw, 5rem)' as unknown as number,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.0,
|
||||
margin: '0 0 1.375rem 0',
|
||||
fontFamily: "'Montserrat', sans-serif",
|
||||
textShadow: '0 0.25rem 0.5rem rgba(0,0,0,0.6)',
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
tagline: {
|
||||
color: 'rgba(255,255,255,0.88)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'italic',
|
||||
margin: '0 0 1.25rem 0',
|
||||
textShadow: '0 0.125rem 0.5rem rgba(0,0,0,0.7)',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
metaLine: {
|
||||
color: 'rgb(170, 170, 170)',
|
||||
fontSize: '0.875rem',
|
||||
margin: '0 0 1rem 0',
|
||||
fontWeight: 400,
|
||||
textShadow: '0 0.125rem 0.25rem rgba(0,0,0,0.8)',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
metaRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.125rem',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
imdbBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
flexShrink: 0,
|
||||
},
|
||||
imdbLogo: {
|
||||
height: '1rem',
|
||||
width: 'auto',
|
||||
display: 'block',
|
||||
borderRadius: '0.1875rem',
|
||||
userSelect: 'none',
|
||||
},
|
||||
imdbScore: {
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
certBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '1px 0.3125rem 0.125rem',
|
||||
border: '1px solid rgba(255,255,255,0.50)',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
borderRadius: '0.125rem',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.02em',
|
||||
flexShrink: 0,
|
||||
},
|
||||
genreText: {
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
description: {
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 0 0',
|
||||
maxWidth: '30rem',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
overflow: 'hidden',
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
awards: {
|
||||
color: 'rgba(255,255,255,0.65)',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.45,
|
||||
margin: '0.75rem 0 0',
|
||||
maxWidth: '30rem',
|
||||
fontWeight: 500,
|
||||
textShadow: '0 1px 0.1875rem rgba(0,0,0,0.8)',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
marginTop: '1.5rem',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
watchBtn: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5625rem',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '0.4375rem',
|
||||
padding: '0.5625rem 1.375rem',
|
||||
fontSize: '0.925rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'Montserrat', sans-serif",
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
boxShadow: '0 0.5rem 2rem rgba(0,0,0,0.3), 0 0.25rem 1rem rgba(0,0,0,0.1)',
|
||||
},
|
||||
indicators: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '0.375rem',
|
||||
zIndex: 10,
|
||||
padding: '0.625rem 1rem',
|
||||
},
|
||||
indicatorTrack: {
|
||||
width: '1.75rem',
|
||||
height: '0.1875rem',
|
||||
borderRadius: '62.4375rem',
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
indicatorFill: {
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
background: 'rgba(255,255,255,0.90)',
|
||||
borderRadius: '62.4375rem',
|
||||
},
|
||||
indicatorFillDone: {
|
||||
width: '100%',
|
||||
},
|
||||
trailerProgressTrack: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '13.75rem',
|
||||
height: '0.1875rem',
|
||||
borderRadius: '62.4375rem',
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
},
|
||||
trailerProgressFill: {
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
background: 'rgba(255,255,255,0.90)',
|
||||
borderRadius: '62.4375rem',
|
||||
},
|
||||
trailerMuteButton: {
|
||||
position: 'absolute',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 20,
|
||||
transition: 'background 0.2s ease, border-color 0.2s ease',
|
||||
},
|
||||
trailerFullscreenButton: {
|
||||
position: 'absolute',
|
||||
top: '1.5rem',
|
||||
left: '1.5rem',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 20,
|
||||
transition: 'background 0.2s ease, border-color 0.2s ease',
|
||||
},
|
||||
};
|
||||
270
src/components/useHeroTrailer.ts
Normal file
270
src/components/useHeroTrailer.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { youtubeVideoId } from './detail/TrailerCarousel';
|
||||
import { httpFetchText } from '../core/engine';
|
||||
import { resolveYoutubeTrailer, type YoutubeTrailerSubtitleTrack } from '../core/effectRunner';
|
||||
import { normalizeTrailerSubtitleUrl, parseTrailerSubtitleCues, selectTrailerSubtitle, type TrailerCue } from '../core/trailerSubtitles';
|
||||
import type { Meta } from '../core/types';
|
||||
|
||||
const STALL_TIMEOUT_MS = 7000;
|
||||
|
||||
export function useHeroTrailer({
|
||||
activeMeta,
|
||||
isActive,
|
||||
autoplayTrailer,
|
||||
autoplayTrailerDelaySecs,
|
||||
preferredSubtitleLanguage,
|
||||
secondarySubtitleLanguage,
|
||||
}: {
|
||||
activeMeta: Meta;
|
||||
isActive: boolean;
|
||||
autoplayTrailer: boolean;
|
||||
autoplayTrailerDelaySecs: number;
|
||||
preferredSubtitleLanguage?: string;
|
||||
secondarySubtitleLanguage?: string;
|
||||
}) {
|
||||
const trailerVideoId = (() => {
|
||||
for (const trailer of activeMeta.trailers ?? []) {
|
||||
const id = youtubeVideoId(trailer.url);
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const [trailerStreamUrl, setTrailerStreamUrl] = useState<string | null>(null);
|
||||
const [trailerAudioUrl, setTrailerAudioUrl] = useState<string | null>(null);
|
||||
const [trailerSubtitles, setTrailerSubtitles] = useState<YoutubeTrailerSubtitleTrack[]>([]);
|
||||
const [trailerSubtitleCues, setTrailerSubtitleCues] = useState<TrailerCue[]>([]);
|
||||
const [activeTrailerSubtitle, setActiveTrailerSubtitle] = useState('');
|
||||
const [trailerReady, setTrailerReady] = useState(false);
|
||||
const [trailerResolving, setTrailerResolving] = useState(false);
|
||||
const [trailerLoading, setTrailerLoading] = useState(false);
|
||||
const [trailerProgress, setTrailerProgress] = useState(0);
|
||||
const [trailerMuted, setTrailerMuted] = useState(true);
|
||||
const lastTrailerProgressAtRef = useRef(0);
|
||||
const trailerVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const trailerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const trailerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeTrailerSubtitleRef = useRef('');
|
||||
const trailerActive = !!trailerStreamUrl && trailerReady;
|
||||
const trailerPending = trailerResolving || trailerLoading || !!trailerStreamUrl;
|
||||
const [selectedTrailerSubtitle, setSelectedTrailerSubtitle] = useState<YoutubeTrailerSubtitleTrack | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
selectTrailerSubtitle(trailerSubtitles, preferredSubtitleLanguage, secondarySubtitleLanguage).then((track) => {
|
||||
if (!cancelled) setSelectedTrailerSubtitle(track);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trailerSubtitles, preferredSubtitleLanguage, secondarySubtitleLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerAudioUrl(null);
|
||||
setTrailerSubtitles([]);
|
||||
setTrailerSubtitleCues([]);
|
||||
setActiveTrailerSubtitle('');
|
||||
activeTrailerSubtitleRef.current = '';
|
||||
setTrailerReady(false);
|
||||
setTrailerProgress(0);
|
||||
setTrailerResolving(false);
|
||||
setTrailerLoading(false);
|
||||
setTrailerMuted(true);
|
||||
}, [activeMeta.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplayTrailer || !isActive || !trailerVideoId) return;
|
||||
let cancelled = false;
|
||||
let delayElapsed = autoplayTrailerDelaySecs <= 0;
|
||||
let resolvedTrailer: Awaited<ReturnType<typeof resolveYoutubeTrailer>> | null = null;
|
||||
let resolveFinished = false;
|
||||
setTrailerResolving(true);
|
||||
|
||||
const applyResolvedTrailer = () => {
|
||||
if (cancelled || !delayElapsed || !resolveFinished) return;
|
||||
if (resolvedTrailer?.streamUrl) {
|
||||
setTrailerSubtitles(resolvedTrailer.subtitles ?? []);
|
||||
setTrailerAudioUrl(resolvedTrailer.audioUrl ?? null);
|
||||
setTrailerReady(false);
|
||||
setTrailerLoading(true);
|
||||
setTrailerStreamUrl(resolvedTrailer.streamUrl);
|
||||
}
|
||||
setTrailerResolving(false);
|
||||
if (!resolvedTrailer?.streamUrl) setTrailerLoading(false);
|
||||
};
|
||||
|
||||
const delayId = window.setTimeout(() => {
|
||||
delayElapsed = true;
|
||||
if (!resolveFinished) setTrailerLoading(true);
|
||||
applyResolvedTrailer();
|
||||
}, autoplayTrailerDelaySecs * 1000);
|
||||
|
||||
resolveYoutubeTrailer(trailerVideoId).then((resolved) => {
|
||||
if (cancelled) return;
|
||||
resolvedTrailer = resolved;
|
||||
resolveFinished = true;
|
||||
applyResolvedTrailer();
|
||||
}).catch((err) => {
|
||||
console.error('resolveYoutubeTrailerUrl failed', err);
|
||||
resolveFinished = true;
|
||||
if (!cancelled) {
|
||||
setTrailerResolving(false);
|
||||
if (delayElapsed) setTrailerLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setTrailerResolving(false);
|
||||
window.clearTimeout(delayId);
|
||||
};
|
||||
}, [trailerVideoId, autoplayTrailer, autoplayTrailerDelaySecs, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTrailerSubtitleCues([]);
|
||||
setActiveTrailerSubtitle('');
|
||||
activeTrailerSubtitleRef.current = '';
|
||||
if (!selectedTrailerSubtitle?.url || !trailerStreamUrl) return;
|
||||
|
||||
normalizeTrailerSubtitleUrl(selectedTrailerSubtitle.url).then(httpFetchText).then(async (response) => {
|
||||
if (cancelled || response.statusCode < 200 || response.statusCode > 299 || !response.body.trim()) return;
|
||||
const cues = await parseTrailerSubtitleCues(response.body);
|
||||
if (cancelled) return;
|
||||
setTrailerSubtitleCues(cues);
|
||||
updateActiveTrailerSubtitle(trailerVideoRef.current?.currentTime ?? 0, cues);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedTrailerSubtitle?.url, trailerStreamUrl]);
|
||||
|
||||
function updateActiveTrailerSubtitle(time: number, cues = trailerSubtitleCues) {
|
||||
const text = cues.find((cue) => time >= cue.start && time <= cue.end)?.text ?? '';
|
||||
if (text !== activeTrailerSubtitleRef.current) {
|
||||
activeTrailerSubtitleRef.current = text;
|
||||
setActiveTrailerSubtitle(text);
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrailerAudio(shouldPlay = false) {
|
||||
if (!trailerAudioUrl) return;
|
||||
const video = trailerVideoRef.current;
|
||||
const audio = trailerAudioRef.current;
|
||||
if (!video || !audio) return;
|
||||
if (Number.isFinite(video.currentTime) && Math.abs(audio.currentTime - video.currentTime) > 0.35) {
|
||||
audio.currentTime = video.currentTime;
|
||||
}
|
||||
audio.muted = trailerMuted;
|
||||
audio.volume = trailerMuted ? 0 : 1;
|
||||
if (trailerMuted || video.paused || video.ended) {
|
||||
audio.pause();
|
||||
} else if (shouldPlay || audio.paused) {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!trailerStreamUrl) return;
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
const id = window.setInterval(() => {
|
||||
if (Date.now() - lastTrailerProgressAtRef.current > STALL_TIMEOUT_MS) {
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerLoading(false);
|
||||
}
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [trailerStreamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trailerStreamUrl) return;
|
||||
const el = trailerVideoRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && el.paused && !el.ended) {
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
}, { threshold: 0.05 });
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [trailerStreamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = trailerVideoRef.current;
|
||||
if (!el) return;
|
||||
el.muted = trailerMuted;
|
||||
el.volume = trailerMuted ? 0 : 1;
|
||||
syncTrailerAudio(!trailerMuted);
|
||||
}, [trailerMuted, trailerAudioUrl]);
|
||||
|
||||
const handleTrailerPlaying = () => {
|
||||
setTrailerReady(true);
|
||||
setTrailerLoading(false);
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
if (trailerVideoRef.current) {
|
||||
trailerVideoRef.current.muted = trailerMuted;
|
||||
trailerVideoRef.current.volume = trailerMuted ? 0 : 1;
|
||||
}
|
||||
syncTrailerAudio(true);
|
||||
updateActiveTrailerSubtitle(trailerVideoRef.current?.currentTime ?? 0);
|
||||
};
|
||||
|
||||
const handleTrailerTimeUpdate = (el: HTMLVideoElement) => {
|
||||
lastTrailerProgressAtRef.current = Date.now();
|
||||
if (el.duration > 0) setTrailerProgress(el.currentTime / el.duration);
|
||||
syncTrailerAudio(false);
|
||||
updateActiveTrailerSubtitle(el.currentTime);
|
||||
};
|
||||
|
||||
const handleTrailerStopped = () => {
|
||||
trailerAudioRef.current?.pause();
|
||||
setTrailerStreamUrl(null);
|
||||
setTrailerAudioUrl(null);
|
||||
setTrailerLoading(false);
|
||||
};
|
||||
|
||||
const toggleTrailerMute = () => {
|
||||
const newMutedState = !trailerMuted;
|
||||
setTrailerMuted(newMutedState);
|
||||
if (trailerVideoRef.current) {
|
||||
trailerVideoRef.current.muted = newMutedState;
|
||||
trailerVideoRef.current.volume = newMutedState ? 0 : 1;
|
||||
if (!newMutedState && trailerVideoRef.current.paused) {
|
||||
trailerVideoRef.current.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (trailerAudioRef.current && trailerVideoRef.current) {
|
||||
trailerAudioRef.current.muted = newMutedState;
|
||||
trailerAudioRef.current.volume = newMutedState ? 0 : 1;
|
||||
if (newMutedState) {
|
||||
trailerAudioRef.current.pause();
|
||||
} else {
|
||||
trailerAudioRef.current.currentTime = trailerVideoRef.current.currentTime;
|
||||
trailerAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fullscreenTrailer = () => {
|
||||
const container = trailerContainerRef.current;
|
||||
if (!container) return;
|
||||
const fullscreenTarget = container as HTMLDivElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
const request = fullscreenTarget.requestFullscreen?.bind(fullscreenTarget)
|
||||
?? fullscreenTarget.webkitRequestFullscreen?.bind(fullscreenTarget);
|
||||
try {
|
||||
const result = request?.();
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return {
|
||||
trailerContainerRef, trailerVideoRef, trailerAudioRef,
|
||||
trailerStreamUrl, trailerAudioUrl, trailerReady, trailerActive, trailerPending, trailerProgress, trailerMuted, activeTrailerSubtitle,
|
||||
handleTrailerPlaying, handleTrailerTimeUpdate, handleTrailerStopped, toggleTrailerMute, fullscreenTrailer,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue