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-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2", "react-idle-timer": "^5.7.2",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-rnd": "^10.5.2",
"short-unique-id": "^5.2.0", "short-unique-id": "^5.2.0",
"srt-webvtt": "^2.0.0", "srt-webvtt": "^2.0.0",
"ts-key-enum": "^3.0.13", "ts-key-enum": "^3.0.13",
@ -38,6 +39,8 @@
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"cpy-cli": "^5.0.0", "cpy-cli": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.2.0" "typescript": "^5.2.0"
}, },
@ -47,7 +50,9 @@
"@mui/icons-material": "^7.0.1", "@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1", "@mui/material": "^7.0.1",
"mediainfo.js": "^0.3.5", "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": { "node_modules/@babel/code-frame": {
@ -1905,6 +1910,16 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true "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": { "node_modules/cosmiconfig": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "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": { "node_modules/react": {
"version": "19.0.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.0.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true, "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.25.0" "scheduler": "^0.26.0"
}, },
"peerDependencies": { "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": { "node_modules/react-dropzone": {
@ -3276,6 +3325,67 @@
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"dev": true "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -3427,16 +3537,23 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.25.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"peer": true "license": "MIT"
}, },
"node_modules/seedrandom": { "node_modules/seedrandom": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "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-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2", "react-idle-timer": "^5.7.2",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-rnd": "^10.5.2",
"short-unique-id": "^5.2.0", "short-unique-id": "^5.2.0",
"srt-webvtt": "^2.0.0", "srt-webvtt": "^2.0.0",
"ts-key-enum": "^3.0.13", "ts-key-enum": "^3.0.13",
@ -49,7 +50,9 @@
"@mui/icons-material": "^7.0.1", "@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1", "@mui/material": "^7.0.1",
"mediainfo.js": "^0.3.5", "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": { "devDependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@ -60,6 +63,8 @@
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"cpy-cli": "^5.0.0", "cpy-cli": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.2.0" "typescript": "^5.2.0"
}, },

View File

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

View File

@ -340,6 +340,7 @@ export const PlaybackRate = ({
increaseSpeed, increaseSpeed,
isScreenSmall, isScreenSmall,
onSelect, onSelect,
openPlaybackMenu
}: any) => { }: any) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const btnRef = useRef(null); const btnRef = useRef(null);
@ -362,130 +363,13 @@ export const PlaybackRate = ({
fontSize: fontSizeSmall, fontSize: fontSizeSmall,
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig, padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}} }}
onClick={() => setIsOpen(true)} onClick={() => openPlaybackMenu()}
> >
<SlowMotionVideoIcon /> <SlowMotionVideoIcon />
</IconButton> </IconButton>
</CustomFontTooltip> </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> </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; onSelectPlaybackRate: (rate: number)=> void;
isMuted: boolean isMuted: boolean
toggleMute: ()=> void 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; const showMobileControls = isScreenSmall && canPlay;
@ -100,7 +102,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
</Box> </Box>
<Box sx={{...controlGroupSX, marginLeft: 'auto'}}> <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 /> */} {/* <ObjectFitButton /> */}
<CustomFontTooltip <CustomFontTooltip
title="Subtitles" title="Subtitles"
@ -111,7 +113,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
<SubtitlesIcon /> <SubtitlesIcon />
</IconButton> </IconButton>
</CustomFontTooltip> </CustomFontTooltip>
{/* <PictureInPictureButton /> */} <PictureInPictureButton togglePictureInPicture={togglePictureInPicture} />
<FullscreenButton toggleFullscreen={toggleFullscreen} /> <FullscreenButton toggleFullscreen={toggleFullscreen} />
</Box> </Box>
</Box> </Box>

View File

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

View File

@ -12,6 +12,8 @@ import { useProgressStore, useVideoStore } from "../../state/video";
import { QortalGetMetadata } from "../../types/interfaces/resources"; import { QortalGetMetadata } from "../../types/interfaces/resources";
import { useResourceStatus } from "../../hooks/useResourceStatus"; import { useResourceStatus } from "../../hooks/useResourceStatus";
import useIdleTimeout from "../../common/useIdleTimeout"; import useIdleTimeout from "../../common/useIdleTimeout";
import { useLocation, useNavigate } from "react-router-dom";
import { useGlobalPlayerStore } from "../../state/pip";
const controlsHeight = "42px"; const controlsHeight = "42px";
const minSpeed = 0.25; const minSpeed = 0.25;
@ -25,10 +27,11 @@ interface UseVideoControls {
retryAttempts?: number; retryAttempts?: number;
isPlayerInitialized: boolean isPlayerInitialized: boolean
isMuted: boolean isMuted: boolean
videoRef: any
} }
export const useVideoPlayerController = (props: UseVideoControls) => { 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 [isFullscreen, setIsFullscreen] = useState(false);
const [showControlsFullScreen, setShowControlsFullScreen] = useState(false) const [showControlsFullScreen, setShowControlsFullScreen] = useState(false)
@ -39,7 +42,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
const [startPlay, setStartPlay] = useState(false); const [startPlay, setStartPlay] = useState(false);
const [startedFetch, setStartedFetch] = useState(false); const [startedFetch, setStartedFetch] = useState(false);
const startedFetchRef = useRef(false); const startedFetchRef = useRef(false);
const navigate = useNavigate()
const { playbackSettings, setPlaybackRate, setVolume } = useVideoStore(); const { playbackSettings, setPlaybackRate, setVolume } = useVideoStore();
const { getProgress } = useProgressStore(); const { getProgress } = useProgressStore();
@ -61,22 +64,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`; return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
}, [qortalVideoResource]); }, [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( const [playbackRate, _setLocalPlaybackRate] = useState(
playbackSettings.playbackRate playbackSettings.playbackRate
@ -294,6 +282,33 @@ const togglePlay = useCallback(async () => {
} }
}, [togglePlay, isReady]); }, [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 { return {
reloadVideo, reloadVideo,
@ -314,6 +329,6 @@ const togglePlay = useCallback(async () => {
isReady, isReady,
resourceUrl, resourceUrl,
startPlay, 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 { IndexManager } from "../components/IndexManager/IndexManager";
import { useIndexes } from "../hooks/useIndexes"; import { useIndexes } from "../hooks/useIndexes";
import { useProgressStore } from "../state/video"; import { useProgressStore } from "../state/video";
import { GlobalPipPlayer } from "../hooks/useGlobalPipPlayer";
// ✅ Define Global Context Type // ✅ Define Global Context Type
interface GlobalContextType { interface GlobalContextType {
@ -80,6 +81,7 @@ export const GlobalProvider = ({
return ( return (
<GlobalContext.Provider value={contextValue}> <GlobalContext.Provider value={contextValue}>
<GlobalPipPlayer />
<Toaster <Toaster
position="top-center" position="top-center"
toastOptions={{ 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),
}));