Skip to content

Commit 61399ce

Browse files
committed
feat: text to speech
1 parent aa71a6f commit 61399ce

27 files changed

+641
-99
lines changed

docs/changelog.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ Consider giving a star ⭐ on [Github](https://github.com/pnd280/complexity).
88

99
💖 Support the development via [Ko-fi](https://ko-fi.com/pnd280) or [Paypal](https://paypal.me/pnd280).
1010

11+
**EXPERIMENTAL** features are subjected to change/removal without prior notice.
12+
13+
## v0.0.5.1
14+
15+
_Release date: 26th Oct, 2024_
16+
17+
- **NEW** | **EXPERIMENTAL**: Text-to-Speech. Credit to `@asura0_00` for helping with the implementation.
18+
![TTS](https://i.imgur.com/BglHpbJ.png)
19+
20+
- This feature **only works on CHROMIUM browsers**, and only available on Perplexity Pro/Enterprise accounts.
21+
- Support 4 different voices (exactly the same as the mobile/mac app). Right click the "Headphones" icon to open the dropdown menu.
22+
- Play/pause, seek, volume control, download will be added soon.
23+
24+
- **FIX**: Fixed minor bugs.
25+
26+
## v0.0.4.8
27+
28+
_Release date: 25th Oct, 2024_
29+
30+
- **FIX**: Fixed crashing issue on certain scenarios.
31+
- **FIX**: Fixed store url on Firefox browsers.
32+
1133
## v0.0.4.7
1234

1335
_Release date: 24th Oct, 2024_

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "complexity",
33
"displayName": "Complexity - Perplexity AI Supercharged",
4-
"version": "0.0.4.8",
4+
"version": "0.0.5.1",
55
"author": "pnd280",
66
"description": "⚡ Supercharge your Perplexity AI",
77
"type": "module",
@@ -38,6 +38,7 @@
3838
"clsx": "^2.1.1",
3939
"cmdk": "^1.0.0",
4040
"dompurify": "^3.1.7",
41+
"engine.io-parser": "^5.2.3",
4142
"immer": "^10.1.1",
4243
"jquery": "^3.7.1",
4344
"lodash": "^4.17.21",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/content-script/components/QueryBox/QueryBox.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@/content-script/components/QueryBox/context";
66
import ImageModelSelector from "@/content-script/components/QueryBox/ImageModelSelector";
77
import useFetchUserSettings from "@/content-script/hooks/useFetchUserSettings";
8+
import { validateHasActivePplxSub } from "@/content-script/hooks/useHasActivePplxSub";
89
import useInitQueryBoxSessionStore from "@/content-script/hooks/useInitQueryBoxSessionStore";
910
import useQueryBoxObserver from "@/content-script/hooks/useQueryBoxObserver";
1011
import useCplxGeneralSettings from "@/cplx-user-settings/hooks/useCplxGeneralSettings";
@@ -23,12 +24,10 @@ export default function QueryBox() {
2324
error: userSettingsFetchError,
2425
} = useFetchUserSettings();
2526

26-
useInitQueryBoxSessionStore();
27-
2827
const hasActivePplxSub =
29-
userSettings &&
30-
(userSettings.subscriptionStatus === "active" ||
31-
userSettings.subscriptionStatus === "trialing");
28+
userSettings && validateHasActivePplxSub(userSettings);
29+
30+
useInitQueryBoxSessionStore();
3231

3332
const [containers, setContainers] = useState<HTMLElement[]>([]);
3433
const [followUpContainers, setFollowUpContainers] = useState<HTMLElement[]>(

src/content-script/components/ThreadMessageStickyToolbar/MiscMenu.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import { FaEllipsis as Ellipsis } from "react-icons/fa6";
32
import {
43
LuListOrdered as ListOrdered,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
type SpeakingAnimationProps = {
2+
isActive?: boolean;
3+
rows?: number;
4+
cols?: number;
5+
};
6+
7+
const SpeakingAnimation: React.FC<SpeakingAnimationProps> = ({
8+
isActive = true,
9+
rows = 4,
10+
cols = 12,
11+
}) => {
12+
const [activeDots, setActiveDots] = useState<number[]>([]);
13+
14+
useEffect(() => {
15+
if (!isActive) {
16+
setActiveDots([]);
17+
return;
18+
}
19+
20+
const interval = setInterval(() => {
21+
const totalDots = rows * cols;
22+
const newActiveDots = Array.from({
23+
length: Math.floor(totalDots / 3),
24+
}).map(() => Math.floor(Math.random() * totalDots));
25+
setActiveDots(newActiveDots);
26+
}, 400);
27+
28+
return () => clearInterval(interval);
29+
}, [isActive, rows, cols]);
30+
31+
return (
32+
<div
33+
className="tw-grid"
34+
style={{
35+
gridTemplateColumns: `repeat(${cols}, 1fr)`,
36+
gridTemplateRows: `repeat(${rows}, 1fr)`,
37+
gap: "3px",
38+
}}
39+
>
40+
{Array.from({ length: rows * cols }).map((_, i) => (
41+
<div
42+
key={i}
43+
className={cn(
44+
"tw-h-[3px] tw-w-[3px] tw-rounded-full tw-transition-colors tw-duration-300", // Increased duration from 300 to 500
45+
activeDots.includes(i)
46+
? "tw-bg-foreground"
47+
: "tw-bg-muted-foreground",
48+
)}
49+
/>
50+
))}
51+
</div>
52+
);
53+
};
54+
55+
export default SpeakingAnimation;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { LuHeadphones, LuPause } from "react-icons/lu";
2+
3+
import { Container } from "@/content-script/components/ThreadMessageStickyToolbar";
4+
import SpeakingAnimation from "@/content-script/components/ThreadMessageStickyToolbar/TtsAnimation";
5+
import useTextToSpeech from "@/content-script/hooks/useTextToSpeech";
6+
import CplxUserSettings from "@/cplx-user-settings/CplxUserSettings";
7+
import {
8+
DropdownMenu,
9+
DropdownMenuContent,
10+
DropdownMenuContext,
11+
DropdownMenuItem,
12+
DropdownMenuTrigger,
13+
} from "@/shared/components/DropdownMenu";
14+
import { DomSelectors } from "@/utils/DomSelectors";
15+
import { TTS_VOICES, TtsVoice } from "@/utils/tts";
16+
17+
export default function TtsButton({
18+
containers,
19+
containerIndex,
20+
}: {
21+
containers: Container[];
22+
containerIndex: number;
23+
}) {
24+
const { isSpeaking, startPlaying, stopPlaying } = useTextToSpeech({
25+
messageIndex: containerIndex + 1,
26+
});
27+
28+
const $bottomButtonBar = $(containers?.[containerIndex]?.messageBlock).find(
29+
DomSelectors.THREAD.MESSAGE.BOTTOM_BAR,
30+
);
31+
32+
const play = async (voice: TtsVoice) => {
33+
if (isSpeaking) {
34+
stopPlaying();
35+
} else {
36+
startPlaying({
37+
voice,
38+
});
39+
}
40+
};
41+
42+
if (!$bottomButtonBar.length) return null;
43+
44+
return (
45+
<DropdownMenu>
46+
<DropdownMenuContext>
47+
{(context) => (
48+
<DropdownMenuTrigger>
49+
<div
50+
className={cn(
51+
"tw-flex tw-w-max tw-items-center tw-gap-2 tw-rounded-md tw-p-3 tw-transition-all tw-animate-in tw-fade-in hover:tw-cursor-pointer hover:tw-bg-secondary active:tw-scale-95",
52+
{
53+
"tw-bg-secondary tw-px-4 tw-shadow-lg": isSpeaking,
54+
},
55+
)}
56+
onClick={(e) => {
57+
e.stopPropagation();
58+
59+
if (context.open) context.setOpen(false);
60+
61+
play(CplxUserSettings.get().defaultTtsVoice ?? TTS_VOICES[0]);
62+
}}
63+
onContextMenu={(e) => {
64+
e.preventDefault();
65+
66+
if (isSpeaking) return;
67+
68+
context.setOpen(!context.open);
69+
}}
70+
>
71+
{isSpeaking ? (
72+
<>
73+
<SpeakingAnimation isActive rows={3} cols={15} />
74+
<LuPause className="tw-size-4" />
75+
</>
76+
) : (
77+
<LuHeadphones className="tw-size-4" />
78+
)}
79+
</div>
80+
</DropdownMenuTrigger>
81+
)}
82+
</DropdownMenuContext>
83+
<DropdownMenuContent>
84+
{TTS_VOICES.map((voice) => (
85+
<DropdownMenuItem
86+
key={voice}
87+
value={voice}
88+
onClick={() => {
89+
play(voice);
90+
CplxUserSettings.set((state) => {
91+
state.defaultTtsVoice = voice;
92+
});
93+
}}
94+
>
95+
{voice}
96+
</DropdownMenuItem>
97+
))}
98+
</DropdownMenuContent>
99+
</DropdownMenu>
100+
);
101+
}

src/content-script/components/ThreadMessageStickyToolbar/index.tsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import debounce from "lodash/debounce";
22
import {
3+
Fragment,
34
useCallback,
45
useDeferredValue,
56
useEffect,
@@ -9,8 +10,11 @@ import {
910
} from "react";
1011
import { Updater, useImmer } from "use-immer";
1112

13+
import appConfig from "@/app.config";
1214
import Toolbar from "@/content-script/components/ThreadMessageStickyToolbar/Toolbar";
13-
import useThreadMessageStickyToolbarObserver from "@/content-script/hooks/useThreadMessageStickyToolbarObserver";
15+
import TtsButton from "@/content-script/components/ThreadMessageStickyToolbar/TtsButton";
16+
import useThreadMessageStickyToolbarObserver from "@/content-script/components/ThreadMessageStickyToolbar/useThreadMessageStickyToolbarObserver";
17+
import useHasActivePplxSub from "@/content-script/hooks/useHasActivePplxSub";
1418
import Portal from "@/shared/components/Portal";
1519
import { DomSelectors } from "@/utils/DomSelectors";
1620
import UiUtils from "@/utils/UiUtils";
@@ -21,6 +25,7 @@ export type Container = {
2125
query: Element;
2226
container: Element;
2327
answer: Element;
28+
answerHeading: Element;
2429
};
2530

2631
export type ContainerStates = {
@@ -48,7 +53,8 @@ const isChanged = (prev: Container[], next: Container[]): boolean => {
4853
prev[i].messageBlock !== next[i].messageBlock ||
4954
prev[i].query !== next[i].query ||
5055
prev[i].container !== next[i].container ||
51-
prev[i].answer !== next[i].answer
56+
prev[i].answer !== next[i].answer ||
57+
prev[i].answerHeading !== next[i].answerHeading
5258
) {
5359
return true;
5460
}
@@ -57,6 +63,8 @@ const isChanged = (prev: Container[], next: Container[]): boolean => {
5763
};
5864

5965
export default function ThreadMessageStickyToolbar() {
66+
const { hasActivePplxSub } = useHasActivePplxSub();
67+
6068
const [containers, setContainers] = useState<Container[]>([]);
6169
const deferredContainers = useDeferredValue(containers);
6270
const [containersStates, setContainersStates] = useImmer<ContainerStates[]>(
@@ -115,17 +123,24 @@ export default function ThreadMessageStickyToolbar() {
115123
if (containers[index] == null) return null;
116124

117125
return (
118-
<Portal key={index} container={container.container as HTMLElement}>
119-
<Toolbar
120-
containers={containers}
121-
containersStates={containersStates}
122-
containerIndex={index}
123-
setContainersStates={setContainersStates}
124-
/>
125-
</Portal>
126+
<Fragment key={index}>
127+
<Portal container={container.container as HTMLElement}>
128+
<Toolbar
129+
containers={containers}
130+
containersStates={containersStates}
131+
containerIndex={index}
132+
setContainersStates={setContainersStates}
133+
/>
134+
</Portal>
135+
{appConfig.BROWSER === "chrome" && hasActivePplxSub && (
136+
<Portal container={container.answerHeading as HTMLElement}>
137+
<TtsButton containers={containers} containerIndex={index} />
138+
</Portal>
139+
)}
140+
</Fragment>
126141
);
127142
},
128-
[containers, containersStates, setContainersStates],
143+
[containers, containersStates, hasActivePplxSub, setContainersStates],
129144
);
130145

131146
return deferredContainers.map(renderToolbar);

0 commit comments

Comments
 (0)