340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
// 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');
|
|
|
|
})();
|