diff --git a/package-lock.json b/package-lock.json
index e69b7d40b6..7619a04bb1 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 b607e9d758..f4693fc61a 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 8e5f5a0f65..08174ff54b 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 0000000000..9acb4e915e
--- /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 0000000000..964988ff0f
--- /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 2e01769781..acd40ce085 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 d44f0eb4a0..6f8470bc9c 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 60889e96bd..6e48de2d6e 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 0000000000..aeb5671ac9
--- /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 0000000000..c0598cdb45
--- /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 eec6c38aa1..81112c5b50 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 0000000000..c081b8d6c7
--- /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 0000000000..82caf08e4e
--- /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 0000000000..333bb53010
--- /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 0000000000..e992ce9c69
--- /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 0000000000..a8bb775d73
--- /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 0000000000..83bb79ae5e
--- /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 0000000000..654eb1a8a0
--- /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 0000000000..cc5517fd5e
--- /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 0000000000..b2e3cca9bf
--- /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 0000000000..0617eccd3d
--- /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 62df2f5933..6ac66da59a 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 b461213acb..5c1dc92348 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 0000000000..476ee71d93
--- /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 0000000000..2d8acff75d
--- /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 0000000000..bb66cdeae7
--- /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 0000000000..a131128901
--- /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 0000000000..d75d693e39
--- /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 86eccc1226..8368cccfe7 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 3d760db27b..83d0cd4a38 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();
+ });
});