Improve layout (#428)

* add Source Code Pro for IPA display

* audo fit player width

* update IPA mapping

* fix caption render

* pre-process transcription timeline for - and %

* tweak

* fix video player

* fix locale
This commit is contained in:
an-lee
2024-03-19 15:41:39 +08:00
committed by GitHub
parent dc56bfb73d
commit f8b3e2a15d
16 changed files with 659 additions and 517 deletions

View File

@@ -376,7 +376,7 @@ export const IPA_MAPPING: { [key: string]: string } = {
g: "g",
q: "k",
ɢ: "g",
ʔ: "",
ʔ: "t",
ɡ: "g",
m: "m",
ɱ: "m",
@@ -420,7 +420,7 @@ export const IPA_MAPPING: { [key: string]: string } = {
ʈʃ: "tʃ",
: "dʒ",
ʋ: "v",
ɹ: "r",
ɹ: "ɹ",
ɻ: "r",
j: "j",
ɰ: "w",
@@ -444,29 +444,29 @@ export const IPA_MAPPING: { [key: string]: string } = {
ɘ: "ə",
ɵ: "ə",
ɤ: "ɒ",
o: "ɔː",
o: "o",
ə: "ə",
oː: "ɔː",
ɛ: "æ",
oː: "oː",
ɛ: "ɛ",
œ: "æ",
ɜ: "əː",
ɜ: "ɜ",
ɞ: "əː",
ʌ: "ʌ",
ɔ: ː",
ɔ: "ɔ",
ɜː: "əː",
uː: "uː",
ɔː: "ɔː",
ɛː: "æ",
ɛː: "ɛ:",
æ: "æ",
a: "ɑː",
ɶ: "ɑː",
ɐ: "ɑː",
ɑ: "ɑː",
a: "ɑ",
ɶ: "ɑ",
ɐ: "ə",
ɑ: "ɑ",
ɒ: "ɒ",
ɑː: "ɑː",
"◌˞": "",
ɚ: "ɪə",
ɝ: "ɪə",
ɚ: "ɚ",
ɝ: "ɝ",
ɹ̩: "r",
eɪ: "eɪ",
əʊ: "əʊ",
@@ -474,20 +474,20 @@ export const IPA_MAPPING: { [key: string]: string } = {
aɪ: "aɪ",
ɔɪ: "ɔɪ",
: "aʊ",
: "ɪə",
ɜr: "ɪə(r)",
ɑr: "ɑː(r)",
ɔr: ː(r)",
oʊr: "əʊ(r)",
oːɹ: "ɔː(r)",
ir: "iː(r)",
ɪɹ: "ɪ(r)",
ɔːɹ: "ɔː(r)",
ɑːɹ: "ɑː(r)",
ʊɹ: (r)",
ʊr: (r)",
ɛr: "æ(r)",
ɛɹ: "æ(r)",
: "iə",
ɜr: "ɜr",
ɑr: "ɑr",
ɔr: r",
oʊr: "əʊr",
oːɹ: "ɔːɹ",
ir: "ir",
ɪɹ: "ɪɹ",
ɔːɹ: "ɔːɹ",
ɑːɹ: "ɑːɹ",
ʊɹ: ɹ",
ʊr: ɹ",
ɛr: "ɛr",
ɛɹ: "ɛɹ",
əl: "ə",
aɪɚ: "aɪ",
aɪə: "aɪ",

View File

@@ -273,7 +273,7 @@
"editResource": "编辑资源",
"deleteResource": "删除资源",
"deleteResourceConfirmation": "您确定要删除资源 {{name}} 吗?",
"transcribeAudioConfirmation": "这将删除原来的语音文本,您确定要重新对 {{name}} 进行语音转文本吗?",
"transcribeMediaConfirmation": "这将删除原来的语音文本,您确定要重新对 {{name}} 进行语音转文本吗?",
"localFile": "本地文件",
"recentlyAdded": "最近添加",
"resourcesYouAddedRecently": "最近添加的资源",

View File

@@ -3,6 +3,7 @@
@import "@vidstack/react/player/styles/default/layouts/audio.css";
@import "@vidstack/react/player/styles/default/layouts/video.css";
@import "intl-tel-input/build/css/intlTelInput.css";
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap");
@tailwind base;
@tailwind components;

View File

@@ -6,17 +6,14 @@ import {
MediaPlayerControls,
MediaTabs,
MediaCurrentRecording,
MediaPlayer,
} from "@renderer/components";
import { formatDuration } from "@renderer/lib/utils";
import { useAudio } from "@renderer/hooks";
export const AudioPlayer = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { media, currentTime, setMedia, setRef } = useContext(
MediaPlayerProviderContext
);
const { setMedia } = useContext(MediaPlayerProviderContext);
const { audio } = useAudio({ id, md5 });
const ref = useRef(null);
useEffect(() => {
if (!audio) return;
@@ -24,14 +21,6 @@ export const AudioPlayer = (props: { id?: string; md5?: string }) => {
setMedia(audio);
}, [audio]);
useEffect(() => {
setRef(ref);
return () => {
setRef(null);
};
}, [ref]);
return (
<div data-testid="audio-player">
<div className="h-[calc(100vh-37.5rem)] mb-4">
@@ -51,18 +40,7 @@ export const AudioPlayer = (props: { id?: string; md5?: string }) => {
</div>
<div className="w-full h-[13rem] px-6 py-2 mb-4">
<div className="border rounded-xl shadow-lg relative">
<div data-testid="media-player-container" 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>
<MediaPlayer />
</div>
<div className="w-full bg-background z-10 shadow-xl">

View File

@@ -6,6 +6,7 @@ export * from "./media-current-recording";
export * from "./media-recorder";
export * from "./media-transcription";
export * from "./media-player";
export * from "./media-provider";
export * from "./media-tabs";
export * from "./media-loading-modal";
export * from "./add-media-button";

View File

@@ -5,7 +5,6 @@ import { Button, toast, ScrollArea, Separator } from "@renderer/components/ui";
import { t } from "i18next";
import { LanguagesIcon, SpeechIcon } from "lucide-react";
import { Timeline } from "echogarden/dist/utilities/Timeline.d.js";
import { IPA_MAPPING } from "@/constants";
import { useAiCommand } from "@renderer/hooks";
import { LoaderIcon } from "lucide-react";
import { convertIpaToNormal } from "@/utils";
@@ -337,7 +336,7 @@ export const MediaCaption = () => {
<ScrollArea className="flex-1 px-6 py-4 font-serif h-full border shadow-lg rounded-lg">
<div className="flex flex-wrap mb-4">
{/* use the words splitted by caption text if it is matched with the timeline length, otherwise use the timeline */}
{caption.text.includes("-")
{caption.text.split(" ").length !== caption.timeline.length
? (caption.timeline || []).map((w, index) => (
<div
key={index}
@@ -354,7 +353,7 @@ export const MediaCaption = () => {
<div className="">
<div className="text-2xl">{w.text}</div>
{displayIpa && (
<div className="text-muted-foreground">
<div className="text-muted-foreground font-code">
{w.timeline
.map((t) =>
t.timeline
@@ -379,7 +378,7 @@ export const MediaCaption = () => {
<div className="">
<div className="text-2xl">{word}</div>
{displayIpa && (
<div className="text-muted-foreground">
<div className="text-muted-foreground font-code">
{caption.timeline[index].timeline
.map((t) =>
t.timeline
@@ -417,7 +416,7 @@ export const MediaCaption = () => {
{word.text}
</div>
<div className="text-sm text-serif text-muted-foreground">
<span className="mr-2">
<span className="mr-2 font-code">
/
{word.timeline
.map((t) =>

View File

@@ -37,6 +37,8 @@ import {
ChevronDownIcon,
MoreVerticalIcon,
TextCursorInputIcon,
MicIcon,
SquareIcon,
} from "lucide-react";
import { t } from "i18next";
import { formatDuration } from "@renderer/lib/utils";
@@ -46,6 +48,7 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
const { height = 192 } = props;
const {
isRecording,
setIsRecording,
currentRecording,
renderPitchContour: renderMediaPitchContour,
regions: mediaRegions,
@@ -67,6 +70,7 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
const [frequencies, setFrequencies] = useState<number[]>([]);
const [peaks, setPeaks] = useState<number[]>([]);
const [width, setWidth] = useState<number>();
const ref = useRef(null);
@@ -191,6 +195,15 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
});
};
const calContainerWidth = () => {
const w = document
.querySelector(".media-recording-container")
?.getBoundingClientRect()?.width;
if (!w) return;
setWidth(w - 48);
};
useEffect(() => {
if (!ref.current) return;
if (isRecording) return;
@@ -305,9 +318,6 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
const scrollContainer = player.getWrapper()?.closest(".scroll");
if (!scrollContainer) return;
scrollContainer.style.width = `${
ref.current.getBoundingClientRect().width
}px`;
scrollContainer.style.scrollbarWidth = "thin";
}, [ref, player]);
@@ -335,6 +345,25 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
mediaActiveRegion,
]);
useEffect(() => {
if (!ref?.current) return;
ref.current.style.width = `${width}px`;
}, [width]);
useEffect(() => {
calContainerWidth();
window.addEventListener("resize", () => {
calContainerWidth();
});
return () => {
window.removeEventListener("resize", () => {
calContainerWidth();
});
};
}, []);
useHotkeys(
["Ctrl+R", "Meta+R"],
(keyboardEvent, hotkeyEvent) => {
@@ -354,18 +383,27 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
if (isRecording) return <MediaRecorder />;
if (!currentRecording?.src)
return (
<div className="h-full w-full border rounded-xl shadow-lg flex items-center justify-center">
<div
className="m-auto"
dangerouslySetInnerHTML={{
__html: t("noRecordingForThisSegmentYet"),
}}
></div>
<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="m-auto"
dangerouslySetInnerHTML={{
__html: t("noRecordingForThisSegmentYet"),
}}
></div>
</div>
<div className="h-full flex flex-col justify-start space-y-1.5">
<MediaRecordButton
isRecording={isRecording}
setIsRecording={setIsRecording}
/>
</div>
</div>
);
return (
<div className="flex space-x-4">
<div className="flex space-x-4 media-recording-container">
<div className="border rounded-xl shadow-lg flex-1 relative">
<div ref={ref}></div>
@@ -380,7 +418,7 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
</div>
</div>
<div className="flex flex-col space-y-1.5">
<div className="flex flex-col justify-around space-y-1.5">
<Button
variant="default"
size="icon"
@@ -407,6 +445,11 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
)}
</Button>
<MediaRecordButton
isRecording={isRecording}
setIsRecording={setIsRecording}
/>
<Button
variant={isComparing ? "secondary" : "outline"}
size="icon"
@@ -492,6 +535,7 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Sheet open={detailIsOpen} onOpenChange={(open) => setDetailIsOpen(open)}>
<SheetContent
side="bottom"
@@ -510,3 +554,29 @@ export const MediaCurrentRecording = (props: { height?: number }) => {
</div>
);
};
export const MediaRecordButton = (props: {
isRecording: boolean;
setIsRecording: (value: boolean) => void;
}) => {
const { isRecording, setIsRecording } = props;
return (
<Button
variant="ghost"
onClick={() => setIsRecording(!isRecording)}
id="media-record-button"
data-tooltip-id="media-player-controls-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"
>
{isRecording ? (
<SquareIcon fill="white" className="w-4 h-4 text-white" />
) : (
<MicIcon className="w-4 h-4 text-white" />
)}
</Button>
);
};

View File

@@ -1,15 +1,6 @@
import { useEffect, useState, useContext } from "react";
import { type Region as RegionType } from "wavesurfer.js/dist/plugins/regions";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogCancel,
AlertDialogAction,
DropdownMenu,
DropdownMenuItem,
DropdownMenuTrigger,
@@ -18,7 +9,6 @@ import {
Popover,
PopoverTrigger,
PopoverContent,
toast,
} from "@renderer/components/ui";
import {
MediaPlayerProviderContext,
@@ -31,17 +21,9 @@ import {
Repeat1Icon,
RepeatIcon,
GaugeIcon,
ZoomInIcon,
ZoomOutIcon,
MicIcon,
MinimizeIcon,
GalleryHorizontalIcon,
SpellCheckIcon,
Share2Icon,
ListRestartIcon,
SkipForwardIcon,
SkipBackIcon,
SquareIcon,
SaveIcon,
UndoIcon,
TextCursorInputIcon,
@@ -54,14 +36,8 @@ import debounce from "lodash/debounce";
import { AlignmentResult } from "echogarden/dist/api/API.d.js";
const PLAYBACK_RATE_OPTIONS = [0.75, 0.8, 0.9, 1.0];
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 MediaPlayerControls = () => {
const {
media,
decoded,
wavesurfer,
currentTime,
@@ -71,7 +47,6 @@ export const MediaPlayerControls = () => {
setZoomRatio,
fitZoomRatio,
transcription,
pitchChart,
regions,
activeRegion,
setActiveRegion,
@@ -79,14 +54,10 @@ export const MediaPlayerControls = () => {
setEditingRegion,
transcriptionDraft,
setTranscriptionDraft,
isRecording,
setIsRecording,
} = useContext(MediaPlayerProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [playMode, setPlayMode] = useState<"loop" | "single" | "all">("single");
const [playbackRate, setPlaybackRate] = useState<number>(1);
const [displayInlineCaption, setDisplayInlineCaption] =
useState<boolean>(true);
const [isSelectingRegion, setIsSelectingRegion] = useState(false);
const playOrPause = () => {
@@ -116,34 +87,6 @@ export const MediaPlayerControls = () => {
setCurrentSegmentIndex(currentSegmentIndex + 1);
};
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,
});
});
};
/*
* Update segmentRegion when currentSegmentIndex is updated
* or when editingRegion is toggled.
@@ -457,228 +400,144 @@ export const MediaPlayerControls = () => {
}, [regions, activeRegion]);
return (
<div className="w-full h-20 flex items-center justify-between px-6">
<div className="flex items-center justify-start space-x-6">
<div className="flex items-center space-x-1">
{wavesurfer?.isPlaying() ? (
<div className="w-full h-20 flex items-center justify-center px-6">
<div className="flex items-center justify-center space-x-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="default"
onClick={debouncedPlayOrPause}
id="media-play-or-pause-button"
variant={`${playbackRate == 1.0 ? "ghost" : "secondary"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("pause")}
className="aspect-square p-0 h-12 rounded-full"
data-tooltip-content={t("playbackSpeed")}
className="relative aspect-square p-0 h-10"
>
<PauseIcon fill="white" className="w-6 h-6" />
<GaugeIcon className="w-6 h-6" />
{playbackRate != 1.0 && (
<span className="absolute left-[1.25rem] top-6 text-[0.6rem] font-bold text-gray-400">
{playbackRate.toFixed(2)}
</span>
)}
</Button>
) : (
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="mb-4 text-center">{t("playbackRate")}</div>
<div className="w-full rounded-full flex items-center justify-between bg-muted">
{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 ${
rate === playbackRate
? "bg-primary text-white text-md"
: "text-black/70 text-xs"
}`}
onClick={() => {
setPlaybackRate(rate);
}}
>
<span className="">{rate}</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
onClick={debouncedPlayOrPause}
id="media-play-or-pause-button"
variant="ghost"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("play")}
className="aspect-square p-0 h-12 rounded-full"
data-tooltip-content={t("switchPlayMode")}
className="aspect-square p-0 h-10"
>
<PlayIcon fill="white" className="w-6 h-6" />
{playMode === "single" && <RepeatIcon className="w-6 h-6" />}
{playMode === "loop" && <Repeat1Icon className="w-6 h-6" />}
{playMode === "all" && <ListRestartIcon className="w-6 h-6" />}
</Button>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className={playMode === "single" ? "bg-muted" : ""}
onClick={() => setPlayMode("single")}
>
<RepeatIcon className="w-4 h-4 mr-2" />
<span>{t("playSingleSegment")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={playMode === "loop" ? "bg-muted" : ""}
onClick={() => setPlayMode("loop")}
>
<Repeat1Icon className="w-4 h-4 mr-2" />
<span>{t("playInLoop")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={playMode === "all" ? "bg-muted" : ""}
onClick={() => setPlayMode("all")}
>
<ListRestartIcon className="w-4 h-4 mr-2" />
<span>{t("playAllSegments")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="lg"
onClick={onPrev}
id="media-play-previous-button"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("playPreviousSegment")}
className="aspect-square p-0 h-10"
>
<SkipBackIcon className="w-6 h-6" />
</Button>
{wavesurfer?.isPlaying() ? (
<Button
variant="ghost"
size="lg"
onClick={onPrev}
id="media-play-previous-button"
variant="default"
onClick={debouncedPlayOrPause}
id="media-play-or-pause-button"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("playPreviousSegment")}
className="aspect-square p-0 h-10"
data-tooltip-content={t("pause")}
className="aspect-square p-0 h-12 rounded-full"
>
<SkipBackIcon className="w-6 h-6" />
<PauseIcon fill="white" className="w-6 h-6" />
</Button>
) : (
<Button
variant="ghost"
size="lg"
onClick={onNext}
id="media-play-next-button"
variant="default"
onClick={debouncedPlayOrPause}
id="media-play-or-pause-button"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("playNextSegment")}
className="aspect-square p-0 h-10"
data-tooltip-content={t("play")}
className="aspect-square p-0 h-12 rounded-full"
>
<SkipForwardIcon className="w-6 h-6" />
<PlayIcon fill="white" className="w-6 h-6" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("switchPlayMode")}
className="aspect-square p-0 h-10"
>
{playMode === "single" && <RepeatIcon className="w-6 h-6" />}
{playMode === "loop" && <Repeat1Icon className="w-6 h-6" />}
{playMode === "all" && <ListRestartIcon className="w-6 h-6" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className={playMode === "single" ? "bg-muted" : ""}
onClick={() => setPlayMode("single")}
>
<RepeatIcon className="w-4 h-4 mr-2" />
<span>{t("playSingleSegment")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={playMode === "loop" ? "bg-muted" : ""}
onClick={() => setPlayMode("loop")}
>
<Repeat1Icon className="w-4 h-4 mr-2" />
<span>{t("playInLoop")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={playMode === "all" ? "bg-muted" : ""}
onClick={() => setPlayMode("all")}
>
<ListRestartIcon className="w-4 h-4 mr-2" />
<span>{t("playAllSegments")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="lg"
onClick={onNext}
id="media-play-next-button"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("playNextSegment")}
className="aspect-square p-0 h-10"
>
<SkipForwardIcon className="w-6 h-6" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant={`${playbackRate == 1.0 ? "ghost" : "secondary"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("playbackSpeed")}
className="relative aspect-square p-0 h-10"
>
<GaugeIcon className="w-6 h-6" />
{playbackRate != 1.0 && (
<span className="absolute left-[1.25rem] top-6 text-[0.6rem] font-bold text-gray-400">
{playbackRate.toFixed(2)}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="mb-4 text-center">{t("playbackRate")}</div>
<div className="w-full rounded-full flex items-center justify-between bg-muted">
{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 ${
rate === playbackRate
? "bg-primary text-white text-md"
: "text-black/70 text-xs"
}`}
onClick={() => {
setPlaybackRate(rate);
}}
>
<span className="">{rate}</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
<Button
variant={`${zoomRatio > 1.0 ? "secondary" : "ghost"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("zoomIn")}
className="relative aspect-square p-0 h-10"
onClick={() => {
if (zoomRatio < MAX_ZOOM_RATIO) {
const nextZoomRatio = ZOOM_RATIO_OPTIONS.find(
(rate) => rate > zoomRatio
);
setZoomRatio(nextZoomRatio || MAX_ZOOM_RATIO);
}
}}
>
<ZoomInIcon className="w-6 h-6" />
</Button>
<Button
variant={`${zoomRatio < 1.0 ? "secondary" : "ghost"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("zoomOut")}
className="relative aspect-square p-0 h-10"
onClick={() => {
if (zoomRatio > MIN_ZOOM_RATIO) {
const nextZoomRatio = ZOOM_RATIO_OPTIONS.reverse().find(
(rate) => rate < zoomRatio
);
setZoomRatio(nextZoomRatio || MIN_ZOOM_RATIO);
}
}}
>
<ZoomOutIcon className="w-6 h-6" />
</Button>
<Button
variant={`${zoomRatio === fitZoomRatio ? "secondary" : "ghost"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("zoomToFit")}
className="relative aspect-square p-0 h-10"
onClick={() => {
if (zoomRatio == fitZoomRatio) {
setZoomRatio(1.0);
} else {
setZoomRatio(fitZoomRatio);
}
}}
>
<MinimizeIcon className="w-6 h-6" />
</Button>
<Button
variant={`${displayInlineCaption ? "secondary" : "ghost"}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("inlineCaption")}
className="relative aspect-square p-0 h-10"
onClick={() => {
setDisplayInlineCaption(!displayInlineCaption);
if (pitchChart) {
pitchChart.options.scales.x.display = !displayInlineCaption;
pitchChart.update();
}
}}
>
<SpellCheckIcon className="w-6 h-6" />
</Button>
<Button
variant={`${
wavesurfer?.options?.autoCenter ? "secondary" : "ghost"
}`}
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("autoCenter")}
className="relative aspect-square p-0 h-10"
onClick={() => {
wavesurfer.setOptions({
autoCenter: !wavesurfer?.options?.autoCenter,
});
}}
>
<GalleryHorizontalIcon className="w-6 h-6" />
</Button>
<Button
variant={isSelectingRegion ? "secondary" : "ghost"}
size="icon"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("selectRegion")}
className="relative aspect-square p-0 h-10"
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
>
<TextCursorInputIcon className="w-6 h-6" />
</Button>
<Button
variant={isSelectingRegion ? "secondary" : "ghost"}
size="icon"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("selectRegion")}
className="relative aspect-square p-0 h-10"
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
>
<TextCursorInputIcon className="w-6 h-6" />
</Button>
<div className="relative">
<Button
variant={`${editingRegion ? "secondary" : "ghost"}`}
data-tooltip-id="media-player-controls-tooltip"
@@ -692,99 +551,44 @@ export const MediaPlayerControls = () => {
>
<ScissorsIcon className="w-6 h-6" />
</Button>
</div>
{editingRegion && (
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="relative aspect-square p-0 h-10"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("cancel")}
onClick={() => {
setEditingRegion(false);
setTranscriptionDraft(null);
}}
>
<UndoIcon className="w-6 h-6" />
</Button>
<Button
variant="default"
className="relative aspect-square p-0 h-10"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("save")}
onClick={() => {
if (!transcriptionDraft) return;
{editingRegion && (
<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-controls-tooltip"
data-tooltip-content={t("cancel")}
onClick={() => {
setEditingRegion(false);
setTranscriptionDraft(null);
}}
>
<UndoIcon className="w-6 h-6" />
</Button>
<Button
variant="default"
className="relative aspect-square p-0 h-10"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("save")}
onClick={() => {
if (!transcriptionDraft) return;
EnjoyApp.transcriptions
.update(transcription.id, {
result: transcriptionDraft,
})
.then(() => {
setTranscriptionDraft(null);
setEditingRegion(false);
});
}}
>
<SaveIcon className="w-6 h-6" />
</Button>
</div>
)}
</div>
<div className="ml-auto flex items-center space-x-4">
<AlertDialog>
<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>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("share")}
className="relative aspect-square p-0 h-10"
>
<Share2Icon className="w-6 h-6" />
</Button>
</AlertDialogTrigger>
</AlertDialog>
<Button
variant="ghost"
onClick={() => setIsRecording(!isRecording)}
id="media-record-button"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={
isRecording ? t("stopRecording") : t("startRecording")
}
className="aspect-square p-0 h-12 rounded-full bg-red-500 hover:bg-red-500/90"
>
{isRecording ? (
<SquareIcon fill="white" className="w-6 h-6 text-white" />
) : (
<MicIcon className="w-6 h-6 text-white" />
EnjoyApp.transcriptions
.update(transcription.id, {
result: transcriptionDraft,
})
.then(() => {
setTranscriptionDraft(null);
setEditingRegion(false);
});
}}
>
<SaveIcon className="w-6 h-6" />
</Button>
</div>
)}
</Button>
</div>
</div>
<Tooltip className="z-10" id="media-player-controls-tooltip" />

View File

@@ -1,43 +1,266 @@
import { useContext } from "react";
import { MediaPlayerProviderContext } from "@renderer/context";
import { useEffect, useContext, useRef, useState } from "react";
import {
MediaPlayer as VidstackMediaPlayer,
MediaProvider,
isAudioProvider,
isVideoProvider,
useMediaRemote,
} from "@vidstack/react";
AppSettingsProviderContext,
MediaPlayerProviderContext,
} from "@renderer/context";
import { formatDuration } from "@renderer/lib/utils";
import { t } from "i18next";
import {
DefaultAudioLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
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,
} 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 { media, setMediaProvider, setDecodeError } = useContext(
MediaPlayerProviderContext
);
const mediaRemote = useMediaRemote();
if (!media?.src) return null;
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const {
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-container")
?.getBoundingClientRect()?.width;
if (!w) return;
setWidth(w - 48);
};
useEffect(() => {
if (ref?.current) {
setRef(ref);
}
}, [ref]);
useEffect(() => {
if (!ref?.current) return;
ref.current.style.width = `${width}px`;
}, [width]);
useEffect(() => {
calContainerWidth();
window.addEventListener("resize", () => {
calContainerWidth();
});
return () => {
window.removeEventListener("resize", () => {
calContainerWidth();
});
};
}, []);
return (
<div className="px-4" data-testid="media-player">
<VidstackMediaPlayer
controls
src={media.src}
onCanPlayThrough={(detail, nativeEvent) => {
mediaRemote.setTarget(nativeEvent.target);
const { provider } = detail;
if (isAudioProvider(provider)) {
setMediaProvider(provider.audio);
} else if (isVideoProvider(provider)) {
setMediaProvider(provider.video);
}
}}
onError={(err) => setDecodeError(err.message)}
>
<MediaProvider />
<DefaultAudioLayout icons={defaultLayoutIcons} />
</VidstackMediaPlayer>
<div
data-testid="media-player-container"
className="flex space-x-4 media-player-container"
>
<div className="flex-1 border rounded-xl shadow-lg relative">
<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-controls-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-controls-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>
<Button
variant={`${zoomRatio < 1.0 ? "secondary" : "outline"}`}
data-tooltip-id="media-player-controls-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-controls-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-controls-tooltip"
data-tooltip-content={t("more")}
className="rounded-full w-8 h-8 p-0"
>
<MoreVerticalIcon className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<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>
</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,43 @@
import { useContext } from "react";
import { MediaPlayerProviderContext } from "@renderer/context";
import {
MediaPlayer as VidstackMediaPlayer,
MediaProvider as VidstackMediaProvider,
isAudioProvider,
isVideoProvider,
useMediaRemote,
} from "@vidstack/react";
import {
DefaultAudioLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
export const MediaProvider = () => {
const { media, setMediaProvider, setDecodeError } = useContext(
MediaPlayerProviderContext
);
const mediaRemote = useMediaRemote();
if (!media?.src) return null;
return (
<div className="px-4" data-testid="media-player">
<VidstackMediaPlayer
controls
src={media.src}
onCanPlayThrough={(detail, nativeEvent) => {
mediaRemote.setTarget(nativeEvent.target);
const { provider } = detail;
if (isAudioProvider(provider)) {
setMediaProvider(provider.audio);
} else if (isVideoProvider(provider)) {
setMediaProvider(provider.video);
}
}}
onError={(err) => setDecodeError(err.message)}
>
<VidstackMediaProvider />
<DefaultAudioLayout icons={defaultLayoutIcons} />
</VidstackMediaPlayer>
</div>
);
};

View File

@@ -8,10 +8,8 @@ import WaveSurfer from "wavesurfer.js";
import { t } from "i18next";
import { useTranscribe } from "@renderer/hooks";
import { toast } from "@renderer/components/ui";
import {
FFMPEG_TRIM_SILENCE_OPTIONS,
FFMPEG_CONVERT_WAV_OPTIONS,
} from "@/constants";
import { MediaRecordButton } from "@renderer/components";
import { FFMPEG_CONVERT_WAV_OPTIONS } from "@/constants";
export const MediaRecorder = (props: { height?: number }) => {
const { height = 192 } = props;
@@ -74,7 +72,7 @@ export const MediaRecorder = (props: { height?: number }) => {
success: t("recordingSaved"),
error: (e) => t("failedToSaveRecording" + " : " + e.message),
position: "bottom-right",
},
}
);
};
@@ -134,12 +132,21 @@ export const MediaRecorder = (props: { height?: number }) => {
}, []);
return (
<div className="border rounded-xl shadow-lg relative">
<span className="absolute bottom-2 right-2 serif">
{duration / 10}
<span className="text-xs"> / 300</span>
</span>
<div className="h-full" ref={ref}></div>
<div className="h-full w-full flex items-center space-x-4">
<div className="flex-1 h-full border rounded-xl shadow-lg relative">
<span className="absolute bottom-2 right-2 serif">
{duration / 10}
<span className="text-xs"> / 300</span>
</span>
<div className="h-full" ref={ref}></div>
</div>
<div className="h-full flex flex-col justify-start space-y-1.5">
<MediaRecordButton
isRecording={isRecording}
setIsRecording={setIsRecording}
/>
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useContext, useState } from "react";
import { MediaPlayerProviderContext } from "@renderer/context";
import {
MediaPlayer,
MediaProvider,
MediaTranscription,
MediaInfoPanel,
MediaRecordings,
@@ -11,7 +11,7 @@ import { t } from "i18next";
export const MediaTabs = () => {
const { media, decoded } = useContext(MediaPlayerProviderContext);
const [tab, setTab] = useState("player");
const [tab, setTab] = useState("provider");
useEffect(() => {
if (!decoded) return;
@@ -27,9 +27,9 @@ export const MediaTabs = () => {
{media.mediaType === "Video" && (
<div
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize ${
tab === "player" ? "bg-background" : ""
tab === "provider" ? "bg-background" : ""
}`}
onClick={() => setTab("player")}
onClick={() => setTab("provider")}
>
{t("player")}
</div>
@@ -61,8 +61,8 @@ export const MediaTabs = () => {
</div>
</div>
<div className={tab === "player" ? "" : "hidden"}>
<MediaPlayer />
<div className={tab === "provider" ? "" : "hidden"}>
<MediaProvider />
</div>
<div className={tab === "recordings" ? "" : "hidden"}>
<MediaRecordings />

View File

@@ -55,6 +55,28 @@ export const Sidebar = () => {
</Button>
</Link>
<Link
to="/conversations"
data-tooltip-id="sidebar-tooltip"
data-tooltip-content={t("sidebar.aiAssistant")}
data-testid="sidebar-conversations"
className="block"
>
<Button
variant={
activeTab.startsWith("conversations")
? "secondary"
: "ghost"
}
className="w-full xl:justify-start"
>
<BotIcon className="xl:mr-2 h-5 w-5" />
<span className="hidden xl:block">
{t("sidebar.aiAssistant")}
</span>
</Button>
</Link>
<Link
to="/community"
data-tooltip-id="sidebar-tooltip"
@@ -163,28 +185,6 @@ export const Sidebar = () => {
{t("sidebar.mine")}
</h3>
<div className="xl:pl-3 space-y-2">
<Link
to="/conversations"
data-tooltip-id="sidebar-tooltip"
data-tooltip-content={t("sidebar.aiAssistant")}
data-testid="sidebar-conversations"
className="block"
>
<Button
variant={
activeTab.startsWith("conversations")
? "secondary"
: "ghost"
}
className="w-full xl:justify-start"
>
<BotIcon className="xl:mr-2 h-5 w-5" />
<span className="hidden xl:block">
{t("sidebar.aiAssistant")}
</span>
</Button>
</Link>
<Link
to="/vocabulary"
data-tooltip-id="sidebar-tooltip"

View File

@@ -1,4 +1,4 @@
import { useEffect, useContext, useRef } from "react";
import { useEffect, useContext } from "react";
import { MediaPlayerProviderContext } from "@renderer/context";
import {
MediaLoadingModal,
@@ -6,17 +6,14 @@ import {
MediaPlayerControls,
MediaTabs,
MediaCurrentRecording,
MediaPlayer,
} from "@renderer/components";
import { formatDuration } from "@renderer/lib/utils";
import { useVideo } from "@renderer/hooks";
export const VideoPlayer = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { media, currentTime, setMedia, setRef } = useContext(
MediaPlayerProviderContext
);
const { setMedia } = useContext(MediaPlayerProviderContext);
const { video } = useVideo({ id, md5 });
const ref = useRef(null);
useEffect(() => {
if (!video) return;
@@ -24,10 +21,6 @@ export const VideoPlayer = (props: { id?: string; md5?: string }) => {
setMedia(video);
}, [video]);
useEffect(() => {
setRef(ref);
}, [ref]);
return (
<div data-testid="video-player">
<div className="h-[calc(100vh-37.5rem)] mb-4">
@@ -47,18 +40,7 @@ export const VideoPlayer = (props: { id?: string; md5?: string }) => {
</div>
<div className="w-full h-[13rem] px-6 py-2 mb-4">
<div className="border rounded-xl shadow-lg relative">
<div data-testid="media-player-container" 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>
<MediaPlayer />
</div>
<div className="w-full bg-background z-10 shadow-xl">

View File

@@ -90,7 +90,9 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
/*
* Pre-process
* Some words end with period should not be a single sentence, like Mr./Ms./Dr. etc
* 1. Some words end with period should not be a single sentence, like Mr./Ms./Dr. etc
* 2. Some words connected by `-`(like scrach-off) are split into multiple words in words timeline, merge them for display;
* 3. Some numbers with `%` are split into `number + percent` in words timeline, merge thme for display;
*/
timeline.forEach((sentence, i) => {
const nextSentence = timeline[i + 1];
@@ -107,6 +109,35 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
];
nextSentence.startTime = sentence.startTime;
timeline.splice(i, 1);
} else {
const words = sentence.text.split(" ");
sentence.timeline.forEach((token, j) => {
const word = words[j]?.trim()?.toLowerCase();
const match = word?.match(/-|%/);
if (!match) return;
for (let k = j + 1; k <= sentence.timeline.length - 1; k++) {
if (word.includes(sentence.timeline[k].text.toLowerCase())) {
let connector = "";
if (match[0] === "-") {
connector = "-";
}
token.text = [token.text, sentence.timeline[k].text].join(
connector
);
token.timeline = [
...token.timeline,
...sentence.timeline[k].timeline,
];
token.endTime = sentence.timeline[k].endTime;
sentence.timeline.splice(k, 1);
} else {
break;
}
}
});
}
});

View File

@@ -11,6 +11,9 @@ module.exports = {
},
},
extend: {
fontFamily: {
code: ['"Source Code Pro"'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",