221 lines
5.5 KiB
JavaScript
221 lines
5.5 KiB
JavaScript
|
|
import { writable, get } from 'svelte/store';
|
||
|
|
import { browser } from '$app/environment';
|
||
|
|
|
||
|
|
// TTS enabled state (default: false)
|
||
|
|
export const ttsEnabled = writable(false);
|
||
|
|
|
||
|
|
// TTS settings
|
||
|
|
export const ttsSettings = writable({
|
||
|
|
voice: null, // SpeechSynthesisVoice object
|
||
|
|
voiceName: '', // Voice name for persistence
|
||
|
|
rate: 1, // 0.5 - 2
|
||
|
|
pitch: 1, // 0.5 - 2
|
||
|
|
volume: 1 // 0 - 1
|
||
|
|
});
|
||
|
|
|
||
|
|
// User filter: 'all' | 'guests' | 'registered'
|
||
|
|
export const ttsUserFilter = writable('all');
|
||
|
|
|
||
|
|
// Set of muted user IDs
|
||
|
|
export const ttsMutedUsers = writable(new Set());
|
||
|
|
|
||
|
|
// Available voices from browser
|
||
|
|
export const availableVoices = writable([]);
|
||
|
|
|
||
|
|
// Current realm ID for per-realm settings
|
||
|
|
let currentRealmId = null;
|
||
|
|
|
||
|
|
// Initialize voices from browser
|
||
|
|
export function initVoices() {
|
||
|
|
if (!browser || !window.speechSynthesis) return;
|
||
|
|
|
||
|
|
const loadVoices = () => {
|
||
|
|
const voices = window.speechSynthesis.getVoices();
|
||
|
|
availableVoices.set(voices);
|
||
|
|
|
||
|
|
// Restore saved voice if available
|
||
|
|
const settings = get(ttsSettings);
|
||
|
|
if (settings.voiceName && voices.length > 0) {
|
||
|
|
const savedVoice = voices.find(v => v.name === settings.voiceName);
|
||
|
|
if (savedVoice) {
|
||
|
|
ttsSettings.update(s => ({ ...s, voice: savedVoice }));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Chrome needs this event, Firefox loads immediately
|
||
|
|
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||
|
|
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||
|
|
}
|
||
|
|
loadVoices();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set voice by name
|
||
|
|
export function setVoice(voiceName) {
|
||
|
|
const voices = get(availableVoices);
|
||
|
|
const voice = voices.find(v => v.name === voiceName);
|
||
|
|
if (voice) {
|
||
|
|
ttsSettings.update(s => ({ ...s, voice, voiceName }));
|
||
|
|
saveSettings();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if message should be spoken
|
||
|
|
function shouldSpeak(message) {
|
||
|
|
if (!get(ttsEnabled)) return false;
|
||
|
|
|
||
|
|
// Check if user is muted
|
||
|
|
const mutedUsers = get(ttsMutedUsers);
|
||
|
|
if (mutedUsers.has(message.userId)) return false;
|
||
|
|
|
||
|
|
// Check user filter
|
||
|
|
const filter = get(ttsUserFilter);
|
||
|
|
if (filter === 'guests' && !message.isGuest) return false;
|
||
|
|
if (filter === 'registered' && message.isGuest) return false;
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Strip HTML/markdown and clean text for TTS
|
||
|
|
function cleanTextForTTS(text) {
|
||
|
|
if (!text) return '';
|
||
|
|
|
||
|
|
// Remove HTML tags
|
||
|
|
let clean = text.replace(/<[^>]*>/g, '');
|
||
|
|
|
||
|
|
// Remove markdown formatting
|
||
|
|
clean = clean.replace(/\*\*(.+?)\*\*/g, '$1'); // bold
|
||
|
|
clean = clean.replace(/\*(.+?)\*/g, '$1'); // italic
|
||
|
|
clean = clean.replace(/__(.+?)__/g, '$1'); // underline
|
||
|
|
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
|
||
|
|
clean = clean.replace(/`(.+?)`/g, '$1'); // code
|
||
|
|
|
||
|
|
// Remove URLs
|
||
|
|
clean = clean.replace(/https?:\/\/\S+/g, 'link');
|
||
|
|
|
||
|
|
// Limit length to prevent very long TTS
|
||
|
|
if (clean.length > 200) {
|
||
|
|
clean = clean.substring(0, 200) + '...';
|
||
|
|
}
|
||
|
|
|
||
|
|
return clean.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Speak a message
|
||
|
|
export function speakMessage(message) {
|
||
|
|
if (!browser || !window.speechSynthesis) return;
|
||
|
|
if (!shouldSpeak(message)) return;
|
||
|
|
|
||
|
|
const settings = get(ttsSettings);
|
||
|
|
const text = cleanTextForTTS(message.content);
|
||
|
|
|
||
|
|
if (!text) return;
|
||
|
|
|
||
|
|
// Add username prefix
|
||
|
|
const fullText = `${message.username} says: ${text}`;
|
||
|
|
|
||
|
|
const utterance = new SpeechSynthesisUtterance(fullText);
|
||
|
|
|
||
|
|
if (settings.voice) {
|
||
|
|
utterance.voice = settings.voice;
|
||
|
|
}
|
||
|
|
utterance.rate = settings.rate;
|
||
|
|
utterance.pitch = settings.pitch;
|
||
|
|
utterance.volume = settings.volume;
|
||
|
|
|
||
|
|
window.speechSynthesis.speak(utterance);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Toggle mute for a user
|
||
|
|
export function toggleUserMute(userId, username) {
|
||
|
|
ttsMutedUsers.update(muted => {
|
||
|
|
const newMuted = new Set(muted);
|
||
|
|
if (newMuted.has(userId)) {
|
||
|
|
newMuted.delete(userId);
|
||
|
|
} else {
|
||
|
|
newMuted.add(userId);
|
||
|
|
}
|
||
|
|
return newMuted;
|
||
|
|
});
|
||
|
|
saveSettings();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if user is muted
|
||
|
|
export function isUserMuted(userId) {
|
||
|
|
return get(ttsMutedUsers).has(userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear all muted users
|
||
|
|
export function clearMutedUsers() {
|
||
|
|
ttsMutedUsers.set(new Set());
|
||
|
|
saveSettings();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Save settings to localStorage
|
||
|
|
function saveSettings() {
|
||
|
|
if (!browser || !currentRealmId) return;
|
||
|
|
|
||
|
|
const settings = get(ttsSettings);
|
||
|
|
const data = {
|
||
|
|
enabled: get(ttsEnabled),
|
||
|
|
voiceName: settings.voiceName,
|
||
|
|
rate: settings.rate,
|
||
|
|
pitch: settings.pitch,
|
||
|
|
volume: settings.volume,
|
||
|
|
userFilter: get(ttsUserFilter),
|
||
|
|
mutedUsers: Array.from(get(ttsMutedUsers))
|
||
|
|
};
|
||
|
|
|
||
|
|
localStorage.setItem(`tts_settings_${currentRealmId}`, JSON.stringify(data));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load settings from localStorage
|
||
|
|
export function loadSettings(realmId) {
|
||
|
|
currentRealmId = realmId;
|
||
|
|
|
||
|
|
if (!browser || !realmId) return;
|
||
|
|
|
||
|
|
const saved = localStorage.getItem(`tts_settings_${realmId}`);
|
||
|
|
if (!saved) {
|
||
|
|
// Reset to defaults for new realm
|
||
|
|
ttsEnabled.set(false);
|
||
|
|
ttsUserFilter.set('all');
|
||
|
|
ttsMutedUsers.set(new Set());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const data = JSON.parse(saved);
|
||
|
|
|
||
|
|
ttsEnabled.set(data.enabled || false);
|
||
|
|
ttsUserFilter.set(data.userFilter || 'all');
|
||
|
|
ttsMutedUsers.set(new Set(data.mutedUsers || []));
|
||
|
|
|
||
|
|
ttsSettings.update(s => ({
|
||
|
|
...s,
|
||
|
|
voiceName: data.voiceName || '',
|
||
|
|
rate: data.rate ?? 1,
|
||
|
|
pitch: data.pitch ?? 1,
|
||
|
|
volume: data.volume ?? 1
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Restore voice object if voices are loaded
|
||
|
|
if (data.voiceName) {
|
||
|
|
const voices = get(availableVoices);
|
||
|
|
const voice = voices.find(v => v.name === data.voiceName);
|
||
|
|
if (voice) {
|
||
|
|
ttsSettings.update(s => ({ ...s, voice }));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to load TTS settings:', e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Subscribe to store changes to auto-save
|
||
|
|
if (browser) {
|
||
|
|
ttsEnabled.subscribe(() => saveSettings());
|
||
|
|
ttsSettings.subscribe(() => saveSettings());
|
||
|
|
ttsUserFilter.subscribe(() => saveSettings());
|
||
|
|
}
|