-
Notifications
You must be signed in to change notification settings - Fork 3
fix: add support for mp4 videos #2968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c04240c
2ffae08
934fec8
d1d6977
7c29a91
65e0f37
982f508
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <MediaContainer> | ||
| {isVideoUrl(src) ? ( | ||
| <VideoJsPlayer src={src} caption={caption} /> | ||
| ) : ( | ||
| <iframe | ||
| src={src} | ||
| frameBorder="0" | ||
| allowFullScreen | ||
| title={caption} | ||
| inert={true} | ||
| /> | ||
| )} | ||
| </MediaContainer> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = ({ | |
| </MediaLayoutToolbar> | ||
| )} | ||
|
|
||
| <MediaContainer> | ||
| <iframe | ||
| src={src} | ||
| frameBorder="0" | ||
| allowFullScreen | ||
| title={caption} | ||
| inert={editable} | ||
| /> | ||
| </MediaContainer> | ||
| <MediaDisplay src={src} caption={caption} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The refactored Suggested FixAdd an Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
|
|
||
| <EditableCaption | ||
| caption={caption} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| import React from "react" | ||
| import { render, screen } from "@testing-library/react" | ||
|
|
||
| // Mock video.js with all functionality inline | ||
| jest.mock("video.js", () => { | ||
| 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<typeof videojs> | ||
|
|
||
| describe("VideoJsPlayer", () => { | ||
| const defaultProps = { | ||
| src: "https://example.cloudfront.net/video.m3u8", | ||
| caption: "Test Video", | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| it("renders a video player container", () => { | ||
| render(<VideoJsPlayer {...defaultProps} />) | ||
| const container = screen.getByTitle(defaultProps.caption) | ||
| expect(container).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it("initializes video.js player with correct options", () => { | ||
| render(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| expect(mockedVideojs).toHaveBeenCalledWith( | ||
| expect.any(HTMLElement), | ||
| expect.objectContaining({ | ||
| controls: true, | ||
| responsive: true, | ||
| fluid: true, | ||
| preload: "auto", | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| it("sets HLS source correctly", () => { | ||
| render(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| const [[, options]] = mockedVideojs.mock.calls | ||
| expect(options.sources).toEqual([ | ||
| { | ||
| src: defaultProps.src, | ||
| type: "application/x-mpegURL", | ||
| }, | ||
| ]) | ||
| }) | ||
|
|
||
| it("registers error handler", () => { | ||
| render(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| expect(mockedVideojs).toHaveBeenCalledTimes(1) | ||
|
|
||
| rerender(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| expect(mockedVideojs).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it("does not reinitialize when src changes", () => { | ||
| const { rerender } = render(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| expect(mockedVideojs).toHaveBeenCalledTimes(1) | ||
|
|
||
| const newSrc = "https://example.cloudfront.net/new-video.m3u8" | ||
| rerender(<VideoJsPlayer src={newSrc} caption={defaultProps.caption} />) | ||
|
|
||
| // 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| 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(<VideoJsPlayer {...defaultProps} />) | ||
|
|
||
| const titleElement = screen.getByTitle(defaultProps.caption) | ||
| const playerContainer = titleElement.parentElement | ||
| expect(playerContainer).toHaveAttribute("data-vjs-player") | ||
| expect(playerContainer).toHaveStyle({ width: "100%", height: "100%" }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty minor comment, but I'm not sure if any of this
.video-jscss is necessary/doing anything.The height is being overridden to 0px, then videojs does some padding shenanigans to make it look good. And, perhaps related to that, the border radius isn't doing anything:
And videojs already sets width: 100%
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see the border radius being applied if you see and I am not following your point sir
