From b756e9735059370ae203e9b40bb617b90e9bf70b Mon Sep 17 00:00:00 2001 From: Maham Akif <113524403+mahamakifdar19@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:14:34 +0500 Subject: [PATCH] feat: added video detail page (#1115) Co-authored-by: Maham Akif --- package-lock.json | 129 +++++++++++++++++ package.json | 1 + src/components/app/data/hooks/index.js | 1 + .../app/data/hooks/useVideoDetails.js | 12 ++ .../app/data/hooks/useVideoDetails.test.jsx | 90 ++++++++++++ src/components/app/data/queries/queries.js | 17 +++ .../app/data/queries/queryKeyFactory.js | 11 ++ src/components/app/data/services/index.js | 1 + src/components/app/data/services/videos.js | 18 +++ .../app/data/services/videos.test.js | 85 +++++++++++ src/components/app/routes/createAppRouter.jsx | 16 +++ .../microlearning/VideoDetailPage.jsx | 128 +++++++++++++++++ .../__mocks__/videojs-vjstranscribe.js | 2 + src/components/microlearning/data/index.js | 1 + src/components/microlearning/data/utils.js | 27 ++++ .../microlearning/data/utils.test.js | 100 +++++++++++++ .../microlearning/data/videosLoader.js | 26 ++++ .../microlearning/data/videosLoader.test.js | 79 +++++++++++ src/components/microlearning/index.js | 3 + .../microlearning/styles/VideoDetailPage.scss | 84 +++++++++++ .../tests/VideoDetailPage.test.jsx | 91 ++++++++++++ src/components/video/VideoJS.jsx | 32 ++++- src/components/video/VideoPlayer.jsx | 28 +++- src/components/video/data/constants.js | 1 + src/components/video/data/service.js | 42 ++++++ .../video/data/tests/service.test.js | 134 ++++++++++++++++++ src/components/video/data/tests/utils.test.js | 43 ++++++ src/components/video/data/utils.js | 28 ++++ src/components/video/tests/VideoJS.test.jsx | 2 + .../video/tests/VideoPlayer.test.jsx | 26 +++- 30 files changed, 1248 insertions(+), 10 deletions(-) create mode 100644 src/components/app/data/hooks/useVideoDetails.js create mode 100644 src/components/app/data/hooks/useVideoDetails.test.jsx create mode 100644 src/components/app/data/services/videos.js create mode 100644 src/components/app/data/services/videos.test.js create mode 100644 src/components/microlearning/VideoDetailPage.jsx create mode 100644 src/components/microlearning/__mocks__/videojs-vjstranscribe.js create mode 100644 src/components/microlearning/data/index.js create mode 100644 src/components/microlearning/data/utils.js create mode 100644 src/components/microlearning/data/utils.test.js create mode 100644 src/components/microlearning/data/videosLoader.js create mode 100644 src/components/microlearning/data/videosLoader.test.js create mode 100644 src/components/microlearning/index.js create mode 100644 src/components/microlearning/styles/VideoDetailPage.scss create mode 100644 src/components/microlearning/tests/VideoDetailPage.test.jsx create mode 100644 src/components/video/data/constants.js create mode 100644 src/components/video/data/service.js create mode 100644 src/components/video/data/tests/service.test.js create mode 100644 src/components/video/data/tests/utils.test.js create mode 100644 src/components/video/data/utils.js diff --git a/package-lock.json b/package-lock.json index e69b7d40b..7619a04bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "universal-cookie": "4.0.4", "uuid": "9.0.0", "video.js": "8.3.0", + "videojs-vjstranscribe": "^1.0.3", "videojs-youtube": "3.0.1" }, "devDependencies": { @@ -20206,6 +20207,134 @@ "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.1.0.tgz", "integrity": "sha512-X1LuPfLZPisPLrANIAKCknZbZu5obVM/ylfd1CN+SsCmPZQ3UMDPcvLTpPBJxcBuTpHQq2MO1QCFt7p8spnZ/w==" }, + "node_modules/videojs-vjstranscribe": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/videojs-vjstranscribe/-/videojs-vjstranscribe-1.0.3.tgz", + "integrity": "sha512-pm4heFfVi0tZ/GX19Sr2Pm++GPAwpoa68RSQOuAHSJaduR4iszKCYFIecpBMo1yPO8rpnfCq4a+RZUsxgkr6Jw==", + "dependencies": { + "video.js": "^8.5" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/@videojs/http-streaming": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.13.1.tgz", + "integrity": "sha512-G7YrgNEq9ETaUmtkoTnTuwkY9U+xP7Xncedzgxio/Rmz2Gn2zmodEbBIVQinb2UDznk7X8uY5XBr/Ew6OD/LWg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "4.0.0", + "aes-decrypter": "4.0.1", + "global": "^4.4.0", + "m3u8-parser": "^7.1.0", + "mpd-parser": "^1.3.0", + "mux.js": "7.0.3", + "video.js": "^7 || ^8" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "video.js": "^8.14.0" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/@videojs/xhr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz", + "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/m3u8-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.1.0.tgz", + "integrity": "sha512-7N+pk79EH4oLKPEYdgRXgAsKDyA/VCo0qCHlUwacttQA0WqsjZQYmNfywMvjlY9MpEBVZEt0jKFd73Kv15EBYQ==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/mux.js": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.3.tgz", + "integrity": "sha512-gzlzJVEGFYPtl2vvEiJneSWAWD4nfYRHD5XgxmB2gWvXraMPOYk+sxfvexmNfjQUFpmk6hwLR5C6iSFmuwCHdQ==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "bin": { + "muxjs-transmux": "bin/transmux.js" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/video.js": { + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.16.1.tgz", + "integrity": "sha512-yAhxu4Vhyx5DdOgPn2PcRKHx3Vzs9tpvCWA0yX+sv5bIeBkg+IWdEX+MHGZgktgDQ/R8fJDxDbEASyvxXnFn1A==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/http-streaming": "3.13.1", + "@videojs/vhs-utils": "^4.0.0", + "@videojs/xhr": "2.7.0", + "aes-decrypter": "^4.0.1", + "global": "4.4.0", + "m3u8-parser": "^7.1.0", + "mpd-parser": "^1.2.2", + "mux.js": "^7.0.1", + "safe-json-parse": "4.0.0", + "videojs-contrib-quality-levels": "4.1.0", + "videojs-font": "4.2.0", + "videojs-vtt.js": "0.15.5" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/videojs-contrib-quality-levels": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz", + "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==", + "dependencies": { + "global": "^4.4.0" + }, + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "peerDependencies": { + "video.js": "^8" + } + }, + "node_modules/videojs-vjstranscribe/node_modules/videojs-font": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz", + "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==" + }, + "node_modules/videojs-vjstranscribe/node_modules/videojs-vtt.js": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", + "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", + "dependencies": { + "global": "^4.3.1" + } + }, "node_modules/videojs-vtt.js": { "version": "0.15.4", "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz", diff --git a/package.json b/package.json index b607e9d75..f4693fc61 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "universal-cookie": "4.0.4", "uuid": "9.0.0", "video.js": "8.3.0", + "videojs-vjstranscribe": "^1.0.3", "videojs-youtube": "3.0.1" }, "devDependencies": { diff --git a/src/components/app/data/hooks/index.js b/src/components/app/data/hooks/index.js index 8e5f5a0f6..08174ff54 100644 --- a/src/components/app/data/hooks/index.js +++ b/src/components/app/data/hooks/index.js @@ -43,3 +43,4 @@ export { default as useAcademyDetails } from './useAcademyDetails'; export { default as usePassLearnerCsodParams } from './usePassLearnerCsodParams'; export { default as useCanUpgradeWithLearnerCredit } from './useCanUpgradeWithLearnerCredit'; export { default as useCourseRunMetadata } from './useCourseRunMetadata'; +export { default as useVideoDetails } from './useVideoDetails'; diff --git a/src/components/app/data/hooks/useVideoDetails.js b/src/components/app/data/hooks/useVideoDetails.js new file mode 100644 index 000000000..9acb4e915 --- /dev/null +++ b/src/components/app/data/hooks/useVideoDetails.js @@ -0,0 +1,12 @@ +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { queryVideoDetail } from '../queries'; +import useEnterpriseCustomer from './useEnterpriseCustomer'; + +export default function useVideoDetails() { + const { videoUUID } = useParams(); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + return useQuery({ + ...queryVideoDetail(videoUUID, enterpriseCustomer.uuid), + }); +} diff --git a/src/components/app/data/hooks/useVideoDetails.test.jsx b/src/components/app/data/hooks/useVideoDetails.test.jsx new file mode 100644 index 000000000..964988ff0 --- /dev/null +++ b/src/components/app/data/hooks/useVideoDetails.test.jsx @@ -0,0 +1,90 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { AppContext } from '@edx/frontend-platform/react'; +import { useParams } from 'react-router-dom'; +import { queryClient } from '../../../../utils/tests'; +import { queryVideoDetail } from '../queries'; +import useVideoDetails from './useVideoDetails'; +import useEnterpriseCustomer from './useEnterpriseCustomer'; +import { authenticatedUserFactory, enterpriseCustomerFactory } from '../services/data/__factories__'; + +jest.mock('../queries', () => ({ + ...jest.requireActual('../queries'), + queryVideoDetail: jest.fn(), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); +jest.mock('./useEnterpriseCustomer'); + +const mockEnterpriseCustomer = enterpriseCustomerFactory(); +const mockAuthenticatedUser = authenticatedUserFactory(); + +const mockVideoDetailsData = { + uuid: 'video-uuid', + title: 'My Awesome Video', + description: 'This is a great video.', + duration: 120, + thumbnail: 'example.com/videos/images/awesome-video.png', +}; + +describe('useVideoDetails', () => { + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + useParams.mockReturnValue({ videoUUID: 'video-uuid' }); + queryVideoDetail.mockImplementation((videoUUID, enterpriseUUID) => ({ + queryKey: ['videoDetail', videoUUID, enterpriseUUID, mockVideoDetailsData], + queryFn: () => Promise.resolve(mockVideoDetailsData), + })); + }); + + it('should handle resolved value correctly', async () => { + const { result, waitForNextUpdate } = renderHook(() => useVideoDetails(), { wrapper: Wrapper }); + await waitForNextUpdate(); + + expect(result.current).toEqual( + expect.objectContaining({ + data: mockVideoDetailsData, + isLoading: false, + isFetching: false, + }), + ); + }); + + it('should handle loading state correctly', () => { + queryVideoDetail.mockImplementation(() => ({ + queryKey: ['videoDetail'], + queryFn: () => new Promise(() => {}), // Simulate loading + })); + + const { result } = renderHook(() => useVideoDetails(), { wrapper: Wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isFetching).toBe(true); + }); + + it('should handle error state correctly', async () => { + const mockError = new Error('Failed to fetch video details'); + queryVideoDetail.mockImplementation(() => ({ + queryKey: ['videoDetail', mockError], + queryFn: () => Promise.reject(mockError), + })); + + const { result, waitForNextUpdate } = renderHook(() => useVideoDetails(), { wrapper: Wrapper }); + await waitForNextUpdate(); + + expect(result.current.error).toEqual(mockError); + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + }); +}); diff --git a/src/components/app/data/queries/queries.js b/src/components/app/data/queries/queries.js index 89f949cbf..7335d96cd 100644 --- a/src/components/app/data/queries/queries.js +++ b/src/components/app/data/queries/queries.js @@ -501,3 +501,20 @@ export function queryLearnerPathwayProgressData(pathwayUUID) { .pathway(pathwayUUID) ._ctx.progress; } + +/** + * Helper function to assist querying video detail data with the React Query package. + * + * This function constructs a query to fetch the details of a video for a specific enterprise customer. + * + * @param {string} videoUUID - The edx video ID of the video to query. + * @param {string} enterpriseUUID - The UUID of the enterprise customer. + * @returns {Types.QueryOptions} The query options for fetching video detail data. + */ +export function queryVideoDetail(videoUUID, enterpriseUUID) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUUID) + ._ctx.video + ._ctx.detail(videoUUID); +} diff --git a/src/components/app/data/queries/queryKeyFactory.js b/src/components/app/data/queries/queryKeyFactory.js index d44f0eb4a..6f8470bc9 100644 --- a/src/components/app/data/queries/queryKeyFactory.js +++ b/src/components/app/data/queries/queryKeyFactory.js @@ -29,6 +29,7 @@ import { fetchRedeemablePolicies, fetchSubscriptions, fetchUserEntitlements, + fetchVideoDetail, } from '../services'; import { SUBSIDY_REQUEST_STATE } from '../../../../constants'; @@ -180,6 +181,16 @@ const enterprise = createQueryKeys('enterprise', { }, }, }, + video: { + queryKey: null, + contextQueries: { + // queryVideoDetail + detail: (videoUUID) => ({ + queryKey: [videoUUID], + queryFn: async ({ queryKey }) => fetchVideoDetail(videoUUID, queryKey[2]), + }), + }, + }, }, }), enterpriseLearner: (username, enterpriseSlug) => ({ diff --git a/src/components/app/data/services/index.js b/src/components/app/data/services/index.js index 60889e96b..6e48de2d6 100644 --- a/src/components/app/data/services/index.js +++ b/src/components/app/data/services/index.js @@ -8,3 +8,4 @@ export * from './programs'; export * from './subsidies'; export * from './user'; export * from './utils'; +export * from './videos'; diff --git a/src/components/app/data/services/videos.js b/src/components/app/data/services/videos.js new file mode 100644 index 000000000..aeb5671ac --- /dev/null +++ b/src/components/app/data/services/videos.js @@ -0,0 +1,18 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { getConfig } from '@edx/frontend-platform/config'; +import { logError } from '@edx/frontend-platform/logging'; +import { transformVideoData } from '../../../microlearning/data/utils'; + +export const fetchVideoDetail = async (edxVideoID) => { + const { ENTERPRISE_CATALOG_API_BASE_URL } = getConfig(); + const url = `${ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/videos/${edxVideoID}`; + + try { + const result = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(transformVideoData(result?.data || {})); + } catch (error) { + logError(error); + return null; + } +}; diff --git a/src/components/app/data/services/videos.test.js b/src/components/app/data/services/videos.test.js new file mode 100644 index 000000000..c0598cdb4 --- /dev/null +++ b/src/components/app/data/services/videos.test.js @@ -0,0 +1,85 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { fetchVideoDetail } from './videos'; +import { transformVideoData } from '../../../microlearning/data/utils'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const APP_CONFIG = { + ENTERPRISE_CATALOG_API_BASE_URL: 'http://localhost:18160', +}; + +jest.mock('@edx/frontend-platform/config', () => ({ + ...jest.requireActual('@edx/frontend-platform/config'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + ...jest.requireActual('@edx/frontend-platform/logging'), + logError: jest.fn(), +})); + +const mockVideoID = 'test-video-id'; +const mockVideoDetailResponse = { + data: { + json_metadata: { + download_link: 'https://d2f1egay8yehza.cloudfront.net/DoaneXBUS242X-V001600_DTH.mp4', + transcript_urls: { + en: 'https://prod-edx-video-transcripts.edx-video.net/video-transcripts/4149c8bc684d4c01a0d82bca3acd8047.sjson', + }, + duration: 135.98, + }, + parent_content_metadata: { + title: 'Needs-Driven Innovation ', + }, + summary_transcripts: { + listItem: 'Needs-Driven Innovation focuses on identifying...', + }, + skills: { + list_item: [ + { + name: 'Planning', + description: 'Planning is the process of thinking...', + category: 'Physical and Inherent Abilities', + subcategory: 'Initiative and Leadership', + }, + ], + }, + }, +}; + +describe('fetchVideoDetail', () => { + const VIDEO_DETAIL_URL = `${APP_CONFIG.ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/videos/${mockVideoID}`; + + beforeEach(() => { + jest.clearAllMocks(); + axiosMock.reset(); + }); + + it('returns the api call with a 200', async () => { + axiosMock.onGet(VIDEO_DETAIL_URL).reply(200, mockVideoDetailResponse); + + const result = await fetchVideoDetail(mockVideoID); + + expect(result).toEqual(camelCaseObject(transformVideoData(result?.data || {}))); + }); + + it('returns the api call with a 404 and logs an error', async () => { + axiosMock.onGet(VIDEO_DETAIL_URL).reply(404, {}); + + const result = await fetchVideoDetail(mockVideoID); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(new Error('Request failed with status code 404')); + expect(result).toEqual(null); + }); +}); diff --git a/src/components/app/routes/createAppRouter.jsx b/src/components/app/routes/createAppRouter.jsx index eec6c38aa..81112c5b5 100644 --- a/src/components/app/routes/createAppRouter.jsx +++ b/src/components/app/routes/createAppRouter.jsx @@ -212,6 +212,22 @@ export default function createAppRouter(queryClient) { }; }} /> + { + const { makeVideosLoader, VideoDetailPage } = await import('../../microlearning'); + return { + Component: VideoDetailPage, + loader: makeVideosLoader(queryClient), + }; + }} + errorElement={( + + )} + /> } /> } /> diff --git a/src/components/microlearning/VideoDetailPage.jsx b/src/components/microlearning/VideoDetailPage.jsx new file mode 100644 index 000000000..c081b8d6c --- /dev/null +++ b/src/components/microlearning/VideoDetailPage.jsx @@ -0,0 +1,128 @@ +import React, { useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { + Container, Breadcrumb, Row, MediaQuery, breakpoints, Badge, Skeleton, +} from '@openedx/paragon'; +import loadable from '@loadable/component'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { useVideoDetails, useEnterpriseCustomer } from '../app/data'; +import { Sidebar } from '../layout'; +import './styles/VideoDetailPage.scss'; +import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContainer'; +import NotFoundPage from '../NotFoundPage'; +import { features } from '../../config'; + +const VideoPlayer = loadable(() => import(/* webpackChunkName: "videojs" */ '../video/VideoPlayer'), { + fallback: ( + + + + ), +}); + +const VideoDetailPage = () => { + const location = useLocation(); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const { data: videoData } = useVideoDetails(); + + const intl = useIntl(); + + const customOptions = { + showPlaybackMenu: true, + showTranscripts: true, + transcriptUrls: videoData?.transcriptUrls, + }; + + useEffect(() => { + if (videoData?.videoURL) { + VideoPlayer.preload(); + } + }, [videoData?.videoURL]); + + const routeLinks = [ + { + label: 'Explore Videos', + to: `/${enterpriseCustomer.slug}/videos`, + }, + ]; + if (location.state?.parentRoute) { + routeLinks.push(location.state.parentRoute); + } + + // Comprehensive error handling will be implemented upon receiving specific error use cases from the UX team + // and corresponding Figma designs. + if (!videoData || !features.FEATURE_ENABLE_VIDEO_CATALOG) { + return ( + + ); + } + + return ( + +
+ +
+ +
+
+
+

+ {videoData?.courseTitle} +

+ + {videoData?.videoDuration && `(${videoData?.videoDuration} minutes)`} + +
+

+ {videoData?.videoSummary} +

+
+

+ +

+
+ {videoData?.videoSkills && ( + videoData?.videoSkills.map((skill) => ( + + {skill.name} + + )) + )} +
+
+
+
+ +
+
+ + {matches => matches && ( + + {/* Course Sidebar will be inserted here */} + + )} + +
+
+ ); +}; + +export default VideoDetailPage; diff --git a/src/components/microlearning/__mocks__/videojs-vjstranscribe.js b/src/components/microlearning/__mocks__/videojs-vjstranscribe.js new file mode 100644 index 000000000..82caf08e4 --- /dev/null +++ b/src/components/microlearning/__mocks__/videojs-vjstranscribe.js @@ -0,0 +1,2 @@ +// __mocks__/videojs-vjstranscribe.js +module.exports = {}; diff --git a/src/components/microlearning/data/index.js b/src/components/microlearning/data/index.js new file mode 100644 index 000000000..333bb5301 --- /dev/null +++ b/src/components/microlearning/data/index.js @@ -0,0 +1 @@ +export { default as makeVideosLoader } from './videosLoader'; diff --git a/src/components/microlearning/data/utils.js b/src/components/microlearning/data/utils.js new file mode 100644 index 000000000..e992ce9c6 --- /dev/null +++ b/src/components/microlearning/data/utils.js @@ -0,0 +1,27 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; + +dayjs.extend(duration); + +export const formatDuration = (durationInSeconds) => { + const time = dayjs.duration({ seconds: durationInSeconds }); + const minutes = Math.floor(time.asMinutes()); + const seconds = durationInSeconds ? (durationInSeconds % 60).toFixed(0).padStart(2, '0') : 0; + return `${minutes}:${seconds}`; +}; + +export const formatSkills = (skills) => skills?.['list-item'].map(skill => ({ + name: skill?.name, + description: skill?.description, + category: skill?.category?.name, + subcategory: skill?.subcategory?.name, +})); + +export const transformVideoData = (data) => ({ + videoUrl: data?.json_metadata?.download_link, + courseTitle: data?.parent_content_metadata?.title, + videoSummary: data?.summary_transcripts?.['list-item'], + transcriptUrls: data?.json_metadata?.transcript_urls, + videoSkills: formatSkills(data?.skills), + videoDuration: formatDuration(data?.json_metadata?.duration), +}); diff --git a/src/components/microlearning/data/utils.test.js b/src/components/microlearning/data/utils.test.js new file mode 100644 index 000000000..a8bb775d7 --- /dev/null +++ b/src/components/microlearning/data/utils.test.js @@ -0,0 +1,100 @@ +import { formatDuration, formatSkills, transformVideoData } from './utils'; + +describe('Microlearning utils tests', () => { + const mockSkills = { + 'list-item': [ + { + name: 'Skill 1', + description: 'Description 1', + category: { name: 'Category 1' }, + subcategory: { name: 'Subcategory 1' }, + }, + { + name: 'Skill 2', + description: 'Description 2', + category: { name: 'Category 2' }, + subcategory: { name: 'Subcategory 2' }, + }, + ], + }; + + const mockData = { + json_metadata: { + download_link: 'http://example.com/video.mp4', + transcript_urls: ['http://example.com/transcript1.vtt'], + duration: 123, + }, + parent_content_metadata: { + title: 'Course Title', + }, + summary_transcripts: { + 'list-item': ['Transcript 1', 'Transcript 2'], + }, + skills: { + 'list-item': [ + { + name: 'Skill 1', + description: 'Description 1', + category: { name: 'Category 1' }, + subcategory: { name: 'Subcategory 1' }, + }, + ], + }, + }; + + it('should format 60 seconds correctly', () => { + expect(formatDuration(60)).toBe('1:00'); + }); + + it('should format skills correctly', () => { + expect(formatSkills(mockSkills)).toEqual([ + { + name: 'Skill 1', + description: 'Description 1', + category: 'Category 1', + subcategory: 'Subcategory 1', + }, + { + name: 'Skill 2', + description: 'Description 2', + category: 'Category 2', + subcategory: 'Subcategory 2', + }, + ]); + }); + + it('should handle empty skills list', () => { + expect(formatSkills({ 'list-item': [] })).toEqual([]); + }); + + it('should transform video data correctly', () => { + expect(transformVideoData(mockData)).toEqual({ + videoUrl: 'http://example.com/video.mp4', + courseTitle: 'Course Title', + videoSummary: ['Transcript 1', 'Transcript 2'], + transcriptUrls: ['http://example.com/transcript1.vtt'], + videoSkills: [ + { + name: 'Skill 1', + description: 'Description 1', + category: 'Category 1', + subcategory: 'Subcategory 1', + }, + ], + videoDuration: '2:03', + }); + }); + + it('should handle missing fields gracefully', () => { + const incompleteData = {}; + + expect(transformVideoData(incompleteData)).toEqual({ + videoUrl: undefined, + courseTitle: undefined, + videoSummary: undefined, + transcriptUrls: undefined, + videoSkills: undefined, + videoDuration: '0:0', + }); + }); +}); diff --git a/src/components/microlearning/data/videosLoader.js b/src/components/microlearning/data/videosLoader.js new file mode 100644 index 000000000..83bb79ae5 --- /dev/null +++ b/src/components/microlearning/data/videosLoader.js @@ -0,0 +1,26 @@ +// In `videos/index.js` or another relevant file +import { ensureAuthenticatedUser } from '../../app/routes/data'; +import { extractEnterpriseCustomer, queryVideoDetail } from '../../app/data'; + +export default function makeVideosLoader(queryClient) { + return async function videosLoader({ params = {}, request }) { + const requestUrl = new URL(request.url); + const authenticatedUser = await ensureAuthenticatedUser(requestUrl, params); + + // User is not authenticated, so we can't do anything in this loader. + if (!authenticatedUser) { + return null; + } + + const { videoUUID, enterpriseSlug } = params; + const enterpriseCustomer = await extractEnterpriseCustomer({ + queryClient, + authenticatedUser, + enterpriseSlug, + }); + + await queryClient.ensureQueryData(queryVideoDetail(videoUUID, enterpriseCustomer.uuid)); + + return null; + }; +} diff --git a/src/components/microlearning/data/videosLoader.test.js b/src/components/microlearning/data/videosLoader.test.js new file mode 100644 index 000000000..654eb1a8a --- /dev/null +++ b/src/components/microlearning/data/videosLoader.test.js @@ -0,0 +1,79 @@ +/* eslint-disable react/jsx-filename-extension */ +import { screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import { renderWithRouterProvider } from '../../../utils/tests'; +import makeVideosLoader from './videosLoader'; +import { ensureAuthenticatedUser } from '../../app/routes/data'; +import { extractEnterpriseCustomer, queryVideoDetail } from '../../app/data'; +import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; + +jest.mock('../../app/routes/data', () => ({ + ...jest.requireActual('../../app/routes/data'), + ensureAuthenticatedUser: jest.fn(), +})); + +jest.mock('../../app/data', () => ({ + ...jest.requireActual('../../app/data'), + extractEnterpriseCustomer: jest.fn(), +})); + +const mockAuthenticatedUser = authenticatedUserFactory(); +const mockEnterpriseCustomer = enterpriseCustomerFactory(); +const mockEnterpriseSlug = mockEnterpriseCustomer.slug; +const mockEnterpriseId = mockEnterpriseCustomer.uuid; +const mockVideoUUID = 'test-video-uuid'; +const mockVideosURL = `/${mockEnterpriseSlug}/videos/${mockVideoUUID}/`; + +const mockQueryClient = { + ensureQueryData: jest.fn().mockResolvedValue({}), +}; + +describe('videosLoader', () => { + beforeEach(() => { + jest.clearAllMocks(); + ensureAuthenticatedUser.mockResolvedValue(mockAuthenticatedUser); + extractEnterpriseCustomer.mockResolvedValue(mockEnterpriseCustomer); + }); + + it('does nothing with unauthenticated users', async () => { + ensureAuthenticatedUser.mockResolvedValue(null); + renderWithRouterProvider( + { + path: '/:enterpriseSlug/videos/:videoUUID/', + element:
hello world
, + loader: makeVideosLoader(mockQueryClient), + }, + { + initialEntries: [mockVideosURL], + }, + ); + + expect(await screen.findByText('hello world')).toBeInTheDocument(); + + expect(mockQueryClient.ensureQueryData).not.toHaveBeenCalled(); + }); + + it('ensures the requisite video data is resolved', async () => { + renderWithRouterProvider( + { + path: '/:enterpriseSlug/videos/:videoUUID/', + element:
hello world
, + loader: makeVideosLoader(mockQueryClient), + }, + { + initialEntries: [mockVideosURL], + }, + ); + + expect(await screen.findByText('hello world')).toBeInTheDocument(); + + expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(1); + expect(mockQueryClient.ensureQueryData).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: queryVideoDetail(mockVideoUUID, mockEnterpriseId).queryKey, + queryFn: expect.any(Function), + }), + ); + }); +}); diff --git a/src/components/microlearning/index.js b/src/components/microlearning/index.js new file mode 100644 index 000000000..cc5517fd5 --- /dev/null +++ b/src/components/microlearning/index.js @@ -0,0 +1,3 @@ +export { default as VideoDetailPage } from './VideoDetailPage'; + +export * from './data'; diff --git a/src/components/microlearning/styles/VideoDetailPage.scss b/src/components/microlearning/styles/VideoDetailPage.scss new file mode 100644 index 000000000..b2e3cca9b --- /dev/null +++ b/src/components/microlearning/styles/VideoDetailPage.scss @@ -0,0 +1,84 @@ +.video-detail-page-wrapper { + .video-container { + gap: 16px; + flex: 1 0 0; + } + + /* + Custom CSS is necessary here because we are using a custom video.js plugin - videojs-vjstranscribe + The elements of this plugin need to be customized to cater to the hidden classes and other specific + requirements of the design. Therefore, we are using custom CSS. + */ + .video-player-wrapper .video-js-wrapper { + position: relative; + padding-top: 56.25%; + } + + .video-player-wrapper .video-js-wrapper .video-js { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100% !important; + } + + .vjs-control-bar .vjs-playback-rate .vjs-playback-rate-value { + font-size: 13px; + margin-top: 2px; + } + + .video-player-container-with-transcript { + display: flex; + } + + .video-js-wrapper { + display: flex; + flex-direction: row; + align-items: flex-start; + width: 100%; + max-width: 100%; + } + + .video-js-wrapper .video-js { + max-width: 100%; + } + + .transcript-container { + max-width: 30% !important; + height: 100%; + max-height: 100%; + } + + .vjs-transcribe-body { + padding: 40px !important; + background: #f9f9f9; + border: 1px solid #ddd; + max-height: 545px !important; + height: 100% !important; + font-size: 14px; + line-height: 24px; + } + + .vjs-transcribe-pip { + position: static !important; + bottom: 1rem; + left: 1rem; + display: none; + max-width: 500px; + width: calc(100% - 2rem); + background: red; + z-index: 100; + } + + .vjs-transcribe-search { + display: none; + } + + .vjs-transcribe-btn .vjs-transcribe-copy { + display: none; + } + + .vjs-transcribe-cueline { + color: #00688d; + } +} diff --git a/src/components/microlearning/tests/VideoDetailPage.test.jsx b/src/components/microlearning/tests/VideoDetailPage.test.jsx new file mode 100644 index 000000000..0617eccd3 --- /dev/null +++ b/src/components/microlearning/tests/VideoDetailPage.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import '@testing-library/jest-dom/extend-expect'; +import { screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; +import { renderWithRouter } from '../../../utils/tests'; +import VideoDetailPage from '../VideoDetailPage'; +import { features } from '../../../config'; +import { useVideoDetails, useEnterpriseCustomer } from '../../app/data'; + +const APP_CONFIG = { + USE_API_CACHE: true, + ENTERPRISE_CATALOG_API_BASE_URL: 'http://localhost:18160', +}; + +const VIDEO_UUID = '3307f1bb-8b2d-43af-a5d5-030e2f8c81bd'; + +const VIDEO_MOCK_DATA = { + courseTitle: 'Test Video', + videoDuration: '10:4', + videoSummary: 'This is a test video summary.', + videoSkills: [{ skill_id: 1, name: 'Skill 1' }, { skill_id: 2, name: 'Skill 2' }], + transcriptUrls: { + en: 'https://prod-edx-video-transcripts.edx-video.net/video-transcripts/4149c8bc684d4c01a0d82bca3acd8047.sjson', + }, + videoUrl: 'test-video-url.mp4', +}; + +const mockEnterpriseCustomer = enterpriseCustomerFactory(); + +jest.mock('../../app/data', () => ({ + ...jest.requireActual('../../app/data'), + useEnterpriseCustomer: jest.fn(), + useVideoDetails: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ enterpriseSlug: 'test-enterprise-uuid', videoUUID: VIDEO_UUID }), +})); + +jest.mock('@edx/frontend-platform/config', () => ({ + ...jest.requireActual('@edx/frontend-platform/config'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth'); +getAuthenticatedHttpClient.mockReturnValue(axios); + +jest.mock('../../../config', () => ({ + features: { FEATURE_ENABLE_VIDEO_CATALOG: true }, +})); + +const VideoDetailPageWrapper = () => ( + + + +); + +describe('VideoDetailPage Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + useVideoDetails.mockReturnValue({ data: VIDEO_MOCK_DATA }); + }); + + it('Renders video details when data is available', () => { + const { container } = renderWithRouter(); + + expect(screen.getByTestId('video-title')).toHaveTextContent('Test Video'); + expect(screen.getByText('(10:4 minutes)')).toBeInTheDocument(); + expect(screen.getByText('This is a test video summary.')).toBeInTheDocument(); + expect(screen.getByText('Skill 1')).toBeInTheDocument(); + expect(screen.getByText('Skill 2')).toBeInTheDocument(); + expect(container.querySelector('.video-player-wrapper')).toBeTruthy(); + }); + + it('renders a not found page when video data is not found', () => { + useVideoDetails.mockReturnValue({ data: null }); + renderWithRouter(); + expect(screen.getByTestId('not-found-page')).toBeInTheDocument(); + }); + + it('renders a not found page when feature flag is turned off', () => { + features.FEATURE_ENABLE_VIDEO_CATALOG = false; + renderWithRouter(); + expect(screen.getByTestId('not-found-page')).toBeInTheDocument(); + }); +}); diff --git a/src/components/video/VideoJS.jsx b/src/components/video/VideoJS.jsx index 62df2f593..6ac66da59 100644 --- a/src/components/video/VideoJS.jsx +++ b/src/components/video/VideoJS.jsx @@ -4,8 +4,14 @@ import PropTypes from 'prop-types'; import 'videojs-youtube'; import videojs from 'video.js'; import 'video.js/dist/video-js.css'; +import { PLAYBACK_RATES } from './data/constants'; +import { fetchAndAddTranscripts } from './data/service'; -const VideoJS = ({ options, onReady }) => { +window.videojs = videojs; +// eslint-disable-next-line import/no-extraneous-dependencies +require('videojs-vjstranscribe'); + +const VideoJS = ({ options, onReady, customOptions }) => { const videoRef = useRef(null); const playerRef = useRef(null); @@ -24,13 +30,21 @@ const VideoJS = ({ options, onReady }) => { onReady(player); } }); + + if (customOptions?.showPlaybackMenu) { + player.playbackRates(PLAYBACK_RATES); + } + + if (customOptions?.showTranscripts && customOptions?.transcriptUrls) { + fetchAndAddTranscripts(customOptions?.transcriptUrls, player); + } } else { const player = playerRef.current; player.autoplay(options.autoplay); player.src(options.sources); } - }, [onReady, options, videoRef]); + }, [onReady, options, videoRef, customOptions]); // Dispose the Video.js player when the functional component unmounts useEffect(() => { @@ -45,9 +59,12 @@ const VideoJS = ({ options, onReady }) => { }, [playerRef]); return ( -
-
-
+ <> +
+
+
+ { customOptions?.showTranscripts &&
} + ); }; @@ -63,6 +80,11 @@ VideoJS.propTypes = { })), }).isRequired, onReady: PropTypes.func, + customOptions: PropTypes.shape({ + showPlaybackMenu: PropTypes.bool, + showTranscripts: PropTypes.bool, + transcriptUrls: PropTypes.objectOf(PropTypes.string), + }).isRequired, }; VideoJS.defaultProps = { diff --git a/src/components/video/VideoPlayer.jsx b/src/components/video/VideoPlayer.jsx index b461213ac..5c1dc9234 100644 --- a/src/components/video/VideoPlayer.jsx +++ b/src/components/video/VideoPlayer.jsx @@ -10,9 +10,16 @@ const defaultOptions = { fluid: true, }; -const VideoPlayer = ({ videoURL, onReady }) => { +const VideoPlayer = ({ videoURL, onReady, customOptions }) => { const videoDetails = useMemo(() => { const isHLSVideo = videoURL.includes(hlsExtension); + const isMp4Video = videoURL.toLowerCase().endsWith('.mp4'); + if (isMp4Video) { + return { + ...defaultOptions, + sources: [{ src: videoURL, type: 'video/mp4' }], + }; + } if (!isHLSVideo) { return { ...defaultOptions, @@ -31,8 +38,13 @@ const VideoPlayer = ({ videoURL, onReady }) => { }, [videoURL]); return ( -
- +
+
); }; @@ -40,10 +52,20 @@ const VideoPlayer = ({ videoURL, onReady }) => { VideoPlayer.propTypes = { videoURL: PropTypes.string.isRequired, onReady: PropTypes.func, + customOptions: PropTypes.shape({ + showPlaybackMenu: PropTypes.bool, + showTranscripts: PropTypes.bool, + transcriptUrls: PropTypes.objectOf(PropTypes.string), + }), }; VideoPlayer.defaultProps = { onReady: null, + customOptions: { + showPlaybackMenu: false, + showTranscripts: false, + transcriptUrls: undefined, + }, }; export default VideoPlayer; diff --git a/src/components/video/data/constants.js b/src/components/video/data/constants.js new file mode 100644 index 000000000..476ee71d9 --- /dev/null +++ b/src/components/video/data/constants.js @@ -0,0 +1 @@ +export const PLAYBACK_RATES = [0.75, 1, 1.25, 1.50, 2.0]; diff --git a/src/components/video/data/service.js b/src/components/video/data/service.js new file mode 100644 index 000000000..2d8acff75 --- /dev/null +++ b/src/components/video/data/service.js @@ -0,0 +1,42 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { convertToWebVtt, createWebVttFile } from './utils'; + +const fetchAndAddTranscripts = async (transcriptUrls, player) => { + const transcriptPromises = Object.entries(transcriptUrls).map(([lang, url]) => fetch(url) + .then(response => { + if (!response.ok) { + logError(`Failed to fetch transcript for ${lang}`); + } + return response.json(); + }) + .then(transcriptData => { + const webVttData = convertToWebVtt(transcriptData); + const webVttFileUrl = createWebVttFile(webVttData); + + player.addRemoteTextTrack({ + kind: 'subtitles', + src: webVttFileUrl, + srclang: lang, + label: lang, + }, false); + + // We are only catering to English transcripts for now as we don't have the option to change + // the transcript language yet. + if (lang === 'en') { + player.vjstranscribe({ + urls: [webVttFileUrl], + }); + } + }) + .catch(error => { + logError(`Error fetching or processing transcript for ${lang}:`, error); + })); + + try { + await Promise.all(transcriptPromises); + } catch (error) { + logError('Error fetching or processing transcripts:', error); + } +}; + +export { fetchAndAddTranscripts }; diff --git a/src/components/video/data/tests/service.test.js b/src/components/video/data/tests/service.test.js new file mode 100644 index 000000000..bb66cdeae --- /dev/null +++ b/src/components/video/data/tests/service.test.js @@ -0,0 +1,134 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { fetchAndAddTranscripts } from '../service'; +import { convertToWebVtt, createWebVttFile } from '../utils'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('../utils', () => ({ + convertToWebVtt: jest.fn(), + createWebVttFile: jest.fn(), +})); + +describe('fetchAndAddTranscripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch, convert, and add transcripts successfully', async () => { + const mockTranscriptUrls = { + en: 'https://example.com/en-transcript.json', + }; + + const mockTranscriptData = { + items: ['example'], + }; + + const mockWebVttData = 'WEBVTT\n\n1\n00:00:00.000 --> 00:00:05.000\nExample subtitle'; + const mockWebVttFileUrl = 'https://example.com/en-transcript.vtt'; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTranscriptData), + }); + + convertToWebVtt.mockReturnValue(mockWebVttData); + createWebVttFile.mockReturnValue(mockWebVttFileUrl); + + const player = { + addRemoteTextTrack: jest.fn(), + vjstranscribe: jest.fn(), + }; + + await fetchAndAddTranscripts(mockTranscriptUrls, player); + + expect(global.fetch).toHaveBeenCalledWith(mockTranscriptUrls.en); + expect(convertToWebVtt).toHaveBeenCalledWith(mockTranscriptData); + expect(createWebVttFile).toHaveBeenCalledWith(mockWebVttData); + expect(player.addRemoteTextTrack).toHaveBeenCalledWith( + { + kind: 'subtitles', + src: mockWebVttFileUrl, + srclang: 'en', + label: 'en', + }, + false, + ); + expect(player.vjstranscribe).toHaveBeenCalledWith({ + urls: [mockWebVttFileUrl], + }); + }); + + it('should log an error if the transcript fetch fails', async () => { + const mockTranscriptUrls = { + en: 'https://example.com/en-transcript.json', + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + }); + + const player = { + addRemoteTextTrack: jest.fn(), + vjstranscribe: jest.fn(), + }; + + await fetchAndAddTranscripts(mockTranscriptUrls, player); + + expect(global.fetch).toHaveBeenCalledWith(mockTranscriptUrls.en); + expect(logError).toHaveBeenCalledWith('Failed to fetch transcript for en'); + }); + + it('should log an error if JSON parsing or file creation fails', async () => { + const mockTranscriptUrls = { + en: 'https://example.com/en-transcript.json', + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.reject(new Error('Parsing error')), + }); + + const player = { + addRemoteTextTrack: jest.fn(), + vjstranscribe: jest.fn(), + }; + + await fetchAndAddTranscripts(mockTranscriptUrls, player); + + expect(global.fetch).toHaveBeenCalledWith(mockTranscriptUrls.en); + expect(logError).toHaveBeenCalledWith( + 'Error fetching or processing transcript for en:', + expect.any(Error), + ); + }); + + it('should log an error if there is an error during Promise.all', async () => { + const mockTranscriptUrls = { + en: 'https://example.com/en-transcript.json', + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + const player = { + addRemoteTextTrack: jest.fn(), + vjstranscribe: jest.fn(), + }; + + createWebVttFile.mockImplementation(() => { + throw new Error('File creation error'); + }); + + await fetchAndAddTranscripts(mockTranscriptUrls, player); + + expect(global.fetch).toHaveBeenCalledWith(mockTranscriptUrls.en); + expect(logError).toHaveBeenCalledWith( + 'Error fetching or processing transcript for en:', + expect.any(Error), + ); + }); +}); diff --git a/src/components/video/data/tests/utils.test.js b/src/components/video/data/tests/utils.test.js new file mode 100644 index 000000000..a13112890 --- /dev/null +++ b/src/components/video/data/tests/utils.test.js @@ -0,0 +1,43 @@ +import { convertToWebVtt, createWebVttFile } from '../utils'; + +describe('Video utils tests', () => { + it('should convert transcript data to WebVTT format correctly', () => { + const mockTranscriptData = { + start: [0, 5000], + end: [5000, 10000], + text: ['Hello World', 'Goodbye'], + }; + + const expectedWebVtt = 'WEBVTT\n\n' + + '1\n' + + '00:00:00.000 --> 00:00:05.000\n' + + 'Hello World\n\n' + + '2\n' + + '00:00:05.000 --> 00:00:10.000\n' + + 'Goodbye\n\n'; + + const result = convertToWebVtt(mockTranscriptData); + + expect(result).toBe(expectedWebVtt); + }); + it('should create a Blob with correct MIME type and generate an Object URL', () => { + const mockWebVttContent = 'WEBVTT\n\n' + + '1\n' + + '00:00:00.000 --> 00:00:05.000\n' + + 'Hello World\n\n'; + + // Mock URL.createObjectURL + URL.createObjectURL = jest.fn().mockReturnValue('blob:https://example.com/12345'); + + const result = createWebVttFile(mockWebVttContent); + + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(result).toBe('blob:https://example.com/12345'); + + // Verify Blob properties + const blob = URL.createObjectURL.mock.calls[0][0]; + expect(blob.type).toBe('text/vtt'); + expect(blob.size).toBe(mockWebVttContent.length); + }); +}); diff --git a/src/components/video/data/utils.js b/src/components/video/data/utils.js new file mode 100644 index 000000000..d75d693e3 --- /dev/null +++ b/src/components/video/data/utils.js @@ -0,0 +1,28 @@ +export const convertToWebVtt = (transcriptData) => { + const formatTime = (timeInMilliseconds) => { + const pad = (num, size) => (`000${num}`).slice(size * -1); + const hours = Math.floor(timeInMilliseconds / 3600000); + const minutes = Math.floor((timeInMilliseconds % 3600000) / 60000); + const seconds = Math.floor((timeInMilliseconds % 60000) / 1000); + const milliseconds = timeInMilliseconds % 1000; + return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`; + }; + + const removeImageTags = (text) => text.replace(/]*>/g, ''); + + const length = Math.min(transcriptData.start.length, transcriptData.end.length, transcriptData.text.length); + + let webVtt = 'WEBVTT\n\n'; + for (let index = 0; index < length; index++) { + webVtt += `${index + 1}\n`; + webVtt += `${formatTime(transcriptData.start[index])} --> ${formatTime(transcriptData.end[index])}\n`; + webVtt += `${removeImageTags(transcriptData.text[index])}\n\n`; + } + + return webVtt; +}; + +export const createWebVttFile = (webVttContent) => { + const blob = new Blob([webVttContent], { type: 'text/vtt' }); + return URL.createObjectURL(blob); +}; diff --git a/src/components/video/tests/VideoJS.test.jsx b/src/components/video/tests/VideoJS.test.jsx index 86eccc122..8368cccfe 100644 --- a/src/components/video/tests/VideoJS.test.jsx +++ b/src/components/video/tests/VideoJS.test.jsx @@ -2,6 +2,8 @@ import React from 'react'; import { VideoJS } from '..'; import { renderWithRouter } from '../../../utils/tests'; +jest.mock('videojs-vjstranscribe'); + const hlsUrl = 'https://test-domain.com/test-prefix/id.m3u8'; const ytUrl = 'https://www.youtube.com/watch?v=oHg5SJYRHA0'; diff --git a/src/components/video/tests/VideoPlayer.test.jsx b/src/components/video/tests/VideoPlayer.test.jsx index 3d760db27..83d0cd4a3 100644 --- a/src/components/video/tests/VideoPlayer.test.jsx +++ b/src/components/video/tests/VideoPlayer.test.jsx @@ -1,13 +1,14 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; -import { VideoPlayer } from '..'; +import { VideoPlayer } from '..'; // Assuming VideoPlayer is exported as named export import { renderWithRouter } from '../../../utils/tests'; const hlsUrl = 'https://test-domain.com/test-prefix/id.m3u8'; const ytUrl = 'https://www.youtube.com/watch?v=oHg5SJYRHA0'; +const mp3Url = 'https://example.com/audio.mp3'; describe('Video Player component', () => { - it('Renders Video Player components correctly.', async () => { + it('Renders Video Player components correctly for HLS videos.', async () => { const { container } = renderWithRouter(); expect(container.querySelector('.video-player-container')).toBeTruthy(); await waitFor(() => expect(container.querySelector('.video-js-wrapper')).toBeTruthy()); @@ -22,4 +23,25 @@ describe('Video Player component', () => { expect(container.querySelector('.vjs-big-play-centered')).toBeTruthy(); expect(container.querySelector('video-js')).toBeTruthy(); }); + + it('Renders Video Player components correctly for mp3 audio.', async () => { + const { container } = renderWithRouter(); + expect(container.querySelector('.video-player-container')).toBeTruthy(); + await waitFor(() => expect(container.querySelector('.video-js-wrapper')).toBeTruthy()); + expect(container.querySelector('.vjs-big-play-centered')).toBeTruthy(); + expect(container.querySelector('video-js')).toBeTruthy(); + }); + + it('Renders Video Player components correctly with transcripts.', async () => { + const customOptions = { + showTranscripts: true, + transcriptUrls: { english: 'https://example.com/transcript-en.txt' }, + }; + const { container } = renderWithRouter(); + expect(container.querySelector('.video-player-container-with-transcript')).toBeTruthy(); + await waitFor(() => expect(container.querySelector('.video-js-wrapper')).toBeTruthy()); + expect(container.querySelector('.vjs-big-play-centered')).toBeTruthy(); + expect(container.querySelector('video-js')).toBeTruthy(); + expect(container.querySelector('#vjs-transcribe')).toBeTruthy(); + }); });