Skip to content

Commit

Permalink
Fix/Feat: Fix and update the Widget
Browse files Browse the repository at this point in the history
  • Loading branch information
TheMhv committed Dec 25, 2024
1 parent 4df1534 commit 1601638
Show file tree
Hide file tree
Showing 5 changed files with 301 additions and 64 deletions.
11 changes: 1 addition & 10 deletions src/app/profile/[npub]/widget/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
import "./widget.css";

import { RemoveLogo } from "@/components/utils/RemoveLogo";
import { TTSWidget } from "@/components/Widget";

import { loadConfig, Settings } from "@/lib/config";
import { NostrProvider } from "@/components/NostrProvider";

const config: Settings = loadConfig();

interface PageProps {
params: { npub: string };
}

export default function widgetPage({ params }: PageProps) {
return (
<>
<RemoveLogo />
<NostrProvider relays={config.RELAYS}>
<TTSWidget pubkey={params.npub} />
</NostrProvider>
<TTSWidget pubkey={params.npub} />
</>
);
}
230 changes: 191 additions & 39 deletions src/components/Widget.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
"use client";

import React, {
useEffect,
useRef,
useState,
useCallback,
useContext,
} from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import {
Client,
Event,
EventSource,
Filter,
Kind,
PublicKey,
Timestamp,
} from "@rust-nostr/nostr-sdk";
import { MsEdgeTTS } from "msedge-tts";
import { MsEdgeTTS, OUTPUT_FORMAT, Voice } from "msedge-tts";
import { loadConfig, Settings } from "@/lib/config";
import { TriangleAlert } from "lucide-react";
import { NostrContext } from "./NostrProvider";
import { LoaderCircle, TriangleAlert } from "lucide-react";
import { clientConnect } from "@/lib/nostr/client";
import { useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader } from "./ui/card";
import Logo from "./logo";
import { Select } from "./ui/select";
import { Range } from "./ui/range";
import { Label } from "./ui/label";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { LuArrowRight } from "react-icons/lu";
import { RemoveLogo } from "./utils/RemoveLogo";

const config: Settings = loadConfig();

// Types for better type safet
interface TTSWidgetProps {
pubkey: string;
onEventProcessed?: (event: Event) => void;
}

// Utility functions moved outside component
const playAudio = async (source: string): Promise<void> => {
const audio = new Audio(source);

Expand All @@ -39,13 +42,19 @@ const playAudio = async (source: string): Promise<void> => {
});
};

const createTTS = async (text: string) => {
const createTTS = async (
text: string,
voice: string,
rate: string,
volume: string,
pitch: string
) => {
const tts = new MsEdgeTTS();
await tts.setMetadata(config.TTS.voice, config.TTS.format);
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
return tts.toStream(text, {
rate: config.TTS.rate,
volume: config.TTS.volume,
pitch: config.TTS.pitch,
rate: `${rate}%`,
volume: `${volume}%`,
pitch: `${pitch}%`,
});
};

Expand All @@ -71,7 +80,7 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
pubkey,
onEventProcessed,
}) => {
const { client } = useContext(NostrContext);
const [client, setClient] = useState<Client>();

const [queue, setQueue] = useState<Event[]>([]);
const [isVisible, setIsVisible] = useState(false);
Expand All @@ -82,9 +91,22 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
const lastZapRef = useRef<Event>();
const isProcessingRef = useRef(false);

// Fetch events handler
const params = useSearchParams();

const ttsVoice = params.get("voice") || "";
const ttsRate = params.get("rate") || "0";
const ttsVolume = params.get("volume") || "0";
const ttsPitch = params.get("pitch") || "0";

const min_sats = parseInt(params.get("min_sats") || "0");
const max_text = parseInt(params.get("max_text") || "0");

const fetchEvents = useCallback(async () => {
if (!client || !pubkey) return;
if (!client) {
setClient(await clientConnect());
}

if (!client || !pubkey || !ttsVoice) return;

try {
const filter = new Filter()
Expand All @@ -110,17 +132,41 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
setErrorText("Error processing audio stream");
console.error("Error fetching events:", error);
}
}, [client, pubkey]);
}, [client, pubkey, ttsVoice]);

// Process queue handler
const processQueue = useCallback(async () => {
if (isProcessingRef.current || queue.length === 0) return;

isProcessingRef.current = true;
const event = queue[0];

try {
const audioStream = await createTTS(event.content);
if (min_sats) {
const description = event.getTagContent("description");
if (description) {
const amount = parseInt(
Event.fromJson(description).getTagContent("amount") || "0"
);

if (amount && amount / 1000 < min_sats) {
isProcessingRef.current = false;
return;
}
}
}

let content = event.content;
if (max_text) {
content = content.substring(0, max_text);
}

const audioStream = await createTTS(
content,
ttsVoice,
ttsRate,
ttsVolume,
ttsPitch
);
const audioStreamData: Uint8Array[] = [];

audioStream.on("data", (data: Uint8Array) => {
Expand All @@ -130,22 +176,24 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
audioStream.on("end", async () => {
const audioData = Buffer.concat(audioStreamData).toString("base64");

// Sequence of actions
await playAudio(config.NOTIFY_AUDIO_URL).catch(() =>
setErrorText("Error when play audio")
);

setWidgetText(event.content);
setIsVisible(true);

await new Promise((resolve) =>
setTimeout(resolve, config.WIDGET_DISPLAY_DELAY)
);

await playAudio(`data:audio/mp4;base64,${audioData}`);
setIsVisible(false);

await new Promise((resolve) =>
setTimeout(resolve, config.WIDGET_HIDE_DELAY)
);

// Cleanup
setQueue((prev) => prev.slice(1));
onEventProcessed?.(event);
isProcessingRef.current = false;
Expand All @@ -161,9 +209,17 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
console.error("Error processing event:", error);
isProcessingRef.current = false;
}
}, [queue, onEventProcessed]);
}, [
queue,
min_sats,
max_text,
ttsVoice,
ttsRate,
ttsVolume,
ttsPitch,
onEventProcessed,
]);

// Set up polling
useEffect(() => {
try {
const intervalId = setInterval(fetchEvents, config.QUEUE_CHECK_INTERVAL);
Expand All @@ -174,7 +230,6 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
}
}, [fetchEvents]);

// Process queue when it changes
useEffect(() => {
try {
processQueue();
Expand All @@ -184,7 +239,6 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
}
}, [queue, processQueue]);

// Handle visibility animations
useEffect(() => {
if (!containerRef.current) return;

Expand All @@ -210,21 +264,119 @@ export const TTSWidget: React.FC<TTSWidgetProps> = ({
}
}, [isVisible]);

// Remove logo if present
useEffect(() => {
const logoElement = document.getElementById("logo");
if (logoElement) {
logoElement.style.display = "none";
}
}, []);

return (
return ttsVoice ? (
<>
<RemoveLogo />

<div id="widget-container" ref={containerRef}>
<div id="widget">{widgetText}</div>
</div>

{errorText && <ErrorAlert text={errorText} />}
</>
) : (
<Configuration />
);
};

const Configuration: React.FC = () => {
const [voices, setVoices] = useState<Voice[]>();

useEffect(() => {
const tts = new MsEdgeTTS();
tts.getVoices().then((data) => setVoices(data));
}, []);

return (
<Card className="max-w-md mx-auto">
<CardHeader>
<Logo className="text-3xl text-center my-2" />

<p className="text-center text-gray-600">
Configure your widget to receive alerts
</p>
</CardHeader>

<CardContent>
{voices ? (
<form action="" method="GET" className="space-y-4">
<Select name="voice" required>
<option value="">Select TTS Voice</option>

{voices.map((voice, index) => (
<option key={index} value={voice.ShortName}>
{voice.FriendlyName}
</option>
))}
</Select>

<div>
<Label htmlFor="rate">Rate:</Label>
<Range
id="rate"
name="rate"
min={-100}
max={100}
defaultValue={0}
indicator
/>
</div>

<div>
<Label htmlFor="volume">Volume:</Label>
<Range
id="volume"
name="volume"
min={-100}
max={100}
defaultValue={0}
indicator
/>
</div>

<div>
<Label htmlFor="pitch">Pitch:</Label>
<Range
id="pitch"
name="pitch"
min={-100}
max={100}
defaultValue={0}
indicator
/>
</div>

<div>
<Label htmlFor="min_sats">Minimum Sats:</Label>
<Input
id="min_sats"
name="min_sats"
type="number"
min={0}
defaultValue={config.MIN_SATOSHI_QNT}
/>
</div>

<div>
<Label htmlFor="max_text">Maximum text length:</Label>
<Input
id="max_text"
name="max_text"
type="number"
min={0}
defaultValue={config.MAX_TEXT_LENGTH}
/>
</div>

<Button type="submit" className="text-center w-full">
Confirm <LuArrowRight className="ml-2" />
</Button>
</form>
) : (
<span className="flex items-center justify-center gap-2">
<LoaderCircle className="animate-spin size-4" /> Loading...
</span>
)}
</CardContent>
</Card>
);
};
Loading

0 comments on commit 1601638

Please sign in to comment.