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
194 lines
5.2 KiB
TypeScript
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;
|