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:
an-lee
2024-09-12 06:48:19 +08:00
committed by GitHub
parent 4741d5be7c
commit fbc1394a70
59 changed files with 1830 additions and 1691 deletions

View File

@@ -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",

View File

@@ -178,7 +178,8 @@
"preferences": "软件设置",
"profile": "个人主页",
"notes": "笔记",
"help": "帮助"
"help": "帮助",
"collapse": "收起"
},
"form": {
"lengthMustBeAtLeast": "{{field}} 长度不可超过 {{length}} 个字符",

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>
)}

View File

@@ -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} />

View File

@@ -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>
)}

View File

@@ -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";

View File

@@ -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);

View File

@@ -0,0 +1,4 @@
export * from "./media-bottom-panel";
export * from "./media-current-recording";
export * from "./media-waveform";
export * from "./media-player-controls";

View File

@@ -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>
);
};

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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>
)
)}

View File

@@ -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)}>

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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>();

View File

@@ -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
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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 />
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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 />

View File

@@ -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 }) {

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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" />
</>
);
};

View File

@@ -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>
</>
);

View File

@@ -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">

View File

@@ -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) {

View File

@@ -32,6 +32,7 @@ const CoursesList = () => {
const fetchCourses = async () => {
if (loading) return;
if (!webApi) return;
webApi
.courses({ page: nextPage, language: learningLanguage })

View File

@@ -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">

View File

@@ -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>
</>
);