@@ -44,9 +44,13 @@ export function VideoPlayer() {
4444 const fileInputRef = useRef < HTMLInputElement > ( null ) ;
4545 const progressRef = useRef < HTMLDivElement > ( null ) ;
4646 const speedMenuRef = useRef < HTMLDivElement > ( null ) ;
47+ const immersiveSurfaceRef = useRef < HTMLDivElement > ( null ) ;
4748 const [ showSpeedMenu , setShowSpeedMenu ] = useState ( false ) ;
4849 const [ volumeBeforeMute , setVolumeBeforeMute ] = useState ( 1 ) ;
4950 const [ isImmersiveMode , setIsImmersiveMode ] = useState ( false ) ;
51+ const [ videoNativeSize , setVideoNativeSize ] = useState ( { width : 0 , height : 0 } ) ;
52+ const [ devicePixelRatio , setDevicePixelRatio ] = useState ( ( ) => window . devicePixelRatio || 1 ) ;
53+ const [ immersiveViewportSize , setImmersiveViewportSize ] = useState ( { width : 0 , height : 0 } ) ;
5054
5155 const showVideo = Boolean ( videoSrc ) && ! isRecording ;
5256
@@ -70,6 +74,30 @@ export function VideoPlayer() {
7074 } ;
7175
7276 const progress = duration > 0 ? ( currentTime / duration ) * 100 : 0 ;
77+ const immersiveVideoStyle =
78+ isImmersiveMode &&
79+ videoNativeSize . width > 0 &&
80+ videoNativeSize . height > 0 &&
81+ immersiveViewportSize . width > 0 &&
82+ immersiveViewportSize . height > 0
83+ ? ( ( ) => {
84+ const safeDevicePixelRatio = Math . max ( 1 , devicePixelRatio ) ;
85+ const nativeCssWidth = Math . max ( 1 , Math . floor ( videoNativeSize . width / safeDevicePixelRatio ) ) ;
86+ const nativeCssHeight = Math . max ( 1 , Math . floor ( videoNativeSize . height / safeDevicePixelRatio ) ) ;
87+ const widthScale = immersiveViewportSize . width / nativeCssWidth ;
88+ const heightScale = immersiveViewportSize . height / nativeCssHeight ;
89+ const scale = Math . min ( widthScale , heightScale , 1 ) ;
90+
91+ return {
92+ width : `${ Math . max ( 1 , Math . floor ( nativeCssWidth * scale ) ) } px` ,
93+ height : `${ Math . max ( 1 , Math . floor ( nativeCssHeight * scale ) ) } px` ,
94+ } ;
95+ } ) ( )
96+ : undefined ;
97+ const immersiveControlsStyle =
98+ isImmersiveMode && immersiveVideoStyle ?. width
99+ ? { width : immersiveVideoStyle . width }
100+ : undefined ;
73101
74102 useEffect ( ( ) => {
75103 if ( ! showSpeedMenu ) {
@@ -113,6 +141,94 @@ export function VideoPlayer() {
113141 } ;
114142 } , [ isImmersiveMode ] ) ;
115143
144+ useEffect ( ( ) => {
145+ if ( ! showVideo ) {
146+ syncIsPlaying ( false ) ;
147+ return ;
148+ }
149+
150+ const syncPlaybackState = ( ) => {
151+ const videoElement = videoRef . current ;
152+ if ( ! videoElement ) {
153+ return ;
154+ }
155+
156+ syncIsPlaying ( ! videoElement . paused && ! videoElement . ended ) ;
157+ } ;
158+
159+ syncPlaybackState ( ) ;
160+ const syncTimeout = window . setTimeout ( syncPlaybackState , 0 ) ;
161+ const syncFrame = window . requestAnimationFrame ( syncPlaybackState ) ;
162+
163+ return ( ) => {
164+ window . clearTimeout ( syncTimeout ) ;
165+ window . cancelAnimationFrame ( syncFrame ) ;
166+ } ;
167+ } , [ isImmersiveMode , showVideo , syncIsPlaying , videoRef ] ) ;
168+
169+ useEffect ( ( ) => {
170+ if ( ! videoSrc ) {
171+ setVideoNativeSize ( { width : 0 , height : 0 } ) ;
172+ }
173+ } , [ videoSrc ] ) ;
174+
175+ useEffect ( ( ) => {
176+ const handleResize = ( ) => {
177+ setDevicePixelRatio ( window . devicePixelRatio || 1 ) ;
178+ } ;
179+
180+ window . addEventListener ( "resize" , handleResize ) ;
181+ return ( ) => {
182+ window . removeEventListener ( "resize" , handleResize ) ;
183+ } ;
184+ } , [ ] ) ;
185+
186+ useEffect ( ( ) => {
187+ if ( ! isImmersiveMode || ! showVideo ) {
188+ setImmersiveViewportSize ( { width : 0 , height : 0 } ) ;
189+ return ;
190+ }
191+
192+ const updateViewportSize = ( ) => {
193+ const surfaceRect = immersiveSurfaceRef . current ?. getBoundingClientRect ( ) ;
194+ if ( ! surfaceRect ) {
195+ return ;
196+ }
197+
198+ const nextWidth = Math . max ( 0 , Math . floor ( surfaceRect . width ) ) ;
199+ const nextHeight = Math . max ( 0 , Math . floor ( surfaceRect . height ) ) ;
200+
201+ setImmersiveViewportSize ( ( currentSize ) =>
202+ currentSize . width === nextWidth && currentSize . height === nextHeight
203+ ? currentSize
204+ : { width : nextWidth , height : nextHeight }
205+ ) ;
206+ } ;
207+
208+ updateViewportSize ( ) ;
209+
210+ if ( typeof ResizeObserver === "undefined" ) {
211+ window . addEventListener ( "resize" , updateViewportSize ) ;
212+ return ( ) => {
213+ window . removeEventListener ( "resize" , updateViewportSize ) ;
214+ } ;
215+ }
216+
217+ const resizeObserver = new ResizeObserver ( ( ) => {
218+ updateViewportSize ( ) ;
219+ } ) ;
220+
221+ if ( immersiveSurfaceRef . current ) {
222+ resizeObserver . observe ( immersiveSurfaceRef . current ) ;
223+ }
224+
225+ window . addEventListener ( "resize" , updateViewportSize ) ;
226+ return ( ) => {
227+ resizeObserver . disconnect ( ) ;
228+ window . removeEventListener ( "resize" , updateViewportSize ) ;
229+ } ;
230+ } , [ isImmersiveMode , showVideo ] ) ;
231+
116232 const handleProgressClick = ( e : React . MouseEvent < HTMLDivElement > ) => {
117233 if ( ! progressRef . current || duration === 0 ) return ;
118234 const rect = progressRef . current . getBoundingClientRect ( ) ;
@@ -122,52 +238,66 @@ export function VideoPlayer() {
122238
123239 const playerSurface = (
124240 < div
241+ ref = { isImmersiveMode ? immersiveSurfaceRef : undefined }
125242 className = {
126243 isImmersiveMode
127- ? "fixed inset-0 z-[200] flex h-screen w-screen items-center justify-center overflow-hidden bg-neutral-950"
244+ ? "fixed inset-0 z-[200] flex items-center justify-center overflow-hidden bg-neutral-950"
128245 : "relative h-full w-full overflow-hidden bg-neutral-950/90"
129246 }
130247 aria-busy = { isVideoLoading }
131248 >
132249 { showVideo && (
133- < video
134- ref = { videoRef }
135- src = { videoSrc || undefined }
250+ < div
136251 className = {
137252 isImmersiveMode
138- ? "block h-auto w-auto max-h-full max-w-full object-contain "
139- : "h-full w-full object-contain "
253+ ? "flex h-full w-full items-center justify-center overflow-hidden "
254+ : "h-full w-full"
140255 }
141- controls = { false }
142- playsInline
143- disablePictureInPicture
144- preload = "metadata"
145- onLoadStart = { ( ) => {
146- setVideoLoading ( true ) ;
147- } }
148- onCanPlay = { ( ) => {
149- setVideoLoading ( false ) ;
150- } }
151- onError = { ( event ) => {
152- setVideoLoading ( false ) ;
153- const mediaError = event . currentTarget . error ;
154- console . error ( "[VideoPlayer] Video load error" , {
155- code : mediaError ?. code ,
156- message : mediaError ?. message ,
157- networkState : event . currentTarget . networkState ,
158- readyState : event . currentTarget . readyState ,
159- src : videoSrc ,
160- } ) ;
161- } }
162- onTimeUpdate = { ( e ) => updateTime ( e . currentTarget . currentTime ) }
163- onLoadedMetadata = { ( e ) => {
164- setVideoLoading ( false ) ;
165- updateDuration ( e . currentTarget . duration ) ;
166- } }
167- onPlay = { ( ) => syncIsPlaying ( true ) }
168- onPause = { ( ) => syncIsPlaying ( false ) }
169- onEnded = { ( ) => syncIsPlaying ( false ) }
170- />
256+ >
257+ < video
258+ ref = { videoRef }
259+ src = { videoSrc || undefined }
260+ className = {
261+ isImmersiveMode
262+ ? "block h-auto w-auto max-h-full max-w-full object-contain"
263+ : "h-full w-full object-contain"
264+ }
265+ style = { immersiveVideoStyle }
266+ controls = { false }
267+ playsInline
268+ disablePictureInPicture
269+ preload = "metadata"
270+ onLoadStart = { ( ) => {
271+ setVideoLoading ( true ) ;
272+ } }
273+ onCanPlay = { ( ) => {
274+ setVideoLoading ( false ) ;
275+ } }
276+ onError = { ( event ) => {
277+ setVideoLoading ( false ) ;
278+ const mediaError = event . currentTarget . error ;
279+ console . error ( "[VideoPlayer] Video load error" , {
280+ code : mediaError ?. code ,
281+ message : mediaError ?. message ,
282+ networkState : event . currentTarget . networkState ,
283+ readyState : event . currentTarget . readyState ,
284+ src : videoSrc ,
285+ } ) ;
286+ } }
287+ onTimeUpdate = { ( e ) => updateTime ( e . currentTarget . currentTime ) }
288+ onLoadedMetadata = { ( e ) => {
289+ setVideoLoading ( false ) ;
290+ updateDuration ( e . currentTarget . duration ) ;
291+ setVideoNativeSize ( {
292+ width : e . currentTarget . videoWidth ,
293+ height : e . currentTarget . videoHeight ,
294+ } ) ;
295+ } }
296+ onPlay = { ( ) => syncIsPlaying ( true ) }
297+ onPause = { ( ) => syncIsPlaying ( false ) }
298+ onEnded = { ( ) => syncIsPlaying ( false ) }
299+ />
300+ </ div >
171301 ) }
172302
173303 { showVideo && isVideoLoading && (
@@ -204,7 +334,14 @@ export function VideoPlayer() {
204334 ) }
205335
206336 { showVideo && (
207- < div className = "absolute bottom-0 left-0 right-0 bg-gradient-to-t from-neutral-950/95 via-neutral-950/70 to-transparent p-3 sm:p-4" >
337+ < div
338+ className = {
339+ isImmersiveMode
340+ ? "absolute bottom-0 left-1/2 w-full -translate-x-1/2 bg-gradient-to-t from-neutral-950/95 via-neutral-950/70 to-transparent p-3 sm:p-4"
341+ : "absolute bottom-0 left-0 right-0 bg-gradient-to-t from-neutral-950/95 via-neutral-950/70 to-transparent p-3 sm:p-4"
342+ }
343+ style = { immersiveControlsStyle }
344+ >
208345 < div className = "flex flex-col gap-3 md:flex-row md:items-center md:gap-3" >
209346 < div className = "flex items-center gap-2 sm:gap-3 md:shrink-0" >
210347 < ControlIconButton
0 commit comments