| import React, { FC, useState } from "react" |
| import { MediaOption, PlayerState, Subtitle } from "../../lib/types" |
| import InteractionHandler from "../action/InteractionHandler" |
| import IconBigPause from "../icon/IconBigPause" |
| import IconBigPlay from "../icon/IconBigPlay" |
| import classNames from "classnames" |
| import InputSlider from "../input/InputSlider" |
| import IconPlay from "../icon/IconPlay" |
| import IconPause from "../icon/IconPause" |
| import IconReplay from "../icon/IconReplay" |
| import { secondsToTime, SYNC_DELTA } from "../../lib/utils" |
| import IconCompress from "../icon/IconCompress" |
| import IconExpand from "../icon/IconExpand" |
| import ControlButton from "../input/ControlButton" |
| import VolumeControl from "./VolumeControl" |
| import IconBackward from "../icon/IconBackward" |
| import IconForward from "../icon/IconForward" |
| import PlayerMenu from "./PlayerMenu" |
| import { Tooltip } from "react-tooltip" |
| import IconMusic from "../icon/IconMusic" |
| import IconPip from "../icon/IconPip" |
|
|
| interface Props extends PlayerState { |
| roomId: string |
| setCurrentSrc: (src: MediaOption) => void |
| setCurrentSub: (sub: Subtitle) => void |
| setPaused: (paused: boolean) => void |
| setVolume: (volume: number) => void |
| setMuted: (muted: boolean) => void |
| setProgress: (progress: number) => void |
| setPlaybackRate: (playbackRate: number) => void |
| setFullscreen: (fullscreen: boolean) => void |
| setLoop: (loop: boolean) => void |
| playIndex: (index: number) => void |
| setSeeking: (seeking: boolean) => void |
| playAgain: () => void |
| isOwner: boolean |
| pipEnabled: boolean |
| setPipEnabled: (enabled: boolean) => void |
| musicMode: boolean |
| setMusicMode: (enabled: boolean) => void |
| } |
|
|
| let interaction = false |
| let interactionTime = 0 |
| let lastMouseMove = 0 |
|
|
| const Controls: FC<Props> = ({ |
| roomId, |
| playing, |
| playlist, |
| currentSrc, |
| setCurrentSrc, |
| currentSub, |
| setCurrentSub, |
| paused, |
| setPaused, |
| volume, |
| setVolume, |
| muted, |
| setMuted, |
| progress, |
| setProgress, |
| playbackRate, |
| loop, |
| setLoop, |
| setPlaybackRate, |
| fullscreen, |
| setFullscreen, |
| duration, |
| playIndex, |
| setSeeking, |
| playAgain, |
| isOwner, |
| pipEnabled, |
| setPipEnabled, |
| musicMode, |
| setMusicMode, |
| }) => { |
| const [showControls, setShowControls] = useState(true) |
| const [showTimePlayed, setShowTimePlayed] = useState(true) |
| const [menuOpen, setMenuOpen] = useState(false) |
|
|
| const interact = () => { |
| interaction = true |
| interactionTime = new Date().getTime() |
|
|
| setTimeout(() => { |
| if (new Date().getTime() - interactionTime > 350) { |
| |
| |
| |
| |
| interaction = false |
| } |
| }, 400) |
| } |
|
|
| const showControlsAction = (touch: boolean | null) => { |
| if (!showControls) { |
| setShowControls(true) |
| } |
| mouseMoved(touch) |
| } |
|
|
| const playEnded = () => { |
| return paused && progress > duration - SYNC_DELTA |
| } |
|
|
| const openPipFallback = () => { |
| |
| const width = 480 |
| const height = 270 |
| const left = window.screen.width - width - 20 |
| const top = window.screen.height - height - 100 |
| |
| |
| const encodedRoomId = encodeURIComponent(roomId) |
| |
| const pipWindow = window.open( |
| `/embed/${encodedRoomId}`, |
| 'PiP Player', |
| `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no,status=no,toolbar=no,menubar=no,location=no` |
| ) |
| |
| if (pipWindow) { |
| pipWindow.focus() |
| console.log("Opened PiP fallback window") |
| } else { |
| console.error("Failed to open PiP fallback window - popup may be blocked") |
| alert("Please allow popups to use Picture-in-Picture mode") |
| } |
| } |
|
|
| const mouseMoved = (touch: boolean | null = null) => { |
| lastMouseMove = new Date().getTime() |
|
|
| setTimeout( |
| () => { |
| if (new Date().getTime() - lastMouseMove > (touch ? 3150 : 1550)) { |
| setShowControls(false) |
| } |
| }, |
| touch ? 3200 : 1600 |
| ) |
| } |
|
|
| const show = showControls || menuOpen |
|
|
| return ( |
| <> |
| <InteractionHandler |
| className={classNames( |
| "absolute top-0 left-0 w-full h-full transition-opacity flex flex-col", |
| show ? "opacity-100" : "opacity-0", |
| fullscreen ? "controls-fullscreen" : "" |
| )} |
| onMove={(_, touch) => { |
| setShowControls(!touch) |
| }} |
| tabIndex={1} |
| onKey={(key) => { |
| console.log("Key down", key) |
| if (key === " ") { |
| setPaused(!paused) |
| } |
| }} |
| > |
| <InteractionHandler |
| className={ |
| "flex grow cursor-pointer items-center justify-center" |
| } |
| onClick={(_, touch) => { |
| if (interaction) { |
| // Second click detected within timeout - toggle fullscreen |
| interaction = false |
| console.log("Toggled fullscreen") |
| setFullscreen(!fullscreen) |
| } else if (touch) { |
| // Single touch on mobile - show controls only, no play/pause toggle |
| // Play/pause only from control buttons |
| setShowControls(true) |
| setMenuOpen(false) |
| } |
| // Desktop click on center overlay - no play/pause toggle |
| // Play/pause only from control buttons |
| |
| interact() |
| mouseMoved(touch) |
| }} |
| onMove={(_, touch) => { |
| showControlsAction(!touch) |
| }} |
| > |
| {/* Center play/pause indicator - positioned absolutely in center */} |
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
| {paused ? <IconBigPlay /> : <IconBigPause />} |
| </div> |
| </InteractionHandler> |
| |
| {/* Bottom control cluster: control buttons on top, progress bar below */} |
| <div className="bg-dark-900/40"> |
| <div className={"flex flex-row px-1 py-0.5 items-center gap-0.5"}> |
| {playlist.currentIndex > 0 && ( |
| <ControlButton |
| tooltip={"Play previous"} |
| onClick={() => { |
| if (show && playlist.currentIndex > 0) { |
| playIndex(playlist.currentIndex - 1) |
| } |
| }} |
| interaction={showControlsAction} |
| > |
| <IconBackward /> |
| </ControlButton> |
| )} |
| <ControlButton |
| tooltip={playEnded() ? "Play again" : paused ? "Play" : "Pause"} |
| onClick={() => { |
| if (show && isOwner) { |
| if (playEnded()) { |
| playAgain() |
| } else { |
| setPaused(!paused) |
| } |
| } |
| }} |
| interaction={showControlsAction} |
| className={!isOwner ? "opacity-50 cursor-not-allowed" : ""} |
| > |
| {playEnded() ? ( |
| <IconReplay /> |
| ) : paused ? ( |
| <IconPlay /> |
| ) : ( |
| <IconPause /> |
| )} |
| </ControlButton> |
| {playlist.currentIndex < playlist.items.length - 1 && ( |
| <ControlButton |
| tooltip={"Skip"} |
| onClick={() => { |
| if (show && playlist.currentIndex < playlist.items.length - 1) { |
| playIndex(playlist.currentIndex + 1) |
| } |
| }} |
| interaction={showControlsAction} |
| > |
| <IconForward /> |
| </ControlButton> |
| )} |
| <VolumeControl |
| muted={muted} |
| setMuted={setMuted} |
| volume={volume} |
| setVolume={setVolume} |
| interaction={showControlsAction} |
| /> |
| <ControlButton |
| tooltip={"Current progress"} |
| className={"ml-auto flex items-center py-0.5"} |
| onClick={() => { |
| if (show) { |
| setShowTimePlayed(!showTimePlayed) |
| } |
| }} |
| interaction={showControlsAction} |
| > |
| <span className="text-sm"> |
| {(showTimePlayed |
| ? secondsToTime(progress) |
| : "-" + secondsToTime(duration - progress)) + |
| " / " + |
| secondsToTime(duration)} |
| </span> |
| </ControlButton> |
| |
| {/* PiP button */} |
| <ControlButton |
| tooltip={pipEnabled ? "Exit PiP" : "Enter PiP"} |
| onClick={async () => { |
| if (pipEnabled) { |
| // Exit PiP |
| setPipEnabled(false) |
| if (document.pictureInPictureElement) { |
| try { |
| await document.exitPictureInPicture() |
| } catch (err) { |
| console.warn("Failed to exit PiP:", err) |
| } |
| } |
| } else { |
| // Try to enter PiP |
| // Robust YouTube URL detection with proper hostname validation |
| let isYouTube = false |
| try { |
| const url = new URL(currentSrc.src) |
| const hostname = url.hostname.toLowerCase() |
| // Check for exact match or subdomain of youtube.com or youtu.be |
| isYouTube = hostname === 'youtube.com' || |
| hostname === 'www.youtube.com' || |
| hostname === 'm.youtube.com' || |
| hostname === 'gaming.youtube.com' || |
| hostname === 'youtu.be' || |
| hostname === 'www.youtu.be' || |
| hostname.endsWith('.youtube.com') || |
| hostname.endsWith('.youtu.be') |
| } catch (e) { |
| // Invalid URL, treat as non-YouTube |
| isYouTube = false |
| } |
| |
| if (isYouTube) { |
| // For YouTube, use ReactPlayer's pip prop |
| setPipEnabled(true) |
| if (!pipEnabled && musicMode) { |
| setMusicMode(false) |
| } |
| } else { |
| // For file sources, try native PiP API |
| // Note: We query for the first video element on the page. |
| // This works for the current app structure where there's only one video player. |
| // If multiple video elements exist, this might select the wrong one. |
| const videoElement = document.querySelector('video') |
| |
| // Check if native PiP is supported and available |
| const nativePipSupported = videoElement && |
| 'requestPictureInPicture' in videoElement && |
| document.pictureInPictureEnabled && |
| videoElement.disablePictureInPicture !== true |
| |
| if (nativePipSupported && videoElement) { |
| try { |
| await videoElement.requestPictureInPicture() |
| setPipEnabled(true) |
| if (musicMode) { |
| setMusicMode(false) |
| } |
| } catch (err) { |
| console.warn("Native PiP failed:", err) |
| // Native PiP failed - use fallback popup |
| // Call synchronously since we're still in the click handler context |
| openPipFallback() |
| } |
| } else { |
| // Fallback: open popup window immediately in click context |
| openPipFallback() |
| } |
| } |
| } |
| }} |
| interaction={showControlsAction} |
| > |
| <IconPip /> |
| </ControlButton> |
| |
| <PlayerMenu |
| roomId={roomId} |
| playing={playing} |
| currentSrc={currentSrc} |
| setCurrentSrc={setCurrentSrc} |
| currentSub={currentSub} |
| setCurrentSub={setCurrentSub} |
| loop={loop} |
| setLoop={setLoop} |
| interaction={showControlsAction} |
| playbackRate={playbackRate} |
| setPlaybackRate={setPlaybackRate} |
| menuOpen={menuOpen} |
| setMenuOpen={setMenuOpen} |
| /> |
| |
| <ControlButton |
| tooltip={ |
| !isOwner |
| ? musicMode |
| ? "Music mode ON (owner only)" |
| : "Music mode (owner only)" |
| : musicMode |
| ? "Exit music mode" |
| : "Enter music mode" |
| } |
| onClick={() => { |
| if (isOwner) { |
| setMusicMode(!musicMode) |
| if (!musicMode && pipEnabled) { |
| setPipEnabled(false) |
| } |
| } |
| }} |
| interaction={showControlsAction} |
| className={classNames( |
| !isOwner && "opacity-50 cursor-not-allowed", |
| musicMode && !isOwner && "text-primary-400" |
| )} |
| > |
| <IconMusic /> |
| </ControlButton> |
| |
| {/* Fullscreen button */} |
| <ControlButton |
| tooltip={fullscreen ? "Leave fullscreen" : "Enter fullscreen"} |
| onClick={async () => { |
| console.log("Toggled fullscreen") |
| const newFullscreen = !fullscreen |
| await setFullscreen(newFullscreen) |
| |
| // Handle screen orientation for mobile devices |
| if ('screen' in window && 'orientation' in window.screen) { |
| try { |
| // Use type assertion for Screen Orientation API (not fully typed in TS) |
| const orientation = window.screen.orientation as ScreenOrientation & { |
| lock?: (orientation: string) => Promise<void> |
| } |
| if (newFullscreen && orientation.lock) { |
| // Lock to landscape when entering fullscreen |
| await orientation.lock('landscape') |
| } else { |
| // Unlock orientation when exiting fullscreen |
| orientation.unlock() |
| } |
| } catch (err) { |
| // Orientation lock not supported or failed - this is expected on desktop |
| console.log("Screen orientation lock not available:", err) |
| } |
| } |
| }} |
| interaction={showControlsAction} |
| > |
| {fullscreen ? <IconCompress /> : <IconExpand />} |
| </ControlButton> |
| </div> |
| {/* Progress bar at bottom */} |
| <InputSlider |
| className={"bg-transparent pb-1"} |
| value={progress} |
| onChange={(value) => { |
| setProgress(value) |
| mouseMoved() |
| }} |
| max={duration} |
| setSeeking={setSeeking} |
| showValueHover={true} |
| /> |
| </div> |
| </InteractionHandler> |
| |
| <Tooltip |
| style={{ |
| backgroundColor: "var(--dark-700)", |
| }} |
| /> |
| </> |
| ) |
| } |
| |
| export default Controls |
| |