diff --git a/frontends/main/package.json b/frontends/main/package.json
index 7a4aa92817..4817890415 100644
--- a/frontends/main/package.json
+++ b/frontends/main/package.json
@@ -40,6 +40,7 @@
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/static-renderer": "^3.13.0",
+ "@types/video.js": "^7.3.58",
"api": "workspace:*",
"classnames": "^2.5.1",
"formik": "^2.4.6",
@@ -62,6 +63,7 @@
"sharp": "0.34.4",
"slick-carousel": "^1.8.1",
"tiny-invariant": "^1.3.3",
+ "video.js": "8.23.7",
"yup": "^1.4.0"
},
"devDependencies": {
diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaDisplay.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaDisplay.tsx
new file mode 100644
index 0000000000..56cd9fb39c
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaDisplay.tsx
@@ -0,0 +1,71 @@
+import React from "react"
+import styled from "@emotion/styled"
+import { isVideoUrl } from "./lib"
+import { VideoJsPlayer } from "./VideoJsPlayer"
+
+const MediaContainer = styled.div(({ theme }) => ({
+ position: "relative",
+ width: "100%",
+ aspectRatio: "16 / 9",
+ overflow: "hidden",
+
+ iframe: {
+ width: "100%",
+ height: "100%",
+ borderRadius: "6px",
+ display: "block",
+ },
+
+ video: {
+ width: "100%",
+ height: "100%",
+ borderRadius: "6px",
+ display: "block",
+ objectFit: "contain",
+ backgroundColor: "#000",
+ },
+
+ // Video.js player styling
+ ".video-js": {
+ width: "100%",
+ height: "100%",
+ borderRadius: "6px",
+ },
+
+ ".layout-full & iframe, .layout-full & video, .layout-full & .video-js": {
+ borderRadius: 0,
+ },
+ ".ProseMirror-selectednode .layout-wide &": {
+ border: `1px solid ${theme.custom.colors.red}`,
+ padding: "8px",
+ borderRadius: "10px",
+ },
+ ".ProseMirror-selectednode .layout-full &": {
+ border: `1px solid ${theme.custom.colors.red}`,
+ padding: "8px 0",
+ borderWidth: "1px 0",
+ },
+}))
+
+interface MediaDisplayProps {
+ src: string
+ caption?: string
+}
+
+export const MediaDisplay = ({ src, caption }: MediaDisplayProps) => {
+ return (
+
+ {isVideoUrl(src) ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedNodeView.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedNodeView.tsx
index 2ac8ff89d9..3e54241190 100644
--- a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedNodeView.tsx
+++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedNodeView.tsx
@@ -5,6 +5,7 @@ import { FullWidth, WideWidth, DefaultWidth } from "./Icons"
import { RiCloseLargeLine } from "@remixicon/react"
import { ActionButton } from "@mitodl/smoot-design"
import { EditableCaption } from "../shared/EditableCaption"
+import { MediaDisplay } from "./MediaDisplay"
const StyledNodeViewWrapper = styled(NodeViewWrapper, {
shouldForwardProp: (prop) =>
@@ -110,34 +111,6 @@ const MediaLayoutToolbar = styled.div({
},
})
-const MediaContainer = styled.div(({ theme }) => ({
- position: "relative",
- width: "100%",
- aspectRatio: "16 / 9",
- overflow: "hidden",
-
- iframe: {
- width: "100%",
- height: "100%",
- borderRadius: "6px",
- display: "block",
- },
-
- ".layout-full & iframe": {
- borderRadius: 0,
- },
- ".ProseMirror-selectednode .layout-wide &": {
- border: `1px solid ${theme.custom.colors.red}`,
- padding: "8px",
- borderRadius: "10px",
- },
- ".ProseMirror-selectednode .layout-full &": {
- border: `1px solid ${theme.custom.colors.red}`,
- padding: "8px 0",
- borderWidth: "1px 0",
- },
-}))
-
interface MediaEmbedNodeProps {
node: NodeViewProps["node"]
editor: NodeViewProps["editor"]
@@ -215,15 +188,7 @@ export const MediaEmbedNodeView = ({
)}
-
-
-
+
{
return (
-
-
-
+
{
+ const mockPlayer = {
+ dispose: jest.fn(),
+ on: jest.fn(),
+ isDisposed: jest.fn(() => false),
+ error: jest.fn(),
+ ready: jest.fn((callback) => {
+ // Call the callback immediately in tests
+ callback()
+ }),
+ addRemoteTextTrack: jest.fn(),
+ textTracks: jest.fn(() => ({
+ length: 0,
+ tracks_: [],
+ })),
+ }
+
+ const mockFn = jest.fn(() => mockPlayer)
+
+ return Object.assign(mockFn, {
+ browser: {
+ IS_SAFARI: false,
+ },
+ })
+})
+
+jest.mock("video.js/dist/video-js.css", () => ({}))
+
+// Import component and videojs
+import { VideoJsPlayer } from "./VideoJsPlayer"
+import videojs from "video.js"
+
+// Type the mocked videojs
+const mockedVideojs = videojs as jest.MockedFunction
+
+describe("VideoJsPlayer", () => {
+ const defaultProps = {
+ src: "https://example.cloudfront.net/video.m3u8",
+ caption: "Test Video",
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("renders a video player container", () => {
+ render()
+ const container = screen.getByTitle(defaultProps.caption)
+ expect(container).toBeInTheDocument()
+ })
+
+ it("initializes video.js player with correct options", () => {
+ render()
+
+ expect(mockedVideojs).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ controls: true,
+ responsive: true,
+ fluid: true,
+ preload: "auto",
+ }),
+ )
+ })
+
+ it("sets HLS source correctly", () => {
+ render()
+
+ const [[, options]] = mockedVideojs.mock.calls
+ expect(options.sources).toEqual([
+ {
+ src: defaultProps.src,
+ type: "application/x-mpegURL",
+ },
+ ])
+ })
+
+ it("registers error handler", () => {
+ render()
+
+ const mockPlayer = mockedVideojs.mock.results[0].value
+ expect(mockPlayer.on).toHaveBeenCalledWith("error", expect.any(Function))
+ })
+
+ it("handles player errors by logging", () => {
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation()
+ const error = { code: 4, message: "Network error" }
+
+ render()
+
+ const mockPlayer = mockedVideojs.mock.results[0].value
+ mockPlayer.error.mockReturnValue(error)
+
+ // Trigger the error handler
+ const errorHandler = mockPlayer.on.mock.calls.find(
+ ([event]: [string]) => event === "error",
+ )?.[1]
+ errorHandler?.()
+
+ expect(consoleSpy).toHaveBeenCalledWith("Video.js error:", error)
+ consoleSpy.mockRestore()
+ })
+
+ it("disposes player on unmount", () => {
+ const { unmount } = render()
+
+ const mockPlayer = mockedVideojs.mock.results[0].value
+
+ unmount()
+
+ expect(mockPlayer.isDisposed).toHaveBeenCalled()
+ expect(mockPlayer.dispose).toHaveBeenCalled()
+ })
+
+ it("does not dispose if already disposed", () => {
+ const { unmount } = render()
+
+ const mockPlayer = mockedVideojs.mock.results[0].value
+ mockPlayer.isDisposed.mockReturnValue(true)
+
+ unmount()
+
+ expect(mockPlayer.isDisposed).toHaveBeenCalled()
+ expect(mockPlayer.dispose).not.toHaveBeenCalled()
+ })
+
+ it("initializes player only once", () => {
+ const { rerender } = render()
+
+ expect(mockedVideojs).toHaveBeenCalledTimes(1)
+
+ rerender()
+
+ expect(mockedVideojs).toHaveBeenCalledTimes(1)
+ })
+
+ it("does not reinitialize when src changes", () => {
+ const { rerender } = render()
+
+ expect(mockedVideojs).toHaveBeenCalledTimes(1)
+
+ const newSrc = "https://example.cloudfront.net/new-video.m3u8"
+ rerender()
+
+ // Player is not reinitialized when src changes because playerRef.current exists
+ // To change video source, the player would need to be updated via player.src()
+ expect(mockedVideojs).toHaveBeenCalledTimes(1)
+ })
+
+ it("applies correct CSS classes to video element", () => {
+ render()
+
+ const videoElement = mockedVideojs.mock.calls[0][0] as HTMLElement
+ expect(videoElement.classList.contains("vjs-big-play-centered")).toBe(true)
+ })
+
+ it("sets container with data-vjs-player attribute", () => {
+ render()
+
+ const titleElement = screen.getByTitle(defaultProps.caption)
+ const playerContainer = titleElement.parentElement
+ expect(playerContainer).toHaveAttribute("data-vjs-player")
+ expect(playerContainer).toHaveStyle({ width: "100%", height: "100%" })
+ })
+})
diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/VideoJsPlayer.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/VideoJsPlayer.tsx
new file mode 100644
index 0000000000..887bb83fa8
--- /dev/null
+++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/VideoJsPlayer.tsx
@@ -0,0 +1,155 @@
+import React, { useEffect, useRef } from "react"
+import videojs from "video.js"
+import type Player from "video.js/dist/types/player"
+import "video.js/dist/video-js.css"
+
+interface VideoJsPlayerProps {
+ src: string
+ caption?: string
+}
+
+const getVideoType = (url: string): string => {
+ const lowerUrl = url.toLowerCase()
+ if (lowerUrl.endsWith(".m3u8")) {
+ return "application/x-mpegURL"
+ }
+ if (lowerUrl.endsWith(".mp4")) {
+ return "video/mp4"
+ }
+ // Default to mp4 for other cases
+ return "video/mp4"
+}
+
+const deriveMediaUrls = (
+ m3u8Url: string,
+): { subtitlesUrl: string; posterUrl: string } | null => {
+ try {
+ // Extract directory and filename
+ const lastSlashIndex = m3u8Url.lastIndexOf("/")
+ if (lastSlashIndex === -1) return null
+
+ const directory = m3u8Url.substring(0, lastSlashIndex)
+ const filename = m3u8Url.substring(lastSlashIndex + 1)
+
+ // Extract base name (remove .5M.m3u8 or similar pattern)
+ // Pattern: video_HLS1.5M.m3u8 -> video_HLS1
+ const baseMatch = filename.match(/^(.+?)(?:\.\d+[MK])?\.m3u8$/)
+ if (!baseMatch) return null
+
+ const baseName = baseMatch[1]
+
+ return {
+ subtitlesUrl: `${directory}/${baseName}.srt`,
+ posterUrl: `${directory}/${baseName}_cover.jpeg`,
+ }
+ } catch {
+ return null
+ }
+}
+
+export const VideoJsPlayer: React.FC = ({
+ src,
+ caption,
+}) => {
+ const videoRef = useRef(null)
+ const playerRef = useRef(null)
+
+ // Derive poster and subtitles URLs synchronously for m3u8 videos
+ const derivedUrls = src.toLowerCase().endsWith(".m3u8")
+ ? deriveMediaUrls(src)
+ : null
+ const posterUrl = derivedUrls?.posterUrl
+ const subtitlesUrl = derivedUrls?.subtitlesUrl
+
+ useEffect(() => {
+ // Make sure Video.js player is only initialized once
+ if (!playerRef.current && videoRef.current) {
+ const videoElement = document.createElement("video-js")
+
+ videoElement.classList.add("vjs-big-play-centered")
+ videoRef.current.appendChild(videoElement)
+
+ const player = (playerRef.current = videojs(videoElement, {
+ controls: true,
+ responsive: true,
+ fluid: true,
+ aspectRatio: "16:9",
+ preload: "auto",
+ poster: posterUrl,
+ html5: {
+ vhs: {
+ // Enable HLS.js integration
+ overrideNative: !videojs.browser.IS_SAFARI,
+ },
+ },
+ sources: [
+ {
+ src,
+ type: getVideoType(src),
+ },
+ ],
+ }))
+
+ // Wait for player to be ready before adding text tracks
+ player.ready(() => {
+ // Add subtitles track if available
+ if (subtitlesUrl) {
+ player.addRemoteTextTrack(
+ {
+ kind: "captions",
+ src: subtitlesUrl,
+ srclang: "en",
+ label: "English",
+ default: false,
+ },
+ false,
+ )
+
+ // Get the track that was just added
+ const textTracks = player.textTracks()
+ const lastTrackIndex = textTracks.length - 1
+ if (lastTrackIndex >= 0) {
+ const track = textTracks.tracks_[lastTrackIndex]
+ if (track) {
+ // Handle track loading errors silently
+ track.addEventListener("error", () => {
+ console.warn("Subtitle track failed to load:", subtitlesUrl)
+ // Hide the track on error to prevent error messages from displaying
+ track.mode = "disabled"
+ })
+
+ // Only show the track if it loads successfully
+ track.addEventListener("load", () => {
+ track.mode = "showing"
+ })
+ }
+ }
+ }
+ })
+
+ // Error handling
+ player.on("error", () => {
+ const error = player.error()
+ console.error("Video.js error:", error)
+ })
+ }
+ }, [src, posterUrl, subtitlesUrl])
+
+ // Dispose the Video.js player when the component unmounts
+ useEffect(() => {
+ const player = playerRef.current
+
+ return () => {
+ if (player && !player.isDisposed()) {
+ player.dispose()
+ playerRef.current = null
+ }
+ }
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/lib.ts b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/lib.ts
index 5a7e8dbe2d..008c183552 100644
--- a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/lib.ts
+++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/lib.ts
@@ -1,3 +1,15 @@
+export function isVideoUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url)
+ return (
+ parsed.pathname.toLowerCase().endsWith(".mp4") ||
+ parsed.pathname.toLowerCase().endsWith(".m3u8")
+ )
+ } catch {
+ return false
+ }
+}
+
export function convertToEmbedUrl(url: string): string | null {
let parsed: URL
@@ -6,9 +18,28 @@ export function convertToEmbedUrl(url: string): string | null {
} catch {
return null // not a valid URL
}
-
const hostname = parsed.hostname.replace("www.", "")
+ // --- MIT LEARN MP4 VIDEOS ---
+ if (
+ hostname ===
+ new URL(process.env.NEXT_PUBLIC_ORIGIN || "https://learn.mit.edu").hostname
+ ) {
+ return url // Return the URL as-is for video element
+ }
+
+ // --- CLOUDFRONT M3U8 VIDEOS ---
+
+ if (
+ hostname ===
+ new URL(
+ process.env.NEXT_PUBLIC_CLOUDFRONT_DOMAIN ||
+ "https://d3tsb3m56iwvoq.cloudfront.net",
+ ).hostname
+ ) {
+ return url // Return the URL as-is for video element
+ }
+
// --- YOUTUBE WATCH ---
if (hostname === "youtube.com" && parsed.pathname === "/watch") {
const videoId = parsed.searchParams.get("v")
diff --git a/yarn.lock b/yarn.lock
index b98444a3b2..63ff83ff91 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1718,6 +1718,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.28.4":
+ version: 7.28.6
+ resolution: "@babel/runtime@npm:7.28.6"
+ checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9
+ languageName: node
+ linkType: hard
+
"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
version: 7.28.3
resolution: "@babel/runtime@npm:7.28.3"
@@ -1725,13 +1732,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.28.4":
- version: 7.28.6
- resolution: "@babel/runtime@npm:7.28.6"
- checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9
- languageName: node
- linkType: hard
-
"@babel/template@npm:^7.25.7, @babel/template@npm:^7.3.3":
version: 7.25.7
resolution: "@babel/template@npm:7.25.7"
@@ -7635,6 +7635,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/video.js@npm:^7.3.58":
+ version: 7.3.58
+ resolution: "@types/video.js@npm:7.3.58"
+ checksum: 10/1e34e0914529d358ae6463ce57d0910a736fb018e1d7462ae83ab7b7e065d251e2ed6c4631fefa09c8857382f2daffa3e7081202d52625ddba3a1fba86bf6816
+ languageName: node
+ linkType: hard
+
"@types/whatwg-mimetype@npm:^3.0.2":
version: 3.0.2
resolution: "@types/whatwg-mimetype@npm:3.0.2"
@@ -8311,6 +8318,45 @@ __metadata:
languageName: node
linkType: hard
+"@videojs/http-streaming@npm:^3.17.3, @videojs/http-streaming@npm:^3.17.4":
+ version: 3.17.4
+ resolution: "@videojs/http-streaming@npm:3.17.4"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ "@videojs/vhs-utils": "npm:^4.1.1"
+ aes-decrypter: "npm:^4.0.2"
+ global: "npm:^4.4.0"
+ m3u8-parser: "npm:^7.2.0"
+ mpd-parser: "npm:^1.3.1"
+ mux.js: "npm:7.1.0"
+ video.js: "npm:^7 || ^8"
+ peerDependencies:
+ video.js: ^8.19.0
+ checksum: 10/9ee755ebecd204085b8e1bb302a68faafbadac2f888e28c61bf7b96f91ebdc0d2614668e274d958fc80549ed29c2540bdf6ebee3c890c596bec41c943899be2f
+ languageName: node
+ linkType: hard
+
+"@videojs/vhs-utils@npm:^4.0.0, @videojs/vhs-utils@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "@videojs/vhs-utils@npm:4.1.1"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ global: "npm:^4.4.0"
+ checksum: 10/060b9e95aa75ed480bb597c2051b5ba362a9e1cfa5fdf6a51671bc00c8afe3ceee61ce2006157af40429799857d6637b72e5b9aeb7c20f6c999bd823715962b9
+ languageName: node
+ linkType: hard
+
+"@videojs/xhr@npm:2.7.0":
+ version: 2.7.0
+ resolution: "@videojs/xhr@npm:2.7.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.5.5"
+ global: "npm:~4.4.0"
+ is-function: "npm:^1.0.1"
+ checksum: 10/7da65adc9f9317c1dcf81e1d8504f06cd8bde675241da5b8fcb385e90d9f8b2d0efdd10f520105d055f75340b7b8413f251b6ef1676638c0cd979181a5380d9d
+ languageName: node
+ linkType: hard
+
"@vitest/expect@npm:2.0.5":
version: 2.0.5
resolution: "@vitest/expect@npm:2.0.5"
@@ -8573,6 +8619,13 @@ __metadata:
languageName: node
linkType: hard
+"@xmldom/xmldom@npm:^0.8.3":
+ version: 0.8.11
+ resolution: "@xmldom/xmldom@npm:0.8.11"
+ checksum: 10/f6d6ffdf71cf19d9b3c10e978fad40d2f85453bf5b2aa05be8aa0c5ad13f84690c3153316729213cc652d06ec12c605ddb0aa03886f1d73d51b974b4105d31e3
+ languageName: node
+ linkType: hard
+
"@xtuc/ieee754@npm:^1.2.0":
version: 1.2.0
resolution: "@xtuc/ieee754@npm:1.2.0"
@@ -8682,6 +8735,18 @@ __metadata:
languageName: node
linkType: hard
+"aes-decrypter@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "aes-decrypter@npm:4.0.2"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ "@videojs/vhs-utils": "npm:^4.1.1"
+ global: "npm:^4.4.0"
+ pkcs7: "npm:^1.0.4"
+ checksum: 10/89f0f90fe728e5df9408f1a2bd5bc3afbaf475b2b871cbcbc9e034e392ef5b579d689c71af3479099347cb40113c4f7272d6f4666bf996abb43b06dece960452
+ languageName: node
+ linkType: hard
+
"agent-base@npm:6":
version: 6.0.2
resolution: "agent-base@npm:6.0.2"
@@ -11069,6 +11134,13 @@ __metadata:
languageName: node
linkType: hard
+"dom-walk@npm:^0.1.0":
+ version: 0.1.2
+ resolution: "dom-walk@npm:0.1.2"
+ checksum: 10/19eb0ce9c6de39d5e231530685248545d9cd2bd97b2cb3486e0bfc0f2a393a9addddfd5557463a932b52fdfcf68ad2a619020cd2c74a5fe46fbecaa8e80872f3
+ languageName: node
+ linkType: hard
+
"domain-browser@npm:^4.22.0":
version: 4.23.0
resolution: "domain-browser@npm:4.23.0"
@@ -13241,6 +13313,16 @@ __metadata:
languageName: node
linkType: hard
+"global@npm:4.4.0, global@npm:^4.3.1, global@npm:^4.4.0, global@npm:~4.4.0":
+ version: 4.4.0
+ resolution: "global@npm:4.4.0"
+ dependencies:
+ min-document: "npm:^2.19.0"
+ process: "npm:^0.11.10"
+ checksum: 10/9c057557c8f5a5bcfbeb9378ba4fe2255d04679452be504608dd5f13b54edf79f7be1db1031ea06a4ec6edd3b9f5f17d2d172fb47e6c69dae57fd84b7e72b77f
+ languageName: node
+ linkType: hard
+
"globals@npm:16.4.0":
version: 16.4.0
resolution: "globals@npm:16.4.0"
@@ -14285,6 +14367,13 @@ __metadata:
languageName: node
linkType: hard
+"is-function@npm:^1.0.1":
+ version: 1.0.2
+ resolution: "is-function@npm:1.0.2"
+ checksum: 10/7d564562e07b4b51359547d3ccc10fb93bb392fd1b8177ae2601ee4982a0ece86d952323fc172a9000743a3971f09689495ab78a1d49a9b14fc97a7e28521dc0
+ languageName: node
+ linkType: hard
+
"is-generator-fn@npm:^2.0.0":
version: 2.1.0
resolution: "is-generator-fn@npm:2.1.0"
@@ -15985,6 +16074,17 @@ __metadata:
languageName: node
linkType: hard
+"m3u8-parser@npm:^7.2.0":
+ version: 7.2.0
+ resolution: "m3u8-parser@npm:7.2.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ "@videojs/vhs-utils": "npm:^4.1.1"
+ global: "npm:^4.4.0"
+ checksum: 10/323a8bfb1670e9bccaac83b3215ae617149b686ac9d470830f91f399f8232d903b7288356377cb2a94680f2d31066be76693abdd6ba540ec3cfa8de1e75e21f8
+ languageName: node
+ linkType: hard
+
"magic-string@npm:0.30.8":
version: 0.30.8
resolution: "magic-string@npm:0.30.8"
@@ -16050,6 +16150,7 @@ __metadata:
"@types/react-dom": "npm:^19.2.3"
"@types/react-slick": "npm:^0.23.13"
"@types/slick-carousel": "npm:^1"
+ "@types/video.js": "npm:^7.3.58"
api: "workspace:*"
classnames: "npm:^2.5.1"
eslint: "npm:8.57.1"
@@ -16082,6 +16183,7 @@ __metadata:
tiny-invariant: "npm:^1.3.3"
ts-jest: "npm:^29.2.4"
typescript: "npm:^5.5.4"
+ video.js: "npm:8.23.7"
yup: "npm:^1.4.0"
languageName: unknown
linkType: soft
@@ -16843,6 +16945,15 @@ __metadata:
languageName: node
linkType: hard
+"min-document@npm:^2.19.0":
+ version: 2.19.2
+ resolution: "min-document@npm:2.19.2"
+ dependencies:
+ dom-walk: "npm:^0.1.0"
+ checksum: 10/9c98ea950451ac3292762fdc7a076ef2d365d795280a2a9357a5d1a17d80c44ecab2354ad507432411c1605515731259b41ce97b87f0b7126e1dfe610b2674ae
+ languageName: node
+ linkType: hard
+
"min-indent@npm:^1.0.0, min-indent@npm:^1.0.1":
version: 1.0.1
resolution: "min-indent@npm:1.0.1"
@@ -17047,6 +17158,20 @@ __metadata:
languageName: node
linkType: hard
+"mpd-parser@npm:^1.3.1":
+ version: 1.3.1
+ resolution: "mpd-parser@npm:1.3.1"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ "@videojs/vhs-utils": "npm:^4.0.0"
+ "@xmldom/xmldom": "npm:^0.8.3"
+ global: "npm:^4.4.0"
+ bin:
+ mpd-to-m3u8-json: bin/parse.js
+ checksum: 10/eaffe86a791449072faf5d0a47a06690acca79014868c8e1dee28947f6280a12a9cfcad3eb8fc7f722eea4afd2a9d324788da6efea63b2bef3c9bc561332b6fd
+ languageName: node
+ linkType: hard
+
"mri@npm:^1.1.0":
version: 1.2.0
resolution: "mri@npm:1.2.0"
@@ -17068,6 +17193,18 @@ __metadata:
languageName: node
linkType: hard
+"mux.js@npm:7.1.0, mux.js@npm:^7.0.1":
+ version: 7.1.0
+ resolution: "mux.js@npm:7.1.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.11.2"
+ global: "npm:^4.4.0"
+ bin:
+ muxjs-transmux: bin/transmux.js
+ checksum: 10/843edc7c18b6c3366281cfea786d7d9b36d5a3c42c463c18aed9f8ac15bdd5b84b4e1e025371b047be778cc54fd83b3f447da8a624ccd7529a310d3a65e7b99e
+ languageName: node
+ linkType: hard
+
"nanoid@npm:^3.3.11, nanoid@npm:^3.3.6, nanoid@npm:^3.3.8":
version: 3.3.11
resolution: "nanoid@npm:3.3.11"
@@ -18081,6 +18218,17 @@ __metadata:
languageName: node
linkType: hard
+"pkcs7@npm:^1.0.4":
+ version: 1.0.4
+ resolution: "pkcs7@npm:1.0.4"
+ dependencies:
+ "@babel/runtime": "npm:^7.5.5"
+ bin:
+ pkcs7: bin/cli.js
+ checksum: 10/faf9c0230bb3461c36d39b054d71e95f347302cc762574ddb53e4e7c288d3442931d29a4527b5a6059b59ae4470104df102f9173a85c57611907a71cc64ff5e9
+ languageName: node
+ linkType: hard
+
"pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0":
version: 4.2.0
resolution: "pkg-dir@npm:4.2.0"
@@ -22880,6 +23028,73 @@ __metadata:
languageName: node
linkType: hard
+"video.js@npm:8.23.7":
+ version: 8.23.7
+ resolution: "video.js@npm:8.23.7"
+ dependencies:
+ "@babel/runtime": "npm:^7.28.4"
+ "@videojs/http-streaming": "npm:^3.17.3"
+ "@videojs/vhs-utils": "npm:^4.1.1"
+ "@videojs/xhr": "npm:2.7.0"
+ aes-decrypter: "npm:^4.0.2"
+ global: "npm:4.4.0"
+ m3u8-parser: "npm:^7.2.0"
+ mpd-parser: "npm:^1.3.1"
+ mux.js: "npm:^7.0.1"
+ videojs-contrib-quality-levels: "npm:4.1.0"
+ videojs-font: "npm:4.2.0"
+ videojs-vtt.js: "npm:0.15.5"
+ checksum: 10/62859ac85ab5bf186cbaf3c0538d9c62aed7bc900150ba88f0a466f3414d73b01ca57895723773ad7f43a666e9b676336c1af90e4d9786ca45e4d7ccff181049
+ languageName: node
+ linkType: hard
+
+"video.js@npm:^7 || ^8":
+ version: 8.23.8
+ resolution: "video.js@npm:8.23.8"
+ dependencies:
+ "@babel/runtime": "npm:^7.28.4"
+ "@videojs/http-streaming": "npm:^3.17.4"
+ "@videojs/vhs-utils": "npm:^4.1.1"
+ "@videojs/xhr": "npm:2.7.0"
+ aes-decrypter: "npm:^4.0.2"
+ global: "npm:4.4.0"
+ m3u8-parser: "npm:^7.2.0"
+ mpd-parser: "npm:^1.3.1"
+ mux.js: "npm:^7.0.1"
+ videojs-contrib-quality-levels: "npm:4.1.0"
+ videojs-font: "npm:4.2.0"
+ videojs-vtt.js: "npm:0.15.5"
+ checksum: 10/a3501b82690e12f725574e0a4545c5bf32434f72b4902a10214f0dcab5566b8dcee05f0608f7df42bb9824bb9ea5f3503f62123571c76b3e7c1743488923d14e
+ languageName: node
+ linkType: hard
+
+"videojs-contrib-quality-levels@npm:4.1.0":
+ version: 4.1.0
+ resolution: "videojs-contrib-quality-levels@npm:4.1.0"
+ dependencies:
+ global: "npm:^4.4.0"
+ peerDependencies:
+ video.js: ^8
+ checksum: 10/e8948607b77cfaef2e45d6d3b12b4585f81887cc8ae645bd1dedef1c39657156e0eaae444074089c0a287f820928b4825dd0c9f133e2be5dee0e6c894bf340b2
+ languageName: node
+ linkType: hard
+
+"videojs-font@npm:4.2.0":
+ version: 4.2.0
+ resolution: "videojs-font@npm:4.2.0"
+ checksum: 10/38b966e228a44873f24c6d3d045629ed25e5bc3160245682901019304d13854a7bca49a91a96b88f37ce839c6ee7d6026fce34054cb391cbbcc5627bef2cc4f2
+ languageName: node
+ linkType: hard
+
+"videojs-vtt.js@npm:0.15.5":
+ version: 0.15.5
+ resolution: "videojs-vtt.js@npm:0.15.5"
+ dependencies:
+ global: "npm:^4.3.1"
+ checksum: 10/87028a40aabf447a2726a4c991ab131fac897cce41d9aef3b34519363811d8c74376f9bf9d37a1769d8739fba9b20bf9a381633d0349560fcda4f0b713d2a1b9
+ languageName: node
+ linkType: hard
+
"vite-compatible-readable-stream@npm:^3.6.1":
version: 3.6.1
resolution: "vite-compatible-readable-stream@npm:3.6.1"