Skip to content
This repository has been archived by the owner on Jan 15, 2025. It is now read-only.

Video recorder: Added camera switcher #1462

Merged
merged 1 commit into from
Sep 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/common/api/threespeak/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export function useThreeSpeakVideo(
const apiQuery = useQuery(
[QueryIdentifiers.THREE_SPEAK_VIDEO_LIST, activeUser?.username ?? ""],
async () => {
if (!activeUser) {
return [];
}

try {
return await getAllVideoStatuses(activeUser!.username);
} catch (e) {
Expand Down
19 changes: 18 additions & 1 deletion src/common/components/video-upload-threespeak/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,26 @@ label {
left: 0;
right: 0;
z-index: 9;
display: flex;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
justify-content: center;

div {
display: flex;
align-items: center;
justify-content: center;
}

.switch-camera {
opacity: 0.75;
cursor: pointer;
color: $white;

&:hover {
opacity: 1;
}
}

.record-btn {
color: $danger;
border: 0.25rem solid $white;
Expand Down
5 changes: 4 additions & 1 deletion src/common/components/video-upload-threespeak/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createFile } from "../../util/create-file";
import { useMappedStore } from "../../store/use-mapped-store";
import { recordVideoSvg } from "../../img/svg";
import { VideoUploadRecorder } from "./video-upload-recorder";
import useMountedState from "react-use/lib/useMountedState";

const DEFAULT_THUMBNAIL = require("./assets/thumbnail-play.jpg");

Expand Down Expand Up @@ -48,6 +49,8 @@ export const VideoUpload = (props: Props & React.HTMLAttributes<HTMLDivElement>)

const canUpload = videoUrl;

const isMounted = useMountedState();

// Reset on dialog hide
useEffect(() => {
if (!props.show) {
Expand Down Expand Up @@ -124,7 +127,7 @@ export const VideoUpload = (props: Props & React.HTMLAttributes<HTMLDivElement>)
/>
) : (
<div className="video-source">
{!selectedFile && "MediaRecorder" in window ? (
{isMounted() && !selectedFile && "MediaRecorder" in window ? (
<div
className="d-flex align-items-center flex-column border rounded p-3 video-upload-item"
onClick={() => setShowRecorder(true)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-get-camera-list";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useState } from "react";
import useMount from "react-use/lib/useMount";

/**
* Retrieves a list of available cameras.
*
* @return {MediaDeviceInfo[]} The list of available cameras.
*/
export function useGetCameraList() {
const [list, setList] = useState<MediaDeviceInfo[]>([]);

useMount(async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
setList(devices.filter(({ kind }) => kind === "videoinput"));
});

return list;
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,66 @@
import { circleSvg, rectSvg } from "../../img/svg";
import { circleSvg, rectSvg, switchCameraSvg } from "../../img/svg";
import React, { useState } from "react";
import { useGetCameraList } from "./utils";

interface Props {
noPermission: boolean;
mediaRecorder?: MediaRecorder;
onCameraSelect: (camera: MediaDeviceInfo) => void;
}

export function VideoUploadRecorderActions({ noPermission, mediaRecorder }: Props) {
export function VideoUploadRecorderActions({ noPermission, mediaRecorder, onCameraSelect }: Props) {
const cameraList = useGetCameraList();

const [currentCameraIndex, setCurrentCameraIndex] = useState(0);
const [recordStarted, setRecordStarted] = useState(false);

const getNextCameraIndex = (index: number) => (index + 1) % cameraList.length;

return (
<div className="actions">
{recordStarted ? (
<div
aria-disabled={noPermission}
className="record-btn"
onClick={() => {
mediaRecorder?.stop();
setRecordStarted(false);
}}
>
{rectSvg}
</div>
) : (
<div
aria-disabled={noPermission}
className="record-btn"
onClick={() => {
mediaRecorder?.start();
setRecordStarted(true);
}}
>
{circleSvg}
</div>
)}
<div>
{!recordStarted && cameraList.length > 1 ? (
<div
className="switch-camera"
onClick={() => {
const nextCameraIndex = getNextCameraIndex(currentCameraIndex);
onCameraSelect(cameraList[nextCameraIndex]);
setCurrentCameraIndex(nextCameraIndex);
}}
>
{switchCameraSvg}
</div>
) : (
<></>
)}
</div>

<div>
{recordStarted ? (
<div
aria-disabled={noPermission}
className="record-btn"
onClick={() => {
mediaRecorder?.stop();
setRecordStarted(false);
}}
>
{rectSvg}
</div>
) : (
<div
aria-disabled={noPermission}
className="record-btn"
onClick={() => {
mediaRecorder?.start();
setRecordStarted(true);
}}
>
{circleSvg}
</div>
)}
</div>
<div />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function VideoUploadRecorder({
const [recordedVideoSrc, setRecordedVideoSrc] = useState<string>();
const [recordedBlob, setRecordedBlob] = useState<Blob>();
const [noPermission, setNoPermission] = useState(false);
const [currentCamera, setCurrentCamera] = useState<MediaDeviceInfo>();

const ref = useRef<HTMLVideoElement | null>(null);

Expand All @@ -39,10 +40,27 @@ export function VideoUploadRecorder({
isSuccess
} = useThreeSpeakVideoUpload("video");

useMount(async () => {
useMount(() => initStream());

useUnmount(() => {
stream?.getTracks().forEach((track) => track.stop());
});

useEffect(() => {
initStream();
}, [currentCamera]);

useEffect(() => {
if (stream && ref.current) {
// @ts-ignore
ref.current?.srcObject = stream;
}
}, [stream, ref]);

const initStream = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
video: currentCamera ? { deviceId: currentCamera.deviceId } : true,
audio: true
});
const mediaRecorder = new MediaRecorder(stream, {
Expand All @@ -60,20 +78,10 @@ export function VideoUploadRecorder({
}
});
} catch (e) {
console.error("Video recording:", e);
setNoPermission(true);
}
});

useUnmount(() => {
stream?.getTracks().forEach((track) => track.stop());
});

useEffect(() => {
if (stream && ref.current) {
// @ts-ignore
ref.current?.srcObject = stream;
}
}, [stream, ref]);
};

return (
<div className="video-upload-recorder">
Expand All @@ -86,7 +94,18 @@ export function VideoUploadRecorder({
)}

{!noPermission && !recordedVideoSrc ? (
<VideoUploadRecorderActions noPermission={noPermission} mediaRecorder={mediaRecorder} />
<VideoUploadRecorderActions
noPermission={noPermission}
mediaRecorder={mediaRecorder}
onCameraSelect={(camera) => {
stream
?.getTracks()
.filter(({ kind }) => kind === "video")
.forEach((track) => track.stop());

setCurrentCamera(camera);
}}
/>
) : (
<></>
)}
Expand Down
21 changes: 20 additions & 1 deletion src/common/img/svg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2052,7 +2052,7 @@ export const recordVideoSvg = (
/>
<path
d="M10.5 12C10.5 13.3807 9.38071 14.5 8 14.5C6.61929 14.5 5.5 13.3807 5.5 12C5.5 10.6193 6.61929 9.5 8 9.5C9.38071 9.5 10.5 10.6193 10.5 12Z"
stroke="#1C274C"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M8 14.5H16" stroke="#1C274C" strokeWidth="1.5" strokeLinecap="round" />
Expand All @@ -2076,3 +2076,22 @@ export const rectSvg = (
<rect x={6} y={6} fill="currentColor" width={12} height={12} />
</svg>
);

export const switchCameraSvg = (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 19H4a2 2 0 01-2-2V7a2 2 0 012-2h5" />
<path d="M13 5h7a2 2 0 012 2v10a2 2 0 01-2 2h-5" />
<circle cx="12" cy="12" r="3" />
<path d="M18 22l-3-3 3-3" />
<path d="M6 2l3 3-3 3" />
</svg>
);