Refactor layout (#1063)
* sidebar collapsable * resizable panel for audio page * rename components * refacotr * refactor media panels * refactor * auto resize waveform * refactor: rename files * refactor media components * refactor * rename * fix media provider * refactor wavefor action buttons * clean code * refactor media recording player * clean up * fix hotkeys * refactor * fix style * fix style * fix chat input * fix shadow player in course * fix style * fix style
This commit is contained in:
@@ -178,7 +178,8 @@
|
||||
"preferences": "Preferences",
|
||||
"profile": "My Profile",
|
||||
"notes": "Note",
|
||||
"help": "Help"
|
||||
"help": "Help",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"form": {
|
||||
"lengthMustBeAtLeast": "{{field}} must be at least {{length}} characters",
|
||||
|
||||
@@ -178,7 +178,8 @@
|
||||
"preferences": "软件设置",
|
||||
"profile": "个人主页",
|
||||
"notes": "笔记",
|
||||
"help": "帮助"
|
||||
"help": "帮助",
|
||||
"collapse": "收起"
|
||||
},
|
||||
"form": {
|
||||
"lengthMustBeAtLeast": "{{field}} 长度不可超过 {{length}} 个字符",
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useEffect, useContext } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaLoadingModal,
|
||||
MediaCaption,
|
||||
MediaPlayerControls,
|
||||
MediaTabs,
|
||||
MediaCurrentRecording,
|
||||
MediaPlayer,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import { useAudio } from "@renderer/hooks";
|
||||
import { MediaShadowPlayer } from "@renderer/components";
|
||||
|
||||
export const AudioPlayer = (props: {
|
||||
id?: string;
|
||||
@@ -17,13 +9,8 @@ export const AudioPlayer = (props: {
|
||||
segmentIndex?: number;
|
||||
}) => {
|
||||
const { id, md5, segmentIndex } = props;
|
||||
const {
|
||||
media,
|
||||
setMedia,
|
||||
layout,
|
||||
setCurrentSegmentIndex,
|
||||
getCachedSegmentIndex,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const { media, setMedia, setCurrentSegmentIndex, getCachedSegmentIndex } =
|
||||
useContext(MediaShadowProviderContext);
|
||||
|
||||
const { audio } = useAudio({ id, md5 });
|
||||
|
||||
@@ -46,38 +33,10 @@ export const AudioPlayer = (props: {
|
||||
}, [media?.id]);
|
||||
|
||||
if (!audio) return null;
|
||||
if (!layout) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div data-testid="audio-player" className={layout.wrapper}>
|
||||
<div className={`${layout.upperWrapper} mb-4`}>
|
||||
<div className="grid grid-cols-5 xl:grid-cols-3 gap-3 xl:gap-6 px-3 xl:px-6 h-full">
|
||||
<div
|
||||
className={`col-span-2 xl:col-span-1 rounded-lg border shadow-lg ${layout.upperWrapper}`}
|
||||
>
|
||||
<MediaTabs />
|
||||
</div>
|
||||
<div className={`col-span-3 xl:col-span-2 ${layout.upperWrapper}`}>
|
||||
<MediaCaption />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col`}>
|
||||
<div className={`${layout.playerWrapper} py-2 px-3 xl:px-6`}>
|
||||
<MediaCurrentRecording />
|
||||
</div>
|
||||
|
||||
<div className={`${layout.playerWrapper} py-2 px-3 xl:px-6`}>
|
||||
<MediaPlayer />
|
||||
</div>
|
||||
|
||||
<div className={`${layout.panelWrapper} bg-background shadow-xl`}>
|
||||
<MediaPlayerControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLoadingModal />
|
||||
<div className="h-full" data-testid="audio-player">
|
||||
<MediaShadowPlayer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useReducer, useContext } from "react";
|
||||
import {
|
||||
AudioCard,
|
||||
AddMediaButton,
|
||||
MediaAddButton,
|
||||
AudiosTable,
|
||||
AudioEditForm,
|
||||
} from "@renderer/components";
|
||||
@@ -228,7 +228,7 @@ export const AudiosComponent = () => {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<AddMediaButton type="Audio" />
|
||||
<MediaAddButton type="Audio" />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary">{t("cleanUp")}</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
|
||||
import { AudioCard, AddMediaButton } from "@renderer/components";
|
||||
import { AudioCard, MediaAddButton } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AudiosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{audios.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
<MediaAddButton />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const ChatInput = () => {
|
||||
isPaused,
|
||||
askAgent,
|
||||
onCreateMessage,
|
||||
shadowing,
|
||||
} = useContext(ChatSessionProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -96,6 +97,7 @@ export const ChatInput = () => {
|
||||
useHotkeys(
|
||||
currentHotkeys.StartOrStopRecording,
|
||||
() => {
|
||||
if (shadowing) return;
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
@@ -107,9 +109,16 @@ export const ChatInput = () => {
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(currentHotkeys.PlayNextSegment, () => askAgent(), {
|
||||
preventDefault: true,
|
||||
});
|
||||
useHotkeys(
|
||||
currentHotkeys.PlayNextSegment,
|
||||
() => {
|
||||
if (shadowing) return;
|
||||
askAgent();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isRecording) {
|
||||
return (
|
||||
|
||||
@@ -83,15 +83,19 @@ export const ChapterContent = (props: {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="select-text prose dark:prose-invert prose-em:font-bold prose-em:text-red-700 mx-auto">
|
||||
<div className="select-text max-w-full prose dark:prose-invert prose-em:font-bold prose-em:text-red-700">
|
||||
<h2>
|
||||
{chapter.sequence}. {chapter?.title}
|
||||
</h2>
|
||||
<MarkdownWrapper>{chapter?.content}</MarkdownWrapper>
|
||||
<MarkdownWrapper className="max-w-full">
|
||||
{chapter?.content}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<details>
|
||||
<summary>{t("translation")}</summary>
|
||||
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
|
||||
<MarkdownWrapper className="max-w-full">
|
||||
{translation.content}
|
||||
</MarkdownWrapper>
|
||||
</details>
|
||||
)}
|
||||
|
||||
|
||||
@@ -145,11 +145,15 @@ export const ExampleContent = (props: {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
|
||||
<MarkdownWrapper>{example.content}</MarkdownWrapper>
|
||||
<MarkdownWrapper className="max-w-full">
|
||||
{example.content}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<details>
|
||||
<summary>{t("translation")}</summary>
|
||||
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
|
||||
<MarkdownWrapper className="max-w-full">
|
||||
{translation.content}
|
||||
</MarkdownWrapper>
|
||||
</details>
|
||||
)}
|
||||
<WavesurferPlayer id={example.id} src={example.audioUrl} />
|
||||
|
||||
@@ -213,11 +213,11 @@ export const LlmMessage = (props: { llmMessage: LlmMessageType }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 py-2 mb-2 bg-background border rounded-lg shadow-sm max-w-full">
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
<MarkdownWrapper className="select-text max-w-full">
|
||||
{llmMessage.response}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
<MarkdownWrapper className="select-text max-w-full">
|
||||
{translation}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
export * from "./media-player-controls";
|
||||
export * from "./media-caption";
|
||||
export * from "./media-info-panel";
|
||||
export * from "./media-recordings";
|
||||
export * from "./media-current-recording";
|
||||
export * from "./media-transcription";
|
||||
export * from "./media-transcription-read-button";
|
||||
export * from "./media-transcription-generate-button";
|
||||
export * from "./media-player";
|
||||
export * from "./media-provider";
|
||||
export * from "./media-tabs";
|
||||
export * from "./media-left-panel";
|
||||
export * from "./media-right-panel";
|
||||
export * from "./media-bottom-panel";
|
||||
|
||||
export * from "./media-loading-modal";
|
||||
export * from "./add-media-button";
|
||||
export * from "./media-transcription-print";
|
||||
export * from "./media-add-button";
|
||||
export * from "./media-shadow-player";
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
|
||||
export const AddMediaButton = (props: { type?: "Audio" | "Video" }) => {
|
||||
export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
|
||||
const { type = "Audio" } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./media-bottom-panel";
|
||||
export * from "./media-current-recording";
|
||||
export * from "./media-waveform";
|
||||
export * from "./media-player-controls";
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
MediaCurrentRecording,
|
||||
MediaWaveform,
|
||||
MediaPlayerControls,
|
||||
} from "@renderer/components";
|
||||
|
||||
export const MediaBottomPanel = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 flex flex-col pt-2 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden px-4 py-2">
|
||||
<MediaCurrentRecording />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-4 py-2">
|
||||
<MediaWaveform />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaPlayerControls />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { RecordingDetail } from "@renderer/components";
|
||||
import { renderPitchContour } from "@renderer/lib/utils";
|
||||
import { cn, renderPitchContour } from "@renderer/lib/utils";
|
||||
import { extractFrequencies } from "@/utils";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import Regions from "wavesurfer.js/dist/plugins/regions";
|
||||
@@ -37,22 +37,28 @@ import {
|
||||
Share2Icon,
|
||||
GaugeCircleIcon,
|
||||
ChevronDownIcon,
|
||||
MoreVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
TextCursorInputIcon,
|
||||
MicIcon,
|
||||
SquareIcon,
|
||||
DownloadIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { LiveAudioVisualizer } from "react-audio-visualize";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
const ACTION_BUTTON_HEIGHT = 35;
|
||||
export const MediaCurrentRecording = () => {
|
||||
const {
|
||||
layout,
|
||||
isRecording,
|
||||
isPaused,
|
||||
cancelRecording,
|
||||
togglePauseResume,
|
||||
stopRecording,
|
||||
recordingTime,
|
||||
mediaRecorder,
|
||||
currentRecording,
|
||||
@@ -65,7 +71,7 @@ export const MediaCurrentRecording = () => {
|
||||
currentSegment,
|
||||
createSegment,
|
||||
currentTime: mediaCurrentTime,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
const [player, setPlayer] = useState(null);
|
||||
@@ -79,7 +85,8 @@ export const MediaCurrentRecording = () => {
|
||||
|
||||
const [frequencies, setFrequencies] = useState<number[]>([]);
|
||||
const [peaks, setPeaks] = useState<number[]>([]);
|
||||
const [width, setWidth] = useState<number>();
|
||||
const [size, setSize] = useState<{ width: number; height: number }>();
|
||||
const [actionButtonsCount, setActionButtonsCount] = useState(0);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
@@ -256,25 +263,35 @@ export const MediaCurrentRecording = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const calContainerWidth = () => {
|
||||
const w = document
|
||||
.querySelector(".media-recording-wrapper")
|
||||
?.getBoundingClientRect()?.width;
|
||||
if (!w) return;
|
||||
const calContainerSize = () => {
|
||||
const size = ref?.current
|
||||
?.closest(".media-recording-wrapper")
|
||||
?.getBoundingClientRect();
|
||||
|
||||
setWidth(w - 48);
|
||||
if (!size) return;
|
||||
|
||||
setSize(size);
|
||||
if (player) {
|
||||
player.setOptions({
|
||||
height: size.height - 10, // -10 to leave space for scrollbar
|
||||
});
|
||||
}
|
||||
|
||||
setActionButtonsCount(Math.floor(size.height / ACTION_BUTTON_HEIGHT));
|
||||
};
|
||||
|
||||
const debouncedCalContainerSize = debounce(calContainerSize, 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
if (isRecording) return;
|
||||
if (!currentRecording?.src) return;
|
||||
if (!layout?.playerHeight) return;
|
||||
|
||||
const height = ref.current.getBoundingClientRect().height - 10; // -10 to leave space for scrollbar
|
||||
const ws = WaveSurfer.create({
|
||||
container: ref.current,
|
||||
container: ref.current.querySelector(".waveform-container"),
|
||||
url: currentRecording.src,
|
||||
height: layout.playerHeight,
|
||||
height,
|
||||
barWidth: 2,
|
||||
cursorWidth: 1,
|
||||
autoCenter: true,
|
||||
@@ -320,7 +337,7 @@ export const MediaCurrentRecording = () => {
|
||||
return () => {
|
||||
ws?.destroy();
|
||||
};
|
||||
}, [ref, currentRecording, isRecording, layout?.playerHeight]);
|
||||
}, [ref, currentRecording, isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTime(0);
|
||||
@@ -409,19 +426,20 @@ export const MediaCurrentRecording = () => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!width) return;
|
||||
if (!ref?.current) return;
|
||||
if (!player) return;
|
||||
|
||||
const container: HTMLDivElement = document.querySelector(
|
||||
".media-recording-container"
|
||||
);
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedCalContainerSize();
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
EnjoyApp.window.onResize(debouncedCalContainerSize);
|
||||
|
||||
container.style.width = `${width}px`;
|
||||
}, [width, currentRecording, isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
calContainerWidth();
|
||||
}, [currentRecording, isRecording, layout?.width]);
|
||||
return () => {
|
||||
EnjoyApp.window.removeListeners();
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref, player]);
|
||||
|
||||
useHotkeys(currentHotkeys.PlayOrPauseRecording, () => {
|
||||
const button = document.getElementById("recording-play-or-pause-button");
|
||||
@@ -442,30 +460,152 @@ export const MediaCurrentRecording = () => {
|
||||
setDetailIsOpen(!detailIsOpen);
|
||||
});
|
||||
|
||||
useHotkeys(
|
||||
currentHotkeys.Compare,
|
||||
() => {
|
||||
toggleCompare();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
|
||||
const Actions = [
|
||||
{
|
||||
id: "recording-record-button-wrapper",
|
||||
name: "record",
|
||||
label: t("record"),
|
||||
icon: MediaRecordButton,
|
||||
active: isRecording,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "recording-play-or-pause-button",
|
||||
name: "playOrPause",
|
||||
label: t("playRecording"),
|
||||
icon: player?.isPlaying() ? PauseIcon : PlayIcon,
|
||||
active: player?.isPlaying(),
|
||||
onClick: () => {
|
||||
const region = regions
|
||||
?.getRegions()
|
||||
?.find((r) => r.id.startsWith("recording-voice-region"));
|
||||
|
||||
if (region) {
|
||||
region.play();
|
||||
} else {
|
||||
player?.playPause();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "media-pronunciation-assessment-button",
|
||||
name: "pronunciationAssessment",
|
||||
label: t("pronunciationAssessment"),
|
||||
icon: GaugeCircleIcon,
|
||||
active: detailIsOpen,
|
||||
iconClassName: currentRecording?.pronunciationAssessment
|
||||
? currentRecording?.pronunciationAssessment.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: currentRecording?.pronunciationAssessment.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: "",
|
||||
onClick: () => setDetailIsOpen(!detailIsOpen),
|
||||
},
|
||||
{
|
||||
id: "media-compare-button",
|
||||
name: "compare",
|
||||
label: t("compare"),
|
||||
icon: GitCompareIcon,
|
||||
active: isComparing,
|
||||
onClick: toggleCompare,
|
||||
},
|
||||
{
|
||||
id: "media-select-region-button",
|
||||
name: "selectRegion",
|
||||
label: t("selectRegion"),
|
||||
icon: TextCursorInputIcon,
|
||||
active: isSelectingRegion,
|
||||
onClick: () => setIsSelectingRegion(!isSelectingRegion),
|
||||
},
|
||||
{
|
||||
id: "media-share-button",
|
||||
name: "share",
|
||||
label: t("share"),
|
||||
icon: Share2Icon,
|
||||
active: isSharing,
|
||||
onClick: () => setIsSharing(true),
|
||||
},
|
||||
{
|
||||
id: "media-download-button",
|
||||
name: "download",
|
||||
label: t("download"),
|
||||
icon: DownloadIcon,
|
||||
active: false,
|
||||
onClick: handleDownload,
|
||||
},
|
||||
];
|
||||
|
||||
if (isRecording || isPaused) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center space-x-4">
|
||||
<div className="flex-1 h-full border rounded-xl shadow-lg relative">
|
||||
<div className="w-full h-full flex justify-center items-center gap-4">
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full flex flex-col justify-start space-y-1.5">
|
||||
<MediaRecordButton />
|
||||
<div className="w-full h-full flex justify-center items-center gap-4 border rounded-xl shadow">
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon fill="white" className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePauseResume}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -473,10 +613,15 @@ export const MediaCurrentRecording = () => {
|
||||
|
||||
if (!currentRecording?.src)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center space-x-4">
|
||||
<div className="flex-1 h-full border rounded-xl shadow-lg flex items-start">
|
||||
<div className="h-full w-full flex items-center justify-center border rounded-xl shadow">
|
||||
<div className="m-auto">
|
||||
<div className="flex justify-center items-center mb-2">
|
||||
<div className="w-8 aspect-square rounded-full overflow-hidden">
|
||||
<MediaRecordButton />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="m-auto"
|
||||
className=""
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("noRecordingForThisSegmentYet", {
|
||||
key: currentHotkeys.StartOrStopRecording?.toUpperCase(),
|
||||
@@ -484,17 +629,22 @@ export const MediaCurrentRecording = () => {
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="h-full flex flex-col justify-start space-y-1.5">
|
||||
<MediaRecordButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4 media-recording-wrapper">
|
||||
<div className="border rounded-xl shadow-lg flex-1 relative media-recording-container">
|
||||
<div className="w-full" ref={ref}></div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="h-full flex media-recording-wrapper border rounded-xl shadow overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 relative media-recording-container">
|
||||
<div
|
||||
style={{
|
||||
width: `${size?.width - 40}px`, // -40 for action buttons
|
||||
height: `${size?.height}px`,
|
||||
}}
|
||||
className="waveform-container"
|
||||
></div>
|
||||
|
||||
<div className="absolute right-2 top-1">
|
||||
<span className="text-sm">{formatDuration(currentTime || 0)}</span>
|
||||
@@ -507,149 +657,60 @@ export const MediaCurrentRecording = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-around space-y-1.5">
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
id="recording-play-or-pause-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("playRecording")}
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
onClick={() => {
|
||||
const region = regions
|
||||
?.getRegions()
|
||||
?.find((r) => r.id.startsWith("recording-voice-region"));
|
||||
<div
|
||||
className={`grid grid-rows-${
|
||||
actionButtonsCount < Actions.length
|
||||
? actionButtonsCount + 1
|
||||
: Actions.length
|
||||
} w-10 border-l rounded-r-lg`}
|
||||
>
|
||||
{Actions.slice(0, actionButtonsCount).map((action) => (
|
||||
<Button
|
||||
key={action.name}
|
||||
id={action.id}
|
||||
variant={action.active ? "secondary" : "ghost"}
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={action.label}
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className={`w-4 h-4 ${cn(action.iconClassName)}`} />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
if (region) {
|
||||
region.play();
|
||||
} else {
|
||||
player?.playPause();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{player?.isPlaying() ? (
|
||||
<PauseIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<PlayIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{actionButtonsCount < Actions.length && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
className="rounded-none w-full h-full p-0"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<MediaRecordButton />
|
||||
|
||||
<Button
|
||||
variant={detailIsOpen ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
id="media-pronunciation-assessment-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("pronunciationAssessment")}
|
||||
className={
|
||||
layout?.name === "sm" ? "hidden" : "rounded-full w-8 h-8 p-0"
|
||||
}
|
||||
onClick={() => setDetailIsOpen(true)}
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4
|
||||
${
|
||||
currentRecording.pronunciationAssessment
|
||||
? currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isComparing ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
id="media-compare-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("compare")}
|
||||
className={
|
||||
layout?.name === "sm" ? "hidden" : "rounded-full w-8 h-8 p-0"
|
||||
}
|
||||
onClick={toggleCompare}
|
||||
>
|
||||
<GitCompareIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{layout?.name === "sm" && (
|
||||
<>
|
||||
<DropdownMenuContent>
|
||||
{Actions.slice(actionButtonsCount).map((action) => (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailIsOpen(true)}
|
||||
id={action.id}
|
||||
key={action.name}
|
||||
className={`cursor-pointer ${
|
||||
action.active ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4 mr-4
|
||||
${
|
||||
currentRecording.pronunciationAssessment
|
||||
? currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
<action.icon
|
||||
className={`${cn(action.iconClassName)} w-4 h-4 mr-4`}
|
||||
/>
|
||||
<span>{t("pronunciationAssessment")}</span>
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={toggleCompare}
|
||||
>
|
||||
<GitCompareIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("compare")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
data-tooltip-content={t("selectRegion")}
|
||||
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
|
||||
>
|
||||
<TextCursorInputIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("selectRegion")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsSharing(true)}
|
||||
>
|
||||
<Share2Icon className="w-4 h-4 mr-4" />
|
||||
<span>{t("share")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("download")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isSharing} onOpenChange={setIsSharing}>
|
||||
@@ -694,7 +755,7 @@ export const MediaCurrentRecording = () => {
|
||||
|
||||
export const MediaRecordButton = () => {
|
||||
const { media, isRecording, startRecording, stopRecording } = useContext(
|
||||
MediaPlayerProviderContext
|
||||
MediaShadowProviderContext
|
||||
);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [access, setAccess] = useState(true);
|
||||
@@ -726,11 +787,11 @@ export const MediaRecordButton = () => {
|
||||
}
|
||||
}}
|
||||
id="media-record-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={
|
||||
isRecording ? t("stopRecording") : t("startRecording")
|
||||
}
|
||||
className="aspect-square p-0 h-8 rounded-full bg-red-500 hover:bg-red-500/90"
|
||||
className="p-0 h-full w-full rounded-none bg-red-500 hover:bg-red-500/90"
|
||||
>
|
||||
{isRecording ? (
|
||||
<SquareIcon fill="white" className="w-4 h-4 text-white" />
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PopoverContent,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
@@ -55,7 +55,7 @@ export const MediaPlayerControls = () => {
|
||||
setEditingRegion,
|
||||
transcriptionDraft,
|
||||
setTranscriptionDraft,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
const [playMode, setPlayMode] = useState<"loop" | "single" | "all">("single");
|
||||
@@ -423,17 +423,6 @@ export const MediaPlayerControls = () => {
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
useHotkeys(
|
||||
currentHotkeys.Compare,
|
||||
() => {
|
||||
// The button is hidden as default in small screens
|
||||
// It's fine to fire the click event directly even other modal is open
|
||||
document.getElementById("media-compare-button")?.click();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
}
|
||||
);
|
||||
useHotkeys(
|
||||
currentHotkeys.IncreasePlaybackRate,
|
||||
() => {
|
||||
@@ -523,15 +512,15 @@ export const MediaPlayerControls = () => {
|
||||
}, [grouping]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center px-6">
|
||||
<div className="w-full h-14 flex items-center justify-center px-6">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={`${playbackRate == 1.0 ? "ghost" : "secondary"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("playbackSpeed")}
|
||||
className="relative aspect-square p-0 h-10"
|
||||
className="relative aspect-square p-0 h-8"
|
||||
>
|
||||
<GaugeIcon className="w-6 h-6" />
|
||||
{playbackRate != 1.0 && (
|
||||
@@ -552,7 +541,7 @@ export const MediaPlayerControls = () => {
|
||||
{PLAYBACK_RATE_OPTIONS.map((rate, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`cursor-pointer h-10 w-10 leading-10 rounded-full flex items-center justify-center ${
|
||||
className={`cursor-pointer h-8 w-8 leading-8 rounded-full flex items-center justify-center ${
|
||||
rate === playbackRate
|
||||
? "bg-primary text-white text-md"
|
||||
: "text-black/70 text-xs"
|
||||
@@ -572,9 +561,9 @@ export const MediaPlayerControls = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("switchPlayMode")}
|
||||
className="aspect-square p-0 h-10"
|
||||
className="aspect-square p-0 h-8"
|
||||
>
|
||||
{playMode === "single" && <RepeatIcon className="w-6 h-6" />}
|
||||
{playMode === "loop" && <Repeat1Icon className="w-6 h-6" />}
|
||||
@@ -611,9 +600,9 @@ export const MediaPlayerControls = () => {
|
||||
size="lg"
|
||||
onClick={onPrev}
|
||||
id="media-play-previous-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("playPreviousSegment")}
|
||||
className="aspect-square p-0 h-10"
|
||||
className="aspect-square p-0 h-8"
|
||||
>
|
||||
<SkipBackIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
@@ -623,9 +612,9 @@ export const MediaPlayerControls = () => {
|
||||
variant="default"
|
||||
onClick={debouncedPlayOrPause}
|
||||
id="media-play-or-pause-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
className="aspect-square p-0 h-12 rounded-full"
|
||||
className="aspect-square p-0 h-10 rounded-full"
|
||||
>
|
||||
<PauseIcon fill="white" className="w-6 h-6" />
|
||||
</Button>
|
||||
@@ -634,9 +623,9 @@ export const MediaPlayerControls = () => {
|
||||
variant="default"
|
||||
onClick={debouncedPlayOrPause}
|
||||
id="media-play-or-pause-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("play")}
|
||||
className="aspect-square p-0 h-12 rounded-full"
|
||||
className="aspect-square p-0 h-10 rounded-full"
|
||||
>
|
||||
<PlayIcon fill="white" className="w-6 h-6" />
|
||||
</Button>
|
||||
@@ -647,9 +636,9 @@ export const MediaPlayerControls = () => {
|
||||
size="lg"
|
||||
onClick={onNext}
|
||||
id="media-play-next-button"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("playNextSegment")}
|
||||
className="aspect-square p-0 h-10"
|
||||
className="aspect-square p-0 h-8"
|
||||
>
|
||||
<SkipForwardIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
@@ -657,9 +646,9 @@ export const MediaPlayerControls = () => {
|
||||
<Button
|
||||
variant={grouping ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("autoGroup")}
|
||||
className="relative aspect-square p-0 h-10"
|
||||
className="relative aspect-square p-0 h-8"
|
||||
onClick={() => setGrouping(!grouping)}
|
||||
>
|
||||
<GroupIcon className="w-6 h-6" />
|
||||
@@ -668,11 +657,11 @@ export const MediaPlayerControls = () => {
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant={`${editingRegion ? "secondary" : "ghost"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={
|
||||
editingRegion ? t("dragRegionBorderToEdit") : t("editRegion")
|
||||
}
|
||||
className="relative aspect-square p-0 h-10"
|
||||
className="relative aspect-square p-0 h-8"
|
||||
onClick={() => {
|
||||
setEditingRegion(!editingRegion);
|
||||
}}
|
||||
@@ -684,8 +673,8 @@ export const MediaPlayerControls = () => {
|
||||
<div className="absolute top-0 left-12 flex items-center space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="relative aspect-square p-0 h-10"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
className="relative aspect-square p-0 h-8"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={() => {
|
||||
setEditingRegion(false);
|
||||
@@ -696,8 +685,8 @@ export const MediaPlayerControls = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="relative aspect-square p-0 h-10"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
className="relative aspect-square p-0 h-8"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("save")}
|
||||
onClick={() => {
|
||||
if (!transcriptionDraft) return;
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
GalleryHorizontalIcon,
|
||||
Share2Icon,
|
||||
SpellCheckIcon,
|
||||
MinimizeIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
MoreHorizontalIcon,
|
||||
DownloadIcon,
|
||||
} from "lucide-react";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
const ZOOM_RATIO_OPTIONS = [
|
||||
0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0,
|
||||
];
|
||||
const MIN_ZOOM_RATIO = 0.25;
|
||||
const MAX_ZOOM_RATIO = 4.0;
|
||||
const ACTION_BUTTON_HEIGHT = 35;
|
||||
|
||||
export const MediaWaveform = () => {
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const {
|
||||
media,
|
||||
currentTime,
|
||||
setWaveformContainerRef,
|
||||
pitchChart,
|
||||
wavesurfer,
|
||||
zoomRatio,
|
||||
setZoomRatio,
|
||||
fitZoomRatio,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const [displayInlineCaption, setDisplayInlineCaption] =
|
||||
useState<boolean>(true);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [size, setSize] = useState<{ width: number; height: number }>();
|
||||
const [actionButtonsCount, setActionButtonsCount] = useState(0);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const onShare = async () => {
|
||||
if (!media.source && !media.isUploaded) {
|
||||
try {
|
||||
await EnjoyApp.audios.upload(media.id);
|
||||
} catch (err) {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
webApi
|
||||
.createPost({
|
||||
targetType: media.mediaType,
|
||||
targetId: media.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedAudio"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const calContainerSize = () => {
|
||||
const size = ref?.current
|
||||
?.closest(".media-player-wrapper")
|
||||
?.getBoundingClientRect();
|
||||
if (!size) return;
|
||||
|
||||
setSize({ width: size.width, height: size.height });
|
||||
if (wavesurfer) {
|
||||
wavesurfer.setOptions({
|
||||
height: size.height - 10,
|
||||
});
|
||||
}
|
||||
|
||||
setActionButtonsCount(Math.floor(size.height / ACTION_BUTTON_HEIGHT));
|
||||
};
|
||||
|
||||
const debouncedCalContainerSize = debounce(calContainerSize, 100);
|
||||
|
||||
const handleDownload = () => {
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: media.filename,
|
||||
filters: [
|
||||
{
|
||||
name: media.mediaType,
|
||||
extensions: [media.filename.split(".").pop()],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(media.src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
setWaveformContainerRef(ref);
|
||||
|
||||
if (!wavesurfer) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedCalContainerSize();
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
|
||||
EnjoyApp.window.onResize(debouncedCalContainerSize);
|
||||
|
||||
return () => {
|
||||
EnjoyApp.window.removeListeners();
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref, wavesurfer]);
|
||||
|
||||
const Actions = [
|
||||
{
|
||||
name: "zoomToFit",
|
||||
label: t("zoomToFit"),
|
||||
icon: MinimizeIcon,
|
||||
active: zoomRatio == fitZoomRatio,
|
||||
onClick: () => {
|
||||
if (zoomRatio == fitZoomRatio) {
|
||||
setZoomRatio(1.0);
|
||||
} else {
|
||||
setZoomRatio(fitZoomRatio);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zoomIn",
|
||||
label: t("zoomIn"),
|
||||
icon: ZoomInIcon,
|
||||
active: zoomRatio > 1.0,
|
||||
onClick: () => {
|
||||
if (zoomRatio < MAX_ZOOM_RATIO) {
|
||||
const nextZoomRatio = ZOOM_RATIO_OPTIONS.find(
|
||||
(rate) => rate > zoomRatio
|
||||
);
|
||||
setZoomRatio(nextZoomRatio || MAX_ZOOM_RATIO);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zoomOut",
|
||||
label: t("zoomOut"),
|
||||
icon: ZoomOutIcon,
|
||||
active: zoomRatio < 1.0,
|
||||
onClick: () => {
|
||||
if (zoomRatio > MIN_ZOOM_RATIO) {
|
||||
const nextZoomRatio = ZOOM_RATIO_OPTIONS.reverse().find(
|
||||
(rate) => rate < zoomRatio
|
||||
);
|
||||
setZoomRatio(nextZoomRatio || MIN_ZOOM_RATIO);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inlineCaption",
|
||||
label: t("inlineCaption"),
|
||||
icon: SpellCheckIcon,
|
||||
active: displayInlineCaption,
|
||||
onClick: () => {
|
||||
setDisplayInlineCaption(!displayInlineCaption);
|
||||
if (pitchChart) {
|
||||
pitchChart.options.scales.x.display = !displayInlineCaption;
|
||||
pitchChart.update();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autoCenter",
|
||||
label: t("autoCenter"),
|
||||
icon: GalleryHorizontalIcon,
|
||||
active: wavesurfer?.options?.autoCenter,
|
||||
onClick: () => {
|
||||
wavesurfer.setOptions({
|
||||
autoCenter: !wavesurfer?.options?.autoCenter,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "share",
|
||||
label: t("share"),
|
||||
icon: Share2Icon,
|
||||
onClick: () => setIsSharing(true),
|
||||
},
|
||||
{
|
||||
name: "download",
|
||||
label: t("download"),
|
||||
icon: DownloadIcon,
|
||||
onClick: handleDownload,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex h-full media-player-wrapper border rounded-lg shadow"
|
||||
>
|
||||
<div
|
||||
data-testid="media-player-container"
|
||||
className="flex-1 relative media-player-container overflow-hidden"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${size?.width - 40}px`, // -40 for action buttons
|
||||
height: `${size?.height}px`,
|
||||
}}
|
||||
className="waveform-container"
|
||||
/>
|
||||
<div className="absolute right-2 top-1">
|
||||
<span className="text-sm">{formatDuration(currentTime || 0)}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-sm">
|
||||
{formatDuration(media?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`grid grid-rows-${
|
||||
actionButtonsCount < Actions.length
|
||||
? actionButtonsCount + 1
|
||||
: Actions.length
|
||||
} w-10 border-l rounded-r-lg`}
|
||||
>
|
||||
{Actions.slice(0, actionButtonsCount).map((action) => (
|
||||
<Button
|
||||
key={action.name}
|
||||
variant={`${action.active ? "secondary" : "ghost"}`}
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={action.label}
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className="w-4 h-4" />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{actionButtonsCount < Actions.length && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{Actions.slice(actionButtonsCount).map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.name}
|
||||
className="cursor-pointer"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className="w-4 h-4 mr-2" />
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isSharing} onOpenChange={setIsSharing}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{media?.mediaType === "Audio"
|
||||
? t("shareAudio")
|
||||
: t("shareVideo")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{media?.mediaType === "Audio"
|
||||
? t("areYouSureToShareThisAudioToCommunity")
|
||||
: t("areYouSureToShareThisVideoToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="default" onClick={onShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,589 +0,0 @@
|
||||
import { useEffect, useState, useContext, useRef } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
} from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { ConversationShortcuts, Vocabulary } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
SpeechIcon,
|
||||
NotebookPenIcon,
|
||||
DownloadIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Timeline,
|
||||
TimelineEntry,
|
||||
} from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { convertWordIpaToNormal } from "@/utils";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { MediaCaptionTabs } from "./media-captions";
|
||||
|
||||
export const MediaCaption = () => {
|
||||
const {
|
||||
media,
|
||||
currentSegmentIndex,
|
||||
currentSegment,
|
||||
createSegment,
|
||||
currentTime,
|
||||
transcription,
|
||||
regions,
|
||||
activeRegion,
|
||||
setActiveRegion,
|
||||
editingRegion,
|
||||
setEditingRegion,
|
||||
setTranscriptionDraft,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const { EnjoyApp, learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
const [multiSelecting, setMultiSelecting] = useState<boolean>(false);
|
||||
|
||||
const [displayIpa, setDisplayIpa] = useState<boolean>(true);
|
||||
const [displayNotes, setDisplayNotes] = useState<boolean>(true);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const [caption, setCaption] = useState<TimelineEntry | null>(null);
|
||||
const [tab, setTab] = useState<string>("translation");
|
||||
|
||||
const toggleMultiSelect = (event: KeyboardEvent) => {
|
||||
setMultiSelecting(event.shiftKey && event.type === "keydown");
|
||||
};
|
||||
|
||||
const toggleSeletedIndex = (index: number) => {
|
||||
if (!activeRegion) return;
|
||||
if (editingRegion) {
|
||||
toast.warning(t("currentRegionIsBeingEdited"));
|
||||
return;
|
||||
}
|
||||
|
||||
const startWord = caption.timeline[index];
|
||||
if (!startWord) return;
|
||||
|
||||
if (multiSelecting) {
|
||||
const min = Math.min(index, ...selectedIndices);
|
||||
const max = Math.max(index, ...selectedIndices);
|
||||
|
||||
// Select all the words between the min and max indices.
|
||||
setSelectedIndices(
|
||||
Array.from({ length: max - min + 1 }, (_, i) => i + min)
|
||||
);
|
||||
} else if (selectedIndices.includes(index)) {
|
||||
setSelectedIndices([]);
|
||||
} else {
|
||||
setSelectedIndices([index]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRegion = (params: number[]) => {
|
||||
if (!activeRegion) return;
|
||||
if (editingRegion) {
|
||||
toast.warning(t("currentRegionIsBeingEdited"));
|
||||
return;
|
||||
}
|
||||
if (params.length === 0) {
|
||||
if (activeRegion.id.startsWith("word-region")) {
|
||||
activeRegion.remove();
|
||||
setActiveRegion(
|
||||
regions.getRegions().find((r) => r.id.startsWith("segment-region"))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = Math.min(...params);
|
||||
const endIndex = Math.max(...params);
|
||||
|
||||
const startWord = caption.timeline[startIndex];
|
||||
if (!startWord) return;
|
||||
|
||||
const endWord = caption.timeline[endIndex] || startWord;
|
||||
|
||||
const start = startWord.startTime;
|
||||
const end = endWord.endTime;
|
||||
|
||||
// If the active region is a word region, then merge the selected words into a single region.
|
||||
if (activeRegion.id.startsWith("word-region")) {
|
||||
activeRegion.remove();
|
||||
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${startIndex}`,
|
||||
start,
|
||||
end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: editingRegion,
|
||||
});
|
||||
|
||||
setActiveRegion(region);
|
||||
// If the active region is a meaning group region, then active the segment region.
|
||||
} else if (activeRegion.id.startsWith("meaning-group-region")) {
|
||||
setActiveRegion(
|
||||
regions.getRegions().find((r) => r.id.startsWith("segment-region"))
|
||||
);
|
||||
// If the active region is a segment region, then create a new word region.
|
||||
} else {
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${startIndex}`,
|
||||
start,
|
||||
end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: false,
|
||||
});
|
||||
|
||||
setActiveRegion(region);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (activeRegion && !activeRegion.id.startsWith("segment-region")) {
|
||||
handleDownloadActiveRegion();
|
||||
} else {
|
||||
handleDownloadSegment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSegment = async () => {
|
||||
const segment = currentSegment || (await createSegment());
|
||||
if (!segment) return;
|
||||
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: `${media.name}(${segment.startTime.toFixed(
|
||||
2
|
||||
)}s-${segment.endTime.toFixed(2)}s).mp3`,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: ["mp3"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(segment.src, savePath as string),
|
||||
{
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadActiveRegion = async () => {
|
||||
if (!activeRegion) return;
|
||||
let src: string;
|
||||
|
||||
try {
|
||||
if (media.mediaType === "Audio") {
|
||||
src = await EnjoyApp.audios.crop(media.id, {
|
||||
startTime: activeRegion.start,
|
||||
endTime: activeRegion.end,
|
||||
});
|
||||
} else if (media.mediaType === "Video") {
|
||||
src = await EnjoyApp.videos.crop(media.id, {
|
||||
startTime: activeRegion.start,
|
||||
endTime: activeRegion.end,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`${t("downloadFailed")}: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!src) return;
|
||||
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: `${media.name}(${activeRegion.start.toFixed(
|
||||
2
|
||||
)}s-${activeRegion.end.toFixed(2)}s).mp3`,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: ["mp3"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!caption) return;
|
||||
|
||||
let index = caption.timeline.findIndex(
|
||||
(w) => currentTime >= w.startTime && currentTime < w.endTime
|
||||
);
|
||||
|
||||
if (index < 0) return;
|
||||
if (index !== activeIndex) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
}, [currentTime, caption]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caption?.timeline) return;
|
||||
if (!activeRegion) return;
|
||||
|
||||
toggleRegion(selectedIndices);
|
||||
}, [caption, selectedIndices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRegion) return;
|
||||
if (!activeRegion.id.startsWith("word-region")) return;
|
||||
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${selectedIndices.join("-")}`,
|
||||
start: activeRegion.start,
|
||||
end: activeRegion.end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: editingRegion,
|
||||
});
|
||||
|
||||
activeRegion?.remove();
|
||||
setActiveRegion(region);
|
||||
|
||||
const subscriptions = [
|
||||
regions.on("region-updated", (region) => {
|
||||
if (!region.id.startsWith("word-region")) return;
|
||||
|
||||
const draft = cloneDeep(transcription.result);
|
||||
const draftCaption = draft.timeline[currentSegmentIndex];
|
||||
|
||||
const firstIndex = selectedIndices[0];
|
||||
const lastIndex = selectedIndices[selectedIndices.length - 1];
|
||||
const firstWord = draftCaption.timeline[firstIndex];
|
||||
const lastWord = draftCaption.timeline[lastIndex];
|
||||
|
||||
// If no word is selected somehow, then ignore the update.
|
||||
if (!firstWord || !lastWord) {
|
||||
setEditingRegion(false);
|
||||
return;
|
||||
}
|
||||
|
||||
firstWord.startTime = region.start;
|
||||
lastWord.endTime = region.end;
|
||||
|
||||
/* Update the timeline of the previous and next words
|
||||
* It happens only when regions are intersecting with the previous or next word.
|
||||
* It will ignore if the previous/next word's position changed in timestamps.
|
||||
*/
|
||||
const prevWord = draftCaption.timeline[firstIndex - 1];
|
||||
const nextWord = draftCaption.timeline[lastIndex + 1];
|
||||
if (
|
||||
prevWord &&
|
||||
prevWord.endTime > region.start &&
|
||||
prevWord.startTime < region.start
|
||||
) {
|
||||
prevWord.endTime = region.start;
|
||||
}
|
||||
if (
|
||||
nextWord &&
|
||||
nextWord.startTime < region.end &&
|
||||
nextWord.endTime > region.end
|
||||
) {
|
||||
nextWord.startTime = region.end;
|
||||
}
|
||||
|
||||
/*
|
||||
* If the last word is the last word of the segment, then update the segment's end time.
|
||||
*/
|
||||
if (lastIndex === draftCaption.timeline.length - 1) {
|
||||
draftCaption.endTime = region.end;
|
||||
}
|
||||
|
||||
setTranscriptionDraft(draft);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [editingRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
setCaption(
|
||||
(transcription?.result?.timeline as Timeline)?.[currentSegmentIndex]
|
||||
);
|
||||
}, [currentSegmentIndex, transcription]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelectedIndices([]);
|
||||
}, [caption]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", (event: KeyboardEvent) =>
|
||||
toggleMultiSelect(event)
|
||||
);
|
||||
document.addEventListener("keyup", (event: KeyboardEvent) =>
|
||||
toggleMultiSelect(event)
|
||||
);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", toggleMultiSelect);
|
||||
document.removeEventListener("keyup", toggleMultiSelect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!transcription) return null;
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex justify-between space-x-4">
|
||||
<div className="flex-1 font-serif h-full border shadow-lg rounded-lg">
|
||||
<MediaCaptionTabs
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
caption={caption}
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
selectedIndices={selectedIndices}
|
||||
setSelectedIndices={setSelectedIndices}
|
||||
>
|
||||
<Caption
|
||||
caption={caption}
|
||||
language={transcription.language}
|
||||
selectedIndices={selectedIndices}
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
activeIndex={activeIndex}
|
||||
displayIpa={displayIpa}
|
||||
displayNotes={displayNotes}
|
||||
onClick={toggleSeletedIndex}
|
||||
/>
|
||||
</MediaCaptionTabs>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
variant={displayIpa ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("displayIpa")}
|
||||
onClick={() => setDisplayIpa(!displayIpa)}
|
||||
>
|
||||
<SpeechIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={displayNotes ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("displayNotes")}
|
||||
onClick={() => setDisplayNotes(!displayNotes)}
|
||||
>
|
||||
<NotebookPenIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<ConversationShortcuts
|
||||
prompt={caption.text as string}
|
||||
trigger={
|
||||
<Button
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="p-0 w-8 h-8 rounded-full"
|
||||
>
|
||||
<BotIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
onClick={() => {
|
||||
if (displayIpa) {
|
||||
const text = caption.timeline
|
||||
.map((word) => {
|
||||
const ipas = word.timeline.map((t) =>
|
||||
t.timeline.map((s) => s.text).join("")
|
||||
);
|
||||
return `${word.text}(${
|
||||
(transcription.language || learningLanguage).startsWith(
|
||||
"en"
|
||||
)
|
||||
? convertWordIpaToNormal(ipas, {
|
||||
mappings: ipaMappings,
|
||||
}).join("")
|
||||
: ipas.join("")
|
||||
})`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
copyToClipboard(text);
|
||||
} else {
|
||||
copyToClipboard(caption.text);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("downloadSegment")}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Caption = (props: {
|
||||
caption: TimelineEntry;
|
||||
language?: string;
|
||||
selectedIndices?: number[];
|
||||
currentSegmentIndex: number;
|
||||
activeIndex?: number;
|
||||
displayIpa?: boolean;
|
||||
displayNotes?: boolean;
|
||||
onClick?: (index: number) => void;
|
||||
}) => {
|
||||
const { currentNotes } = useContext(MediaPlayerProviderContext);
|
||||
const { learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const notes = currentNotes.filter((note) => note.parameters?.quoteIndices);
|
||||
const {
|
||||
caption,
|
||||
selectedIndices = [],
|
||||
currentSegmentIndex,
|
||||
activeIndex,
|
||||
displayIpa,
|
||||
displayNotes,
|
||||
onClick,
|
||||
} = props;
|
||||
const language = props.language || learningLanguage;
|
||||
|
||||
const [notedquoteIndices, setNotedquoteIndices] = useState<number[]>([]);
|
||||
|
||||
let words = caption.text.split(" ");
|
||||
const ipas = caption.timeline.map((w) =>
|
||||
w.timeline?.map((t) =>
|
||||
t.timeline && language.startsWith("en")
|
||||
? convertWordIpaToNormal(
|
||||
t.timeline.map((s) => s.text),
|
||||
{ mappings: ipaMappings }
|
||||
).join("")
|
||||
: t.text
|
||||
)
|
||||
);
|
||||
|
||||
if (words.length !== caption.timeline.length) {
|
||||
words = caption.timeline.map((w) => w.text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap px-4 py-2 rounded-t-lg bg-muted/50">
|
||||
{/* use the words splitted by caption text if it is matched with the timeline length, otherwise use the timeline */}
|
||||
{words.map((word, index) => (
|
||||
<div
|
||||
className=""
|
||||
key={`word-${currentSegmentIndex}-${index}`}
|
||||
id={`word-${currentSegmentIndex}-${index}`}
|
||||
>
|
||||
<div
|
||||
className={`font-serif text-lg xl:text-xl 2xl:text-2xl p-1 pb-2 rounded ${
|
||||
onClick && "hover:bg-red-500/10 cursor-pointer"
|
||||
} ${index === activeIndex ? "text-red-500" : ""} ${
|
||||
selectedIndices.includes(index) ? "bg-red-500/10 selected" : ""
|
||||
} ${
|
||||
notedquoteIndices.includes(index)
|
||||
? "border-b border-red-500 border-dashed"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onClick && onClick(index)}
|
||||
>
|
||||
{word}
|
||||
</div>
|
||||
|
||||
{displayIpa && (
|
||||
<div
|
||||
className={`select-text text-sm 2xl:text-base text-muted-foreground font-code mb-1 px-1 ${
|
||||
index === 0 ? "before:content-['/']" : ""
|
||||
} ${
|
||||
index === caption.timeline.length - 1
|
||||
? "after:content-['/']"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{ipas[index]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayNotes &&
|
||||
notes
|
||||
.filter((note) => note.parameters.quoteIndices[0] === index)
|
||||
.map((note) => (
|
||||
<div
|
||||
key={`note-${currentSegmentIndex}-${note.id}`}
|
||||
className="mb-1 text-xs 2xl:text-sm text-red-500 max-w-64 line-clamp-3 font-code cursor-pointer"
|
||||
onMouseOver={() =>
|
||||
setNotedquoteIndices(note.parameters.quoteIndices)
|
||||
}
|
||||
onMouseLeave={() => setNotedquoteIndices([])}
|
||||
onClick={() =>
|
||||
document.getElementById("note-" + note.id)?.scrollIntoView()
|
||||
}
|
||||
>
|
||||
{note.parameters.quoteIndices[0] === index && note.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./media-caption-tabs";
|
||||
export * from "./media-tab-content-analysis";
|
||||
export * from "./media-tab-content-note";
|
||||
export * from "./media-tab-content-translation";
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { MediaTabContentTranslation } from "./media-tab-content-translation";
|
||||
import { MediaTabContentAnalysis } from "./media-tab-content-analysis";
|
||||
import { MediaTabContentNote } from "./media-tab-content-note";
|
||||
|
||||
export const MediaCaptionTabs = (props: {
|
||||
caption: TimelineEntry;
|
||||
tab: string;
|
||||
currentSegmentIndex: number;
|
||||
selectedIndices: number[];
|
||||
setTab: (v: string) => void;
|
||||
setSelectedIndices: (indices: number[]) => void;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const {
|
||||
caption,
|
||||
currentSegmentIndex,
|
||||
selectedIndices,
|
||||
setSelectedIndices,
|
||||
children,
|
||||
tab,
|
||||
setTab,
|
||||
} = props;
|
||||
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full relative">
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value)} className="">
|
||||
{children}
|
||||
|
||||
<div className="px-4 pb-10 min-h-32">
|
||||
<MediaTabContentNote
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
selectedIndices={selectedIndices}
|
||||
setSelectedIndices={setSelectedIndices}
|
||||
/>
|
||||
|
||||
<MediaTabContentTranslation
|
||||
caption={caption}
|
||||
selectedIndices={selectedIndices}
|
||||
/>
|
||||
|
||||
<MediaTabContentAnalysis text={caption.text} />
|
||||
</div>
|
||||
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none absolute w-full bottom-0 px-4">
|
||||
<TabsTrigger value="translation" className="block truncate px-1">
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="note" className="block truncate px-1">
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analysis" className="block truncate px-1">
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./media-left-panel";
|
||||
|
||||
export * from "./media-provider";
|
||||
export * from "./media-info";
|
||||
export * from "./media-transcription";
|
||||
export * from "./media-transcription-read-button";
|
||||
export * from "./media-transcription-generate-button";
|
||||
export * from "./media-transcription-print";
|
||||
export * from "./media-recordings";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { formatDuration, formatDateTime } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
@@ -9,8 +9,8 @@ import { Button, toast } from "@renderer/components/ui";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
|
||||
export const MediaInfoPanel = () => {
|
||||
const { media, transcription } = useContext(MediaPlayerProviderContext);
|
||||
export const MediaInfo = () => {
|
||||
const { media, transcription } = useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { summarizeTopic } = useAiCommand();
|
||||
const [summarizing, setSummarizing] = useState<boolean>(false);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaProvider,
|
||||
MediaTranscription,
|
||||
MediaInfo,
|
||||
MediaRecordings,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const MediaLeftPanel = () => {
|
||||
const { media, decoded } = useContext(MediaShadowProviderContext);
|
||||
const [tab, setTab] = useState("provider");
|
||||
|
||||
useEffect(() => {
|
||||
if (!decoded) return;
|
||||
|
||||
setTab("transcription");
|
||||
}, [decoded]);
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="h-full flex flex-col">
|
||||
<TabsList
|
||||
className={`grid gap-4 rounded-none w-full px-4 ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media?.mediaType === "Video" && (
|
||||
<TabsTrigger
|
||||
value="provider"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("player")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="transcription"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("transcription")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="recordings"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="capitalize block truncate px-1">
|
||||
{t("mediaInfo")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<TabsContent forceMount={true} value="provider">
|
||||
<div className={`${tab === "provider" ? "block" : "hidden"}`}>
|
||||
<MediaProvider />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent forceMount={true} value="recordings">
|
||||
<div className={`${tab === "recordings" ? "block" : "hidden"}`}>
|
||||
<MediaRecordings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="transcription">
|
||||
<MediaTranscription display={tab === "transcription"} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info">
|
||||
<MediaInfo />
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import {
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
ThemeProviderContext,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import { toast } from "@renderer/components/ui";
|
||||
export const MediaProvider = () => {
|
||||
const { theme } = useContext(ThemeProviderContext);
|
||||
const { media, setMediaProvider, setDecodeError, transcription } = useContext(
|
||||
MediaPlayerProviderContext
|
||||
MediaShadowProviderContext
|
||||
);
|
||||
const mediaRemote = useMediaRemote();
|
||||
const player = useRef<MediaPlayerInstance>(null);
|
||||
@@ -63,7 +63,7 @@ export const MediaProvider = () => {
|
||||
if (!media?.src) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 py-4" data-testid="media-player">
|
||||
<div className="px-2 py-4">
|
||||
<VidstackMediaPlayer
|
||||
ref={player}
|
||||
className="my-auto"
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
GaugeCircleIcon,
|
||||
@@ -45,7 +45,7 @@ export const MediaRecordings = () => {
|
||||
currentSegmentIndex,
|
||||
transcription,
|
||||
media,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [selectedRecording, setSelectedRecording] = useState(null);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
Button,
|
||||
@@ -32,7 +32,7 @@ export const MediaTranscriptionGenerateButton = (props: {
|
||||
transcription,
|
||||
transcribingProgress,
|
||||
transcribingOutput,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -2,7 +2,7 @@ import { useContext } from "react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { AlignmentResult } from "echogarden/dist/api/API.d.js";
|
||||
@@ -10,7 +10,7 @@ import { convertWordIpaToNormal } from "@/utils";
|
||||
import template from "./transcription.template.html?raw";
|
||||
|
||||
export const MediaTranscriptionPrint = () => {
|
||||
const { media, transcription } = useContext(MediaPlayerProviderContext);
|
||||
const { media, transcription } = useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp, learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
import { useRecordings } from "@renderer/hooks";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import {
|
||||
Caption,
|
||||
MediaCaption,
|
||||
RecordingDetail,
|
||||
WavesurferPlayer,
|
||||
} from "@renderer/components";
|
||||
@@ -56,7 +56,7 @@ export const MediaTranscriptionReadButton = (props: {
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { media, transcription, setRecordingType } = useContext(
|
||||
MediaPlayerProviderContext
|
||||
MediaShadowProviderContext
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,7 +94,7 @@ export const MediaTranscriptionReadButton = (props: {
|
||||
<span className="text-sm text-muted-foreground min-w-max leading-8">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<Caption
|
||||
<MediaCaption
|
||||
caption={sentence}
|
||||
currentSegmentIndex={index}
|
||||
displayIpa={true}
|
||||
@@ -118,7 +118,7 @@ export const MediaTranscriptionReadButton = (props: {
|
||||
const TranscriptionRecordingsList = () => {
|
||||
const [deleting, setDeleting] = useState<RecordingType>(null);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { media } = useContext(MediaPlayerProviderContext);
|
||||
const { media } = useContext(MediaShadowProviderContext);
|
||||
const [assessing, setAssessing] = useState<RecordingType>();
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -294,7 +294,7 @@ const RecorderButton = () => {
|
||||
stopRecording,
|
||||
mediaRecorder,
|
||||
recordingTime,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [access, setAccess] = useState<boolean>(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
|
||||
transcription,
|
||||
transcribing,
|
||||
transcribingProgress,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-testid="media-transcription-result">
|
||||
<div className="px-4 py-1 bg-background">
|
||||
<div className="px-4 py-0.5 bg-background">
|
||||
<div className="flex items-cener justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{transcribing || transcription.state === "processing" ? (
|
||||
@@ -179,7 +179,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
|
||||
<div
|
||||
key={index}
|
||||
id={`segment-${index}`}
|
||||
className={`py-2 px-4 cursor-pointer hover:bg-yellow-400/10 ${
|
||||
className={`py-1.5 px-4 cursor-pointer hover:bg-yellow-400/10 ${
|
||||
currentSegmentIndex === index ? "bg-yellow-400/25" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
@@ -204,7 +204,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sentence sentence={sentence.text} />
|
||||
<Sentence className="font-serif" sentence={sentence.text} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogHeader,
|
||||
@@ -31,7 +31,7 @@ export const MediaLoadingModal = () => {
|
||||
transcribingProgress,
|
||||
transcribingOutput,
|
||||
generateTranscription,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
|
||||
return (
|
||||
<AlertDialog open={!decoded || !Boolean(transcription?.result?.timeline)}>
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import { useEffect, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
GalleryHorizontalIcon,
|
||||
Share2Icon,
|
||||
SpellCheckIcon,
|
||||
MinimizeIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
MoreVerticalIcon,
|
||||
DownloadIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const ZOOM_RATIO_OPTIONS = [
|
||||
0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0,
|
||||
];
|
||||
const MIN_ZOOM_RATIO = 0.25;
|
||||
const MAX_ZOOM_RATIO = 4.0;
|
||||
|
||||
export const MediaPlayer = () => {
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const {
|
||||
layout,
|
||||
media,
|
||||
currentTime,
|
||||
setRef,
|
||||
pitchChart,
|
||||
wavesurfer,
|
||||
zoomRatio,
|
||||
setZoomRatio,
|
||||
fitZoomRatio,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const [displayInlineCaption, setDisplayInlineCaption] =
|
||||
useState<boolean>(true);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [width, setWidth] = useState<number>();
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const onShare = async () => {
|
||||
if (!media.source && !media.isUploaded) {
|
||||
try {
|
||||
await EnjoyApp.audios.upload(media.id);
|
||||
} catch (err) {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
webApi
|
||||
.createPost({
|
||||
targetType: media.mediaType,
|
||||
targetId: media.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedAudio"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const calContainerWidth = () => {
|
||||
const w = document
|
||||
.querySelector(".media-player-wrapper")
|
||||
?.getBoundingClientRect()?.width;
|
||||
if (!w) return;
|
||||
|
||||
setWidth(w - 48);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: media.filename,
|
||||
filters: [
|
||||
{
|
||||
name: media.mediaType,
|
||||
extensions: [media.filename.split(".").pop()],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(media.src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ref?.current) {
|
||||
setRef(ref);
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const container: HTMLDivElement = document.querySelector(
|
||||
".media-player-container"
|
||||
);
|
||||
if (!container) return;
|
||||
|
||||
ref.current.style.width = `${width}px`;
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
calContainerWidth();
|
||||
}, [layout.width]);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4 media-player-wrapper">
|
||||
<div
|
||||
data-testid="media-player-container"
|
||||
className="flex-1 border rounded-xl shadow-lg relative media-player-container"
|
||||
>
|
||||
<div ref={ref} />
|
||||
<div className="absolute right-2 top-1">
|
||||
<span className="text-sm">{formatDuration(currentTime || 0)}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-sm">
|
||||
{formatDuration(media?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-around space-y-1.5">
|
||||
<Button
|
||||
variant={`${zoomRatio === fitZoomRatio ? "secondary" : "outline"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("zoomToFit")}
|
||||
className="relative aspect-square rounded-full p-0 h-8"
|
||||
onClick={() => {
|
||||
if (zoomRatio == fitZoomRatio) {
|
||||
setZoomRatio(1.0);
|
||||
} else {
|
||||
setZoomRatio(fitZoomRatio);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MinimizeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={`${zoomRatio > 1.0 ? "secondary" : "outline"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("zoomIn")}
|
||||
className="relative aspect-square rounded-full p-0 h-8"
|
||||
onClick={() => {
|
||||
if (zoomRatio < MAX_ZOOM_RATIO) {
|
||||
const nextZoomRatio = ZOOM_RATIO_OPTIONS.find(
|
||||
(rate) => rate > zoomRatio
|
||||
);
|
||||
setZoomRatio(nextZoomRatio || MAX_ZOOM_RATIO);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{
|
||||
layout.name === "lg" && (
|
||||
<>
|
||||
<Button
|
||||
variant={`${zoomRatio < 1.0 ? "secondary" : "outline"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("zoomOut")}
|
||||
className="relative aspect-square rounded-full p-0 h-8"
|
||||
onClick={() => {
|
||||
if (zoomRatio > MIN_ZOOM_RATIO) {
|
||||
const nextZoomRatio = ZOOM_RATIO_OPTIONS.reverse().find(
|
||||
(rate) => rate < zoomRatio
|
||||
);
|
||||
setZoomRatio(nextZoomRatio || MIN_ZOOM_RATIO);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={`${displayInlineCaption ? "secondary" : "outline"}`}
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("inlineCaption")}
|
||||
className="relative aspect-square rounded-full p-0 h-8"
|
||||
onClick={() => {
|
||||
setDisplayInlineCaption(!displayInlineCaption);
|
||||
if (pitchChart) {
|
||||
pitchChart.options.scales.x.display = !displayInlineCaption;
|
||||
pitchChart.update();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SpellCheckIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{
|
||||
layout.name === "sm" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (zoomRatio > MIN_ZOOM_RATIO) {
|
||||
const nextZoomRatio = ZOOM_RATIO_OPTIONS.reverse().find(
|
||||
(rate) => rate < zoomRatio
|
||||
);
|
||||
setZoomRatio(nextZoomRatio || MIN_ZOOM_RATIO);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("zoomOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setDisplayInlineCaption(!displayInlineCaption);
|
||||
if (pitchChart) {
|
||||
pitchChart.options.scales.x.display = !displayInlineCaption;
|
||||
pitchChart.update();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SpellCheckIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("inlineCaption")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
wavesurfer.setOptions({
|
||||
autoCenter: !wavesurfer?.options?.autoCenter,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<GalleryHorizontalIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("autoCenter")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsSharing(true)}
|
||||
>
|
||||
<Share2Icon className="w-4 h-4 mr-4" />
|
||||
<span>{t("share")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4 mr-4" />
|
||||
<span>{t("download")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={isSharing} onOpenChange={setIsSharing}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{media?.mediaType === "Audio"
|
||||
? t("shareAudio")
|
||||
: t("shareVideo")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{media?.mediaType === "Audio"
|
||||
? t("areYouSureToShareThisAudioToCommunity")
|
||||
: t("areYouSureToShareThisVideoToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="default" onClick={onShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./media-right-panel";
|
||||
export * from "./media-caption";
|
||||
export * from "./media-caption-actions";
|
||||
export * from "./media-caption-analysis";
|
||||
export * from "./media-caption-note";
|
||||
export * from "./media-caption-translation";
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState, useContext, useRef } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ConversationShortcuts, MediaCaption } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
SpeechIcon,
|
||||
NotebookPenIcon,
|
||||
DownloadIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Timeline,
|
||||
TimelineEntry,
|
||||
} from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { convertWordIpaToNormal } from "@/utils";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
|
||||
export const MediaCaptionActions = (props: {
|
||||
caption: TimelineEntry;
|
||||
displayIpa: boolean;
|
||||
setDisplayIpa: (display: boolean) => void;
|
||||
displayNotes: boolean;
|
||||
setDisplayNotes: (display: boolean) => void;
|
||||
}) => {
|
||||
const { caption, displayIpa, setDisplayIpa, displayNotes, setDisplayNotes } =
|
||||
props;
|
||||
const { media, currentSegment, createSegment, transcription, activeRegion } =
|
||||
useContext(MediaShadowProviderContext);
|
||||
const { EnjoyApp, learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const [fbtOpen, setFbtOpen] = useState<boolean>(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (activeRegion && !activeRegion.id.startsWith("segment-region")) {
|
||||
handleDownloadActiveRegion();
|
||||
} else {
|
||||
handleDownloadSegment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSegment = async () => {
|
||||
const segment = currentSegment || (await createSegment());
|
||||
if (!segment) return;
|
||||
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: `${media.name}(${segment.startTime.toFixed(
|
||||
2
|
||||
)}s-${segment.endTime.toFixed(2)}s).mp3`,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: ["mp3"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(segment.src, savePath as string),
|
||||
{
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadActiveRegion = async () => {
|
||||
if (!activeRegion) return;
|
||||
let src: string;
|
||||
|
||||
try {
|
||||
if (media.mediaType === "Audio") {
|
||||
src = await EnjoyApp.audios.crop(media.id, {
|
||||
startTime: activeRegion.start,
|
||||
endTime: activeRegion.end,
|
||||
});
|
||||
} else if (media.mediaType === "Video") {
|
||||
src = await EnjoyApp.videos.crop(media.id, {
|
||||
startTime: activeRegion.start,
|
||||
endTime: activeRegion.end,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`${t("downloadFailed")}: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!src) return;
|
||||
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: `${media.name}(${activeRegion.start.toFixed(
|
||||
2
|
||||
)}s-${activeRegion.end.toFixed(2)}s).mp3`,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: ["mp3"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
if (!transcription) return null;
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<Popover open={fbtOpen} onOpenChange={setFbtOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={fbtOpen ? "secondary" : "outline"}
|
||||
className="rounded-full w-8 h-8 p-0 shadow-lg z-30"
|
||||
>
|
||||
{fbtOpen ? (
|
||||
<XIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-8 bg-transparent p-0 border-none shadow-none"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Button
|
||||
variant={displayIpa ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayIpa")}
|
||||
onClick={() => setDisplayIpa(!displayIpa)}
|
||||
>
|
||||
<SpeechIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={displayNotes ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayNotes")}
|
||||
onClick={() => setDisplayNotes(!displayNotes)}
|
||||
>
|
||||
<NotebookPenIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<ConversationShortcuts
|
||||
prompt={caption.text as string}
|
||||
trigger={
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="p-0 w-8 h-8 rounded-full"
|
||||
>
|
||||
<BotIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
onClick={() => {
|
||||
if (displayIpa) {
|
||||
const text = caption.timeline
|
||||
.map((word) => {
|
||||
const ipas = word.timeline.map((t) =>
|
||||
t.timeline.map((s) => s.text).join("")
|
||||
);
|
||||
return `${word.text}(${
|
||||
(transcription.language || learningLanguage).startsWith(
|
||||
"en"
|
||||
)
|
||||
? convertWordIpaToNormal(ipas, {
|
||||
mappings: ipaMappings,
|
||||
}).join("")
|
||||
: ipas.join("")
|
||||
})`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
copyToClipboard(text);
|
||||
} else {
|
||||
copyToClipboard(caption.text);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("downloadSegment")}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { LoaderIcon } from "lucide-react";
|
||||
import { md5 } from "js-md5";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export function MediaTabContentAnalysis(props: { text: string }) {
|
||||
export function MediaCaptionAnalysis(props: { text: string }) {
|
||||
const { text } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
@@ -121,7 +121,7 @@ const AIButton = (props: {
|
||||
title={tooltip}
|
||||
trigger={
|
||||
<Button
|
||||
data-tooltip-id="media-player-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={tooltip}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import { Button, TabsContent, toast } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useState } from "react";
|
||||
@@ -7,14 +7,14 @@ import { NoteCard, NoteForm } from "@renderer/components";
|
||||
/*
|
||||
* Note tab content.
|
||||
*/
|
||||
export const MediaTabContentNote = (props: {
|
||||
export const MediaCaptionNote = (props: {
|
||||
currentSegmentIndex: number;
|
||||
selectedIndices: number[];
|
||||
setSelectedIndices: (indices: number[]) => void;
|
||||
}) => {
|
||||
const { selectedIndices, setSelectedIndices } = props;
|
||||
const { currentSegment, createSegment, currentNotes } = useContext(
|
||||
MediaPlayerProviderContext
|
||||
MediaShadowProviderContext
|
||||
);
|
||||
const [editingNote, setEditingNote] = useState<NoteType>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
DictProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { TabsContent, Separator } from "@renderer/components/ui";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
/*
|
||||
* Translation tab content.
|
||||
*/
|
||||
export function MediaTabContentTranslation(props: {
|
||||
export function MediaCaptionTranslation(props: {
|
||||
caption: TimelineEntry;
|
||||
selectedIndices: number[];
|
||||
}) {
|
||||
@@ -44,7 +44,7 @@ const SelectedWords = (props: {
|
||||
const { selectedIndices, caption } = props;
|
||||
|
||||
const { currentDictValue } = useContext(DictProviderContext);
|
||||
const { transcription } = useContext(MediaPlayerProviderContext);
|
||||
const { transcription } = useContext(MediaShadowProviderContext);
|
||||
const { learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useContext } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { convertWordIpaToNormal } from "@/utils";
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
|
||||
export const MediaCaption = (props: {
|
||||
caption: TimelineEntry;
|
||||
language?: string;
|
||||
selectedIndices?: number[];
|
||||
currentSegmentIndex: number;
|
||||
activeIndex?: number;
|
||||
displayIpa?: boolean;
|
||||
displayNotes?: boolean;
|
||||
onClick?: (index: number) => void;
|
||||
}) => {
|
||||
const { currentNotes } = useContext(MediaShadowProviderContext);
|
||||
const { learningLanguage, ipaMappings } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const notes = currentNotes.filter((note) => note.parameters?.quoteIndices);
|
||||
const {
|
||||
caption,
|
||||
selectedIndices = [],
|
||||
currentSegmentIndex,
|
||||
activeIndex,
|
||||
displayIpa,
|
||||
displayNotes,
|
||||
onClick,
|
||||
} = props;
|
||||
const language = props.language || learningLanguage;
|
||||
|
||||
const [notedquoteIndices, setNotedquoteIndices] = useState<number[]>([]);
|
||||
|
||||
let words = caption.text.split(" ");
|
||||
const ipas = caption.timeline.map((w) =>
|
||||
w.timeline?.map((t) =>
|
||||
t.timeline && language.startsWith("en")
|
||||
? convertWordIpaToNormal(
|
||||
t.timeline.map((s) => s.text),
|
||||
{ mappings: ipaMappings }
|
||||
).join("")
|
||||
: t.text
|
||||
)
|
||||
);
|
||||
|
||||
if (words.length !== caption.timeline.length) {
|
||||
words = caption.timeline.map((w) => w.text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap px-4 py-2 bg-muted/50">
|
||||
{/* use the words splitted by caption text if it is matched with the timeline length, otherwise use the timeline */}
|
||||
{words.map((word, index) => (
|
||||
<div
|
||||
className=""
|
||||
key={`word-${currentSegmentIndex}-${index}`}
|
||||
id={`word-${currentSegmentIndex}-${index}`}
|
||||
>
|
||||
<div
|
||||
className={`font-serif xl:text-lg 2xl:text-xl px-1 ${
|
||||
onClick && "hover:bg-red-500/10 cursor-pointer"
|
||||
} ${index === activeIndex ? "text-red-500" : ""} ${
|
||||
selectedIndices.includes(index) ? "bg-red-500/10 selected" : ""
|
||||
} ${
|
||||
notedquoteIndices.includes(index)
|
||||
? "border-b border-red-500 border-dashed"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onClick && onClick(index)}
|
||||
>
|
||||
{word}
|
||||
</div>
|
||||
|
||||
{displayIpa && (
|
||||
<div
|
||||
className={`select-text text-sm 2xl:text-base text-muted-foreground font-code px-1 ${
|
||||
index === 0 ? "before:content-['/']" : ""
|
||||
} ${
|
||||
index === caption.timeline.length - 1
|
||||
? "after:content-['/']"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{ipas[index]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayNotes &&
|
||||
notes
|
||||
.filter((note) => note.parameters.quoteIndices[0] === index)
|
||||
.map((note) => (
|
||||
<div
|
||||
key={`note-${currentSegmentIndex}-${note.id}`}
|
||||
className="mb-1 text-xs 2xl:text-sm text-red-500 max-w-64 line-clamp-3 font-code cursor-pointer"
|
||||
onMouseOver={() =>
|
||||
setNotedquoteIndices(note.parameters.quoteIndices)
|
||||
}
|
||||
onMouseLeave={() => setNotedquoteIndices([])}
|
||||
onClick={() =>
|
||||
document.getElementById("note-" + note.id)?.scrollIntoView()
|
||||
}
|
||||
>
|
||||
{note.parameters.quoteIndices[0] === index && note.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useState, useContext, useRef } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import {
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { MediaCaption, MediaCaptionActions } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
Timeline,
|
||||
TimelineEntry,
|
||||
} from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import {
|
||||
MediaCaptionAnalysis,
|
||||
MediaCaptionNote,
|
||||
MediaCaptionTranslation,
|
||||
} from "@renderer/components";
|
||||
|
||||
export const MediaRightPanel = () => {
|
||||
const {
|
||||
currentSegmentIndex,
|
||||
currentTime,
|
||||
transcription,
|
||||
regions,
|
||||
activeRegion,
|
||||
setActiveRegion,
|
||||
editingRegion,
|
||||
setEditingRegion,
|
||||
setTranscriptionDraft,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
const [multiSelecting, setMultiSelecting] = useState<boolean>(false);
|
||||
|
||||
const [displayIpa, setDisplayIpa] = useState<boolean>(true);
|
||||
const [displayNotes, setDisplayNotes] = useState<boolean>(true);
|
||||
|
||||
const [caption, setCaption] = useState<TimelineEntry | null>(null);
|
||||
const [tab, setTab] = useState<string>("translation");
|
||||
|
||||
const toggleMultiSelect = (event: KeyboardEvent) => {
|
||||
setMultiSelecting(event.shiftKey && event.type === "keydown");
|
||||
};
|
||||
|
||||
const toggleSeletedIndex = (index: number) => {
|
||||
if (!activeRegion) return;
|
||||
if (editingRegion) {
|
||||
toast.warning(t("currentRegionIsBeingEdited"));
|
||||
return;
|
||||
}
|
||||
|
||||
const startWord = caption.timeline[index];
|
||||
if (!startWord) return;
|
||||
|
||||
if (multiSelecting) {
|
||||
const min = Math.min(index, ...selectedIndices);
|
||||
const max = Math.max(index, ...selectedIndices);
|
||||
|
||||
// Select all the words between the min and max indices.
|
||||
setSelectedIndices(
|
||||
Array.from({ length: max - min + 1 }, (_, i) => i + min)
|
||||
);
|
||||
} else if (selectedIndices.includes(index)) {
|
||||
setSelectedIndices([]);
|
||||
} else {
|
||||
setSelectedIndices([index]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRegion = (params: number[]) => {
|
||||
if (!activeRegion) return;
|
||||
if (editingRegion) {
|
||||
toast.warning(t("currentRegionIsBeingEdited"));
|
||||
return;
|
||||
}
|
||||
if (params.length === 0) {
|
||||
if (activeRegion.id.startsWith("word-region")) {
|
||||
activeRegion.remove();
|
||||
setActiveRegion(
|
||||
regions.getRegions().find((r) => r.id.startsWith("segment-region"))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = Math.min(...params);
|
||||
const endIndex = Math.max(...params);
|
||||
|
||||
const startWord = caption.timeline[startIndex];
|
||||
if (!startWord) return;
|
||||
|
||||
const endWord = caption.timeline[endIndex] || startWord;
|
||||
|
||||
const start = startWord.startTime;
|
||||
const end = endWord.endTime;
|
||||
|
||||
// If the active region is a word region, then merge the selected words into a single region.
|
||||
if (activeRegion.id.startsWith("word-region")) {
|
||||
activeRegion.remove();
|
||||
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${startIndex}`,
|
||||
start,
|
||||
end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: editingRegion,
|
||||
});
|
||||
|
||||
setActiveRegion(region);
|
||||
// If the active region is a meaning group region, then active the segment region.
|
||||
} else if (activeRegion.id.startsWith("meaning-group-region")) {
|
||||
setActiveRegion(
|
||||
regions.getRegions().find((r) => r.id.startsWith("segment-region"))
|
||||
);
|
||||
// If the active region is a segment region, then create a new word region.
|
||||
} else {
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${startIndex}`,
|
||||
start,
|
||||
end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: false,
|
||||
});
|
||||
|
||||
setActiveRegion(region);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!caption) return;
|
||||
|
||||
let index = caption.timeline.findIndex(
|
||||
(w) => currentTime >= w.startTime && currentTime < w.endTime
|
||||
);
|
||||
|
||||
if (index < 0) return;
|
||||
if (index !== activeIndex) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
}, [currentTime, caption]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caption?.timeline) return;
|
||||
if (!activeRegion) return;
|
||||
|
||||
toggleRegion(selectedIndices);
|
||||
}, [caption, selectedIndices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRegion) return;
|
||||
if (!activeRegion.id.startsWith("word-region")) return;
|
||||
|
||||
const region = regions.addRegion({
|
||||
id: `word-region-${selectedIndices.join("-")}`,
|
||||
start: activeRegion.start,
|
||||
end: activeRegion.end,
|
||||
color: "#fb6f9233",
|
||||
drag: false,
|
||||
resize: editingRegion,
|
||||
});
|
||||
|
||||
activeRegion?.remove();
|
||||
setActiveRegion(region);
|
||||
|
||||
const subscriptions = [
|
||||
regions.on("region-updated", (region) => {
|
||||
if (!region.id.startsWith("word-region")) return;
|
||||
|
||||
const draft = cloneDeep(transcription.result);
|
||||
const draftCaption = draft.timeline[currentSegmentIndex];
|
||||
|
||||
const firstIndex = selectedIndices[0];
|
||||
const lastIndex = selectedIndices[selectedIndices.length - 1];
|
||||
const firstWord = draftCaption.timeline[firstIndex];
|
||||
const lastWord = draftCaption.timeline[lastIndex];
|
||||
|
||||
// If no word is selected somehow, then ignore the update.
|
||||
if (!firstWord || !lastWord) {
|
||||
setEditingRegion(false);
|
||||
return;
|
||||
}
|
||||
|
||||
firstWord.startTime = region.start;
|
||||
lastWord.endTime = region.end;
|
||||
|
||||
/* Update the timeline of the previous and next words
|
||||
* It happens only when regions are intersecting with the previous or next word.
|
||||
* It will ignore if the previous/next word's position changed in timestamps.
|
||||
*/
|
||||
const prevWord = draftCaption.timeline[firstIndex - 1];
|
||||
const nextWord = draftCaption.timeline[lastIndex + 1];
|
||||
if (
|
||||
prevWord &&
|
||||
prevWord.endTime > region.start &&
|
||||
prevWord.startTime < region.start
|
||||
) {
|
||||
prevWord.endTime = region.start;
|
||||
}
|
||||
if (
|
||||
nextWord &&
|
||||
nextWord.startTime < region.end &&
|
||||
nextWord.endTime > region.end
|
||||
) {
|
||||
nextWord.startTime = region.end;
|
||||
}
|
||||
|
||||
/*
|
||||
* If the last word is the last word of the segment, then update the segment's end time.
|
||||
*/
|
||||
if (lastIndex === draftCaption.timeline.length - 1) {
|
||||
draftCaption.endTime = region.end;
|
||||
}
|
||||
|
||||
setTranscriptionDraft(draft);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [editingRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
setCaption(
|
||||
(transcription?.result?.timeline as Timeline)?.[currentSegmentIndex]
|
||||
);
|
||||
}, [currentSegmentIndex, transcription]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelectedIndices([]);
|
||||
}, [caption]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", (event: KeyboardEvent) =>
|
||||
toggleMultiSelect(event)
|
||||
);
|
||||
document.addEventListener("keyup", (event: KeyboardEvent) =>
|
||||
toggleMultiSelect(event)
|
||||
);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", toggleMultiSelect);
|
||||
document.removeEventListener("keyup", toggleMultiSelect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!transcription) return null;
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<div className="flex-1 font-serif h-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none w-full px-4">
|
||||
<TabsTrigger
|
||||
value="translation"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="note"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<MediaCaption
|
||||
caption={caption}
|
||||
language={transcription.language}
|
||||
selectedIndices={selectedIndices}
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
activeIndex={activeIndex}
|
||||
displayIpa={displayIpa}
|
||||
displayNotes={displayNotes}
|
||||
onClick={toggleSeletedIndex}
|
||||
/>
|
||||
|
||||
<div className="px-4 pb-10 min-h-32">
|
||||
<MediaCaptionNote
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
selectedIndices={selectedIndices}
|
||||
setSelectedIndices={setSelectedIndices}
|
||||
/>
|
||||
|
||||
<MediaCaptionTranslation
|
||||
caption={caption}
|
||||
selectedIndices={selectedIndices}
|
||||
/>
|
||||
|
||||
<MediaCaptionAnalysis text={caption.text} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<MediaCaptionActions
|
||||
caption={caption}
|
||||
displayIpa={displayIpa}
|
||||
setDisplayIpa={setDisplayIpa}
|
||||
displayNotes={displayNotes}
|
||||
setDisplayNotes={setDisplayNotes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
enjoy/src/renderer/components/medias/media-shadow-player.tsx
Normal file
42
enjoy/src/renderer/components/medias/media-shadow-player.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MediaLoadingModal,
|
||||
MediaRightPanel,
|
||||
MediaLeftPanel,
|
||||
MediaBottomPanel,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@renderer/components/ui";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
|
||||
export const MediaShadowPlayer = () => {
|
||||
const [layout, setLayout] = useState<number[]>();
|
||||
const debouncedLayout = useDebounce(layout, 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizablePanelGroup direction="vertical" onLayout={setLayout}>
|
||||
<ResizablePanel defaultSize={60} minSize={50}>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<MediaLeftPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={20}>
|
||||
<MediaRightPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel minSize={20}>
|
||||
<MediaBottomPanel layout={debouncedLayout} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<MediaLoadingModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaProvider,
|
||||
MediaTranscription,
|
||||
MediaInfoPanel,
|
||||
MediaRecordings,
|
||||
} from "@renderer/components";
|
||||
import { ScrollArea } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const MediaTabs = () => {
|
||||
const { media, decoded } = useContext(MediaPlayerProviderContext);
|
||||
const [tab, setTab] = useState("provider");
|
||||
|
||||
useEffect(() => {
|
||||
if (!decoded) return;
|
||||
|
||||
setTab("transcription");
|
||||
}, [decoded]);
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={`p-1 bg-muted rounded-t-lg mb-2 text-sm sticky top-0 z-[1] grid gap-4 ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media.mediaType === "Video" && (
|
||||
<div
|
||||
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
|
||||
tab === "provider" ? "bg-background" : ""
|
||||
}`}
|
||||
onClick={() => setTab("provider")}
|
||||
>
|
||||
{t("player")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
|
||||
tab === "transcription" ? "bg-background" : ""
|
||||
}`}
|
||||
onClick={() => setTab("transcription")}
|
||||
>
|
||||
{t("transcription")}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
|
||||
tab === "recordings" ? "bg-background" : ""
|
||||
}`}
|
||||
onClick={() => setTab("recordings")}
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
|
||||
tab === "info" ? "bg-background" : ""
|
||||
}`}
|
||||
onClick={() => setTab("info")}
|
||||
>
|
||||
{t("mediaInfo")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={tab === "provider" ? "" : "hidden"}>
|
||||
<MediaProvider />
|
||||
</div>
|
||||
<div className={tab === "recordings" ? "" : "hidden"}>
|
||||
<MediaRecordings />
|
||||
</div>
|
||||
<div className={tab === "transcription" ? "" : "hidden"}>
|
||||
<MediaTranscription display={tab === "transcription"} />
|
||||
</div>
|
||||
<div className={tab === "info" ? "" : "hidden"}>
|
||||
<MediaInfoPanel />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -297,12 +297,12 @@ export const AssistantMessageComponent = (props: {
|
||||
<SheetContent
|
||||
aria-describedby={undefined}
|
||||
side="bottom"
|
||||
className="h-screen p-0"
|
||||
className="h-screen p-0 flex flex-col"
|
||||
displayClose={false}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="flex items-center justify-center h-14">
|
||||
<SheetHeader className="flex items-center justify-center h-12">
|
||||
<SheetTitle className="sr-only">{t("shadow")}</SheetTitle>
|
||||
<SheetClose>
|
||||
<ChevronDownIcon />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Markdown from "react-markdown";
|
||||
import { visitParents } from "unist-util-visit-parents";
|
||||
import { Sentence } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
function rehypeWrapText() {
|
||||
return function wrapTextTransform(tree: any) {
|
||||
@@ -27,7 +28,7 @@ export const MarkdownWrapper = ({
|
||||
}) => {
|
||||
return (
|
||||
<Markdown
|
||||
className={className}
|
||||
className={cn("prose dark:prose-invert", className)}
|
||||
rehypePlugins={[rehypeWrapText]}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
|
||||
@@ -33,18 +33,22 @@ import {
|
||||
SpeechIcon,
|
||||
GraduationCapIcon,
|
||||
MessagesSquareIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { Preferences } from "@renderer/components";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { NoticiationsChannel } from "@/renderer/cables";
|
||||
import { NoticiationsChannel } from "@renderer/cables";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const location = useLocation();
|
||||
const activeTab = location.pathname;
|
||||
const { EnjoyApp, cable } = useContext(AppSettingsProviderContext);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cable) return;
|
||||
@@ -57,26 +61,51 @@ export const Sidebar = () => {
|
||||
};
|
||||
}, [cable]);
|
||||
|
||||
// Save the sidebar state to cache
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.set("sidebarOpen", isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
// Restore the sidebar state from cache
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.get("sidebarOpen").then((value) => {
|
||||
if (value !== undefined) {
|
||||
setIsOpen(!!value);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-[100vh] w-16 xl:w-40 transition-all relative"
|
||||
className={`h-[100vh] transition-all relative ${
|
||||
isOpen ? "w-36" : "w-14"
|
||||
}`}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<div className="fixed top-0 left-0 h-full w-16 xl:w-40 bg-muted">
|
||||
<div
|
||||
className={`fixed top-0 left-0 h-full bg-muted ${
|
||||
isOpen ? "w-36" : "w-14"
|
||||
}`}
|
||||
>
|
||||
<ScrollArea className="w-full h-full pb-12">
|
||||
<div className="py-4 mb-4 flex items-center space-x-1 justify-center">
|
||||
<img src="./assets/logo-light.svg" className="w-8 h-8" />
|
||||
<span className="hidden xl:block text-xl font-semibold text-[#4797F5]">
|
||||
<span
|
||||
className={`text-xl font-semibold text-[#4797F5] ${
|
||||
isOpen ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
ENJOY
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-2 mb-4">
|
||||
<SidebarItem
|
||||
href="/"
|
||||
label={t("sidebar.home")}
|
||||
tooltip={t("sidebar.home")}
|
||||
active={activeTab === "/"}
|
||||
Icon={HomeIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -85,6 +114,7 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.chats")}
|
||||
active={activeTab.startsWith("/chats")}
|
||||
Icon={MessagesSquareIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -93,9 +123,10 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.courses")}
|
||||
active={activeTab.startsWith("/courses")}
|
||||
Icon={GraduationCapIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<Separator className="hidden xl:block" />
|
||||
<Separator />
|
||||
|
||||
<SidebarItem
|
||||
href="/audios"
|
||||
@@ -103,6 +134,7 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.audios")}
|
||||
active={activeTab.startsWith("/audios")}
|
||||
Icon={HeadphonesIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -111,6 +143,7 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.videos")}
|
||||
active={activeTab.startsWith("/videos")}
|
||||
Icon={VideoIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -119,9 +152,10 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.stories")}
|
||||
active={activeTab.startsWith("/stories")}
|
||||
Icon={NewspaperIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<Separator className="hidden xl:block" />
|
||||
<Separator />
|
||||
|
||||
<SidebarItem
|
||||
href="/conversations"
|
||||
@@ -130,6 +164,7 @@ export const Sidebar = () => {
|
||||
active={activeTab.startsWith("/conversations")}
|
||||
Icon={BotIcon}
|
||||
testid="sidebar-conversations"
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -139,6 +174,7 @@ export const Sidebar = () => {
|
||||
active={activeTab.startsWith("/pronunciation_assessments")}
|
||||
Icon={SpeechIcon}
|
||||
testid="sidebar-pronunciation-assessments"
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -147,6 +183,7 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.notes")}
|
||||
active={activeTab === "/notes"}
|
||||
Icon={NotebookPenIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -155,9 +192,10 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.vocabulary")}
|
||||
active={activeTab.startsWith("/vocabulary")}
|
||||
Icon={BookMarkedIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<Separator className="hidden xl:block" />
|
||||
<Separator />
|
||||
|
||||
<SidebarItem
|
||||
href="/community"
|
||||
@@ -165,6 +203,7 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.community")}
|
||||
active={activeTab === "/community"}
|
||||
Icon={UsersRoundIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
@@ -173,25 +212,29 @@ export const Sidebar = () => {
|
||||
tooltip={t("sidebar.profile")}
|
||||
active={activeTab.startsWith("/profile")}
|
||||
Icon={UserIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<Separator className="hidden xl:block" />
|
||||
<Separator />
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div className="px-1 xl:px-2">
|
||||
<div className="px-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
id="preferences-button"
|
||||
className="w-full xl:justify-start"
|
||||
className={`w-full ${
|
||||
isOpen ? "justify-start" : "justify-center"
|
||||
}`}
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("sidebar.preferences")}
|
||||
data-tooltip-place="right"
|
||||
>
|
||||
<SettingsIcon className="xl:mr-2 h-5 w-5" />
|
||||
<span className="hidden xl:block">
|
||||
{t("sidebar.preferences")}
|
||||
</span>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
{isOpen && (
|
||||
<span className="ml-2"> {t("sidebar.preferences")} </span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
@@ -206,71 +249,90 @@ export const Sidebar = () => {
|
||||
<Preferences />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="px-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`w-full ${
|
||||
isOpen ? "justify-start" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
<HelpCircleIcon className="h-5 w-5" />
|
||||
{isOpen && (
|
||||
<span className="ml-2"> {t("sidebar.help")} </span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="px-6">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal("https://998h.org/enjoy-app/")
|
||||
}
|
||||
className="flex justify-between space-x-4"
|
||||
>
|
||||
<span>{t("userGuide")}</span>
|
||||
<ExternalLinkIcon className="h-6 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{t("feedback")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal(
|
||||
"https://mixin.one/codes/f6ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e"
|
||||
)
|
||||
}
|
||||
className="flex justify-between space-x-4"
|
||||
>
|
||||
<span>Mixin</span>
|
||||
<ExternalLinkIcon className="h-6 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal(
|
||||
"https://github.com/zuodaotech/everyone-can-use-english/discussions"
|
||||
)
|
||||
}
|
||||
className="flex justify-between space-x-4"
|
||||
>
|
||||
<span>Github</span>
|
||||
<ExternalLinkIcon className="h-6 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="w-full absolute bottom-0 px-1 xl:px-2 py-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full xl:justify-start px-2 xl:px-4"
|
||||
>
|
||||
<HelpCircleIcon className="h-5 w-5" />
|
||||
<span className="ml-2 hidden xl:block">
|
||||
{t("sidebar.help")}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="px-4">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal("https://1000h.org/enjoy-app/")
|
||||
}
|
||||
className="flex justify-between space-x-2"
|
||||
>
|
||||
<span>{t("userGuide")}</span>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{t("feedback")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal(
|
||||
"https://mixin.one/codes/f8ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e"
|
||||
)
|
||||
}
|
||||
className="flex justify-between space-x-2"
|
||||
>
|
||||
<span>Mixin</span>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
EnjoyApp.shell.openExternal(
|
||||
"https://github.com/zuodaotech/everyone-can-use-english/discussions"
|
||||
)
|
||||
}
|
||||
className="flex justify-between space-x-2"
|
||||
>
|
||||
<span>Github</span>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-full absolute bottom-0 pt-4 pb-2 px-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`w-full ${isOpen ? "justify-start" : "justify-center"}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<PanelRightOpenIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightCloseIcon className="h-5 w-5" />
|
||||
)}
|
||||
{isOpen && <span className="ml-2"> {t("sidebar.collapse")} </span>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,8 +346,9 @@ const SidebarItem = (props: {
|
||||
active: boolean;
|
||||
Icon: LucideIcon;
|
||||
testid?: string;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
const { href, label, tooltip, active, Icon, testid } = props;
|
||||
const { href, label, tooltip, active, Icon, testid, isOpen } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -294,14 +357,15 @@ const SidebarItem = (props: {
|
||||
data-tooltip-content={tooltip}
|
||||
data-tooltip-place="right"
|
||||
data-testid={testid}
|
||||
className="block px-1 xl:px-2"
|
||||
className="block px-1"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className="w-full xl:justify-start px-2 xl:px-4"
|
||||
className={`w-full ${isOpen ? "justify-start" : "justify-center"}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="ml-2 hidden xl:block">{label}</span>
|
||||
{isOpen && <span className="ml-2">{label}</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export const NoteSemgent = (props: {
|
||||
id={`note-segment-${segment.id}-${index}`}
|
||||
>
|
||||
<div
|
||||
className={`select-text font-serif text-lg xl:text-xl 2xl:text-2xl p-1 ${
|
||||
className={`select-text font-serif text-base xl:text-lg 2xl:text-lg p-1 ${
|
||||
notedquoteIndices.includes(index)
|
||||
? "border-b border-red-500 border-dashed"
|
||||
: ""
|
||||
@@ -56,7 +56,7 @@ export const NoteSemgent = (props: {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`select-text text-sm 2xl:text-base text-muted-foreground font-code mb-1 ${
|
||||
className={`select-text text-xs 2xl:text-sm text-muted-foreground font-code mb-1 ${
|
||||
index === 0 ? "before:content-['/']" : ""
|
||||
} ${
|
||||
index === caption.timeline.length - 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -30,7 +30,7 @@ export const TranscriptionEditButton = (props: {
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const { media, transcription, generateTranscription } = useContext(
|
||||
MediaPlayerProviderContext
|
||||
MediaShadowProviderContext
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submiting, setSubmiting] = useState(false);
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useEffect, useContext } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaLoadingModal,
|
||||
MediaCaption,
|
||||
MediaPlayerControls,
|
||||
MediaTabs,
|
||||
MediaCurrentRecording,
|
||||
MediaPlayer,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import { MediaShadowPlayer } from "@renderer/components";
|
||||
|
||||
import { useVideo } from "@renderer/hooks";
|
||||
|
||||
export const VideoPlayer = (props: {
|
||||
@@ -17,13 +10,8 @@ export const VideoPlayer = (props: {
|
||||
segmentIndex?: number;
|
||||
}) => {
|
||||
const { id, md5, segmentIndex } = props;
|
||||
const {
|
||||
media,
|
||||
setMedia,
|
||||
layout,
|
||||
setCurrentSegmentIndex,
|
||||
getCachedSegmentIndex,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const { media, setMedia, setCurrentSegmentIndex, getCachedSegmentIndex } =
|
||||
useContext(MediaShadowProviderContext);
|
||||
const { video } = useVideo({ id, md5 });
|
||||
|
||||
const updateCurrentSegmentIndex = async () => {
|
||||
@@ -45,38 +33,10 @@ export const VideoPlayer = (props: {
|
||||
}, [media]);
|
||||
|
||||
if (!video) return null;
|
||||
if (!layout) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div data-testid="video-player" className={layout.wrapper}>
|
||||
<div className={`${layout.upperWrapper} mb-4`}>
|
||||
<div className="grid grid-cols-5 xl:grid-cols-3 gap-3 xl:gap-6 px-6 h-full">
|
||||
<div
|
||||
className={`col-span-2 xl:col-span-1 rounded-lg border shadow-lg ${layout.upperWrapper}`}
|
||||
>
|
||||
<MediaTabs />
|
||||
</div>
|
||||
<div className={`col-span-3 xl:col-span-2 ${layout.upperWrapper}`}>
|
||||
<MediaCaption />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${layout.lowerWrapper} flex flex-col`}>
|
||||
<div className={`${layout.playerWrapper} py-2 px-3 xl:px-6`}>
|
||||
<MediaCurrentRecording />
|
||||
</div>
|
||||
|
||||
<div className={`${layout.playerWrapper} py-2 px-3 xl:px-6`}>
|
||||
<MediaPlayer />
|
||||
</div>
|
||||
|
||||
<div className={`${layout.panelWrapper} bg-background shadow-xl`}>
|
||||
<MediaPlayerControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLoadingModal />
|
||||
<div className="h-full" data-testid="video-player">
|
||||
<MediaShadowPlayer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
VideoCard,
|
||||
VideosTable,
|
||||
VideoEditForm,
|
||||
AddMediaButton,
|
||||
MediaAddButton,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
@@ -225,7 +225,7 @@ export const VideosComponent = () => {
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<AddMediaButton type="Video" />
|
||||
<MediaAddButton type="Video" />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary">{t("cleanUp")}</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
|
||||
import { VideoCard, AddMediaButton } from "@renderer/components";
|
||||
import { VideoCard, MediaAddButton } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -62,7 +62,7 @@ export const VideosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
<MediaAddButton />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Vocabulary } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const Sentence = ({ sentence }: { sentence: string }) => {
|
||||
export const Sentence = ({
|
||||
sentence,
|
||||
className,
|
||||
}: {
|
||||
sentence: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
let words = sentence.split(" ");
|
||||
|
||||
return (
|
||||
<span className="break-all align-middle">
|
||||
<span className={cn("break-words align-middle", className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProvider,
|
||||
MediaShadowProvider,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -348,7 +348,7 @@ export const ChatSessionProvider = ({
|
||||
onUpdateMessage,
|
||||
}}
|
||||
>
|
||||
<MediaPlayerProvider>
|
||||
<MediaShadowProvider>
|
||||
{children}
|
||||
|
||||
<AlertDialog
|
||||
@@ -384,12 +384,12 @@ export const ChatSessionProvider = ({
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="h-screen p-0"
|
||||
className="h-screen p-0 flex flex-col"
|
||||
displayClose={false}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="flex items-center justify-center h-14">
|
||||
<SheetHeader className="flex items-center justify-center h-12">
|
||||
<SheetTitle className="sr-only">Shadow</SheetTitle>
|
||||
<SheetDescription className="sr-only"></SheetDescription>
|
||||
<SheetClose>
|
||||
@@ -428,7 +428,7 @@ export const ChatSessionProvider = ({
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MediaPlayerProvider>
|
||||
</MediaShadowProvider>
|
||||
</ChatSessionProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
MediaPlayerProvider,
|
||||
MediaShadowProvider,
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
@@ -61,7 +61,7 @@ export const CourseProvider = ({
|
||||
setShadowing,
|
||||
}}
|
||||
>
|
||||
<MediaPlayerProvider>
|
||||
<MediaShadowProvider>
|
||||
{children}
|
||||
<Sheet
|
||||
modal={false}
|
||||
@@ -72,12 +72,12 @@ export const CourseProvider = ({
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="h-screen p-0"
|
||||
className="h-screen p-0 flex flex-col"
|
||||
displayClose={false}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="flex items-center justify-center h-14">
|
||||
<SheetHeader className="flex items-center justify-center h-12">
|
||||
<SheetTitle className="sr-only">Shadow</SheetTitle>
|
||||
<SheetDescription className="sr-only"></SheetDescription>
|
||||
<SheetClose>
|
||||
@@ -88,7 +88,7 @@ export const CourseProvider = ({
|
||||
<AudioPlayer id={shadowing?.id} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MediaPlayerProvider>
|
||||
</MediaShadowProvider>
|
||||
</CourseProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,6 @@ export * from "./chat-session-provider";
|
||||
export * from "./course-provider";
|
||||
export * from "./db-provider";
|
||||
export * from "./hotkeys-settings-provider";
|
||||
export * from "./media-player-provider";
|
||||
export * from "./media-shadow-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./dict-provider";
|
||||
|
||||
@@ -23,25 +23,14 @@ import { SttEngineOptionEnum } from "@/types/enums";
|
||||
const ONE_MINUTE = 60;
|
||||
const TEN_MINUTES = 10 * ONE_MINUTE;
|
||||
|
||||
type MediaPlayerContextType = {
|
||||
layout: {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
wrapper: string;
|
||||
upperWrapper: string;
|
||||
lowerWrapper: string;
|
||||
playerWrapper: string;
|
||||
panelWrapper: string;
|
||||
playerHeight: number;
|
||||
};
|
||||
type MediaShadowContextType = {
|
||||
media: AudioType | VideoType;
|
||||
setMedia: (media: AudioType | VideoType) => void;
|
||||
setMediaProvider: (mediaProvider: HTMLAudioElement | null) => void;
|
||||
waveform: WaveFormDataType;
|
||||
// wavesurfer
|
||||
wavesurfer: WaveSurfer;
|
||||
setRef: (ref: any) => void;
|
||||
setWaveformContainerRef: (ref: any) => void;
|
||||
decoded: boolean;
|
||||
decodeError: string;
|
||||
setDecodeError: (error: string) => void;
|
||||
@@ -85,6 +74,7 @@ type MediaPlayerContextType = {
|
||||
// Recordings
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
togglePauseResume: () => void;
|
||||
recordingBlob: Blob;
|
||||
isRecording: boolean;
|
||||
@@ -109,31 +99,10 @@ type MediaPlayerContextType = {
|
||||
setCachedSegmentIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
export const MediaPlayerProviderContext =
|
||||
createContext<MediaPlayerContextType>(null);
|
||||
export const MediaShadowProviderContext =
|
||||
createContext<MediaShadowContextType>(null);
|
||||
|
||||
const LAYOUT = {
|
||||
sm: {
|
||||
name: "sm",
|
||||
wrapper: "h-[calc(100vh-3.5rem)]",
|
||||
upperWrapper: "h-[calc(100vh-27.5rem)] min-h-64",
|
||||
lowerWrapper: "h-[23rem]",
|
||||
playerWrapper: "h-[9rem] mb-2",
|
||||
panelWrapper: "h-16 w-full z-10 sticky bottom-0",
|
||||
playerHeight: 128,
|
||||
},
|
||||
lg: {
|
||||
name: "lg",
|
||||
wrapper: "h-[calc(100vh-3.5rem)]",
|
||||
upperWrapper: "h-[calc(100vh-37.5rem)]",
|
||||
lowerWrapper: "h-[33rem]",
|
||||
panelWrapper: "h-20 w-full z-10 sticky bottom-0",
|
||||
playerWrapper: "h-[13rem] mb-4",
|
||||
playerHeight: 192,
|
||||
},
|
||||
};
|
||||
|
||||
export const MediaPlayerProvider = ({
|
||||
export const MediaShadowProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
@@ -143,18 +112,6 @@ export const MediaPlayerProvider = ({
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
const [layout, setLayout] = useState<{
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
wrapper: string;
|
||||
upperWrapper: string;
|
||||
lowerWrapper: string;
|
||||
playerWrapper: string;
|
||||
panelWrapper: string;
|
||||
playerHeight: number;
|
||||
}>();
|
||||
|
||||
const [media, setMedia] = useState<AudioType | VideoType>(null);
|
||||
const [mediaProvider, setMediaProvider] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
@@ -167,9 +124,9 @@ export const MediaPlayerProvider = ({
|
||||
const [editingRegion, setEditingRegion] = useState<boolean>(false);
|
||||
const [pitchChart, setPitchChart] = useState<Chart>(null);
|
||||
|
||||
const [ref, setRef] = useState(null);
|
||||
const [waveformContainerRef, setWaveformContainerRef] = useState(null);
|
||||
|
||||
// Player state
|
||||
// Player state
|
||||
const [decoded, setDecoded] = useState<boolean>(false);
|
||||
const [decodeError, setDecodeError] = useState<string>(null);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
@@ -179,6 +136,7 @@ export const MediaPlayerProvider = ({
|
||||
|
||||
const [currentRecording, setCurrentRecording] = useState<RecordingType>(null);
|
||||
const [recordingType, setRecordingType] = useState<string>("segment");
|
||||
const [cancelingRecording, setCancelingRecording] = useState(false);
|
||||
|
||||
const [transcriptionDraft, setTranscriptionDraft] =
|
||||
useState<TranscriptionType["result"]>();
|
||||
@@ -192,6 +150,10 @@ export const MediaPlayerProvider = ({
|
||||
abortGenerateTranscription,
|
||||
} = useTranscriptions(media);
|
||||
|
||||
const cancelRecording = () => {
|
||||
setCancelingRecording(true);
|
||||
};
|
||||
|
||||
const {
|
||||
recordings,
|
||||
fetchRecordings,
|
||||
@@ -244,14 +206,20 @@ export const MediaPlayerProvider = ({
|
||||
});
|
||||
|
||||
const initializeWavesurfer = async () => {
|
||||
if (!layout?.playerHeight) return;
|
||||
if (!media) return;
|
||||
if (!mediaProvider) return;
|
||||
if (!ref?.current) return;
|
||||
if (!waveformContainerRef?.current) return;
|
||||
|
||||
const height =
|
||||
waveformContainerRef.current.getBoundingClientRect().height - 10; // -10 to leave space for scrollbar
|
||||
const container = waveformContainerRef.current.querySelector(
|
||||
".waveform-container"
|
||||
);
|
||||
if (!container) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: ref.current,
|
||||
height: layout.playerHeight,
|
||||
container: container as HTMLElement,
|
||||
height,
|
||||
waveColor: "#eaeaea",
|
||||
progressColor: "#c0d6df",
|
||||
cursorColor: "#ff0054",
|
||||
@@ -290,6 +258,7 @@ export const MediaPlayerProvider = ({
|
||||
if (!region) return;
|
||||
if (!waveform?.frequencies?.length) return;
|
||||
if (!wavesurfer) return;
|
||||
if (!waveformContainerRef?.current) return;
|
||||
|
||||
const caption = transcription?.result?.timeline?.[currentSegmentIndex];
|
||||
if (!caption) return;
|
||||
@@ -304,6 +273,8 @@ export const MediaPlayerProvider = ({
|
||||
);
|
||||
|
||||
const wrapper = (wavesurfer as any).renderer.getWrapper();
|
||||
if (!wrapper) return;
|
||||
|
||||
// remove existing pitch contour
|
||||
if (repaint) {
|
||||
wrapper
|
||||
@@ -315,6 +286,7 @@ export const MediaPlayerProvider = ({
|
||||
|
||||
// calculate offset and width
|
||||
const wrapperWidth = wrapper.getBoundingClientRect().width;
|
||||
const height = waveformContainerRef.current.getBoundingClientRect().height;
|
||||
const offsetLeft = (region.start / duration) * wrapperWidth;
|
||||
const width = ((region.end - region.start) / duration) * wrapperWidth;
|
||||
|
||||
@@ -324,7 +296,7 @@ export const MediaPlayerProvider = ({
|
||||
const canvasId = options?.canvasId || `pitch-contour-${region.id}-canvas`;
|
||||
canvas.id = canvasId;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${layout.playerHeight}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
pitchContourWidthContainer.appendChild(canvas);
|
||||
|
||||
pitchContourWidthContainer.style.position = "absolute";
|
||||
@@ -332,7 +304,7 @@ export const MediaPlayerProvider = ({
|
||||
pitchContourWidthContainer.style.left = "0";
|
||||
|
||||
pitchContourWidthContainer.style.width = `${width}px`;
|
||||
pitchContourWidthContainer.style.height = `${layout.playerHeight}px`;
|
||||
pitchContourWidthContainer.style.height = `${height}px`;
|
||||
pitchContourWidthContainer.style.marginLeft = `${offsetLeft}px`;
|
||||
pitchContourWidthContainer.classList.add(
|
||||
"pitch-contour",
|
||||
@@ -446,25 +418,11 @@ export const MediaPlayerProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
const calculateHeight = () => {
|
||||
if (window.innerHeight <= 1080) {
|
||||
setLayout({
|
||||
...LAYOUT.sm,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
} else {
|
||||
setLayout({
|
||||
...LAYOUT.lg,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
const onRecorded = async (blob: Blob) => {
|
||||
if (cancelingRecording) {
|
||||
setCancelingRecording(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const deboundeCalculateHeight = debounce(calculateHeight, 100);
|
||||
|
||||
const createRecording = async (blob: Blob) => {
|
||||
if (!blob) return;
|
||||
if (!media) return;
|
||||
if (!transcription?.result?.timeline) return;
|
||||
@@ -555,12 +513,13 @@ export const MediaPlayerProvider = ({
|
||||
* update fitZoomRatio when currentSegmentIndex is updated
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
if (!waveformContainerRef?.current) return;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
if (!activeRegion) return;
|
||||
|
||||
const containerWidth = ref.current.getBoundingClientRect().width;
|
||||
const containerWidth =
|
||||
waveformContainerRef.current.getBoundingClientRect().width;
|
||||
const duration = activeRegion.end - activeRegion.start;
|
||||
if (activeRegion.id.startsWith("word-region")) {
|
||||
setFitZoomRatio(containerWidth / 3 / duration / minPxPerSec);
|
||||
@@ -571,7 +530,7 @@ export const MediaPlayerProvider = ({
|
||||
return () => {
|
||||
setFitZoomRatio(1.0);
|
||||
};
|
||||
}, [ref, wavesurfer, activeRegion]);
|
||||
}, [waveformContainerRef, wavesurfer, activeRegion]);
|
||||
|
||||
/*
|
||||
* Zoom chart when zoomRatio update
|
||||
@@ -628,7 +587,7 @@ export const MediaPlayerProvider = ({
|
||||
setDecoded(false);
|
||||
setDecodeError(null);
|
||||
};
|
||||
}, [media?.src, ref?.current, mediaProvider, layout?.playerHeight]);
|
||||
}, [media?.src, waveformContainerRef?.current, mediaProvider]);
|
||||
|
||||
/* cache last segment index */
|
||||
useEffect(() => {
|
||||
@@ -639,18 +598,10 @@ export const MediaPlayerProvider = ({
|
||||
}, [currentSegmentIndex]);
|
||||
|
||||
/*
|
||||
* Update layout when window is resized
|
||||
* Abort transcription when component is unmounted
|
||||
*/
|
||||
useEffect(() => {
|
||||
calculateHeight();
|
||||
|
||||
EnjoyApp.window.onResize(() => {
|
||||
deboundeCalculateHeight();
|
||||
});
|
||||
|
||||
return () => {
|
||||
EnjoyApp.window.removeListeners();
|
||||
abortGenerateTranscription();
|
||||
};
|
||||
}, []);
|
||||
@@ -659,9 +610,15 @@ export const MediaPlayerProvider = ({
|
||||
* create recording when recordingBlob is updated
|
||||
*/
|
||||
useEffect(() => {
|
||||
createRecording(recordingBlob);
|
||||
onRecorded(recordingBlob);
|
||||
}, [recordingBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cancelingRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
}, [cancelingRecording]);
|
||||
|
||||
/**
|
||||
* auto stop recording when recording time is over
|
||||
*/
|
||||
@@ -677,14 +634,13 @@ export const MediaPlayerProvider = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaPlayerProviderContext.Provider
|
||||
<MediaShadowProviderContext.Provider
|
||||
value={{
|
||||
layout,
|
||||
media,
|
||||
setMedia,
|
||||
setMediaProvider,
|
||||
wavesurfer,
|
||||
setRef,
|
||||
setWaveformContainerRef,
|
||||
decoded,
|
||||
decodeError,
|
||||
setDecodeError,
|
||||
@@ -712,6 +668,7 @@ export const MediaPlayerProvider = ({
|
||||
setTranscriptionDraft,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
togglePauseResume,
|
||||
recordingBlob,
|
||||
isRecording,
|
||||
@@ -735,8 +692,8 @@ export const MediaPlayerProvider = ({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaPlayerProviderContext.Provider>
|
||||
<Tooltip className="z-10" id="media-player-tooltip" />
|
||||
</MediaShadowProviderContext.Provider>
|
||||
<Tooltip className="z-10" id="media-shadow-tooltip" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { AudioPlayer } from "@renderer/components";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { MediaPlayerProvider } from "@renderer/context";
|
||||
import { MediaShadowProvider } from "@renderer/context";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,17 +13,19 @@ export default () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full relative">
|
||||
<div className="flex space-x-1 items-center h-14 px-4 xl:px-8">
|
||||
<div className="h-screen flex flex-col relative">
|
||||
<div className="flex space-x-1 items-center h-12 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{t("shadowingAudio")}</span>
|
||||
<span className="text-sm">{t("shadowingAudio")}</span>
|
||||
</div>
|
||||
|
||||
<MediaPlayerProvider>
|
||||
<AudioPlayer id={id} segmentIndex={parseInt(segmentIndex)} />
|
||||
</MediaPlayerProvider>
|
||||
<div className="flex-1">
|
||||
<MediaShadowProvider>
|
||||
<AudioPlayer id={id} segmentIndex={parseInt(segmentIndex)} />
|
||||
</MediaShadowProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { t } from "i18next";
|
||||
import {
|
||||
DbProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProvider,
|
||||
MediaShadowProvider,
|
||||
} from "@renderer/context";
|
||||
import { messagesReducer } from "@renderer/reducers";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -268,7 +268,7 @@ export default () => {
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<MediaPlayerProvider>
|
||||
<MediaShadowProvider>
|
||||
<ScrollArea ref={containerRef} className="px-4 flex-1">
|
||||
<div className="messages flex flex-col-reverse gap-6 my-6">
|
||||
<div className="w-full h-24"></div>
|
||||
@@ -315,7 +315,7 @@ export default () => {
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</MediaPlayerProvider>
|
||||
</MediaShadowProvider>
|
||||
|
||||
<div className="bg-background px-4 absolute w-full bottom-0 left-0 z-50">
|
||||
<div className="focus-within:bg-background pr-4 py-2 flex items-end space-x-4 rounded-lg shadow-lg border scrollbar">
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ConversationCard,
|
||||
ConversationForm,
|
||||
} from "@renderer/components";
|
||||
import { ConversationCard, ConversationForm } from "@renderer/components";
|
||||
import { useState, useEffect, useContext, useReducer } from "react";
|
||||
import { ChevronLeftIcon, LoaderIcon } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
@@ -92,7 +89,7 @@ export default () => {
|
||||
.findAll({
|
||||
order: [["updatedAt", "DESC"]],
|
||||
limit,
|
||||
offset: conversations.length,
|
||||
offset: conversations?.length || 0,
|
||||
})
|
||||
.then((_conversations) => {
|
||||
if (_conversations.length === 0) {
|
||||
|
||||
@@ -32,6 +32,7 @@ const CoursesList = () => {
|
||||
|
||||
const fetchCourses = async () => {
|
||||
if (loading) return;
|
||||
if (!webApi) return;
|
||||
|
||||
webApi
|
||||
.courses({ page: nextPage, language: learningLanguage })
|
||||
|
||||
@@ -22,12 +22,14 @@ export default () => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApi) return;
|
||||
|
||||
webApi.config("ytb_channels").then((channels) => {
|
||||
if (!channels) return;
|
||||
|
||||
setChannels(channels);
|
||||
});
|
||||
}, []);
|
||||
}, [webApi]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VideoPlayer } from "@renderer/components";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { MediaPlayerProvider } from "@renderer/context";
|
||||
import { MediaShadowProvider } from "@renderer/context";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,17 +13,17 @@ export default () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full relative">
|
||||
<div className="flex space-x-1 items-center h-14 px-4 xl:px-8">
|
||||
<div className="h-screen flex flex-col relative">
|
||||
<div className="flex space-x-1 items-center h-12 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{t("shadowingVideo")}</span>
|
||||
</div>
|
||||
|
||||
<MediaPlayerProvider>
|
||||
<MediaShadowProvider>
|
||||
<VideoPlayer id={id} segmentIndex={parseInt(segmentIndex)} />
|
||||
</MediaPlayerProvider>
|
||||
</MediaShadowProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user