diff --git a/assets/js/chat.js b/assets/js/chat.js index ac7ce37..5d7cd71 100644 --- a/assets/js/chat.js +++ b/assets/js/chat.js @@ -4,7 +4,7 @@ (function() { 'use strict'; - AppModules.require('api', 'ui-controls'); + AppModules.require('api', 'ui-controls', 'screen-reader'); AppModules.register('chat'); // Initialize chat system @@ -21,6 +21,9 @@ UIControls.showToast('Admin mode activated'); } + // Announce chat initialization to screen readers + ScreenReader.connectionStatus('connected'); + // Set up nickname handling const nicknameInput = document.getElementById('nickname'); if (nicknameInput) { @@ -163,10 +166,20 @@ const nicknameValidation = validateNickname(nickname); updateValidationUI('nickname', nicknameErrorElement, nicknameValidation.valid, nicknameValidation.message); + // Announce validation errors + if (!nicknameValidation.valid) { + ScreenReader.formValidation('nickname', false, nicknameValidation.message); + } + // Validate message const messageValidation = validateMessage(message); updateValidationUI('messageInput', messageErrorElement, messageValidation.valid, messageValidation.message); + // Announce validation errors + if (!messageValidation.valid) { + ScreenReader.formValidation('messageInput', false, messageValidation.message); + } + // If either validation failed, focus the appropriate field and stop if (!nicknameValidation.valid) { nicknameInput.focus(); @@ -193,6 +206,9 @@ updateValidationUI('nickname', nicknameErrorElement, true); updateValidationUI('messageInput', messageErrorElement, true); + // Announce successful message send + ScreenReader.formSuccess('Message sent successfully'); + UIControls.showToast('Message sent successfully!'); // Clear success state after a delay @@ -206,12 +222,14 @@ // Show server error updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message: ' + data.error); UIControls.updateConnectionStatus(false); + ScreenReader.connectionStatus('error', data.error); } }) .catch(error => { AppLogger.error('Error sending message:', error); updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.'); UIControls.updateConnectionStatus(false); + ScreenReader.connectionStatus('error', 'Network error while sending message'); }) .finally(() => { // End loading state @@ -232,6 +250,7 @@ if (data.success) { removeMessageFromUI(messageId); UIControls.showToast('Message deleted'); + ScreenReader.systemMessage('Message deleted by admin', 'normal'); } else { UIControls.showToast('Failed to delete message'); } @@ -252,6 +271,7 @@ .then(data => { if (data.success) { UIControls.showToast('User banned'); + ScreenReader.systemMessage('User banned from chat by admin', 'normal'); } else { UIControls.showToast('Failed to ban user'); } @@ -273,6 +293,7 @@ if (data.success) { clearMessagesUI(); UIControls.showToast('Chat cleared'); + ScreenReader.systemMessage('Chat was cleared by admin', 'high'); AppState.lastMessageId = ''; API.sendHeartbeat(); // Force refresh all users } else { @@ -333,6 +354,12 @@ // Handle initial message load function handleInitialMessageLoad(data) { AppState.allMessages = data.all_messages || []; + + // Announce message group if loading multiple messages + if (AppState.allMessages.length > 0) { + ScreenReader.messageGroup(AppState.allMessages, 'added'); + } + displayAllMessages(); } diff --git a/assets/js/screen-reader.js b/assets/js/screen-reader.js new file mode 100644 index 0000000..3321388 --- /dev/null +++ b/assets/js/screen-reader.js @@ -0,0 +1,340 @@ +// Screen Reader Announcements Module +// Handles ARIA live region announcements for accessibility +(function() { + 'use strict'; + + AppModules.require('ui-controls'); + AppModules.register('screen-reader'); + + // Announcement regions + let regions = {}; + + // State tracking to avoid announcement spam + let lastViewerCount = 0; + let lastStreamStatus = 'unknown'; + let lastConnectionStatus = 'unknown'; + + // Announcement queue to prevent rapid-fire announcements + let announcementQueue = []; + let isProcessingQueue = false; + + // Configuration + const ANNOUNCEMENT_THRESHOLDS = { + viewerCount: 5, // Only announce when count changes by 5+ users + messageGroup: 3, // Minimum messages for group announcements + activityDelay: 500 // Delay for activity announcements to batch them + }; + + // Initialize announcement regions + function initializeAnnouncements() { + // Get all announcement region elements + regions = { + stream: document.getElementById('sr-stream-announcer'), + connection: document.getElementById('sr-connection-announcer'), + system: document.getElementById('sr-system-announcer'), + messageGroup: document.getElementById('sr-message-group-announcer'), + viewer: document.getElementById('sr-viewer-announcer'), + activity: document.getElementById('sr-activity-announcer'), + form: document.getElementById('sr-form-announcer') + }; + + // Verify all regions exist + const missingRegions = Object.entries(regions).filter(([key, element]) => !element); + if (missingRegions.length > 0) { + AppLogger.error('Missing screen reader announcement regions:', missingRegions.map(([key]) => key)); + } else { + AppLogger.log('Screen reader announcement regions initialized'); + } + } + + // Queue announcement to prevent spam + function queueAnnouncement(region, text, options = {}) { + if (!text || !text.trim()) return; + + announcementQueue.push({ + region, + text: text.trim(), + options: { + priority: options.priority || 'normal', // 'low', 'normal', 'high' + delay: options.delay || 0, + ...options + } + }); + + // Start processing queue if not already running + if (!isProcessingQueue) { + processAnnouncementQueue(); + } + } + + // Process the announcement queue + async function processAnnouncementQueue() { + if (isProcessingQueue || announcementQueue.length === 0) return; + + isProcessingQueue = true; + + while (announcementQueue.length > 0) { + const announcement = announcementQueue.shift(); + + try { + await announce(announcement.region, announcement.text, announcement.options); + } catch (error) { + AppLogger.error('Error processing announcement:', error); + } + } + + isProcessingQueue = false; + } + + // Actually announce to a region + async function announce(regionName, text, options = {}) { + const regionElement = regions[regionName]; + if (!regionElement) { + AppLogger.error(`Unknown announcement region: ${regionName}`); + return; + } + + // Apply delay if specified + if (options.delay > 0) { + await new Promise(resolve => setTimeout(resolve, options.delay)); + } + + // Clear region first for assertive updates + regionElement.textContent = ''; + + // Small delay to ensure clear is processed + await new Promise(resolve => setTimeout(resolve, 10)); + + // Set the announcement text + regionElement.textContent = text; + + // Log for debugging + AppLogger.log(`Screen reader announcement (${regionName}): ${text}`); + } + + // Public announcement functions + const announcements = { + + // Stream status changes + streamStatus(status, details = '') { + if (status === lastStreamStatus) return; // Avoid duplicate announcements + + lastStreamStatus = status; + let message = ''; + + switch (status) { + case 'online': + message = 'Stream is now online and available'; + break; + case 'offline': + message = 'Stream is currently offline'; + break; + case 'reconnecting': + message = 'Attempting to reconnect to stream'; + break; + case 'quality-changed': + message = `Stream quality changed to ${details}`; + break; + case 'buffering': + message = 'Stream is buffering'; + break; + } + + if (message) { + queueAnnouncement('stream', message, { priority: 'high' }); + } + }, + + // Connection status changes + connectionStatus(status, details = '') { + if (status === lastConnectionStatus) return; + + lastConnectionStatus = status; + let message = ''; + + switch (status) { + case 'connected': + message = 'Chat connection established'; + break; + case 'disconnected': + message = 'Chat connection lost - attempting to reconnect'; + break; + case 'reconnecting': + message = 'Reconnecting to chat'; + break; + case 'error': + message = `Connection error: ${details}`; + break; + } + + if (message) { + queueAnnouncement('connection', message, { priority: 'high' }); + } + }, + + // Viewer count changes (with threshold) + viewerCount(count) { + const oldCount = lastViewerCount; + const diff = Math.abs(count - oldCount); + + // Only announce if significant change (threshold) or crossing boundaries + if (diff >= ANNOUNCEMENT_THRESHOLDS.viewerCount || + (oldCount === 0 && count > 0) || + (oldCount > 0 && count === 0)) { + + lastViewerCount = count; + + let message; + if (count === 0) { + message = 'No viewers currently online'; + } else if (count === 1) { + message = '1 viewer online'; + } else { + message = `${count} viewers online`; + } + + queueAnnouncement('viewer', message, { priority: 'low', delay: 500 }); + } + }, + + // System announcements (admin actions, etc.) + systemMessage(message, priority = 'normal') { + queueAnnouncement('system', message, { + priority: priority, + delay: priority === 'high' ? 0 : 200 + }); + }, + + // Message group announcements + messageGroup(messages, action = 'added') { + if (!Array.isArray(messages) || messages.length === 0) return; + + const count = messages.length; + let message = ''; + + if (count >= ANNOUNCEMENT_THRESHOLDS.messageGroup) { + if (action === 'added') { + message = `${count} new messages loaded`; + } else if (action === 'filtered') { + message = `Showing ${count} messages matching filter`; + } + } + + if (message) { + queueAnnouncement('messageGroup', message, { + priority: 'low', + delay: 300 + }); + } + }, + + // Individual message arrival (for existing messages) + messageReceived(message, isUserMessage = false) { + // Don't announce user's own messages to avoid echo feedback + if (isUserMessage) return; + + // For individual messages, let the aria-live="polite" on chat handle it + // Only use this for special announcements + }, + + // Activity announcements + activity(userAction, details = '') { + let message = ''; + + switch (userAction) { + case 'joined': + message = `User ${details} joined the chat`; + break; + case 'left': + message = `User ${details} left the chat`; + break; + case 'admin-action': + message = details; // Admin actions passed through + break; + default: + if (details) { + message = details; + } + } + + if (message) { + queueAnnouncement('activity', message, { + priority: 'low', + delay: ANNOUNCEMENT_THRESHOLDS.activityDelay + }); + } + }, + + // Form validation messages + formValidation(field, isValid, message) { + if (!message) return; + + queueAnnouncement('form', message, { + priority: isValid ? 'low' : 'high', + delay: 100 + }); + }, + + // Form success messages + formSuccess(message) { + queueAnnouncement('form', message, { + priority: 'normal', + delay: 100 + }); + }, + + // Generic high-priority announcements (errors, critical state changes) + critical(message) { + queueAnnouncement('system', message, { priority: 'high' }); + }, + + // Status updates (dashboard stats changes) + statusUpdate(type, value, unit = '') { + let message = ''; + + switch (type) { + case 'uptime': + message = `Stream uptime: ${value}`; + break; + case 'quality': + message = `Stream quality: ${value}`; + break; + case 'buffer': + const bufferPercent = Math.round(value * 100); + message = `Buffer ${bufferPercent}%`; + break; + default: + if (unit) { + message = `${type}: ${value} ${unit}`; + } else { + message = `${type}: ${value}`; + } + } + + if (message) { + // Use activity region for status updates + queueAnnouncement('activity', message, { + priority: 'low', + delay: 500 + }); + } + } + }; + + // Public API + window.ScreenReader = { + ...announcements, + initialize: initializeAnnouncements, + queueAnnouncement: queueAnnouncement, + announce: announce // Direct announce function for special cases + }; + + // Initialize when DOM is ready + document.addEventListener('DOMContentLoaded', function() { + // Small delay to ensure DOM is fully ready + setTimeout(initializeAnnouncements, 100); + }); + + AppLogger.log('Screen reader announcements module loaded'); + +})(); diff --git a/assets/js/ui-controls.js b/assets/js/ui-controls.js index 41b26b7..287af21 100644 --- a/assets/js/ui-controls.js +++ b/assets/js/ui-controls.js @@ -4,7 +4,7 @@ (function() { 'use strict'; - AppModules.require('api'); + AppModules.require('api', 'screen-reader'); AppModules.register('ui-controls'); // Toast notification system @@ -288,6 +288,9 @@ if (viewerElement) { viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers'); } + + // Announce viewer count changes to screen readers + ScreenReader.viewerCount(count); } // Connection status dot diff --git a/index.php b/index.php index 952d6cd..35104f0 100644 --- a/index.php +++ b/index.php @@ -516,6 +516,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + +
+
+
+
+
+
+
+