From fbc1394a708fe8c832cf40e0d1c9d25ec9bd50e8 Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 12 Sep 2024 06:48:19 +0800 Subject: [PATCH] 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 --- enjoy/src/i18n/en.json | 3 +- enjoy/src/i18n/zh-CN.json | 3 +- .../components/audios/audio-player.tsx | 53 +- .../components/audios/audios-component.tsx | 4 +- .../components/audios/audios-segment.tsx | 4 +- .../renderer/components/chats/chat-input.tsx | 15 +- .../components/courses/chapter-content.tsx | 10 +- .../components/courses/example-content.tsx | 8 +- .../components/llm-chats/llm-message.tsx | 4 +- enjoy/src/renderer/components/medias/index.ts | 19 +- ...-media-button.tsx => media-add-button.tsx} | 2 +- .../medias/media-bottom-panel/index.ts | 4 + .../media-bottom-panel/media-bottom-panel.tsx | 22 + .../media-current-recording.tsx | 459 ++++++++------ .../media-player-controls.tsx | 59 +- .../media-bottom-panel/media-waveform.tsx | 335 ++++++++++ .../components/medias/media-caption.tsx | 589 ------------------ .../components/medias/media-captions/index.ts | 4 - .../media-captions/media-caption-tabs.tsx | 69 -- .../medias/media-left-panel/index.ts | 9 + .../media-info.tsx} | 6 +- .../media-left-panel/media-left-panel.tsx | 82 +++ .../{ => media-left-panel}/media-provider.tsx | 6 +- .../media-recordings.tsx | 4 +- .../media-transcription-generate-button.tsx | 4 +- .../media-transcription-print.tsx | 4 +- .../media-transcription-read-button.tsx | 12 +- .../media-transcription.tsx | 10 +- .../transcription.template.html | 0 .../components/medias/media-loading-modal.tsx | 4 +- .../components/medias/media-player.tsx | 337 ---------- .../medias/media-right-panel/index.ts | 6 + .../media-caption-actions.tsx | 267 ++++++++ .../media-caption-analysis.tsx} | 4 +- .../media-caption-note.tsx} | 6 +- .../media-caption-translation.tsx} | 6 +- .../media-right-panel/media-caption.tsx | 113 ++++ .../media-right-panel/media-right-panel.tsx | 328 ++++++++++ .../components/medias/media-shadow-player.tsx | 42 ++ .../renderer/components/medias/media-tabs.tsx | 82 --- .../components/messages/assistant-message.tsx | 4 +- .../components/misc/markdown-wrapper.tsx | 3 +- .../src/renderer/components/misc/sidebar.tsx | 226 ++++--- .../components/notes/note-segment.tsx | 4 +- .../transcription-edit-button.tsx | 4 +- .../components/videos/video-player.tsx | 54 +- .../components/videos/videos-component.tsx | 4 +- .../components/videos/videos-segment.tsx | 4 +- .../renderer/components/widgets/sentence.tsx | 11 +- .../context/chat-session-provider.tsx | 10 +- .../src/renderer/context/course-provider.tsx | 10 +- enjoy/src/renderer/context/index.ts | 2 +- ...provider.tsx => media-shadow-provider.tsx} | 143 ++--- enjoy/src/renderer/pages/audio.tsx | 16 +- enjoy/src/renderer/pages/conversation.tsx | 6 +- enjoy/src/renderer/pages/conversations.tsx | 7 +- enjoy/src/renderer/pages/courses/index.tsx | 1 + enjoy/src/renderer/pages/home.tsx | 4 +- enjoy/src/renderer/pages/video.tsx | 10 +- 59 files changed, 1830 insertions(+), 1691 deletions(-) rename enjoy/src/renderer/components/medias/{add-media-button.tsx => media-add-button.tsx} (98%) create mode 100644 enjoy/src/renderer/components/medias/media-bottom-panel/index.ts create mode 100644 enjoy/src/renderer/components/medias/media-bottom-panel/media-bottom-panel.tsx rename enjoy/src/renderer/components/medias/{ => media-bottom-panel}/media-current-recording.tsx (63%) rename enjoy/src/renderer/components/medias/{ => media-bottom-panel}/media-player-controls.tsx (92%) create mode 100644 enjoy/src/renderer/components/medias/media-bottom-panel/media-waveform.tsx delete mode 100644 enjoy/src/renderer/components/medias/media-caption.tsx delete mode 100644 enjoy/src/renderer/components/medias/media-captions/index.ts delete mode 100644 enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx create mode 100644 enjoy/src/renderer/components/medias/media-left-panel/index.ts rename enjoy/src/renderer/components/medias/{media-info-panel.tsx => media-left-panel/media-info.tsx} (95%) create mode 100644 enjoy/src/renderer/components/medias/media-left-panel/media-left-panel.tsx rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-provider.tsx (95%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-recordings.tsx (99%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-transcription-generate-button.tsx (96%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-transcription-print.tsx (95%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-transcription-read-button.tsx (98%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/media-transcription.tsx (96%) rename enjoy/src/renderer/components/medias/{ => media-left-panel}/transcription.template.html (100%) delete mode 100644 enjoy/src/renderer/components/medias/media-player.tsx create mode 100644 enjoy/src/renderer/components/medias/media-right-panel/index.ts create mode 100644 enjoy/src/renderer/components/medias/media-right-panel/media-caption-actions.tsx rename enjoy/src/renderer/components/medias/{media-captions/media-tab-content-analysis.tsx => media-right-panel/media-caption-analysis.tsx} (97%) rename enjoy/src/renderer/components/medias/{media-captions/media-tab-content-note.tsx => media-right-panel/media-caption-note.tsx} (95%) rename enjoy/src/renderer/components/medias/{media-captions/media-tab-content-translation.tsx => media-right-panel/media-caption-translation.tsx} (96%) create mode 100644 enjoy/src/renderer/components/medias/media-right-panel/media-caption.tsx create mode 100644 enjoy/src/renderer/components/medias/media-right-panel/media-right-panel.tsx create mode 100644 enjoy/src/renderer/components/medias/media-shadow-player.tsx delete mode 100644 enjoy/src/renderer/components/medias/media-tabs.tsx rename enjoy/src/renderer/context/{media-player-provider.tsx => media-shadow-provider.tsx} (87%) diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 2e9cc03c..3dd3eb92 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -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", diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index aa80da36..4ce392c8 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -178,7 +178,8 @@ "preferences": "软件设置", "profile": "个人主页", "notes": "笔记", - "help": "帮助" + "help": "帮助", + "collapse": "收起" }, "form": { "lengthMustBeAtLeast": "{{field}} 长度不可超过 {{length}} 个字符", diff --git a/enjoy/src/renderer/components/audios/audio-player.tsx b/enjoy/src/renderer/components/audios/audio-player.tsx index 84128a77..081cf1aa 100644 --- a/enjoy/src/renderer/components/audios/audio-player.tsx +++ b/enjoy/src/renderer/components/audios/audio-player.tsx @@ -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 ; return ( -
-
-
-
- -
-
- -
-
-
- -
-
- -
- -
- -
- -
- -
-
- - +
+
); }; diff --git a/enjoy/src/renderer/components/audios/audios-component.tsx b/enjoy/src/renderer/components/audios/audios-component.tsx index d611c7df..beb628bc 100644 --- a/enjoy/src/renderer/components/audios/audios-component.tsx +++ b/enjoy/src/renderer/components/audios/audios-component.tsx @@ -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)} /> - + diff --git a/enjoy/src/renderer/components/audios/audios-segment.tsx b/enjoy/src/renderer/components/audios/audios-segment.tsx index 897e7c0b..88f6b88d 100644 --- a/enjoy/src/renderer/components/audios/audios-segment.tsx +++ b/enjoy/src/renderer/components/audios/audios-segment.tsx @@ -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 ? (
- +
) : ( diff --git a/enjoy/src/renderer/components/chats/chat-input.tsx b/enjoy/src/renderer/components/chats/chat-input.tsx index ba651b98..f3badeb2 100644 --- a/enjoy/src/renderer/components/chats/chat-input.tsx +++ b/enjoy/src/renderer/components/chats/chat-input.tsx @@ -51,6 +51,7 @@ export const ChatInput = () => { isPaused, askAgent, onCreateMessage, + shadowing, } = useContext(ChatSessionProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); const inputRef = useRef(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 ( diff --git a/enjoy/src/renderer/components/courses/chapter-content.tsx b/enjoy/src/renderer/components/courses/chapter-content.tsx index 54bf76c2..75f9c344 100644 --- a/enjoy/src/renderer/components/courses/chapter-content.tsx +++ b/enjoy/src/renderer/components/courses/chapter-content.tsx @@ -83,15 +83,19 @@ export const ChapterContent = (props: { )}
-
+

{chapter.sequence}. {chapter?.title}

- {chapter?.content} + + {chapter?.content} + {translation && (
{t("translation")} - {translation.content} + + {translation.content} +
)} diff --git a/enjoy/src/renderer/components/courses/example-content.tsx b/enjoy/src/renderer/components/courses/example-content.tsx index fc029cc1..ad907204 100644 --- a/enjoy/src/renderer/components/courses/example-content.tsx +++ b/enjoy/src/renderer/components/courses/example-content.tsx @@ -145,11 +145,15 @@ export const ExampleContent = (props: { return (
- {example.content} + + {example.content} + {translation && (
{t("translation")} - {translation.content} + + {translation.content} +
)} diff --git a/enjoy/src/renderer/components/llm-chats/llm-message.tsx b/enjoy/src/renderer/components/llm-chats/llm-message.tsx index 4c26eb40..068295e5 100644 --- a/enjoy/src/renderer/components/llm-chats/llm-message.tsx +++ b/enjoy/src/renderer/components/llm-chats/llm-message.tsx @@ -213,11 +213,11 @@ export const LlmMessage = (props: { llmMessage: LlmMessageType }) => {
- + {llmMessage.response} {translation && ( - + {translation} )} diff --git a/enjoy/src/renderer/components/medias/index.ts b/enjoy/src/renderer/components/medias/index.ts index 249f2929..b881b8bd 100644 --- a/enjoy/src/renderer/components/medias/index.ts +++ b/enjoy/src/renderer/components/medias/index.ts @@ -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"; diff --git a/enjoy/src/renderer/components/medias/add-media-button.tsx b/enjoy/src/renderer/components/medias/media-add-button.tsx similarity index 98% rename from enjoy/src/renderer/components/medias/add-media-button.tsx rename to enjoy/src/renderer/components/medias/media-add-button.tsx index 407d071f..879434c2 100644 --- a/enjoy/src/renderer/components/medias/add-media-button.tsx +++ b/enjoy/src/renderer/components/medias/media-add-button.tsx @@ -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); diff --git a/enjoy/src/renderer/components/medias/media-bottom-panel/index.ts b/enjoy/src/renderer/components/medias/media-bottom-panel/index.ts new file mode 100644 index 00000000..68e2c42e --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-bottom-panel/index.ts @@ -0,0 +1,4 @@ +export * from "./media-bottom-panel"; +export * from "./media-current-recording"; +export * from "./media-waveform"; +export * from "./media-player-controls"; diff --git a/enjoy/src/renderer/components/medias/media-bottom-panel/media-bottom-panel.tsx b/enjoy/src/renderer/components/medias/media-bottom-panel/media-bottom-panel.tsx new file mode 100644 index 00000000..c4dd54d7 --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-bottom-panel/media-bottom-panel.tsx @@ -0,0 +1,22 @@ +import { + MediaCurrentRecording, + MediaWaveform, + MediaPlayerControls, +} from "@renderer/components"; + +export const MediaBottomPanel = () => { + return ( +
+
+
+ +
+
+ +
+
+ + +
+ ); +}; diff --git a/enjoy/src/renderer/components/medias/media-current-recording.tsx b/enjoy/src/renderer/components/medias/media-bottom-panel/media-current-recording.tsx similarity index 63% rename from enjoy/src/renderer/components/medias/media-current-recording.tsx rename to enjoy/src/renderer/components/medias/media-bottom-panel/media-current-recording.tsx index 7ba1b775..98fdd7c7 100644 --- a/enjoy/src/renderer/components/medias/media-current-recording.tsx +++ b/enjoy/src/renderer/components/medias/media-bottom-panel/media-current-recording.tsx @@ -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([]); const [peaks, setPeaks] = useState([]); - const [width, setWidth] = useState(); + 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 ( -
-
-
- - - {Math.floor(recordingTime / 60)}: - {String(recordingTime % 60).padStart(2, "0")} - -
-
-
- +
+ + + {Math.floor(recordingTime / 60)}: + {String(recordingTime % 60).padStart(2, "0")} + +
+ + +
); @@ -473,10 +613,15 @@ export const MediaCurrentRecording = () => { if (!currentRecording?.src) return ( -
-
+
+
+
+
+ +
+
{ }} >
- -
- -
); return ( -
-
-
+
+
+
{formatDuration(currentTime || 0)} @@ -507,149 +657,60 @@ export const MediaCurrentRecording = () => {
-
- + ))} - if (region) { - region.play(); - } else { - player?.playPause(); - } - }} - > - {player?.isPlaying() ? ( - - ) : ( - - )} - + {actionButtonsCount < Actions.length && ( + + + + - - - - - - - - - - - - - {layout?.name === "sm" && ( - <> + + {Actions.slice(actionButtonsCount).map((action) => ( setDetailIsOpen(true)} + id={action.id} + key={action.name} + className={`cursor-pointer ${ + action.active ? "bg-muted" : "" + }`} + onClick={action.onClick} > - = 80 - ? "text-green-500" - : currentRecording.pronunciationAssessment - .pronunciationScore >= 60 - ? "text-yellow-600" - : "text-red-500" - : "" - } - `} + - {t("pronunciationAssessment")} + {action.label} - - - - {t("compare")} - - - )} - setIsSelectingRegion(!isSelectingRegion)} - > - - {t("selectRegion")} - - setIsSharing(true)} - > - - {t("share")} - - - - - {t("download")} - - - + ))} + + + )}
@@ -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 ? ( diff --git a/enjoy/src/renderer/components/medias/media-player-controls.tsx b/enjoy/src/renderer/components/medias/media-bottom-panel/media-player-controls.tsx similarity index 92% rename from enjoy/src/renderer/components/medias/media-player-controls.tsx rename to enjoy/src/renderer/components/medias/media-bottom-panel/media-player-controls.tsx index f7e949b2..db388fd9 100644 --- a/enjoy/src/renderer/components/medias/media-player-controls.tsx +++ b/enjoy/src/renderer/components/medias/media-bottom-panel/media-player-controls.tsx @@ -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 ( -
+
@@ -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" > @@ -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" > @@ -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" > @@ -657,9 +646,9 @@ export const MediaPlayerControls = () => { + ))} + + {actionButtonsCount < Actions.length && ( + + + + + + + {Actions.slice(actionButtonsCount).map((action) => ( + + + {action.label} + + ))} + + + )} + + + + + + {media?.mediaType === "Audio" + ? t("shareAudio") + : t("shareVideo")} + + + {media?.mediaType === "Audio" + ? t("areYouSureToShareThisAudioToCommunity") + : t("areYouSureToShareThisVideoToCommunity")} + + + + {t("cancel")} + + + + + + +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/medias/media-caption.tsx b/enjoy/src/renderer/components/medias/media-caption.tsx deleted file mode 100644 index fa4fa07d..00000000 --- a/enjoy/src/renderer/components/medias/media-caption.tsx +++ /dev/null @@ -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(0); - const [selectedIndices, setSelectedIndices] = useState([]); - const [multiSelecting, setMultiSelecting] = useState(false); - - const [displayIpa, setDisplayIpa] = useState(true); - const [displayNotes, setDisplayNotes] = useState(true); - const [_, copyToClipboard] = useCopyToClipboard(); - const [copied, setCopied] = useState(false); - - const [caption, setCaption] = useState(null); - const [tab, setTab] = useState("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 ( -
-
- - - -
- -
- - - - - - - - } - /> - - - - -
-
- ); -}; - -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([]); - - 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 ( -
- {/* use the words splitted by caption text if it is matched with the timeline length, otherwise use the timeline */} - {words.map((word, index) => ( -
-
onClick && onClick(index)} - > - {word} -
- - {displayIpa && ( -
- {ipas[index]} -
- )} - - {displayNotes && - notes - .filter((note) => note.parameters.quoteIndices[0] === index) - .map((note) => ( -
- setNotedquoteIndices(note.parameters.quoteIndices) - } - onMouseLeave={() => setNotedquoteIndices([])} - onClick={() => - document.getElementById("note-" + note.id)?.scrollIntoView() - } - > - {note.parameters.quoteIndices[0] === index && note.content} -
- ))} -
- ))} -
- ); -}; diff --git a/enjoy/src/renderer/components/medias/media-captions/index.ts b/enjoy/src/renderer/components/medias/media-captions/index.ts deleted file mode 100644 index 0e6328ac..00000000 --- a/enjoy/src/renderer/components/medias/media-captions/index.ts +++ /dev/null @@ -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"; diff --git a/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx b/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx deleted file mode 100644 index 6201e379..00000000 --- a/enjoy/src/renderer/components/medias/media-captions/media-caption-tabs.tsx +++ /dev/null @@ -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 ( - - setTab(value)} className=""> - {children} - -
- - - - - -
- - - - {t("captionTabs.translation")} - - - {t("captionTabs.note")} - - - {t("captionTabs.analysis")} - - -
-
- ); -}; diff --git a/enjoy/src/renderer/components/medias/media-left-panel/index.ts b/enjoy/src/renderer/components/medias/media-left-panel/index.ts new file mode 100644 index 00000000..37b53dbd --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-left-panel/index.ts @@ -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"; diff --git a/enjoy/src/renderer/components/medias/media-info-panel.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-info.tsx similarity index 95% rename from enjoy/src/renderer/components/medias/media-info-panel.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-info.tsx index 16726857..f76704b5 100644 --- a/enjoy/src/renderer/components/medias/media-info-panel.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-info.tsx @@ -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(false); diff --git a/enjoy/src/renderer/components/medias/media-left-panel/media-left-panel.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-left-panel.tsx new file mode 100644 index 00000000..5f59525d --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-left-panel.tsx @@ -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 ( + + + {media?.mediaType === "Video" && ( + + {t("player")} + + )} + + {t("transcription")} + + + {t("myRecordings")} + + + {t("mediaInfo")} + + + + + +
+ +
+
+ +
+ +
+
+ + + + + + +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/medias/media-provider.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-provider.tsx similarity index 95% rename from enjoy/src/renderer/components/medias/media-provider.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-provider.tsx index 15d4fee0..a49f40d3 100644 --- a/enjoy/src/renderer/components/medias/media-provider.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-provider.tsx @@ -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(null); @@ -63,7 +63,7 @@ export const MediaProvider = () => { if (!media?.src) return null; return ( -
+
{ currentSegmentIndex, transcription, media, - } = useContext(MediaPlayerProviderContext); + } = useContext(MediaShadowProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); const [selectedRecording, setSelectedRecording] = useState(null); diff --git a/enjoy/src/renderer/components/medias/media-transcription-generate-button.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-generate-button.tsx similarity index 96% rename from enjoy/src/renderer/components/medias/media-transcription-generate-button.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-transcription-generate-button.tsx index 3f330166..e2ebec4a 100644 --- a/enjoy/src/renderer/components/medias/media-transcription-generate-button.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-generate-button.tsx @@ -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 ( diff --git a/enjoy/src/renderer/components/medias/media-transcription-print.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-print.tsx similarity index 95% rename from enjoy/src/renderer/components/medias/media-transcription-print.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-transcription-print.tsx index ba959033..ffe7f1d9 100644 --- a/enjoy/src/renderer/components/medias/media-transcription-print.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-print.tsx @@ -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 ); diff --git a/enjoy/src/renderer/components/medias/media-transcription-read-button.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-read-button.tsx similarity index 98% rename from enjoy/src/renderer/components/medias/media-transcription-read-button.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-transcription-read-button.tsx index 8cf5cbe5..13d94167 100644 --- a/enjoy/src/renderer/components/medias/media-transcription-read-button.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription-read-button.tsx @@ -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: { #{index + 1} - { const [deleting, setDeleting] = useState(null); const { EnjoyApp } = useContext(AppSettingsProviderContext); - const { media } = useContext(MediaPlayerProviderContext); + const { media } = useContext(MediaShadowProviderContext); const [assessing, setAssessing] = useState(); const handleDelete = () => { @@ -294,7 +294,7 @@ const RecorderButton = () => { stopRecording, mediaRecorder, recordingTime, - } = useContext(MediaPlayerProviderContext); + } = useContext(MediaShadowProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); const [access, setAccess] = useState(false); diff --git a/enjoy/src/renderer/components/medias/media-transcription.tsx b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription.tsx similarity index 96% rename from enjoy/src/renderer/components/medias/media-transcription.tsx rename to enjoy/src/renderer/components/medias/media-left-panel/media-transcription.tsx index d0b1e689..3ca71713 100644 --- a/enjoy/src/renderer/components/medias/media-transcription.tsx +++ b/enjoy/src/renderer/components/medias/media-left-panel/media-transcription.tsx @@ -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 (
-
+
{transcribing || transcription.state === "processing" ? ( @@ -179,7 +179,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
{ @@ -204,7 +204,7 @@ export const MediaTranscription = (props: { display?: boolean }) => {
- +
) )} diff --git a/enjoy/src/renderer/components/medias/transcription.template.html b/enjoy/src/renderer/components/medias/media-left-panel/transcription.template.html similarity index 100% rename from enjoy/src/renderer/components/medias/transcription.template.html rename to enjoy/src/renderer/components/medias/media-left-panel/transcription.template.html diff --git a/enjoy/src/renderer/components/medias/media-loading-modal.tsx b/enjoy/src/renderer/components/medias/media-loading-modal.tsx index b71fe057..34c7e320 100644 --- a/enjoy/src/renderer/components/medias/media-loading-modal.tsx +++ b/enjoy/src/renderer/components/medias/media-loading-modal.tsx @@ -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 ( diff --git a/enjoy/src/renderer/components/medias/media-player.tsx b/enjoy/src/renderer/components/medias/media-player.tsx deleted file mode 100644 index 549cd912..00000000 --- a/enjoy/src/renderer/components/medias/media-player.tsx +++ /dev/null @@ -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(true); - const [isSharing, setIsSharing] = useState(false); - const [width, setWidth] = useState(); - - 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 ( -
-
-
-
- {formatDuration(currentTime || 0)} - / - - {formatDuration(media?.duration || 0)} - -
-
-
- - - - - { - layout.name === "lg" && ( - <> - - - - - ) - } - - - - - - - - { - layout.name === "sm" && ( - <> - { - if (zoomRatio > MIN_ZOOM_RATIO) { - const nextZoomRatio = ZOOM_RATIO_OPTIONS.reverse().find( - (rate) => rate < zoomRatio - ); - setZoomRatio(nextZoomRatio || MIN_ZOOM_RATIO); - } - }} - > - - {t("zoomOut")} - - - { - setDisplayInlineCaption(!displayInlineCaption); - if (pitchChart) { - pitchChart.options.scales.x.display = !displayInlineCaption; - pitchChart.update(); - } - }} - > - - {t("inlineCaption")} - - - ) - } - { - wavesurfer.setOptions({ - autoCenter: !wavesurfer?.options?.autoCenter, - }); - }} - > - - {t("autoCenter")} - - - setIsSharing(true)} - > - - {t("share")} - - - - - {t("download")} - - - - - - - - - {media?.mediaType === "Audio" - ? t("shareAudio") - : t("shareVideo")} - - - {media?.mediaType === "Audio" - ? t("areYouSureToShareThisAudioToCommunity") - : t("areYouSureToShareThisVideoToCommunity")} - - - - {t("cancel")} - - - - - - -
-
- ); -}; diff --git a/enjoy/src/renderer/components/medias/media-right-panel/index.ts b/enjoy/src/renderer/components/medias/media-right-panel/index.ts new file mode 100644 index 00000000..5fe4f301 --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-right-panel/index.ts @@ -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"; diff --git a/enjoy/src/renderer/components/medias/media-right-panel/media-caption-actions.tsx b/enjoy/src/renderer/components/medias/media-right-panel/media-caption-actions.tsx new file mode 100644 index 00000000..57c87306 --- /dev/null +++ b/enjoy/src/renderer/components/medias/media-right-panel/media-caption-actions.tsx @@ -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(false); + + const [fbtOpen, setFbtOpen] = useState(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 ( + + + + + +
+ + + + + + + + } + /> + + + + +
+
+
+ ); +}; diff --git a/enjoy/src/renderer/components/medias/media-captions/media-tab-content-analysis.tsx b/enjoy/src/renderer/components/medias/media-right-panel/media-caption-analysis.tsx similarity index 97% rename from enjoy/src/renderer/components/medias/media-captions/media-tab-content-analysis.tsx rename to enjoy/src/renderer/components/medias/media-right-panel/media-caption-analysis.tsx index b7b57701..477f61ac 100644 --- a/enjoy/src/renderer/components/medias/media-captions/media-tab-content-analysis.tsx +++ b/enjoy/src/renderer/components/medias/media-right-panel/media-caption-analysis.tsx @@ -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(false); @@ -121,7 +121,7 @@ const AIButton = (props: { title={tooltip} trigger={
@@ -206,71 +249,90 @@ export const Sidebar = () => { + + + +
+ +
+
+ + + + + EnjoyApp.shell.openExternal("https://998h.org/enjoy-app/") + } + className="flex justify-between space-x-4" + > + {t("userGuide")} + + + + + + + + {t("feedback")} + + + + + EnjoyApp.shell.openExternal( + "https://mixin.one/codes/f6ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e" + ) + } + className="flex justify-between space-x-4" + > + Mixin + + + + EnjoyApp.shell.openExternal( + "https://github.com/zuodaotech/everyone-can-use-english/discussions" + ) + } + className="flex justify-between space-x-4" + > + Github + + + + + + + +
-
- - - - - - - - - EnjoyApp.shell.openExternal("https://1000h.org/enjoy-app/") - } - className="flex justify-between space-x-2" - > - {t("userGuide")} - - - - - - - - {t("feedback")} - - - - - EnjoyApp.shell.openExternal( - "https://mixin.one/codes/f8ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e" - ) - } - className="flex justify-between space-x-2" - > - Mixin - - - - EnjoyApp.shell.openExternal( - "https://github.com/zuodaotech/everyone-can-use-english/discussions" - ) - } - className="flex justify-between space-x-2" - > - Github - - - - - - - - +
+
@@ -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 ( ); diff --git a/enjoy/src/renderer/components/notes/note-segment.tsx b/enjoy/src/renderer/components/notes/note-segment.tsx index 854eafea..07a6e695 100644 --- a/enjoy/src/renderer/components/notes/note-segment.tsx +++ b/enjoy/src/renderer/components/notes/note-segment.tsx @@ -45,7 +45,7 @@ export const NoteSemgent = (props: { id={`note-segment-${segment.id}-${index}`} >
{ const { media, transcription, generateTranscription } = useContext( - MediaPlayerProviderContext + MediaShadowProviderContext ); const [open, setOpen] = useState(false); const [submiting, setSubmiting] = useState(false); diff --git a/enjoy/src/renderer/components/videos/video-player.tsx b/enjoy/src/renderer/components/videos/video-player.tsx index bace556c..1b10a2cd 100644 --- a/enjoy/src/renderer/components/videos/video-player.tsx +++ b/enjoy/src/renderer/components/videos/video-player.tsx @@ -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 ; return ( -
-
-
-
- -
-
- -
-
-
- -
-
- -
- -
- -
- -
- -
-
- - +
+
); }; diff --git a/enjoy/src/renderer/components/videos/videos-component.tsx b/enjoy/src/renderer/components/videos/videos-component.tsx index a2a47536..8f2746c1 100644 --- a/enjoy/src/renderer/components/videos/videos-component.tsx +++ b/enjoy/src/renderer/components/videos/videos-component.tsx @@ -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)} /> - + diff --git a/enjoy/src/renderer/components/videos/videos-segment.tsx b/enjoy/src/renderer/components/videos/videos-segment.tsx index 110a8bec..b42675e2 100644 --- a/enjoy/src/renderer/components/videos/videos-segment.tsx +++ b/enjoy/src/renderer/components/videos/videos-segment.tsx @@ -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 ? (
- +
) : ( diff --git a/enjoy/src/renderer/components/widgets/sentence.tsx b/enjoy/src/renderer/components/widgets/sentence.tsx index d3f850bd..44a058c5 100644 --- a/enjoy/src/renderer/components/widgets/sentence.tsx +++ b/enjoy/src/renderer/components/widgets/sentence.tsx @@ -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 ( - + {words.map((word, index) => { return ( diff --git a/enjoy/src/renderer/context/chat-session-provider.tsx b/enjoy/src/renderer/context/chat-session-provider.tsx index ce395da2..e82a3144 100644 --- a/enjoy/src/renderer/context/chat-session-provider.tsx +++ b/enjoy/src/renderer/context/chat-session-provider.tsx @@ -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, }} > - + {children} event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} > - + Shadow @@ -428,7 +428,7 @@ export const ChatSessionProvider = ({ )} - + ); }; diff --git a/enjoy/src/renderer/context/course-provider.tsx b/enjoy/src/renderer/context/course-provider.tsx index f38f29f9..e0132643 100644 --- a/enjoy/src/renderer/context/course-provider.tsx +++ b/enjoy/src/renderer/context/course-provider.tsx @@ -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, }} > - + {children} event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} > - + Shadow @@ -88,7 +88,7 @@ export const CourseProvider = ({ - + ); }; diff --git a/enjoy/src/renderer/context/index.ts b/enjoy/src/renderer/context/index.ts index 0bb2ab7a..a9ab0675 100644 --- a/enjoy/src/renderer/context/index.ts +++ b/enjoy/src/renderer/context/index.ts @@ -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"; diff --git a/enjoy/src/renderer/context/media-player-provider.tsx b/enjoy/src/renderer/context/media-shadow-provider.tsx similarity index 87% rename from enjoy/src/renderer/context/media-player-provider.tsx rename to enjoy/src/renderer/context/media-shadow-provider.tsx index a65632f7..2f55449a 100644 --- a/enjoy/src/renderer/context/media-player-provider.tsx +++ b/enjoy/src/renderer/context/media-shadow-provider.tsx @@ -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(null); +export const MediaShadowProviderContext = + createContext(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(null); const [mediaProvider, setMediaProvider] = useState( null @@ -167,9 +124,9 @@ export const MediaPlayerProvider = ({ const [editingRegion, setEditingRegion] = useState(false); const [pitchChart, setPitchChart] = useState(null); - const [ref, setRef] = useState(null); + const [waveformContainerRef, setWaveformContainerRef] = useState(null); - // Player state + // Player state const [decoded, setDecoded] = useState(false); const [decodeError, setDecodeError] = useState(null); const [currentTime, setCurrentTime] = useState(0); @@ -179,6 +136,7 @@ export const MediaPlayerProvider = ({ const [currentRecording, setCurrentRecording] = useState(null); const [recordingType, setRecordingType] = useState("segment"); + const [cancelingRecording, setCancelingRecording] = useState(false); const [transcriptionDraft, setTranscriptionDraft] = useState(); @@ -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 ( <> - {children} - - + + ); }; diff --git a/enjoy/src/renderer/pages/audio.tsx b/enjoy/src/renderer/pages/audio.tsx index efd01310..48ffb83f 100644 --- a/enjoy/src/renderer/pages/audio.tsx +++ b/enjoy/src/renderer/pages/audio.tsx @@ -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 ( <> -
-
+
+
- {t("shadowingAudio")} + {t("shadowingAudio")}
- - - +
+ + + +
); diff --git a/enjoy/src/renderer/pages/conversation.tsx b/enjoy/src/renderer/pages/conversation.tsx index ba5f35f5..1da83d1e 100644 --- a/enjoy/src/renderer/pages/conversation.tsx +++ b/enjoy/src/renderer/pages/conversation.tsx @@ -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 () => {
- +
@@ -315,7 +315,7 @@ export default () => { )}
-
+
diff --git a/enjoy/src/renderer/pages/conversations.tsx b/enjoy/src/renderer/pages/conversations.tsx index b8db30b2..8b974183 100644 --- a/enjoy/src/renderer/pages/conversations.tsx +++ b/enjoy/src/renderer/pages/conversations.tsx @@ -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) { diff --git a/enjoy/src/renderer/pages/courses/index.tsx b/enjoy/src/renderer/pages/courses/index.tsx index 03dd68e6..44896726 100644 --- a/enjoy/src/renderer/pages/courses/index.tsx +++ b/enjoy/src/renderer/pages/courses/index.tsx @@ -32,6 +32,7 @@ const CoursesList = () => { const fetchCourses = async () => { if (loading) return; + if (!webApi) return; webApi .courses({ page: nextPage, language: learningLanguage }) diff --git a/enjoy/src/renderer/pages/home.tsx b/enjoy/src/renderer/pages/home.tsx index 457a5edf..5bddbcca 100644 --- a/enjoy/src/renderer/pages/home.tsx +++ b/enjoy/src/renderer/pages/home.tsx @@ -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 (
diff --git a/enjoy/src/renderer/pages/video.tsx b/enjoy/src/renderer/pages/video.tsx index 2b859d56..1fc86165 100644 --- a/enjoy/src/renderer/pages/video.tsx +++ b/enjoy/src/renderer/pages/video.tsx @@ -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 ( <> -
-
+
+
{t("shadowingVideo")}
- + - +
);