feat: text to speech and animation for zap alerts
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing

This commit is contained in:
verbiricha 2023-09-04 11:38:56 +02:00
parent 15be10aa02
commit 29b208b136
8 changed files with 370 additions and 65 deletions

View File

@ -142,12 +142,16 @@ a {
.btn-border {
border: 1px solid transparent;
color: inherit;
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
background:
linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
transition: 0.3s;
}
.btn-border:hover {
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
background:
linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
}
.btn-primary {

View File

@ -71,6 +71,12 @@
"6Z2pvJ": {
"defaultMessage": "Stream Providers"
},
"6pr6hJ": {
"defaultMessage": "Minimum amount for text to speech"
},
"8YT6ja": {
"defaultMessage": "Insert text to speak"
},
"9WRlF4": {
"defaultMessage": "Send"
},
@ -86,6 +92,9 @@
"Atr2p4": {
"defaultMessage": "NSFW Content"
},
"AukrPM": {
"defaultMessage": "No viewer data available"
},
"AyGauy": {
"defaultMessage": "Login"
},
@ -248,6 +257,9 @@
"cyR7Kh": {
"defaultMessage": "Back"
},
"d5zWyh": {
"defaultMessage": "Test voice"
},
"dVD/AR": {
"defaultMessage": "Top Zappers"
},
@ -269,6 +281,9 @@
"hGQqkW": {
"defaultMessage": "Schedule"
},
"heyxZL": {
"defaultMessage": "Enable text to speech"
},
"hpl4BP": {
"defaultMessage": "Chat Widget"
},
@ -299,6 +314,9 @@
"ljmS5P": {
"defaultMessage": "Endpoint"
},
"mnJYBQ": {
"defaultMessage": "Voice"
},
"mtNGwh": {
"defaultMessage": "A short description of the content"
},
@ -341,6 +359,9 @@
"s7V+5p": {
"defaultMessage": "Confirm your age"
},
"sInm1h": {
"defaultMessage": "Zap message"
},
"tG1ST3": {
"defaultMessage": "Incoming Zap"
},
@ -371,6 +392,9 @@
"wEQDC6": {
"defaultMessage": "Edit"
},
"wMKVFz": {
"defaultMessage": "Select voice..."
},
"wOy57k": {
"defaultMessage": "Add stream goal"
},

View File

@ -1,49 +1,83 @@
.zap-alert-widgets .zap-alert {
animation: cssAnimation 0s ease-in 10s forwards;
animation-fill-mode: forwards;
.zap-alerts-widget .zap-alert {
animation: fadeInOut 10s;
}
@keyframes fadeInOut {
0% {
opacity: 0;
}
16% {
opacity: 1;
}
84% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.text-to-speech-settings {
display: flex;
flex-direction: column;
gap: 4px;
}
.text-to-speech-settings .labeled-input {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.text-to-speech-settings .labeled-input label {
font-size: 14px;
color: var(--text-muted);
}
.text-to-speech-settings textarea {
resize: vertical;
}
.zap-alert {
display: inline-flex;
flex-direction: column;
align-items: center;
border-radius: 17px;
background: #2d2d2d;
margin-top: 24px;
}
.zap-alert > div:nth-of-type(1) {
.zap-alert > .zap-alert-title {
width: fit-content;
text-align: center;
left: 120px;
margin-top: -20px;
font-size: 21px;
font-weight: 600;
border-radius: 33px;
background: linear-gradient(135deg, #882bff 0%, #f83838 100%);
margin: 0;
padding: 8px 24px;
margin-bottom: -11px;
z-index: 2;
}
.zap-alert > div:nth-of-type(2) {
.zap-alert > .zap-alert-header {
z-index: 1;
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: 17px;
background: #2d2d2d;
padding: 27px 90px;
padding: 12px 90px 24px 90px;
font-size: 29px;
font-weight: 600;
}
.zap-alert .highlight {
color: #ff4468;
.zap-alert-body {
padding: 0 24px 24px 24px;
margin-top: -20px;
margin-bottom: 0;
}
@keyframes cssAnimation {
from {
opacity: 1;
}
to {
opacity: 0;
}
.zap-alert .highlight {
color: #ff4468;
}
.views {

View File

@ -1,14 +1,149 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import "./widgets.css";
import { useState, useMemo } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { NostrPrefix, createNostrLink } from "@snort/system";
import Copy from "element/copy";
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { getVoices, speak, toTextToSpeechParams } from "text2speech";
import { useLogin } from "hooks/login";
import { FormattedMessage } from "react-intl";
import { eventToLink, hexToBech32 } from "utils";
import { ZapAlertItem } from "./widgets/zaps";
import { TopZappersWidget } from "./widgets/top-zappers";
import { Views } from "./widgets/views";
import groupBy from "lodash/groupBy";
interface ZapAlertConfigurationProps {
npub: string;
baseUrl: string;
}
function ZapAlertConfiguration({ npub, baseUrl }: ZapAlertConfigurationProps) {
const login = useLogin();
const { formatMessage, formatDisplayName } = useIntl();
const [testText, setTestText] = useState("");
const [textToSpeech, setTextToSpeech] = useState<boolean>(false);
const [voice, setVoice] = useState<string | null>(null);
const [minSatsForTextToSpeech, setMinSatsForTextToSpeech] = useState<string>("21");
// Google propietary voices are not available on OBS browser
const voices = getVoices().filter(v => !v.name.includes("Google"));
const groupedVoices = useMemo(() => {
return groupBy(voices, v => v.lang);
}, [voices]);
const languages = useMemo(() => {
return Object.keys(groupedVoices).sort();
}, [groupedVoices]);
const selectedVoice = useMemo(() => {
return voices.find(v => v.voiceURI === voice);
}, [voice]);
const isTextToSpeechSupported = "speechSynthesis" in window;
const isTextToSpeechEnabled = voices.length > 0 && textToSpeech;
const query = useMemo(() => {
const params = toTextToSpeechParams({
voiceURI: voice,
minSats: voice ? Number(minSatsForTextToSpeech) : null,
});
const queryParams = params.toString();
return queryParams.length > 0 ? `?${queryParams}` : "";
}, [voice, minSatsForTextToSpeech]);
function testVoice() {
if (selectedVoice) {
speak(selectedVoice, testText);
}
}
return (
<>
<h3>
<FormattedMessage defaultMessage="Zap Alert" />
</h3>
<Copy text={`${baseUrl}/alert/${npub}/zaps${query}`} />
<ZapAlertItem
item={{
id: "",
valid: true,
content: testText,
zapService: "",
anonZap: false,
errors: [],
sender: login?.pubkey,
amount: 1_000_000,
}}
/>
<div className="text-to-speech-settings">
<div
className="paper"
onClick={() => setTextToSpeech(!textToSpeech)}
style={{ cursor: isTextToSpeechSupported ? "pointer" : "not-allowed" }}>
<input
disabled={!isTextToSpeechSupported}
type="checkbox"
checked={textToSpeech}
onChange={ev => setTextToSpeech(ev.target.checked)}
/>
<FormattedMessage defaultMessage="Enable text to speech" />
</div>
{isTextToSpeechEnabled && (
<>
<div className="paper labeled-input">
<label htmlFor="minimum-sats">
<FormattedMessage defaultMessage="Minimum amount for text to speech" />
</label>
<input
id="minimum-sats"
type="number"
min="1"
value={minSatsForTextToSpeech}
onChange={ev => setMinSatsForTextToSpeech(ev.target.value)}
/>
</div>
<div className="paper labeled-input">
<label htmlFor="voice-selector">
<FormattedMessage defaultMessage="Voice" />
</label>
<select id="voice-selector" onChange={ev => setVoice(ev.target.value)}>
<option value="">
<FormattedMessage defaultMessage="Select voice..." />
</option>
{languages.map(l => (
<optgroup label={formatDisplayName(l, { type: "language" })}>
{groupedVoices[l].map(v => (
<option value={v.voiceURI}>{v.name}</option>
))}
</optgroup>
))}
</select>
</div>
{voice && (
<>
<div className="paper labeled-input">
<label htmlFor="zap-alert-text">
<FormattedMessage defaultMessage="Zap message" />
</label>
<textarea
id="zap-alert-text"
placeholder={formatMessage({ defaultMessage: "Insert text to speak" })}
value={testText}
onChange={ev => setTestText(ev.target.value)}
/>
</div>
<button disabled={testText.length === 0} className="btn" onClick={testVoice}>
<FormattedMessage defaultMessage="Test voice" />
</button>
</>
)}
</>
)}
</div>
</>
);
}
export function WidgetsPage() {
const login = useLogin();
@ -18,6 +153,7 @@ export function WidgetsPage() {
const npub = hexToBech32("npub", login?.pubkey);
const baseUrl = `${window.location.protocol}//${window.location.host}`;
return (
<div className="widgets g8">
<div className="flex f-col g8">
@ -27,21 +163,7 @@ export function WidgetsPage() {
<Copy text={`${baseUrl}/chat/${npub}`} />
</div>
<div className="flex f-col g8">
<h3>
<FormattedMessage defaultMessage="Zap Alert" />
</h3>
<Copy text={`${baseUrl}/alert/${npub}/zaps`} />
<ZapAlertItem
item={{
id: "",
valid: true,
zapService: "",
anonZap: false,
errors: [],
sender: login?.pubkey,
amount: 1_000_000,
}}
/>
<ZapAlertConfiguration npub={npub} baseUrl={baseUrl} />
</div>
<div className="flex f-col g8">
<h3>

View File

@ -7,9 +7,14 @@ export function Views({ link }: { link: NostrLink }) {
const current = useCurrentStreamFeed(link, true);
const viewers = findTag(current, "current_participants");
const n = Number(viewers);
return (
<div className="views">
<FormattedMessage defaultMessage="{n} viewers" values={{ n: Number(viewers) }} />
{isNaN(n) ? (
<FormattedMessage defaultMessage="No viewer data available" />
) : (
<FormattedMessage defaultMessage="{n} viewers" values={{ n }} />
)}
</div>
);
}

View File

@ -1,47 +1,102 @@
import { useMemo, useState, useEffect } from "react";
import { hexToBech32 } from "@snort/shared";
import { NostrLink, ParsedZap } from "@snort/system";
import type { NostrLink, ParsedZap } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { useZaps } from "hooks/zaps";
import { useMutedPubkeys } from "hooks/lists";
import { formatSats } from "number";
import { useTextToSpeechParams, getVoices, speak } from "text2speech";
import { FormattedMessage } from "react-intl";
import { eventToLink } from "utils";
import { getHost, eventToLink } from "utils";
function useZapQueue(zapStream: ParsedZap[], zapTime = 10_000) {
const zaps = useMemo(() => {
return zapStream.reverse();
}, [zapStream]);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const zap = useMemo(() => {
return zaps.at(currentIndex);
}, [zaps, currentIndex]);
useEffect(() => {
if (zap) {
setTimeout(() => {
setCurrentIndex(currentIndex + 1);
}, zapTime);
}
}, [zap]);
return zap;
}
export function ZapAlerts({ link }: { link: NostrLink }) {
const currentEvent = useCurrentStreamFeed(link, true);
const currentLink = currentEvent ? eventToLink(currentEvent) : undefined;
const host = getHost(currentEvent);
const zaps = useZaps(currentLink, true);
const zap = useZapQueue(zaps);
const mutedPubkeys = useMutedPubkeys(host, true);
const { voiceURI, minSats } = useTextToSpeechParams();
const voices = getVoices();
const voice = useMemo(() => {
return voices.find(v => v.voiceURI === voiceURI);
}, [voices, voiceURI]);
return (
<div className="flex f-center f-col zap-alert-widgets">
{zaps.slice(0, 5).map(v => (
<ZapAlertItem key={v.id} item={v} />
))}
</div>
);
useEffect(() => {
if (!zap) return;
if (mutedPubkeys.has(zap?.sender ?? "")) {
return;
}
const text = zap.content ?? "";
if (text.length > 0 && voice) {
try {
if (zap.amount >= minSats) {
speak(voice, text);
}
} catch (e) {
console.error(e);
}
}
}, [zap?.id]);
return <div className="flex f-center f-col zap-alerts-widget">{zap && <ZapAlertItem key={zap.id} item={zap} />}</div>;
}
export function ZapAlertItem({ item }: { item: ParsedZap }) {
const profile = useUserProfile(item.sender);
if (!profile) return;
return (
<div className="zap-alert">
<div>
<FormattedMessage defaultMessage="Incoming Zap" />
<>
<div className="zap-alert">
<div className="zap-alert-title">
<FormattedMessage defaultMessage="Incoming Zap" />
</div>
<div className="zap-alert-header">
<FormattedMessage
defaultMessage="{name} with {amount}"
values={{
name: (
<span className="highlight">
{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)}&nbsp;
</span>
),
amount: <span className="highlight">&nbsp;{formatSats(item.amount)}</span>,
}}
/>
</div>
{item.content && item.content.length > 0 && (
<p dir="auto" className="zap-alert-body">
{item.content}
</p>
)}
</div>
<div>
<FormattedMessage
defaultMessage="{name} with {amount}"
values={{
name: (
<span className="highlight">
{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)}&nbsp;
</span>
),
amount: <span className="highlight">&nbsp;{formatSats(item.amount)}</span>,
}}
/>
</div>
</div>
</>
);
}

53
src/text2speech.ts Normal file
View File

@ -0,0 +1,53 @@
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
function useQuery() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
interface TextToSpeechConfig {
voiceURI: string | null;
minSats: number;
}
export function useTextToSpeechParams(): TextToSpeechConfig {
const q = useQuery();
const voiceURI = q.get("voiceURI");
const minSats = Number(q.get("minSats")) ?? 21;
return { voiceURI, minSats };
}
interface TextToSpeechConfigParams {
voiceURI: string | null;
minSats: number | null;
}
export function toTextToSpeechParams(config: TextToSpeechConfigParams): URLSearchParams {
const params = new URLSearchParams();
if (config.voiceURI) {
params.set("voiceURI", config.voiceURI);
}
if (config.minSats) {
params.set("minSats", String(config.minSats));
}
return params;
}
export function getVoices() {
if ("speechSynthesis" in window) {
return speechSynthesis.getVoices();
}
return [];
}
export function speak(voice: SpeechSynthesisVoice, text: string) {
try {
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = voice;
utterance.rate = 0.8;
speechSynthesis.speak(utterance);
} catch (e) {
console.error(e);
}
}

View File

@ -23,11 +23,14 @@
"5QYdPU": "Start Time",
"5kx+2v": "Server Url",
"6Z2pvJ": "Stream Providers",
"6pr6hJ": "Minimum amount for text to speech",
"8YT6ja": "Insert text to speak",
"9WRlF4": "Send",
"9a9+ww": "Title",
"9anxhq": "Starts",
"AIHaPH": "{person} zapped {amount} sats",
"Atr2p4": "NSFW Content",
"AukrPM": "No viewer data available",
"AyGauy": "Login",
"BGxpTN": "Stream Chat",
"Bep/gA": "Private key",
@ -82,6 +85,7 @@
"cPIKU2": "Following",
"cvAsEh": "Streamed on {date}",
"cyR7Kh": "Back",
"d5zWyh": "Test voice",
"dVD/AR": "Top Zappers",
"ebmhes": "Nostr Extension",
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
@ -89,6 +93,7 @@
"fc2iho": "Add File",
"feZ/kG": "Login with Private Key (insecure)",
"hGQqkW": "Schedule",
"heyxZL": "Enable text to speech",
"hpl4BP": "Chat Widget",
"ieGrWo": "Follow",
"itPgxd": "Profile",
@ -99,6 +104,7 @@
"kp0NPF": "Planned",
"lZpRMR": "Check here if this stream contains nudity or pornographic content.",
"ljmS5P": "Endpoint",
"mnJYBQ": "Voice",
"mtNGwh": "A short description of the content",
"nBCvvJ": "Topup",
"nOaArs": "Setup Profile",
@ -113,6 +119,7 @@
"rgsbu9": "Current Viewers",
"s5ksS7": "Image Link",
"s7V+5p": "Confirm your age",
"sInm1h": "Zap message",
"tG1ST3": "Incoming Zap",
"tM6fNW": "Amazing! Continue..",
"thsiMl": "terms and conditions",
@ -123,9 +130,10 @@
"w0Xm2F": "Start typing",
"wCIL7o": "Broadcast on Nostr",
"wEQDC6": "Edit",
"wMKVFz": "Select voice...",
"wOy57k": "Add stream goal",
"wzWWzV": "Top zappers",
"x82IOl": "Mute",
"yzKwBQ": "eg. nsec1xyz",
"zVDHAu": "Zap Alert"
}
}