feat: add a component for text reading aloud (#522)
* feat: add a component for text reading aloud * docs: update changelog
@@ -49,6 +49,7 @@ Information about release notes of Coco Server is provided here.
|
||||
- style: chat input icons show #515
|
||||
- refactor: refactoring icon component #514
|
||||
- refactor: optimizing list styles in markdown content #520
|
||||
- feat: add a component for text reading aloud #522
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import ConnectPrompt from "./ConnectPrompt";
|
||||
import type { Chat } from "./types";
|
||||
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
// import ReadAloud from "./ReadAloud";
|
||||
|
||||
interface ChatAIProps {
|
||||
isSearchActive?: boolean;
|
||||
@@ -378,6 +379,8 @@ const ChatAI = memo(
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
)}
|
||||
|
||||
{/* <ReadAloud /> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/components/Assistant/ReadAloud/imgs/back-dark.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/components/Assistant/ReadAloud/imgs/back-light.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/components/Assistant/ReadAloud/imgs/close-dark.png
Executable file
|
After Width: | Height: | Size: 346 B |
BIN
src/components/Assistant/ReadAloud/imgs/close-light.png
Executable file
|
After Width: | Height: | Size: 347 B |
BIN
src/components/Assistant/ReadAloud/imgs/forward-dark.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/components/Assistant/ReadAloud/imgs/forward-light.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/components/Assistant/ReadAloud/imgs/loading-dark.png
Executable file
|
After Width: | Height: | Size: 485 B |
BIN
src/components/Assistant/ReadAloud/imgs/loading-light.png
Executable file
|
After Width: | Height: | Size: 491 B |
BIN
src/components/Assistant/ReadAloud/imgs/pause-dark.png
Executable file
|
After Width: | Height: | Size: 504 B |
BIN
src/components/Assistant/ReadAloud/imgs/pause-light.png
Executable file
|
After Width: | Height: | Size: 500 B |
BIN
src/components/Assistant/ReadAloud/imgs/play-dark.png
Executable file
|
After Width: | Height: | Size: 203 B |
BIN
src/components/Assistant/ReadAloud/imgs/play-light.png
Executable file
|
After Width: | Height: | Size: 196 B |
134
src/components/Assistant/ReadAloud/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import loadingLight from "./imgs/loading-light.png";
|
||||
import loadingDark from "./imgs/loading-dark.png";
|
||||
import playLight from "./imgs/play-light.png";
|
||||
import playDark from "./imgs/play-dark.png";
|
||||
import pauseLight from "./imgs/pause-light.png";
|
||||
import pauseDark from "./imgs/pause-dark.png";
|
||||
import backLight from "./imgs/back-light.png";
|
||||
import backDark from "./imgs/back-dark.png";
|
||||
import forwardLight from "./imgs/forward-light.png";
|
||||
import forwardDark from "./imgs/forward-dark.png";
|
||||
import closeLight from "./imgs/close-light.png";
|
||||
import closeDark from "./imgs/close-dark.png";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useReactive } from "ahooks";
|
||||
import dayjs from "dayjs";
|
||||
import durationPlugin from "dayjs/plugin/duration";
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
playing: boolean;
|
||||
totalDuration: number;
|
||||
currentDuration: number;
|
||||
}
|
||||
|
||||
const ReadAloud = () => {
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const state = useReactive<State>({
|
||||
loading: false,
|
||||
playing: true,
|
||||
totalDuration: 300,
|
||||
currentDuration: 0,
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const formatTime = useMemo(() => {
|
||||
return dayjs.duration(state.currentDuration * 1000).format("mm:ss");
|
||||
}, [state.currentDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.playing && state.currentDuration >= state.totalDuration) {
|
||||
state.currentDuration = 0;
|
||||
}
|
||||
|
||||
changeCurrentDuration();
|
||||
}, [state.playing]);
|
||||
|
||||
const changeCurrentDuration = (duration = state.currentDuration) => {
|
||||
clearTimeout(timerRef.current);
|
||||
|
||||
let nextDuration = duration;
|
||||
|
||||
if (duration < 0) {
|
||||
nextDuration = 0;
|
||||
}
|
||||
|
||||
if (duration >= state.totalDuration) {
|
||||
state.currentDuration = state.totalDuration;
|
||||
|
||||
state.playing = false;
|
||||
}
|
||||
|
||||
if (!state.playing) return;
|
||||
|
||||
state.currentDuration = nextDuration;
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
changeCurrentDuration(duration + 1);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-[60px] left-1/2 z-1000 w-[200px] h-12 px-4 flex items-center justify-between -translate-x-1/2 border rounded-lg text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-black dark:border-[#272828] shadow-[0_4px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_4px_8px_rgba(255,255,255,0.15)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{state.loading ? (
|
||||
<img
|
||||
src={isDark ? loadingDark : loadingLight}
|
||||
className="size-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
state.playing = !state.playing;
|
||||
}}
|
||||
>
|
||||
{state.playing ? (
|
||||
<img
|
||||
src={isDark ? playDark : playLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={isDark ? pauseDark : pauseLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-sm">{formatTime}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{!state.loading && (
|
||||
<>
|
||||
<img
|
||||
src={isDark ? backDark : backLight}
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
changeCurrentDuration(state.currentDuration - 15);
|
||||
}}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={isDark ? forwardDark : forwardLight}
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
changeCurrentDuration(state.currentDuration + 15);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={isDark ? closeDark : closeLight}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadAloud;
|
||||