Files
ALwrity/frontend/src/hooks/useTextToSpeech.ts
ajaysi 644e72d289 feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
Issue #518 - Subscription not updating after checkout:
- Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef)
- Move checkout success polling from InitialRouteHandler into SubscriptionContext
- Remove redundant polling code from InitialRouteHandler
- Fix plan label: 'Free' instead of 'No Plan', proper capitalization
- Add plan refresh button in UserBadge
- Add 'View Costing Details' to UserBadge dropdown
- Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI
- Clean subscription=success URL param after verification

Blog Writer WYSIWYG Editor enhancements:
- Per-section preview toggle (view/edit icons)
- Enhanced hover-based toolbar
- Circular SVG progress stats bar with detailed tooltip
- Research tool chips in stats bar footer
- Per-section TTS with useTextToSpeech hook (browser native)
- Full blog preview modal with print/PDF support
- PlayAllTTSButton: sequential playback with progress bar
- OnThisPageNav: floating sidebar with scroll tracking
- Section data attributes for scroll anchoring

GSC Brainstorm Topics feature:
- Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations)
- Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation
- Frontend: gscBrainstorm.ts API client
- Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect)
- Frontend: useGSCBrainstorm hook (connect check + brainstorm call)
- Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs)
- Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay)
- Wire BrainstormButton into ManualResearchForm and ResearchAction
- Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
2026-05-20 22:44:15 +05:30

194 lines
5.2 KiB
TypeScript

import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
export interface SpeechSynthesisOptions {
voice?: SpeechSynthesisVoice;
rate?: number; // 0.1 to 10
pitch?: number; // 0 to 2
volume?: number; // 0 to 1
}
export interface UseTextToSpeechReturn {
speak: (text: string, options?: SpeechSynthesisOptions) => void;
stop: () => void;
pause: () => void;
resume: () => void;
isSupported: boolean;
isSpeaking: boolean;
isPaused: boolean;
voices: SpeechSynthesisVoice[];
currentText: string | null;
}
// Singleton to manage global speech synthesis state
let globalIsSpeaking = false;
let globalIsPaused = false;
let globalCurrentText: string | null = null;
let globalOnStateChange: ((state: { isSpeaking: boolean; isPaused: boolean; currentText: string | null }) => void) | null = null;
const notifyStateChange = () => {
if (globalOnStateChange) {
globalOnStateChange({
isSpeaking: globalIsSpeaking,
isPaused: globalIsPaused,
currentText: globalCurrentText,
});
}
};
export const useTextToSpeech = (): UseTextToSpeechReturn => {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const synthRef = useRef<SpeechSynthesis | null>(null);
const isSupported = typeof window !== 'undefined' && 'speechSynthesis' in window;
// Initialize singleton listener
useEffect(() => {
globalOnStateChange = (state) => {
// Force re-render by using state setter (handled through component-local state)
};
return () => {
globalOnStateChange = null;
};
}, []);
// Load available voices
useEffect(() => {
if (!isSupported) return;
synthRef.current = window.speechSynthesis;
const loadVoices = () => {
const availableVoices = synthRef.current?.getVoices() || [];
setVoices(availableVoices);
};
loadVoices();
// Voices may load asynchronously
synthRef.current.onvoiceschanged = loadVoices;
// Cleanup on unmount - stop any ongoing speech
return () => {
if (synthRef.current) {
synthRef.current.cancel();
synthRef.current.onvoiceschanged = null;
}
};
}, [isSupported]);
const stop = useCallback(() => {
if (synthRef.current) {
synthRef.current.cancel();
}
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
}, []);
const pause = useCallback(() => {
if (synthRef.current && globalIsSpeaking && !globalIsPaused) {
synthRef.current.pause();
globalIsPaused = true;
notifyStateChange();
}
}, []);
const resume = useCallback(() => {
if (synthRef.current && globalIsPaused) {
synthRef.current.resume();
globalIsPaused = false;
notifyStateChange();
}
}, []);
const speak = useCallback((text: string, options?: SpeechSynthesisOptions) => {
if (!isSupported || !synthRef.current || !text?.trim()) return;
// Stop any current speech first
synthRef.current.cancel();
const utterance = new SpeechSynthesisUtterance(text);
// Apply options
if (options?.voice) {
utterance.voice = options.voice;
}
if (options?.rate !== undefined) {
utterance.rate = Math.max(0.1, Math.min(10, options.rate));
}
if (options?.pitch !== undefined) {
utterance.pitch = Math.max(0, Math.min(2, options.pitch));
}
if (options?.volume !== undefined) {
utterance.volume = Math.max(0, Math.min(1, options.volume));
}
// Set up event handlers
utterance.onstart = () => {
globalIsSpeaking = true;
globalIsPaused = false;
globalCurrentText = text;
notifyStateChange();
};
utterance.onend = () => {
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
};
utterance.onerror = (event) => {
// Ignore 'interrupted' errors (happens when stopping speech or switching sections)
if (event.error !== 'interrupted') {
console.error('Speech synthesis error:', event.error);
}
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
};
utterance.onpause = () => {
globalIsPaused = true;
notifyStateChange();
};
utterance.onresume = () => {
globalIsPaused = false;
notifyStateChange();
};
synthRef.current.speak(utterance);
}, [isSupported]);
// Use local state for reactivity but sync with global state
const [localIsSpeaking, setLocalIsSpeaking] = useState(false);
const [localIsPaused, setLocalIsPaused] = useState(false);
const [localCurrentText, setLocalCurrentText] = useState<string | null>(null);
// Sync with global state periodically
useEffect(() => {
const interval = setInterval(() => {
setLocalIsSpeaking(globalIsSpeaking);
setLocalIsPaused(globalIsPaused);
setLocalCurrentText(globalCurrentText);
}, 100);
return () => clearInterval(interval);
}, []);
return {
speak,
stop,
pause,
resume,
isSupported,
isSpeaking: localIsSpeaking,
isPaused: localIsPaused,
voices,
currentText: localCurrentText,
};
};
export default useTextToSpeech;