From 71394481953022c46ba60c01a68d9dfee56c4624 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 1 Jun 2025 22:14:49 +0200 Subject: [PATCH] feat: create improved player for hosted videos --- .../Activities/Video/LearnHousePlayer.tsx | 205 ++++++++++++++++++ .../Objects/Activities/Video/Video.tsx | 51 ++--- .../NewActivityModal/VideoActivityModal.tsx | 197 ++++++++++++----- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 53 +++++ apps/web/types/react-plyr.d.ts | 17 ++ 6 files changed, 435 insertions(+), 90 deletions(-) create mode 100644 apps/web/components/Objects/Activities/Video/LearnHousePlayer.tsx create mode 100644 apps/web/types/react-plyr.d.ts diff --git a/apps/web/components/Objects/Activities/Video/LearnHousePlayer.tsx b/apps/web/components/Objects/Activities/Video/LearnHousePlayer.tsx new file mode 100644 index 00000000..651a2532 --- /dev/null +++ b/apps/web/components/Objects/Activities/Video/LearnHousePlayer.tsx @@ -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 = ({ src, details, onReady }) => { + const videoRef = useRef(null) + const playerRef = useRef(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 ( +
+ + +
+ ) +} + +export default LearnHousePlayer \ No newline at end of file diff --git a/apps/web/components/Objects/Activities/Video/Video.tsx b/apps/web/components/Objects/Activities/Video/Video.tsx index 4c1037a0..dafb7b51 100644 --- a/apps/web/components/Objects/Activities/Video/Video.tsx +++ b/apps/web/components/Objects/Activities/Video/Video.tsx @@ -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(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 (
{activity && ( <> + {console.log('Activity type:', activity.activity_sub_type)} + {console.log('Video source:', getVideoSrc())} {activity.activity_sub_type === 'SUBTYPE_VIDEO_HOSTED' && (
- + {(() => { + const src = getVideoSrc() + return src ? ( + + ) : null + })()}
)} @@ -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) + } + }} />
diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx index dfe737b8..5088324a 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx @@ -95,70 +95,147 @@ function VideoModal({ } } - const VideoSettingsForm = () => ( -
-

Video Settings

-
-
- - 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 ( +
+

Video Settings

+
+
+ +
+
+ { + 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" + /> + Minutes +
+
+ { + 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" + /> + Seconds +
+
+
+ +
+ +
+
+ { + 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" + /> + Minutes +
+
+ { + 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" + /> + Seconds +
+
+
-
- - setVideoDetails({ - ...videoDetails, - endTime: e.target.value ? parseInt(e.target.value) : null - })} - placeholder="Leave empty for full duration" - /> +
+ + +
- -
- - - -
-
- ) + ); + }; return ( diff --git a/apps/web/package.json b/apps/web/package.json index 99068a94..0c5d0dde 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,6 +67,7 @@ "next": "15.3.2", "next-auth": "^4.24.11", "nextjs-toploader": "^1.6.12", + "plyr": "^3.7.8", "prosemirror-state": "^1.4.3", "randomcolor": "^0.6.2", "re-resizable": "^6.11.2", @@ -75,6 +76,7 @@ "react-dom": "19.0.0", "react-hot-toast": "^2.5.2", "react-katex": "^3.0.1", + "react-plyr": "^2.2.0", "react-spinners": "^0.13.8", "react-youtube": "^10.1.0", "require-in-the-middle": "^7.5.2", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 7fa7024f..06482ec9 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: nextjs-toploader: specifier: ^1.6.12 version: 1.6.12(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + plyr: + specifier: ^3.7.8 + version: 3.7.8 prosemirror-state: specifier: ^1.4.3 version: 1.4.3 @@ -204,6 +207,9 @@ importers: react-katex: specifier: ^3.0.1 version: 3.0.1(prop-types@15.8.1)(react@19.0.0) + react-plyr: + specifier: ^2.2.0 + version: 2.2.0(react@19.0.0) react-spinners: specifier: ^0.13.8 version: 0.13.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2063,6 +2069,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js@3.42.0: + resolution: {integrity: sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -2090,6 +2099,9 @@ packages: currency-codes@2.2.0: resolution: {integrity: sha512-vpbQc5sEYHGdTVAYUhHnKv0DWiYLRvzl/KKyqeHzBh7HD/j3UlWoScpZ9tN/jG6w2feddWoObsBbaNVu5yDapg==} + custom-event-polyfill@1.0.7: + resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2783,6 +2795,9 @@ packages: load-script@1.0.0: resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} + loadjs@4.3.0: + resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3006,6 +3021,9 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + plyr@3.7.8: + resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3121,6 +3139,9 @@ packages: randomcolor@0.6.2: resolution: {integrity: sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==} + rangetouch@2.0.1: + resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} + re-resizable@6.11.2: resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} peerDependencies: @@ -3164,6 +3185,11 @@ packages: prop-types: ^15.8.1 react: '>=15.3.2 <=18' + react-plyr@2.2.0: + resolution: {integrity: sha512-05oBKvCC9F2VMP7BNhh3x7xeK6PNUMYBPPWIip5sDRuey4HmpghfLQyOfsbNKTssR6kkxRSi2X9neYmfr91NuA==} + peerDependencies: + react: 16.x + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3552,6 +3578,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-polyfill@1.1.13: + resolution: {integrity: sha512-tXzkojrv2SujumYthZ/WjF7jaSfNhSXlYMpE5AYdL2I3D7DCeo+mch8KtW2rUuKjDg+3VXODXHVgipt8yGY/eQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -5388,6 +5417,8 @@ snapshots: cookie@0.7.2: {} + core-js@3.42.0: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -5417,6 +5448,8 @@ snapshots: first-match: 0.0.1 nub: 0.0.0 + custom-event-polyfill@1.0.7: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -6238,6 +6271,8 @@ snapshots: load-script@1.0.0: {} + loadjs@4.3.0: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6463,6 +6498,14 @@ snapshots: picomatch@4.0.2: {} + plyr@3.7.8: + dependencies: + core-js: 3.42.0 + custom-event-polyfill: 1.0.7 + loadjs: 4.3.0 + rangetouch: 2.0.1 + url-polyfill: 1.1.13 + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -6617,6 +6660,8 @@ snapshots: randomcolor@0.6.2: {} + rangetouch@2.0.1: {} + re-resizable@6.11.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 @@ -6656,6 +6701,12 @@ snapshots: prop-types: 15.8.1 react: 19.0.0 + react-plyr@2.2.0(react@19.0.0): + dependencies: + plyr: 3.7.8 + prop-types: 15.8.1 + react: 19.0.0 + react-redux@9.2.0(@types/react@19.0.10)(react@19.0.0)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -7150,6 +7201,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-polyfill@1.1.13: {} + use-callback-ref@1.3.3(@types/react@19.0.10)(react@19.0.0): dependencies: react: 19.0.0 diff --git a/apps/web/types/react-plyr.d.ts b/apps/web/types/react-plyr.d.ts new file mode 100644 index 00000000..bf3e317d --- /dev/null +++ b/apps/web/types/react-plyr.d.ts @@ -0,0 +1,17 @@ +declare module 'react-plyr' { + import { Component } from 'react' + + interface PlyrProps { + source: { + type: string + sources: Array<{ + src: string + type: string + }> + } + options?: any + onReady?: () => void + } + + export default class Plyr extends Component {} +} \ No newline at end of file