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:
@@ -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ʒ: "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ʊ: "aʊ",
|
||||
iə: "ɪə",
|
||||
ɜr: "ɪə(r)",
|
||||
ɑr: "ɑː(r)",
|
||||
ɔr: "ɔː(r)",
|
||||
oʊr: "əʊ(r)",
|
||||
oːɹ: "ɔː(r)",
|
||||
ir: "iː(r)",
|
||||
ɪɹ: "ɪ(r)",
|
||||
ɔːɹ: "ɔː(r)",
|
||||
ɑːɹ: "ɑː(r)",
|
||||
ʊɹ: "ʊ(r)",
|
||||
ʊr: "ʊ(r)",
|
||||
ɛr: "æ(r)",
|
||||
ɛɹ: "æ(r)",
|
||||
iə: "iə",
|
||||
ɜr: "ɜr",
|
||||
ɑr: "ɑr",
|
||||
ɔr: "ɔr",
|
||||
oʊr: "əʊr",
|
||||
oːɹ: "ɔːɹ",
|
||||
ir: "ir",
|
||||
ɪɹ: "ɪɹ",
|
||||
ɔːɹ: "ɔːɹ",
|
||||
ɑːɹ: "ɑːɹ",
|
||||
ʊɹ: "ʊɹ",
|
||||
ʊr: "ʊɹ",
|
||||
ɛr: "ɛr",
|
||||
ɛɹ: "ɛɹ",
|
||||
əl: "ə",
|
||||
aɪɚ: "aɪ",
|
||||
aɪə: "aɪ",
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
"editResource": "编辑资源",
|
||||
"deleteResource": "删除资源",
|
||||
"deleteResourceConfirmation": "您确定要删除资源 {{name}} 吗?",
|
||||
"transcribeAudioConfirmation": "这将删除原来的语音文本,您确定要重新对 {{name}} 进行语音转文本吗?",
|
||||
"transcribeMediaConfirmation": "这将删除原来的语音文本,您确定要重新对 {{name}} 进行语音转文本吗?",
|
||||
"localFile": "本地文件",
|
||||
"recentlyAdded": "最近添加",
|
||||
"resourcesYouAddedRecently": "最近添加的资源",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
43
enjoy/src/renderer/components/medias/media-provider.tsx
Normal file
43
enjoy/src/renderer/components/medias/media-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
code: ['"Source Code Pro"'],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
|
||||
Reference in New Issue
Block a user