mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
split recorder player
This commit is contained in:
@@ -5,7 +5,6 @@ import useTopics from "../useTopics";
|
|||||||
import useWaveform from "../useWaveform";
|
import useWaveform from "../useWaveform";
|
||||||
import useMp3 from "../useMp3";
|
import useMp3 from "../useMp3";
|
||||||
import { TopicList } from "../topicList";
|
import { TopicList } from "../topicList";
|
||||||
import Recorder from "../recorder";
|
|
||||||
import { Topic } from "../webSocketTypes";
|
import { Topic } from "../webSocketTypes";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import "../../../styles/button.css";
|
import "../../../styles/button.css";
|
||||||
@@ -13,6 +12,7 @@ import FinalSummary from "../finalSummary";
|
|||||||
import ShareLink from "../shareLink";
|
import ShareLink from "../shareLink";
|
||||||
import QRCode from "react-qr-code";
|
import QRCode from "react-qr-code";
|
||||||
import TranscriptTitle from "../transcriptTitle";
|
import TranscriptTitle from "../transcriptTitle";
|
||||||
|
import Player from "../player";
|
||||||
|
|
||||||
type TranscriptDetails = {
|
type TranscriptDetails = {
|
||||||
params: {
|
params: {
|
||||||
@@ -62,14 +62,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!waveform?.loading && (
|
{!waveform?.loading && (
|
||||||
<Recorder
|
<Player
|
||||||
topics={topics?.topics || []}
|
topics={topics?.topics || []}
|
||||||
useActiveTopic={useActiveTopic}
|
useActiveTopic={useActiveTopic}
|
||||||
waveform={waveform?.waveform}
|
waveform={waveform?.waveform}
|
||||||
isPastMeeting={true}
|
media={mp3.media}
|
||||||
transcriptId={transcript?.response?.id}
|
mediaDuration={transcript.response.duration}
|
||||||
media={mp3?.media}
|
|
||||||
mediaDuration={transcript?.response?.duration}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
www/app/[domain]/transcripts/player.tsx
Normal file
167
www/app/[domain]/transcripts/player.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import WaveSurfer from "wavesurfer.js";
|
||||||
|
import CustomRegionsPlugin from "../../lib/custom-plugins/regions";
|
||||||
|
|
||||||
|
import { formatTime } from "../../lib/time";
|
||||||
|
import { Topic } from "./webSocketTypes";
|
||||||
|
import { AudioWaveform } from "../../api";
|
||||||
|
import { waveSurferStyles } from "../../styles/recorder";
|
||||||
|
|
||||||
|
type PlayerProps = {
|
||||||
|
topics: Topic[];
|
||||||
|
useActiveTopic: [
|
||||||
|
Topic | null,
|
||||||
|
React.Dispatch<React.SetStateAction<Topic | null>>,
|
||||||
|
];
|
||||||
|
waveform: AudioWaveform;
|
||||||
|
media: HTMLMediaElement;
|
||||||
|
mediaDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Player(props: PlayerProps) {
|
||||||
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [waveRegions, setWaveRegions] = useState<CustomRegionsPlugin | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [activeTopic, setActiveTopic] = props.useActiveTopic;
|
||||||
|
const topicsRef = useRef(props.topics);
|
||||||
|
|
||||||
|
// Waveform setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (waveformRef.current) {
|
||||||
|
// XXX duration is required to prevent recomputing peaks from audio
|
||||||
|
// However, the current waveform returns only the peaks, and no duration
|
||||||
|
// And the backend does not save duration properly.
|
||||||
|
// So at the moment, we deduct the duration from the topics.
|
||||||
|
// This is not ideal, but it works for now.
|
||||||
|
const _wavesurfer = WaveSurfer.create({
|
||||||
|
container: waveformRef.current,
|
||||||
|
peaks: props.waveform.data,
|
||||||
|
hideScrollbar: true,
|
||||||
|
autoCenter: true,
|
||||||
|
barWidth: 2,
|
||||||
|
height: "auto",
|
||||||
|
duration: props.mediaDuration,
|
||||||
|
|
||||||
|
...waveSurferStyles.player,
|
||||||
|
});
|
||||||
|
|
||||||
|
// styling
|
||||||
|
const wsWrapper = _wavesurfer.getWrapper();
|
||||||
|
wsWrapper.style.cursor = waveSurferStyles.playerStyle.cursor;
|
||||||
|
wsWrapper.style.backgroundColor =
|
||||||
|
waveSurferStyles.playerStyle.backgroundColor;
|
||||||
|
wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius;
|
||||||
|
|
||||||
|
_wavesurfer.on("play", () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
});
|
||||||
|
_wavesurfer.on("pause", () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
_wavesurfer.on("timeupdate", setCurrentTime);
|
||||||
|
|
||||||
|
setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create()));
|
||||||
|
|
||||||
|
_wavesurfer.toggleInteraction(true);
|
||||||
|
|
||||||
|
_wavesurfer.setMediaElement(props.media);
|
||||||
|
|
||||||
|
setWavesurfer(_wavesurfer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
_wavesurfer.destroy();
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer) return;
|
||||||
|
if (!props.media) return;
|
||||||
|
wavesurfer.setMediaElement(props.media);
|
||||||
|
}, [props.media, wavesurfer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
topicsRef.current = props.topics;
|
||||||
|
renderMarkers();
|
||||||
|
}, [props.topics, waveRegions]);
|
||||||
|
|
||||||
|
const renderMarkers = () => {
|
||||||
|
if (!waveRegions) return;
|
||||||
|
|
||||||
|
waveRegions.clearRegions();
|
||||||
|
|
||||||
|
for (let topic of topicsRef.current) {
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.setAttribute("style", waveSurferStyles.marker);
|
||||||
|
content.onmouseover = () => {
|
||||||
|
content.style.backgroundColor =
|
||||||
|
waveSurferStyles.markerHover.backgroundColor;
|
||||||
|
content.style.zIndex = "999";
|
||||||
|
content.style.width = "300px";
|
||||||
|
};
|
||||||
|
content.onmouseout = () => {
|
||||||
|
content.setAttribute("style", waveSurferStyles.marker);
|
||||||
|
};
|
||||||
|
content.textContent = topic.title;
|
||||||
|
|
||||||
|
const region = waveRegions.addRegion({
|
||||||
|
start: topic.timestamp,
|
||||||
|
content,
|
||||||
|
color: "f00",
|
||||||
|
drag: false,
|
||||||
|
});
|
||||||
|
region.on("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveTopic(topic);
|
||||||
|
wavesurfer?.setTime(region.start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTopic) {
|
||||||
|
wavesurfer?.setTime(activeTopic.timestamp);
|
||||||
|
}
|
||||||
|
}, [activeTopic]);
|
||||||
|
|
||||||
|
const handlePlayClick = () => {
|
||||||
|
wavesurfer?.playPause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeLabel = () => {
|
||||||
|
if (props.mediaDuration)
|
||||||
|
return `${formatTime(currentTime)}/${formatTime(props.mediaDuration)}`;
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center w-full relative">
|
||||||
|
<div className="flex-grow items-end relative">
|
||||||
|
<div
|
||||||
|
ref={waveformRef}
|
||||||
|
className="flex-grow rounded-lg md:rounded-xl h-20"
|
||||||
|
></div>
|
||||||
|
<div className="absolute right-2 bottom-0">{timeLabel()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${
|
||||||
|
isPlaying
|
||||||
|
? "bg-orange-400 hover:bg-orange-500 focus-visible:bg-orange-500"
|
||||||
|
: "bg-green-400 hover:bg-green-500 focus-visible:bg-green-500"
|
||||||
|
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
|
||||||
|
id="play-btn"
|
||||||
|
onClick={handlePlayClick}
|
||||||
|
>
|
||||||
|
{isPlaying ? "Pause" : "Play"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,11 +6,8 @@ import CustomRegionsPlugin from "../../lib/custom-plugins/regions";
|
|||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faMicrophone } from "@fortawesome/free-solid-svg-icons";
|
import { faMicrophone } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faDownload } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
import { formatTime } from "../../lib/time";
|
import { formatTime } from "../../lib/time";
|
||||||
import { Topic } from "./webSocketTypes";
|
|
||||||
import { AudioWaveform } from "../../api";
|
|
||||||
import AudioInputsDropdown from "./audioInputsDropdown";
|
import AudioInputsDropdown from "./audioInputsDropdown";
|
||||||
import { Option } from "react-dropdown";
|
import { Option } from "react-dropdown";
|
||||||
import { waveSurferStyles } from "../../styles/recorder";
|
import { waveSurferStyles } from "../../styles/recorder";
|
||||||
@@ -19,17 +16,8 @@ import { useError } from "../../(errors)/errorContext";
|
|||||||
type RecorderProps = {
|
type RecorderProps = {
|
||||||
setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>;
|
setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
topics: Topic[];
|
|
||||||
getAudioStream?: (deviceId) => Promise<MediaStream | null>;
|
getAudioStream?: (deviceId) => Promise<MediaStream | null>;
|
||||||
audioDevices?: Option[];
|
audioDevices?: Option[];
|
||||||
useActiveTopic: [
|
|
||||||
Topic | null,
|
|
||||||
React.Dispatch<React.SetStateAction<Topic | null>>,
|
|
||||||
];
|
|
||||||
waveform?: AudioWaveform | null;
|
|
||||||
isPastMeeting: boolean;
|
|
||||||
transcriptId?: string | null;
|
|
||||||
media?: HTMLMediaElement | null;
|
|
||||||
mediaDuration?: number | null;
|
mediaDuration?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +26,7 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
|
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
|
||||||
const [record, setRecord] = useState<RecordPlugin | null>(null);
|
const [record, setRecord] = useState<RecordPlugin | null>(null);
|
||||||
const [isRecording, setIsRecording] = useState<boolean>(false);
|
const [isRecording, setIsRecording] = useState<boolean>(false);
|
||||||
const [hasRecorded, setHasRecorded] = useState<boolean>(props.isPastMeeting);
|
const [hasRecorded, setHasRecorded] = useState<boolean>(false);
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
const [timeInterval, setTimeInterval] = useState<number | null>(null);
|
const [timeInterval, setTimeInterval] = useState<number | null>(null);
|
||||||
@@ -48,8 +36,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
);
|
);
|
||||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||||
const [recordStarted, setRecordStarted] = useState(false);
|
const [recordStarted, setRecordStarted] = useState(false);
|
||||||
const [activeTopic, setActiveTopic] = props.useActiveTopic;
|
|
||||||
const topicsRef = useRef(props.topics);
|
|
||||||
const [showDevices, setShowDevices] = useState(false);
|
const [showDevices, setShowDevices] = useState(false);
|
||||||
const { setError } = useError();
|
const { setError } = useError();
|
||||||
|
|
||||||
@@ -73,8 +59,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
if (!record.isRecording()) return;
|
if (!record.isRecording()) return;
|
||||||
handleRecClick();
|
handleRecClick();
|
||||||
break;
|
break;
|
||||||
case "^":
|
|
||||||
throw new Error("Unhandled Exception thrown by '^' shortcut");
|
|
||||||
case "(":
|
case "(":
|
||||||
location.href = "/login";
|
location.href = "/login";
|
||||||
break;
|
break;
|
||||||
@@ -104,14 +88,8 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
// Waveform setup
|
// Waveform setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (waveformRef.current) {
|
if (waveformRef.current) {
|
||||||
// XXX duration is required to prevent recomputing peaks from audio
|
|
||||||
// However, the current waveform returns only the peaks, and no duration
|
|
||||||
// And the backend does not save duration properly.
|
|
||||||
// So at the moment, we deduct the duration from the topics.
|
|
||||||
// This is not ideal, but it works for now.
|
|
||||||
const _wavesurfer = WaveSurfer.create({
|
const _wavesurfer = WaveSurfer.create({
|
||||||
container: waveformRef.current,
|
container: waveformRef.current,
|
||||||
peaks: props.waveform?.data,
|
|
||||||
hideScrollbar: true,
|
hideScrollbar: true,
|
||||||
autoCenter: true,
|
autoCenter: true,
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
@@ -121,10 +99,8 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
...waveSurferStyles.player,
|
...waveSurferStyles.player,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!props.transcriptId) {
|
const _wshack: any = _wavesurfer;
|
||||||
const _wshack: any = _wavesurfer;
|
_wshack.renderer.renderSingleCanvas = () => {};
|
||||||
_wshack.renderer.renderSingleCanvas = () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// styling
|
// styling
|
||||||
const wsWrapper = _wavesurfer.getWrapper();
|
const wsWrapper = _wavesurfer.getWrapper();
|
||||||
@@ -144,12 +120,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
setRecord(_wavesurfer.registerPlugin(RecordPlugin.create()));
|
setRecord(_wavesurfer.registerPlugin(RecordPlugin.create()));
|
||||||
setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create()));
|
setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create()));
|
||||||
|
|
||||||
if (props.isPastMeeting) _wavesurfer.toggleInteraction(true);
|
|
||||||
|
|
||||||
if (props.media) {
|
|
||||||
_wavesurfer.setMediaElement(props.media);
|
|
||||||
}
|
|
||||||
|
|
||||||
setWavesurfer(_wavesurfer);
|
setWavesurfer(_wavesurfer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -161,58 +131,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!wavesurfer) return;
|
|
||||||
if (!props.media) return;
|
|
||||||
wavesurfer.setMediaElement(props.media);
|
|
||||||
}, [props.media, wavesurfer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
topicsRef.current = props.topics;
|
|
||||||
if (!isRecording) renderMarkers();
|
|
||||||
}, [props.topics, waveRegions]);
|
|
||||||
|
|
||||||
const renderMarkers = () => {
|
|
||||||
if (!waveRegions) return;
|
|
||||||
|
|
||||||
waveRegions.clearRegions();
|
|
||||||
|
|
||||||
for (let topic of topicsRef.current) {
|
|
||||||
const content = document.createElement("div");
|
|
||||||
content.setAttribute("style", waveSurferStyles.marker);
|
|
||||||
content.onmouseover = () => {
|
|
||||||
content.style.backgroundColor =
|
|
||||||
waveSurferStyles.markerHover.backgroundColor;
|
|
||||||
content.style.zIndex = "999";
|
|
||||||
content.style.width = "300px";
|
|
||||||
};
|
|
||||||
content.onmouseout = () => {
|
|
||||||
content.setAttribute("style", waveSurferStyles.marker);
|
|
||||||
};
|
|
||||||
content.textContent = topic.title;
|
|
||||||
|
|
||||||
const region = waveRegions.addRegion({
|
|
||||||
start: topic.timestamp,
|
|
||||||
content,
|
|
||||||
color: "f00",
|
|
||||||
drag: false,
|
|
||||||
});
|
|
||||||
region.on("click", (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setActiveTopic(topic);
|
|
||||||
wavesurfer?.setTime(region.start);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!record) return;
|
|
||||||
|
|
||||||
return record.on("stopRecording", () => {
|
|
||||||
renderMarkers();
|
|
||||||
});
|
|
||||||
}, [record]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
@@ -229,12 +147,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
}
|
}
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTopic) {
|
|
||||||
wavesurfer?.setTime(activeTopic.timestamp);
|
|
||||||
}
|
|
||||||
}, [activeTopic]);
|
|
||||||
|
|
||||||
const handleRecClick = async () => {
|
const handleRecClick = async () => {
|
||||||
if (!record) return console.log("no record");
|
if (!record) return console.log("no record");
|
||||||
|
|
||||||
@@ -320,7 +232,6 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (!destinationStream) return;
|
if (!destinationStream) return;
|
||||||
if (props.setStream) props.setStream(destinationStream);
|
if (props.setStream) props.setStream(destinationStream);
|
||||||
waveRegions?.clearRegions();
|
|
||||||
if (destinationStream) {
|
if (destinationStream) {
|
||||||
record.startRecording(destinationStream);
|
record.startRecording(destinationStream);
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
@@ -379,23 +290,9 @@ export default function Recorder(props: RecorderProps) {
|
|||||||
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
|
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
|
||||||
id="play-btn"
|
id="play-btn"
|
||||||
onClick={handlePlayClick}
|
onClick={handlePlayClick}
|
||||||
disabled={isRecording}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? "Pause" : "Play"}
|
{isPlaying ? "Pause" : "Play"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{props.transcriptId && (
|
|
||||||
<a
|
|
||||||
title="Download recording"
|
|
||||||
className="text-center cursor-pointer text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg outline-blue-400"
|
|
||||||
download={`recording-${
|
|
||||||
props.transcriptId?.split("-")[0] || "0000"
|
|
||||||
}`}
|
|
||||||
href={`${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!hasRecorded && (
|
{!hasRecorded && (
|
||||||
|
|||||||
Reference in New Issue
Block a user