From 0f99e7020ec1dd8dc47e4da2be258a5e96a93b09 Mon Sep 17 00:00:00 2001 From: Maham Akif Date: Fri, 12 Jul 2024 20:58:29 +0500 Subject: [PATCH] feat: added video detail page --- package-lock.json | 129 +++++++++++++++++ package.json | 1 + src/components/app/routes/createAppRouter.jsx | 15 ++ .../microlearning/VideoDetailPage.jsx | 133 ++++++++++++++++++ src/components/microlearning/data/hooks.js | 27 ++++ src/components/microlearning/data/service.js | 11 ++ src/components/microlearning/data/utils.js | 21 +++ src/components/microlearning/index.js | 1 + .../microlearning/styles/VideoDetailPage.scss | 99 +++++++++++++ src/components/video/VideoJS.jsx | 54 ++++++- src/components/video/VideoPlayer.jsx | 28 +++- src/components/video/data/constants.js | 1 + src/components/video/data/service.js | 28 ++++ src/components/video/data/utils.js | 28 ++++ 14 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 src/components/microlearning/VideoDetailPage.jsx create mode 100644 src/components/microlearning/data/hooks.js create mode 100644 src/components/microlearning/data/service.js create mode 100644 src/components/microlearning/data/utils.js create mode 100644 src/components/microlearning/index.js create mode 100644 src/components/microlearning/styles/VideoDetailPage.scss 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/utils.js 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/routes/createAppRouter.jsx b/src/components/app/routes/createAppRouter.jsx index eec6c38aa1..f0217cbb96 100644 --- a/src/components/app/routes/createAppRouter.jsx +++ b/src/components/app/routes/createAppRouter.jsx @@ -212,6 +212,21 @@ export default function createAppRouter(queryClient) { }; }} /> + { + const { VideoDetailPage } = await import('../../microlearning'); + return { + Component: VideoDetailPage, + }; + }} + errorElement={( + + )} + /> } /> } /> diff --git a/src/components/microlearning/VideoDetailPage.jsx b/src/components/microlearning/VideoDetailPage.jsx new file mode 100644 index 0000000000..33acf1b432 --- /dev/null +++ b/src/components/microlearning/VideoDetailPage.jsx @@ -0,0 +1,133 @@ +import React, { useEffect } from 'react'; +import { useParams, Link, useLocation } from 'react-router-dom'; +import { + Container, Breadcrumb, Row, MediaQuery, breakpoints, Badge, Skeleton, +} from '@openedx/paragon'; +import loadable from '@loadable/component'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useEnterpriseCustomer } from '../app/data'; +import { Sidebar } from '../layout'; +import './styles/VideoDetailPage.scss'; +import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContainer'; +import { useVideoData } from './data/hooks'; +import { features } from '../../config'; + +const VideoPlayer = loadable(() => import(/* webpackChunkName: "videojs" */ '../video/VideoPlayer'), { + fallback: ( + + + + ), +}); + +const VideoDetailPage = () => { + const { edxVideoID } = useParams(); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const location = useLocation(); + + const [videoData, isLoading, error] = useVideoData(edxVideoID); + + 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); + } + + if (isLoading) { + return ( + + + + ); + } + + // Comprehensive error handling will be implemented upon receiving specific error use cases from the UX team + // and corresponding Figma designs. + if (error || !features.FEATURE_ENABLE_VIDEO_CATALOG) { + return ( +
+

404

+

{error?.message}

+
+ ); + } + + 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/data/hooks.js b/src/components/microlearning/data/hooks.js new file mode 100644 index 0000000000..3d30424a19 --- /dev/null +++ b/src/components/microlearning/data/hooks.js @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import { fetchVideoData } from './service'; + +export function useVideoData(edxVideoID) { + const [videoData, setVideoData] = useState({}); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await fetchVideoData(edxVideoID); + setVideoData(data); + } catch (err) { + logError(err); + setError(err); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [edxVideoID]); + + return [videoData, isLoading, error]; +} diff --git a/src/components/microlearning/data/service.js b/src/components/microlearning/data/service.js new file mode 100644 index 0000000000..ab8eae89b4 --- /dev/null +++ b/src/components/microlearning/data/service.js @@ -0,0 +1,11 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { getConfig } from '@edx/frontend-platform/config'; +import { transformVideoData } from './utils'; + +export const fetchVideoData = async (edxVideoID) => { + const config = getConfig(); + const url = `${config.ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/videos/${edxVideoID}`; + const result = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(transformVideoData(result?.data || {})); +}; diff --git a/src/components/microlearning/data/utils.js b/src/components/microlearning/data/utils.js new file mode 100644 index 0000000000..c89111401c --- /dev/null +++ b/src/components/microlearning/data/utils.js @@ -0,0 +1,21 @@ +export const transformVideoData = (data) => { + const skillNames = Object.values(data.skills).map(skill => ({ + skill_id: skill.skill_id, + name: skill.name, + })); + + const minutes = Math.floor(data.json_metadata.duration / 60); + const seconds = Math.round(data.json_metadata.duration % 60); + const duration = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + + const transformedData = { + 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: skillNames, + videoDuration: duration, + }; + + return transformedData; +}; diff --git a/src/components/microlearning/index.js b/src/components/microlearning/index.js new file mode 100644 index 0000000000..748188a05f --- /dev/null +++ b/src/components/microlearning/index.js @@ -0,0 +1 @@ +export { default as VideoDetailPage } from './VideoDetailPage'; diff --git a/src/components/microlearning/styles/VideoDetailPage.scss b/src/components/microlearning/styles/VideoDetailPage.scss new file mode 100644 index 0000000000..f61e6fd63f --- /dev/null +++ b/src/components/microlearning/styles/VideoDetailPage.scss @@ -0,0 +1,99 @@ +.video-container { + gap: 16px; + flex: 1 0 0; +} + +.video-skills { + padding: 8px; + margin-right: 7px; + margin-top: 8px; + font-size: 12.5px; + font-weight: normal; + gap: 16px; +} + +.video-wrapper { + position: relative; + max-width: 100%; + overflow: hidden; + margin-top: 2rem; + margin-bottom: 2rem; +} + +.video-wrapper .video-js-wrapper { + position: relative; + padding-top: 56.25%; +} + +.video-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-btn { + display: none; +} + +.vjs-transcribe-cueline { + color: #00688D; +} diff --git a/src/components/video/VideoJS.jsx b/src/components/video/VideoJS.jsx index 62df2f5933..b317a7e845 100644 --- a/src/components/video/VideoJS.jsx +++ b/src/components/video/VideoJS.jsx @@ -4,8 +4,15 @@ import PropTypes from 'prop-types'; import 'videojs-youtube'; import videojs from 'video.js'; import 'video.js/dist/video-js.css'; +import { logError } from '@edx/frontend-platform/logging'; +import { PLAYBACK_RATES } from './data/constants'; +import { fetchTranscriptFiles } 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 +31,42 @@ const VideoJS = ({ options, onReady }) => { onReady(player); } }); + + if (customOptions.showPlaybackMenu) { + player.playbackRates(PLAYBACK_RATES); + } + + if (customOptions.showTranscripts && customOptions.transcriptUrls) { + const transcribeFileUrls = []; + fetchTranscriptFiles(customOptions.transcriptUrls) + .then((webVttData) => { + // We are only catering to English transcripts for now as we don't have the option to change + // the transcript language yet. + const englishTranscript = webVttData.find(({ lang }) => lang === 'en'); + webVttData.forEach(({ lang, webVttFileUrl }) => { + player.addRemoteTextTrack({ + kind: 'subtitles', + src: webVttFileUrl, + srclang: lang, + label: lang, + }, false); + transcribeFileUrls.push(webVttFileUrl); + }); + player.vjstranscribe({ + urls: [englishTranscript.webVttFileUrl], + }); + }) + .catch((error) => { + logError('Error adding transcripts to player:', error); + }); + } } 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 +81,12 @@ const VideoJS = ({ options, onReady }) => { }, [playerRef]); return ( -
-
-
+ <> +
+
+
+ { customOptions.showTranscripts &&
} + ); }; @@ -63,6 +102,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..2bffd669f3 --- /dev/null +++ b/src/components/video/data/service.js @@ -0,0 +1,28 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { convertToWebVtt, createWebVttFile } from './utils'; + +// Function to fetch and convert transcript files to WebVTT files +const fetchTranscriptFiles = async (transcriptUrls) => { + try { + const webVttDataArray = await Promise.all( + Object.entries(transcriptUrls).map(async ([lang, url]) => { + const response = await fetch(url); + if (!response.ok) { + logError(`Failed to fetch transcript for ${lang}`); + } + const transcriptData = await response.json(); + const webVttData = convertToWebVtt(transcriptData); + const webVttFileUrl = createWebVttFile(webVttData); + + return { lang, webVttData, webVttFileUrl }; + }), + ); + + return webVttDataArray; + } catch (error) { + logError('Error fetching or processing transcripts:', error); + return []; + } +}; + +export { fetchTranscriptFiles }; 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); +};