mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: create improved player for hosted videos
This commit is contained in:
parent
e4d5c44aae
commit
7139448195
6 changed files with 435 additions and 90 deletions
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useEffect, useRef } from 'react'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
interface VideoDetails {
|
||||
startTime?: number
|
||||
endTime?: number | null
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
interface LearnHousePlayerProps {
|
||||
src: string
|
||||
details?: VideoDetails
|
||||
onReady?: () => void
|
||||
}
|
||||
|
||||
const LearnHousePlayer: React.FC<LearnHousePlayerProps> = ({ src, details, onReady }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const playerRef = useRef<Plyr | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
// Initialize Plyr
|
||||
playerRef.current = new Plyr(videoRef.current, {
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'settings',
|
||||
'pip',
|
||||
'fullscreen'
|
||||
],
|
||||
settings: ['quality', 'speed', 'loop'],
|
||||
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] },
|
||||
tooltips: { controls: true, seek: true },
|
||||
keyboard: { focused: true, global: true },
|
||||
seekTime: 10,
|
||||
volume: 1,
|
||||
muted: details?.muted ?? false,
|
||||
autoplay: details?.autoplay ?? false,
|
||||
disableContextMenu: true,
|
||||
hideControls: true,
|
||||
resetOnEnd: false,
|
||||
invertTime: false,
|
||||
ratio: '16:9',
|
||||
fullscreen: { enabled: true, iosNative: true }
|
||||
})
|
||||
|
||||
// Set initial time if specified
|
||||
if (details?.startTime) {
|
||||
playerRef.current.currentTime = details.startTime
|
||||
}
|
||||
|
||||
// Handle end time
|
||||
if (details?.endTime) {
|
||||
playerRef.current.on('timeupdate', () => {
|
||||
if (playerRef.current && playerRef.current.currentTime >= details.endTime!) {
|
||||
playerRef.current.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Call onReady if provided
|
||||
if (onReady) {
|
||||
playerRef.current.on('ready', onReady)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [details, onReady])
|
||||
|
||||
return (
|
||||
<div className="w-full aspect-video rounded-lg overflow-hidden">
|
||||
<style jsx global>{`
|
||||
.plyr--video {
|
||||
--plyr-color-main: #ffffff;
|
||||
--plyr-video-background: #000000;
|
||||
--plyr-menu-background: #ffffff;
|
||||
--plyr-menu-color: #000000;
|
||||
--plyr-tooltip-background: #ffffff;
|
||||
--plyr-tooltip-color: #000000;
|
||||
--plyr-range-track-height: 4px;
|
||||
--plyr-range-thumb-height: 12px;
|
||||
--plyr-range-thumb-background: #ffffff;
|
||||
--plyr-range-fill-background: #ffffff;
|
||||
--plyr-control-icon-size: 18px;
|
||||
--plyr-control-spacing: 10px;
|
||||
--plyr-control-radius: 4px;
|
||||
--plyr-video-controls-background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
.plyr--full-ui input[type=range] {
|
||||
color: #ffffff;
|
||||
}
|
||||
.plyr__control--overlaid {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid #000;
|
||||
}
|
||||
.plyr__control--overlaid svg {
|
||||
fill: #000 !important;
|
||||
}
|
||||
.plyr__control--overlaid:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
.plyr__control.plyr__tab-focus,
|
||||
.plyr__control:hover,
|
||||
.plyr__control[aria-expanded=true] {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.plyr__menu__container {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e5;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.plyr__menu__container > div {
|
||||
background: #ffffff;
|
||||
}
|
||||
.plyr__menu__container,
|
||||
.plyr__menu__container *,
|
||||
.plyr__menu__container button,
|
||||
.plyr__menu__container button:focus,
|
||||
.plyr__menu__container button:active,
|
||||
.plyr__menu__container button[aria-selected="true"] {
|
||||
color: #000 !important;
|
||||
}
|
||||
.plyr__menu__container button:hover {
|
||||
background: #f5f5f5;
|
||||
color: #000 !important;
|
||||
}
|
||||
.plyr__control svg {
|
||||
fill: #ffffff;
|
||||
}
|
||||
.plyr__control:hover svg {
|
||||
fill: #ffffff;
|
||||
}
|
||||
/* Settings (gear) icon: white by default, black on hover/open */
|
||||
.plyr__controls .plyr__control[data-plyr="settings"] svg {
|
||||
fill: #fff;
|
||||
}
|
||||
.plyr__controls .plyr__control[data-plyr="settings"]:hover svg,
|
||||
.plyr__controls .plyr__control[data-plyr="settings"][aria-expanded="true"] svg {
|
||||
fill: #000;
|
||||
}
|
||||
.plyr__time {
|
||||
color: #ffffff;
|
||||
}
|
||||
.plyr__progress__buffer {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.plyr__volume--display {
|
||||
color: #ffffff;
|
||||
}
|
||||
.plyr__control[aria-expanded=true] svg {
|
||||
fill: #000000;
|
||||
}
|
||||
.plyr__control[aria-expanded=true] .plyr__tooltip {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
.plyr__tooltip {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
.plyr__tooltip::before {
|
||||
border-top-color: #ffffff;
|
||||
}
|
||||
/* Menu and settings icons */
|
||||
.plyr__menu__container .plyr__control svg,
|
||||
.plyr__menu__container button svg {
|
||||
fill: #000000;
|
||||
}
|
||||
.plyr__menu__container .plyr__control:hover svg,
|
||||
.plyr__menu__container button:hover svg {
|
||||
fill: #000000;
|
||||
}
|
||||
/* Settings button when menu is open */
|
||||
.plyr__control[aria-expanded=true] svg {
|
||||
fill: #000000;
|
||||
}
|
||||
/* Settings button hover */
|
||||
.plyr__control[aria-expanded=true]:hover svg {
|
||||
fill: #000000;
|
||||
}
|
||||
`}</style>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="plyr-react plyr"
|
||||
playsInline
|
||||
controls
|
||||
>
|
||||
<source src={src} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LearnHousePlayer
|
||||
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import YouTube from 'react-youtube'
|
||||
import { getActivityMediaDirectory } from '@services/media/media'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import LearnHousePlayer from './LearnHousePlayer'
|
||||
|
||||
interface VideoDetails {
|
||||
startTime?: number
|
||||
|
|
@ -28,7 +29,6 @@ interface VideoActivityProps {
|
|||
function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||
const org = useOrg() as any
|
||||
const [videoId, setVideoId] = React.useState('')
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activity?.content?.uri) {
|
||||
|
|
@ -48,41 +48,24 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Handle native video time update
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current
|
||||
if (video && activity.details?.endTime) {
|
||||
if (video.currentTime >= activity.details.endTime) {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle native video load
|
||||
const handleVideoLoad = () => {
|
||||
const video = videoRef.current
|
||||
if (video && activity.details) {
|
||||
video.currentTime = activity.details.startTime || 0
|
||||
video.autoplay = activity.details.autoplay || false
|
||||
video.muted = activity.details.muted || false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-full px-2 sm:px-4">
|
||||
{activity && (
|
||||
<>
|
||||
{console.log('Activity type:', activity.activity_sub_type)}
|
||||
{console.log('Video source:', getVideoSrc())}
|
||||
{activity.activity_sub_type === 'SUBTYPE_VIDEO_HOSTED' && (
|
||||
<div className="my-3 md:my-5 w-full">
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
src={getVideoSrc()}
|
||||
onLoadedMetadata={handleVideoLoad}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
></video>
|
||||
{(() => {
|
||||
const src = getVideoSrc()
|
||||
return src ? (
|
||||
<LearnHousePlayer
|
||||
src={src}
|
||||
details={activity.details}
|
||||
/>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -98,10 +81,18 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
|
|||
autoplay: activity.details?.autoplay ? 1 : 0,
|
||||
mute: activity.details?.muted ? 1 : 0,
|
||||
start: activity.details?.startTime || 0,
|
||||
end: activity.details?.endTime || undefined
|
||||
end: activity.details?.endTime || undefined,
|
||||
controls: 1,
|
||||
modestbranding: 1,
|
||||
rel: 0
|
||||
},
|
||||
}}
|
||||
videoId={videoId}
|
||||
onReady={(event) => {
|
||||
if (activity.details?.startTime) {
|
||||
event.target.seekTo(activity.details.startTime, true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -95,70 +95,147 @@ function VideoModal({
|
|||
}
|
||||
}
|
||||
|
||||
const VideoSettingsForm = () => (
|
||||
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Video Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="start-time">Start Time (seconds)</Label>
|
||||
<Input
|
||||
id="start-time"
|
||||
type="number"
|
||||
min="0"
|
||||
value={videoDetails.startTime}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
startTime: Math.max(0, parseInt(e.target.value) || 0)
|
||||
})}
|
||||
placeholder="0"
|
||||
/>
|
||||
const VideoSettingsForm = () => {
|
||||
const convertToSeconds = (minutes: number, seconds: number) => {
|
||||
return minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const convertFromSeconds = (totalSeconds: number) => {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return { minutes, seconds };
|
||||
};
|
||||
|
||||
const startTimeParts = convertFromSeconds(videoDetails.startTime);
|
||||
const endTimeParts = videoDetails.endTime ? convertFromSeconds(videoDetails.endTime) : { minutes: 0, seconds: 0 };
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Video Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Start Time</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={startTimeParts.minutes}
|
||||
onChange={(e) => {
|
||||
const minutes = Math.max(0, parseInt(e.target.value) || 0);
|
||||
const seconds = startTimeParts.seconds;
|
||||
setVideoDetails({
|
||||
...videoDetails,
|
||||
startTime: convertToSeconds(minutes, seconds)
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1 block">Minutes</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={startTimeParts.seconds}
|
||||
onChange={(e) => {
|
||||
const minutes = startTimeParts.minutes;
|
||||
const seconds = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
|
||||
setVideoDetails({
|
||||
...videoDetails,
|
||||
startTime: convertToSeconds(minutes, seconds)
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1 block">Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>End Time (optional)</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={endTimeParts.minutes}
|
||||
onChange={(e) => {
|
||||
const minutes = Math.max(0, parseInt(e.target.value) || 0);
|
||||
const seconds = endTimeParts.seconds;
|
||||
const totalSeconds = convertToSeconds(minutes, seconds);
|
||||
if (totalSeconds > videoDetails.startTime) {
|
||||
setVideoDetails({
|
||||
...videoDetails,
|
||||
endTime: totalSeconds
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1 block">Minutes</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={endTimeParts.seconds}
|
||||
onChange={(e) => {
|
||||
const minutes = endTimeParts.minutes;
|
||||
const seconds = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
|
||||
const totalSeconds = convertToSeconds(minutes, seconds);
|
||||
if (totalSeconds > videoDetails.startTime) {
|
||||
setVideoDetails({
|
||||
...videoDetails,
|
||||
endTime: totalSeconds
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1 block">Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="end-time">End Time (seconds, optional)</Label>
|
||||
<Input
|
||||
id="end-time"
|
||||
type="number"
|
||||
min={videoDetails.startTime + 1}
|
||||
value={videoDetails.endTime || ''}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
endTime: e.target.value ? parseInt(e.target.value) : null
|
||||
})}
|
||||
placeholder="Leave empty for full duration"
|
||||
/>
|
||||
<div className="flex items-center space-x-6 mt-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={videoDetails.autoplay}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
autoplay: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Autoplay video</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={videoDetails.muted}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
muted: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Start muted</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6 mt-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={videoDetails.autoplay}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
autoplay: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Autoplay video</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={videoDetails.muted}
|
||||
onChange={(e) => setVideoDetails({
|
||||
...videoDetails,
|
||||
muted: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Start muted</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Root onSubmit={handleSubmit}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue