diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx index 59565da..dea5a4c 100644 --- a/src/components/HeroSection.tsx +++ b/src/components/HeroSection.tsx @@ -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(null); - const [trailerAudioUrl, setTrailerAudioUrl] = useState(null); - const [trailerSubtitles, setTrailerSubtitles] = useState([]); - const [trailerSubtitleCues, setTrailerSubtitleCues] = useState([]); - 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(null); - const trailerAudioRef = useRef(null); - const trailerContainerRef = useRef(null); - const activeTrailerSubtitleRef = useRef(''); - const trailerActive = !!trailerStreamUrl && trailerReady; - const trailerPending = trailerResolving || trailerLoading || !!trailerStreamUrl; - const [selectedTrailerSubtitle, setSelectedTrailerSubtitle] = useState(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> | 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; - }; - const request = fullscreenTarget.requestFullscreen?.bind(fullscreenTarget) - ?? fullscreenTarget.webkitRequestFullscreen?.bind(fullscreenTarget); - try { - const result = request?.(); - if (result && typeof result.catch === 'function') result.catch(() => {}); - } catch {} - }; - return (
- + {bgUrl && ( { - 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 && (
); }); - -function NavArrow({ direction, onClick }: { direction: 'left' | 'right'; onClick: () => void }) { - const [hovered, setHovered] = useState(false); - return ( - - ); -} - -function HeroIconBtn({ onClick, title, ariaLabel, children }: { onClick?: () => void; title?: string; ariaLabel?: string; children: React.ReactNode }) { - const [hovered, setHovered] = useState(false); - return ( - - ); -} - -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; - for (const key of keys) { - const value = record[key]; - if (typeof value === 'string' && value.trim()) return value.trim(); - } - return null; -} - -const styles: Record = { - 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', - }, -}; diff --git a/src/components/HeroSectionParts.tsx b/src/components/HeroSectionParts.tsx new file mode 100644 index 0000000..5baa720 --- /dev/null +++ b/src/components/HeroSectionParts.tsx @@ -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 ( + + ); +} + +export function HeroIconBtn({ onClick, title, ariaLabel, children }: { onClick?: () => void; title?: string; ariaLabel?: string; children: React.ReactNode }) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +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; + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} diff --git a/src/components/heroStyles.ts b/src/components/heroStyles.ts new file mode 100644 index 0000000..ccc1d3f --- /dev/null +++ b/src/components/heroStyles.ts @@ -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 = { + 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', + }, +}; diff --git a/src/components/useHeroTrailer.ts b/src/components/useHeroTrailer.ts new file mode 100644 index 0000000..ad8138d --- /dev/null +++ b/src/components/useHeroTrailer.ts @@ -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(null); + const [trailerAudioUrl, setTrailerAudioUrl] = useState(null); + const [trailerSubtitles, setTrailerSubtitles] = useState([]); + const [trailerSubtitleCues, setTrailerSubtitleCues] = useState([]); + 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(null); + const trailerAudioRef = useRef(null); + const trailerContainerRef = useRef(null); + const activeTrailerSubtitleRef = useRef(''); + const trailerActive = !!trailerStreamUrl && trailerReady; + const trailerPending = trailerResolving || trailerLoading || !!trailerStreamUrl; + const [selectedTrailerSubtitle, setSelectedTrailerSubtitle] = useState(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> | 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; + }; + 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, + }; +}