added global player

This commit is contained in:
PhilReact 2025-06-17 18:21:59 +03:00
parent 2c3b8a6472
commit 275ddc4ac8
10 changed files with 791 additions and 193 deletions

145
package-lock.json generated
View File

@ -23,6 +23,7 @@
"react-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2",
"react-intersection-observer": "^9.16.0",
"react-rnd": "^10.5.2",
"short-unique-id": "^5.2.0",
"srt-webvtt": "^2.0.0",
"ts-key-enum": "^3.0.13",
@ -38,6 +39,8 @@
"@types/react": "^19.0.10",
"cpy-cli": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"tsup": "^8.4.0",
"typescript": "^5.2.0"
},
@ -47,7 +50,9 @@
"@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1",
"mediainfo.js": "^0.3.5",
"react": "^19.0.0"
"react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2"
}
},
"node_modules/@babel/code-frame": {
@ -1905,6 +1910,16 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@ -3194,24 +3209,58 @@
}
]
},
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
"integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"peer": true,
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.0.0"
"react": "^19.1.0"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-dropzone": {
@ -3276,6 +3325,67 @@
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"dev": true
},
"node_modules/react-rnd": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz",
"integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==",
"license": "MIT",
"dependencies": {
"re-resizable": "6.11.2",
"react-draggable": "4.4.6",
"tslib": "2.6.2"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-rnd/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/react-router": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz",
"integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz",
"integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.6.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -3427,16 +3537,23 @@
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"peer": true
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"dev": true,
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -37,6 +37,7 @@
"react-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2",
"react-intersection-observer": "^9.16.0",
"react-rnd": "^10.5.2",
"short-unique-id": "^5.2.0",
"srt-webvtt": "^2.0.0",
"ts-key-enum": "^3.0.13",
@ -49,7 +50,9 @@
"@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1",
"mediainfo.js": "^0.3.5",
"react": "^19.0.0"
"react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
@ -60,6 +63,8 @@
"@types/react": "^19.0.10",
"cpy-cli": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"tsup": "^8.4.0",
"typescript": "^5.2.0"
},

View File

@ -173,13 +173,22 @@ const SubtitleManagerComponent = ({
getPublishedSubtitles,
]);
const handleClose = () => {
const ref = useRef<any>(null)
console.log('isOpen', open)
useEffect(()=> {
if(open){
ref?.current?.focus()
}
}, [open])
const handleBlur = (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget) && !isOpenPublish) {
console.log('handleBlur')
close();
setMode(1);
// setTitle("");
// setDescription("");
// setHasMetadata(false);
};
setIsOpenPublish(false)
}
};
const publishHandler = async (subtitles: Subtitle[]) => {
try {
@ -250,23 +259,20 @@ const SubtitleManagerComponent = ({
};
const theme = useTheme();
if(!open) return
return (
<>
<Popover
open={!!open}
anchorEl={subtitleBtnRef.current}
onClose={handleClose}
slots={{
transition: Fade,
}}
slotProps={{
transition: {
timeout: 200,
},
paper: {
sx: {
bgcolor: alpha("#181818", 0.98),
<Box
ref={ref}
tabIndex={-1}
onBlur={handleBlur}
bgcolor={alpha("#181818", 0.98)}
sx={
{
position: 'absolute',
bottom: 60,
right: 5,
color: "white",
opacity: 0.9,
borderRadius: 2,
@ -277,17 +283,9 @@ const SubtitleManagerComponent = ({
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
zIndex: 10,
}
}
>
<Box
sx={{
@ -421,14 +419,15 @@ const SubtitleManagerComponent = ({
</Typography>
))}
</Box> */}
</Popover>
</Box>
<PublishSubtitles
isOpen={isOpenPublish}
setIsOpen={setIsOpenPublish}
publishHandler={publishHandler}
mySubtitles={mySubtitles}
/>
</>
// <Dialog
// open={!!open}

View File

@ -340,6 +340,7 @@ export const PlaybackRate = ({
increaseSpeed,
isScreenSmall,
onSelect,
openPlaybackMenu
}: any) => {
const [isOpen, setIsOpen] = useState(false);
const btnRef = useRef(null);
@ -362,130 +363,13 @@ export const PlaybackRate = ({
fontSize: fontSizeSmall,
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={() => setIsOpen(true)}
onClick={() => openPlaybackMenu()}
>
<SlowMotionVideoIcon />
</IconButton>
</CustomFontTooltip>
<Popover
open={isOpen}
anchorEl={btnRef.current}
onClose={() => setIsOpen(false)}
slots={{
transition: Fade,
}}
slotProps={{
transition: {
timeout: 200,
},
paper: {
sx: {
bgcolor: alpha("#181818", 0.98),
color: "white",
opacity: 0.9,
borderRadius: 2,
boxShadow: 5,
p: 1,
minWidth: 225,
height: 300,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
>
<Box
sx={{
padding: "5px 0px 10px 0px",
display: "flex",
gap: "10px",
width: "100%",
}}
>
<ButtonBase onClick={onBack}>
<ArrowBackIosIcon
sx={{
fontSize: "1.15em",
}}
/>
</ButtonBase>
<ButtonBase>
<Typography
onClick={onBack}
sx={{
fontSize: "0.85rem",
}}
>
Playback speed
</Typography>
</ButtonBase>
</Box>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
overflow: "auto",
"::-webkit-scrollbar-track": {
backgroundColor: "transparent",
},
"::-webkit-scrollbar": {
width: "16px",
height: "10px",
},
"::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
transition: "0.3s background-color",
},
"::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.primary.dark,
},
}}
>
{speeds?.map((speed) => {
const isSelected = speed === playbackRate;
return (
<ButtonBase
disabled={isSelected}
key={speed}
onClick={() => {
onSelect(speed)
setIsOpen(false)
}}
sx={{
px: 2,
py: 1,
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
width: "100%",
justifyContent: "space-between",
}}
>
<Typography>{speed}</Typography>
{isSelected ? <CheckIcon /> : <ArrowForwardIosIcon />}
</ButtonBase>
);
})}
</Box>
</Popover>
</>
);
};
@ -551,3 +435,138 @@ export const FullscreenButton = ({ toggleFullscreen, isScreenSmall }: any) => {
</CustomFontTooltip>
);
};
interface PlayBackMenuProps {
close: ()=> void
isOpen: boolean
onSelect: (speed: number)=> void;
playbackRate: number
}
export const PlayBackMenu = ({close, onSelect, isOpen, playbackRate}: PlayBackMenuProps)=> {
const theme = useTheme()
const ref = useRef<any>(null)
console.log('isOpen', isOpen)
useEffect(()=> {
if(isOpen){
ref?.current?.focus()
}
}, [isOpen])
const handleBlur = (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
close();
}
};
if(!isOpen) return null
return (
<Box
ref={ref}
tabIndex={-1}
onBlur={handleBlur}
bgcolor={alpha("#181818", 0.98)}
sx={
{
position: 'absolute',
bottom: 60,
right: 5,
color: "white",
opacity: 0.9,
borderRadius: 2,
boxShadow: 5,
p: 1,
minWidth: 225,
height: 300,
overflow: "hidden",
display: "flex",
flexDirection: "column",
zIndex: 10,
}
}
>
<Box
sx={{
padding: "5px 0px 10px 0px",
display: "flex",
gap: "10px",
width: "100%",
}}
>
<ButtonBase onClick={close}>
<ArrowBackIosIcon
sx={{
fontSize: "1.15em",
}}
/>
</ButtonBase>
<ButtonBase>
<Typography
onClick={close}
sx={{
fontSize: "0.85rem",
}}
>
Playback speed
</Typography>
</ButtonBase>
</Box>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
overflow: "auto",
"::-webkit-scrollbar-track": {
backgroundColor: "transparent",
},
"::-webkit-scrollbar": {
width: "16px",
height: "10px",
},
"::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.primary.main,
borderRadius: "8px",
backgroundClip: "content-box",
border: "4px solid transparent",
transition: "0.3s background-color",
},
"::-webkit-scrollbar-thumb:hover": {
backgroundColor: theme.palette.primary.dark,
},
}}
>
{speeds?.map((speed) => {
const isSelected = speed === playbackRate;
return (
<ButtonBase
disabled={isSelected}
key={speed}
onClick={(e) => {
onSelect(speed)
close()
}}
sx={{
px: 2,
py: 1,
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
width: "100%",
justifyContent: "space-between",
}}
>
<Typography>{speed}</Typography>
{isSelected ? <CheckIcon /> : <ArrowForwardIosIcon />}
</ButtonBase>
);
})}
</Box>
</Box>
)
}

View File

@ -40,9 +40,11 @@ interface VideoControlsBarProps {
onSelectPlaybackRate: (rate: number)=> void;
isMuted: boolean
toggleMute: ()=> void
openPlaybackMenu: ()=> void
togglePictureInPicture: ()=> void
}
export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, increaseSpeed,decreaseSpeed, isFullScreen, showControlsFullScreen, reloadVideo, onVolumeChange, volume, isPlaying, canPlay, isScreenSmall, controlsHeight, playerRef, duration, progress, togglePlay, toggleFullscreen, extractFrames, openSubtitleManager, onSelectPlaybackRate, isMuted, toggleMute}: VideoControlsBarProps) => {
export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, increaseSpeed,decreaseSpeed, isFullScreen, showControlsFullScreen, reloadVideo, onVolumeChange, volume, isPlaying, canPlay, isScreenSmall, controlsHeight, playerRef, duration, progress, togglePlay, toggleFullscreen, extractFrames, openSubtitleManager, onSelectPlaybackRate, isMuted, toggleMute, openPlaybackMenu, togglePictureInPicture}: VideoControlsBarProps) => {
const showMobileControls = isScreenSmall && canPlay;
@ -100,7 +102,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
</Box>
<Box sx={{...controlGroupSX, marginLeft: 'auto'}}>
<PlaybackRate onSelect={onSelectPlaybackRate} playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} />
<PlaybackRate openPlaybackMenu={openPlaybackMenu} onSelect={onSelectPlaybackRate} playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} />
{/* <ObjectFitButton /> */}
<CustomFontTooltip
title="Subtitles"
@ -111,7 +113,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
<SubtitlesIcon />
</IconButton>
</CustomFontTooltip>
{/* <PictureInPictureButton /> */}
<PictureInPictureButton togglePictureInPicture={togglePictureInPicture} />
<FullscreenButton toggleFullscreen={toggleFullscreen} />
</Box>
</Box>

View File

@ -4,6 +4,7 @@ import {
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -28,6 +29,9 @@ import {
import { base64ToBlobUrl } from "../../utils/base64";
import convert from "srt-webvtt";
import { TimelineActionsComponent } from "./TimelineActionsComponent";
import { PlayBackMenu } from "./VideoControls";
import { useGlobalPlayerStore } from "../../state/pip";
import { useLocation } from "react-router-dom";
export async function srtBase64ToVttBlobUrl(
base64Srt: string
@ -208,6 +212,9 @@ export const VideoPlayer = ({
const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false);
const subtitleBtnRef = useRef(null);
const [currentSubTrack, setCurrentSubTrack] = useState<null | string>(null)
const location = useLocation();
const locationRef = useRef<string | null>(null)
const [isOpenPlaybackMenu, setIsOpenPlaybackmenu] = useState(false)
const {
reloadVideo,
togglePlay,
@ -231,17 +238,27 @@ export const VideoPlayer = ({
percentLoaded,
showControlsFullScreen,
onSelectPlaybackRate,
seekTo
seekTo,
togglePictureInPicture
} = useVideoPlayerController({
autoPlay,
playerRef,
qortalVideoResource,
retryAttempts,
isPlayerInitialized,
isMuted
isMuted,
videoRef
});
useEffect(()=> {
if(location){
locationRef.current = location.pathname
}
},[location])
console.log('isFullscreen', isFullscreen)
const { getProgress } = useProgressStore();
const enterFullscreen = useCallback(() => {
const ref = containerRef?.current as any;
@ -304,6 +321,10 @@ export const VideoPlayer = ({
if (!qortalVideoResource) return null;
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
}, [qortalVideoResource]);
const videoLocationRef = useRef< null | string>(null)
useEffect(()=> {
videoLocationRef.current = videoLocation
}, [videoLocation])
useVideoPlayerHotKeys(hotkeyHandlers);
const updateProgress = useCallback(() => {
@ -316,6 +337,15 @@ export const VideoPlayer = ({
setLocalProgress(currentTime);
}
}, [videoLocation]);
useEffect(()=> {
if(videoLocation){
const vidId = useGlobalPlayerStore.getState().videoId
if(vidId === videoLocation){
togglePlay()
}
}
}, [videoLocation])
// useEffect(() => {
// const ref = videoRef as React.RefObject<HTMLVideoElement>;
// if (!ref.current) return;
@ -442,6 +472,13 @@ export const VideoPlayer = ({
resetHideTimer();
};
const closePlaybackMenu = useCallback(()=> {
setIsOpenPlaybackmenu(false)
}, [])
const openPlaybackMenu = useCallback(()=> {
setIsOpenPlaybackmenu(true)
}, [])
useEffect(() => {
resetHideTimer(); // initial show
return () => {
@ -593,6 +630,24 @@ export const VideoPlayer = ({
return JSON.stringify(qortalVideoResource);
}, [qortalVideoResource]);
const savedVideoRef = useRef<HTMLVideoElement | null>(null);
useEffect(()=> {
if(startPlay){
useGlobalPlayerStore.getState().reset()
}
}, [startPlay])
useLayoutEffect(() => {
// Save the video element while it's still mounted
const video = videoRef as any
if (video.current) {
savedVideoRef.current = video.current;
}
}, []);
useEffect(() => {
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay)
return;
@ -630,6 +685,13 @@ export const VideoPlayer = ({
playerRef.current?.poster("");
playerRef.current?.playbackRate(playbackRate);
playerRef.current?.volume(volume);
if(videoLocationRef.current){
const savedProgress = getProgress(videoLocationRef.current);
if (typeof savedProgress === "number") {
playerRef.current?.currentTime(savedProgress);
}
}
playerRef.current?.play();
@ -684,8 +746,30 @@ export const VideoPlayer = ({
console.error("useEffect start player", error);
}
return () => {
console.log('hello1002')
const video = savedVideoRef as any
const videoEl = video?.current!;
const player = playerRef.current;
console.log('videohello', videoEl);
const isPlaying = !player?.paused();
if (videoEl && isPlaying && videoLocationRef.current) {
const current = player?.currentTime?.();
const currentSource = player?.currentType();
useGlobalPlayerStore.getState().setVideoState({
videoSrc: videoEl.src,
currentTime: current ?? 0,
isPlaying: true,
mode: 'floating',
videoId: videoLocationRef.current,
location: locationRef.current || "",
type: currentSource || 'video/mp4'
});
}
canceled = true;
const player = playerRef.current;
if (player && typeof player.dispose === "function") {
try {
@ -753,6 +837,7 @@ export const VideoPlayer = ({
onVolumeChange={onVolumeChangeHandler}
controls={false}
/>
<PlayBackMenu close={closePlaybackMenu} isOpen={isOpenPlaybackMenu} onSelect={onSelectPlaybackRate} playbackRate={playbackRate} />
{/* <canvas ref={canvasRef} style={{ display: "none" }}></canvas> */}
{isReady && (
@ -781,11 +866,12 @@ export const VideoPlayer = ({
onSelectPlaybackRate={onSelectPlaybackRate}
isMuted={isMuted}
toggleMute={toggleMute}
openPlaybackMenu={openPlaybackMenu}
togglePictureInPicture={togglePictureInPicture}
/>
)}
{timelineActions && Array.isArray(timelineActions) && (
<TimelineActionsComponent seekTo={seekTo} containerRef={containerRef} progress={localProgress} timelineActions={timelineActions}/>
)}
<SubtitleManager
subtitleBtnRef={subtitleBtnRef}

View File

@ -12,6 +12,8 @@ import { useProgressStore, useVideoStore } from "../../state/video";
import { QortalGetMetadata } from "../../types/interfaces/resources";
import { useResourceStatus } from "../../hooks/useResourceStatus";
import useIdleTimeout from "../../common/useIdleTimeout";
import { useLocation, useNavigate } from "react-router-dom";
import { useGlobalPlayerStore } from "../../state/pip";
const controlsHeight = "42px";
const minSpeed = 0.25;
@ -25,10 +27,11 @@ interface UseVideoControls {
retryAttempts?: number;
isPlayerInitialized: boolean
isMuted: boolean
videoRef: any
}
export const useVideoPlayerController = (props: UseVideoControls) => {
const { autoPlay, playerRef, qortalVideoResource, retryAttempts, isPlayerInitialized, isMuted } = props;
const { autoPlay, videoRef , playerRef, qortalVideoResource, retryAttempts, isPlayerInitialized, isMuted } = props;
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControlsFullScreen, setShowControlsFullScreen] = useState(false)
@ -39,7 +42,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
const [startPlay, setStartPlay] = useState(false);
const [startedFetch, setStartedFetch] = useState(false);
const startedFetchRef = useRef(false);
const navigate = useNavigate()
const { playbackSettings, setPlaybackRate, setVolume } = useVideoStore();
const { getProgress } = useProgressStore();
@ -61,22 +64,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
}, [qortalVideoResource]);
useEffect(() => {
if (videoLocation && isPlayerInitialized) {
try {
const ref = playerRef as any;
if (!ref.current) return;
const savedProgress = getProgress(videoLocation);
if (typeof savedProgress === "number") {
playerRef.current?.currentTime(savedProgress);
}
} catch (error) {
console.error('line 74', error)
}
}
}, [videoLocation, getProgress, isPlayerInitialized]);
const [playbackRate, _setLocalPlaybackRate] = useState(
playbackSettings.playbackRate
@ -294,6 +282,33 @@ const togglePlay = useCallback(async () => {
}
}, [togglePlay, isReady]);
// videoRef?.current?.addEventListener("enterpictureinpicture", () => {
// setPipVideoPath(window.location.pathname);
// });
// // when PiP ends (and you're on the wrong page), go back
// videoRef?.current?.addEventListener("leavepictureinpicture", () => {
// const { pipVideoPath } = usePipStore.getState();
// if (pipVideoPath && window.location.pathname !== pipVideoPath) {
// navigate(pipVideoPath);
// }
// });
const togglePictureInPicture = async () => {
if (!videoRef.current) return;
const player = playerRef.current;
if (!player || typeof player.currentTime !== 'function' || typeof player.duration !== 'function') return;
const current = player.currentTime();
useGlobalPlayerStore.getState().setVideoState({
videoSrc: videoRef.current.src,
currentTime: current,
isPlaying: true,
mode: 'floating', // or 'floating'
});
};
return {
reloadVideo,
@ -314,6 +329,6 @@ const togglePlay = useCallback(async () => {
isReady,
resourceUrl,
startPlay,
status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate, seekTo
status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate, seekTo, togglePictureInPicture
};
};

View File

@ -14,6 +14,7 @@ import { usePersistentStore } from "../hooks/usePersistentStore";
import { IndexManager } from "../components/IndexManager/IndexManager";
import { useIndexes } from "../hooks/useIndexes";
import { useProgressStore } from "../state/video";
import { GlobalPipPlayer } from "../hooks/useGlobalPipPlayer";
// ✅ Define Global Context Type
interface GlobalContextType {
@ -80,6 +81,7 @@ export const GlobalProvider = ({
return (
<GlobalContext.Provider value={contextValue}>
<GlobalPipPlayer />
<Toaster
position="top-center"
toastOptions={{

View File

@ -0,0 +1,323 @@
// GlobalVideoPlayer.tsx
import videojs from 'video.js';
import { useGlobalPlayerStore } from '../state/pip';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, IconButton } from '@mui/material';
import { VideoContainer } from '../components/VideoPlayer/VideoPlayer-styles';
import { Rnd } from "react-rnd";
import { useProgressStore } from '../state/video';
import CloseIcon from '@mui/icons-material/Close';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import { useNavigate } from 'react-router-dom';
export const GlobalPipPlayer = () => {
const { videoSrc, reset, isPlaying, location, type, currentTime, mode, videoId } = useGlobalPlayerStore();
const [playing , setPlaying] = useState(false)
const [hasStarted, setHasStarted] = useState(false)
const playerRef = useRef<any>(null);
const navigate = useNavigate()
const videoNode = useRef<HTMLVideoElement>(null);
const { setProgress } = useProgressStore();
const updateProgress = useCallback(() => {
const player = playerRef?.current;
if (!player || typeof player?.currentTime !== "function") return;
const currentTime = player.currentTime();
console.log('videoId', videoId)
if (typeof currentTime === "number" && videoId && currentTime > 0.1) {
setProgress(videoId, currentTime);
}
}, [videoId]);
const rndRef = useRef<any>(null)
useEffect(() => {
if (!playerRef.current && videoNode.current) {
playerRef.current = videojs(videoNode.current, { autoplay: true, controls: false,
responsive: true, fluid: true });
// Resume playback if needed
playerRef.current.on('ready', () => {
if (videoSrc) {
playerRef.current.src(videoSrc);
playerRef.current.currentTime(currentTime);
if (isPlaying) playerRef.current.play();
}
});
}
return () => {
// optional: don't destroy, just hide
};
}, []);
useEffect(()=> {
if(!videoSrc){
setHasStarted(false)
}
}, [videoSrc])
useEffect(() => {
const player = playerRef.current;
if (!player) return;
if (!videoSrc && player.src) {
// Only pause the player and unload the source without re-triggering playback
player.pause();
// Remove the video source safely
const tech = player.tech({ IWillNotUseThisInPlugins: true });
if (tech && tech.el_) {
tech.setAttribute('src', '');
setPlaying(false)
setHasStarted(false)
}
// Optionally clear the poster and currentTime
player.poster('');
player.currentTime(0);
return;
}
if(videoSrc){
// Set source and resume if needed
player.src({ src: videoSrc, type: type });
player.currentTime(currentTime);
if (isPlaying) {
const playPromise = player.play();
if (playPromise?.catch) {
playPromise.catch((err: any) => {
console.warn('Unable to autoplay:', err);
});
}
} else {
player.pause();
}
}
}, [videoSrc, type, isPlaying, currentTime]);
// const onDragStart = () => {
// timer = Date.now();
// isDragging.current = true;
// };
// const handleStopDrag = async () => {
// const time = Date.now();
// if (timer && time - timer < 300) {
// isDragging.current = false;
// } else {
// isDragging.current = true;
// }
// };
// const onDragStop = () => {
// handleStopDrag();
// };
// const checkIfDrag = useCallback(() => {
// return isDragging.current;
// }, []);
const margin = 50;
const [height, setHeight] = useState(300)
const [width, setWidth] = useState(400)
useEffect(() => {
rndRef.current.updatePosition({
x: window.innerWidth - 400 - margin,
y: window.innerHeight - 300 - margin,
width: 400,
height: 300
});
}, [videoSrc]);
const [showControls, setShowControls] = useState(false)
const handleMouseMove = () => {
setShowControls(true)
};
const handleMouseLeave = () => {
setShowControls(false);
};
const startPlay = useCallback(() => {
try {
const player = playerRef.current;
if (!player) return;
try {
player.play();
} catch (err) {
console.warn('Play failed:', err);
}
} catch (error) {
console.error('togglePlay', error)
}
}, []);
const stopPlay = useCallback(() => {
const player = playerRef.current;
if (!player) return;
try {
player.pause();
} catch (err) {
console.warn('Play failed:', err);
}
}, []);
const onPlayHandlerStart = useCallback(() => {
setPlaying(true)
setHasStarted(true)
}, [setPlaying]);
const onPlayHandlerStop = useCallback(() => {
setPlaying(false)
}, [setPlaying]);
return (
<Rnd
enableResizing={{
top: false,
right: false,
bottom: false,
left: false,
topRight: true,
bottomLeft: true,
topLeft: true,
bottomRight: true,
}}
ref={rndRef}
// onDragStart={onDragStart}
// onDragStop={onDragStop}
style={{
display: hasStarted ? "block" : "none",
position: "fixed",
zIndex: 999999999,
cursor: 'default'
}}
size={{ width, height }}
onResize={(e, direction, ref, delta, position) => {
setWidth(ref.offsetWidth);
setHeight(ref.offsetHeight);
}}
// default={{
// x: 500,
// y: 500,
// width: 350,
// height: "auto",
// }}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDrag={() => {}}
>
{/* <div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
// width: '100px',
// height: '100px',
zIndex: 9999,
display: videoSrc ? 'block' : 'none'
}}
> */}
<Box sx={{height, width, position: 'relative' , background: 'black', overflow: 'hidden', borderRadius: '10px' }} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}>
{/* {backgroundColor: showControls ? 'rgba(0,0,0,.5)' : 'unset'} */}
{showControls && (
<Box sx={{
position: 'absolute',
top: 0, bottom: 0, left: 0, right: 0,
zIndex: 1,
opacity: 0,
transition: 'opacity 1s',
"&:hover": {
opacity: 1
}
}}>
<Box sx={{
position: 'absolute',
background: 'rgba(0,0,0,.5)',
top: 0, bottom: 0, left: 0, right: 0,
zIndex: 1,
opacity: 0,
transition: 'opacity 1s',
"&:hover": {
opacity: 1
}
}} />
<IconButton sx={{
position: 'absolute',
top: 10,
opacity: 1,
right: 10,
zIndex: 2,
}} onClick={reset}><CloseIcon /></IconButton>
{location && (
<IconButton sx={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 2,
opacity: 1,
}} onClick={()=> navigate(location)}><OpenInFullIcon /></IconButton>
)}
{playing && (
<IconButton sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
opacity: 1,
zIndex: 2,
}} onClick={stopPlay}><PauseIcon /></IconButton>
)}
{!playing && (
<IconButton sx={{
position: 'absolute',
opacity: 1,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 2,
}} onClick={startPlay}><PlayArrowIcon /></IconButton>
)}
<Box/>
</Box>
)}
<VideoContainer>
<video onPlay={onPlayHandlerStart} onPause={onPlayHandlerStop} onTimeUpdate={updateProgress}
ref={videoNode} className="video-js" style={{
}}/>
</VideoContainer>
</Box>
{/* </div> */}
</Rnd>
);
};

30
src/state/pip.ts Normal file
View File

@ -0,0 +1,30 @@
import { create } from 'zustand';
type PlayerMode = 'embedded' | 'floating' | 'none';
interface GlobalPlayerState {
videoSrc: string | null;
videoId: string,
isPlaying: boolean;
currentTime: number;
location: string;
mode: PlayerMode;
setVideoState: (state: Partial<GlobalPlayerState>) => void;
type: string,
reset: ()=> void;
}
const initialState = {
videoSrc: null,
videoId: "",
location: "",
isPlaying: false,
currentTime: 0,
type: 'video/mp4',
mode: 'embedded' as const,
};
export const useGlobalPlayerStore = create<GlobalPlayerState>((set) => ({
...initialState,
setVideoState: (state) => set((prev) => ({ ...prev, ...state })),
reset: () => set(initialState),
}));