Arnav Singh commited on
Commit
09a5b52
·
unverified ·
2 Parent(s): 7c4b4d433360f4

Merge pull request #5 from ArnavSingh76533/copilot/fix-four-player-issues

Browse files

Fix player UX issues: playlist auto-reorder, mobile fullscreen, PiP, and tap-to-pause

components/player/Controls.tsx CHANGED
@@ -44,7 +44,6 @@ interface Props extends PlayerState {
44
  }
45
 
46
  let interaction = false
47
- let doubleClick = false
48
  let interactionTime = 0
49
  let lastMouseMove = 0
50
 
@@ -90,17 +89,10 @@ const Controls: FC<Props> = ({
90
 
91
  setTimeout(() => {
92
  if (new Date().getTime() - interactionTime > 350) {
93
- if (interaction && !doubleClick) {
94
- doubleClick = false
95
- if (isOwner) {
96
- if (playEnded()) {
97
- playAgain()
98
- } else {
99
- setPaused(!paused)
100
- }
101
- }
102
- }
103
-
104
  interaction = false
105
  }
106
  }, 400)
@@ -117,6 +109,31 @@ const Controls: FC<Props> = ({
117
  return paused && progress > duration - SYNC_DELTA
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  const mouseMoved = (touch: boolean | null = null) => {
121
  lastMouseMove = new Date().getTime()
122
 
@@ -137,7 +154,8 @@ const Controls: FC<Props> = ({
137
  <InteractionHandler
138
  className={classNames(
139
  "absolute top-0 left-0 w-full h-full transition-opacity flex flex-col",
140
- show ? "opacity-100" : "opacity-0"
 
141
  )}
142
  onMove={(_, touch) => {
143
  setShowControls(!touch)
@@ -156,11 +174,12 @@ const Controls: FC<Props> = ({
156
  }
157
  onClick={(_, touch) => {
158
  if (interaction) {
159
- doubleClick = true
160
  interaction = false
161
  console.log("Toggled fullscreen")
162
  setFullscreen(!fullscreen)
163
  } else if (touch) {
 
164
  setShowControls(!showControls)
165
  setMenuOpen(false)
166
  }
@@ -305,10 +324,67 @@ const Controls: FC<Props> = ({
305
 
306
  <ControlButton
307
  tooltip={pipEnabled ? "Exit PiP" : "Enter PiP"}
308
- onClick={() => {
309
- setPipEnabled(!pipEnabled)
310
- if (!pipEnabled && musicMode) {
311
- setMusicMode(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  }
313
  }}
314
  interaction={showControlsAction}
 
44
  }
45
 
46
  let interaction = false
 
47
  let interactionTime = 0
48
  let lastMouseMove = 0
49
 
 
89
 
90
  setTimeout(() => {
91
  if (new Date().getTime() - interactionTime > 350) {
92
+ // Reset interaction flag
93
+ // Double-click detection works by checking if interaction is still true
94
+ // when the second click happens (within 400ms)
95
+ // We don't toggle pause anymore - only show/hide controls on single tap
 
 
 
 
 
 
 
96
  interaction = false
97
  }
98
  }, 400)
 
109
  return paused && progress > duration - SYNC_DELTA
110
  }
111
 
112
+ const openPipFallback = () => {
113
+ // Open a small popup window as PiP fallback
114
+ const width = 480
115
+ const height = 270
116
+ const left = window.screen.width - width - 20
117
+ const top = window.screen.height - height - 100
118
+
119
+ // Encode roomId to prevent any potential injection issues
120
+ const encodedRoomId = encodeURIComponent(roomId)
121
+
122
+ const pipWindow = window.open(
123
+ `/embed/${encodedRoomId}`,
124
+ 'PiP Player',
125
+ `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no,status=no,toolbar=no,menubar=no,location=no`
126
+ )
127
+
128
+ if (pipWindow) {
129
+ pipWindow.focus()
130
+ console.log("Opened PiP fallback window")
131
+ } else {
132
+ console.error("Failed to open PiP fallback window - popup may be blocked")
133
+ alert("Please allow popups to use Picture-in-Picture mode")
134
+ }
135
+ }
136
+
137
  const mouseMoved = (touch: boolean | null = null) => {
138
  lastMouseMove = new Date().getTime()
139
 
 
154
  <InteractionHandler
155
  className={classNames(
156
  "absolute top-0 left-0 w-full h-full transition-opacity flex flex-col",
157
+ show ? "opacity-100" : "opacity-0",
158
+ fullscreen ? "controls-fullscreen" : ""
159
  )}
160
  onMove={(_, touch) => {
161
  setShowControls(!touch)
 
174
  }
175
  onClick={(_, touch) => {
176
  if (interaction) {
177
+ // Second click detected within timeout - toggle fullscreen
178
  interaction = false
179
  console.log("Toggled fullscreen")
180
  setFullscreen(!fullscreen)
181
  } else if (touch) {
182
+ // Single touch - toggle controls visibility
183
  setShowControls(!showControls)
184
  setMenuOpen(false)
185
  }
 
324
 
325
  <ControlButton
326
  tooltip={pipEnabled ? "Exit PiP" : "Enter PiP"}
327
+ onClick={async () => {
328
+ if (pipEnabled) {
329
+ // Exit PiP
330
+ setPipEnabled(false)
331
+ if (document.pictureInPictureElement) {
332
+ try {
333
+ await document.exitPictureInPicture()
334
+ } catch (err) {
335
+ console.warn("Failed to exit PiP:", err)
336
+ }
337
+ }
338
+ } else {
339
+ // Try to enter PiP
340
+ // Robust YouTube URL detection with proper hostname validation
341
+ let isYouTube = false
342
+ try {
343
+ const url = new URL(currentSrc.src)
344
+ const hostname = url.hostname.toLowerCase()
345
+ // Check for exact match or subdomain of youtube.com or youtu.be
346
+ isYouTube = hostname === 'youtube.com' ||
347
+ hostname === 'www.youtube.com' ||
348
+ hostname === 'm.youtube.com' ||
349
+ hostname === 'gaming.youtube.com' ||
350
+ hostname === 'youtu.be' ||
351
+ hostname === 'www.youtu.be' ||
352
+ hostname.endsWith('.youtube.com') ||
353
+ hostname.endsWith('.youtu.be')
354
+ } catch (e) {
355
+ // Invalid URL, treat as non-YouTube
356
+ isYouTube = false
357
+ }
358
+
359
+ if (isYouTube) {
360
+ // For YouTube, use ReactPlayer's pip prop
361
+ setPipEnabled(true)
362
+ if (!pipEnabled && musicMode) {
363
+ setMusicMode(false)
364
+ }
365
+ } else {
366
+ // For file sources, try native PiP API
367
+ // Note: We query for the first video element on the page.
368
+ // This works for the current app structure where there's only one video player.
369
+ // If multiple video elements exist, this might select the wrong one.
370
+ const videoElement = document.querySelector('video')
371
+
372
+ if (videoElement && 'requestPictureInPicture' in videoElement && document.pictureInPictureEnabled) {
373
+ try {
374
+ await videoElement.requestPictureInPicture()
375
+ setPipEnabled(true)
376
+ if (musicMode) {
377
+ setMusicMode(false)
378
+ }
379
+ } catch (err) {
380
+ console.warn("Native PiP failed, trying fallback:", err)
381
+ openPipFallback()
382
+ }
383
+ } else {
384
+ // Fallback: open popup window
385
+ openPipFallback()
386
+ }
387
+ }
388
  }
389
  }}
390
  interaction={showControlsAction}
components/player/Player.tsx CHANGED
@@ -265,14 +265,14 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
265
  </div>
266
  )}
267
  <ReactPlayer
 
268
  style={{
269
- maxHeight: fullscreen || fullHeight ? "100vh" : "calc(100vh - 210px)",
270
  visibility: musicMode ? "hidden" : "visible",
271
- height: musicMode ? "0px" : (fullscreen || fullHeight ? "100vh" : "calc((9 / 16) * 100vw)"),
272
  }}
273
  ref={player}
274
  width={"100%"}
275
- height={fullscreen || fullHeight ? "100vh" : "calc((9 / 16) * 100vw)"}
276
  config={{
277
  youtube: {
278
  playerVars: {
 
265
  </div>
266
  )}
267
  <ReactPlayer
268
+ className={fullscreen || fullHeight ? "video-fullscreen" : "video-normal"}
269
  style={{
 
270
  visibility: musicMode ? "hidden" : "visible",
271
+ height: musicMode ? "0px" : undefined,
272
  }}
273
  ref={player}
274
  width={"100%"}
275
+ height={fullscreen || fullHeight ? "100dvh" : "calc((9 / 16) * 100vw)"}
276
  config={{
277
  youtube: {
278
  playerVars: {
pages/api/socketio.ts CHANGED
@@ -197,20 +197,32 @@ const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
197
  log("playback ended")
198
 
199
  if (room.targetState.loop) {
 
200
  room.targetState.progress = 0
201
  room.targetState.paused = false
202
  } else if (
203
  room.targetState.playlist.currentIndex + 1 <
204
  room.targetState.playlist.items.length
205
  ) {
 
 
 
 
 
206
  room.targetState.playing =
207
- room.targetState.playlist.items[
208
- room.targetState.playlist.currentIndex + 1
209
- ]
210
- room.targetState.playlist.currentIndex += 1
 
 
 
 
211
  room.targetState.progress = 0
212
  room.targetState.paused = false
 
213
  } else {
 
214
  room.targetState.progress =
215
  room.users.find((user) => user.socketIds[0] === socket.id)?.player
216
  .progress || 0
 
197
  log("playback ended")
198
 
199
  if (room.targetState.loop) {
200
+ // Loop mode: restart the current video without modifying playlist
201
  room.targetState.progress = 0
202
  room.targetState.paused = false
203
  } else if (
204
  room.targetState.playlist.currentIndex + 1 <
205
  room.targetState.playlist.items.length
206
  ) {
207
+ // Auto-advance to next item: play next video and remove finished one
208
+ // This condition ensures there IS a next item before we splice
209
+ const currentIdx = room.targetState.playlist.currentIndex
210
+
211
+ // Get the next item before removing current
212
  room.targetState.playing =
213
+ room.targetState.playlist.items[currentIdx + 1]
214
+
215
+ // Remove the finished item from playlist (shift remaining items left)
216
+ room.targetState.playlist.items.splice(currentIdx, 1)
217
+
218
+ // Reset currentIndex to 0 since items have shifted
219
+ // (the next item is now at position 0)
220
+ room.targetState.playlist.currentIndex = 0
221
  room.targetState.progress = 0
222
  room.targetState.paused = false
223
+ log("Removed finished item from playlist, shifted remaining items")
224
  } else {
225
+ // Last item finished: pause at end
226
  room.targetState.progress =
227
  room.users.find((user) => user.socketIds[0] === socket.id)?.player
228
  .progress || 0
pages/global.css CHANGED
@@ -72,3 +72,46 @@ input:focus, textarea:focus {
72
  html {
73
  scroll-behavior: smooth;
74
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  html {
73
  scroll-behavior: smooth;
74
  }
75
+
76
+ /* Fullscreen video player styles for mobile */
77
+ .video-fullscreen {
78
+ height: 100dvh !important;
79
+ max-height: 100dvh !important;
80
+ width: 100vw !important;
81
+ }
82
+
83
+ /* Support for older browsers that don't support dvh */
84
+ @supports not (height: 100dvh) {
85
+ .video-fullscreen {
86
+ height: 100vh !important;
87
+ max-height: 100vh !important;
88
+ }
89
+ }
90
+
91
+ /* Safe area handling for iOS notches */
92
+ .video-fullscreen {
93
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
94
+ }
95
+
96
+ /* Non-fullscreen video styles */
97
+ .video-normal {
98
+ height: calc((9 / 16) * 100vw);
99
+ max-height: calc(100dvh - 210px);
100
+ }
101
+
102
+ @supports not (height: 100dvh) {
103
+ .video-normal {
104
+ max-height: calc(100vh - 210px);
105
+ }
106
+ }
107
+
108
+ /* Controls overlay improvements for fullscreen */
109
+ .controls-fullscreen {
110
+ position: absolute;
111
+ top: 0;
112
+ left: 0;
113
+ right: 0;
114
+ bottom: 0;
115
+ z-index: 10;
116
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
117
+ }