Complete 4.5.2: Screen Reader Support - Implement comprehensive ARIA live regions, attributes, and dynamic announcements
This commit is contained in:
parent
4a9941aa9a
commit
8d7b7dfe80
4 changed files with 382 additions and 2 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
AppModules.require('api', 'ui-controls');
|
AppModules.require('api', 'ui-controls', 'screen-reader');
|
||||||
AppModules.register('chat');
|
AppModules.register('chat');
|
||||||
|
|
||||||
// Initialize chat system
|
// Initialize chat system
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
UIControls.showToast('Admin mode activated');
|
UIControls.showToast('Admin mode activated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce chat initialization to screen readers
|
||||||
|
ScreenReader.connectionStatus('connected');
|
||||||
|
|
||||||
// Set up nickname handling
|
// Set up nickname handling
|
||||||
const nicknameInput = document.getElementById('nickname');
|
const nicknameInput = document.getElementById('nickname');
|
||||||
if (nicknameInput) {
|
if (nicknameInput) {
|
||||||
|
|
@ -163,10 +166,20 @@
|
||||||
const nicknameValidation = validateNickname(nickname);
|
const nicknameValidation = validateNickname(nickname);
|
||||||
updateValidationUI('nickname', nicknameErrorElement, nicknameValidation.valid, nicknameValidation.message);
|
updateValidationUI('nickname', nicknameErrorElement, nicknameValidation.valid, nicknameValidation.message);
|
||||||
|
|
||||||
|
// Announce validation errors
|
||||||
|
if (!nicknameValidation.valid) {
|
||||||
|
ScreenReader.formValidation('nickname', false, nicknameValidation.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate message
|
// Validate message
|
||||||
const messageValidation = validateMessage(message);
|
const messageValidation = validateMessage(message);
|
||||||
updateValidationUI('messageInput', messageErrorElement, messageValidation.valid, messageValidation.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 either validation failed, focus the appropriate field and stop
|
||||||
if (!nicknameValidation.valid) {
|
if (!nicknameValidation.valid) {
|
||||||
nicknameInput.focus();
|
nicknameInput.focus();
|
||||||
|
|
@ -193,6 +206,9 @@
|
||||||
updateValidationUI('nickname', nicknameErrorElement, true);
|
updateValidationUI('nickname', nicknameErrorElement, true);
|
||||||
updateValidationUI('messageInput', messageErrorElement, true);
|
updateValidationUI('messageInput', messageErrorElement, true);
|
||||||
|
|
||||||
|
// Announce successful message send
|
||||||
|
ScreenReader.formSuccess('Message sent successfully');
|
||||||
|
|
||||||
UIControls.showToast('Message sent successfully!');
|
UIControls.showToast('Message sent successfully!');
|
||||||
|
|
||||||
// Clear success state after a delay
|
// Clear success state after a delay
|
||||||
|
|
@ -206,12 +222,14 @@
|
||||||
// Show server error
|
// Show server error
|
||||||
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message: ' + data.error);
|
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message: ' + data.error);
|
||||||
UIControls.updateConnectionStatus(false);
|
UIControls.updateConnectionStatus(false);
|
||||||
|
ScreenReader.connectionStatus('error', data.error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
AppLogger.error('Error sending message:', error);
|
AppLogger.error('Error sending message:', error);
|
||||||
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.');
|
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.');
|
||||||
UIControls.updateConnectionStatus(false);
|
UIControls.updateConnectionStatus(false);
|
||||||
|
ScreenReader.connectionStatus('error', 'Network error while sending message');
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// End loading state
|
// End loading state
|
||||||
|
|
@ -232,6 +250,7 @@
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
removeMessageFromUI(messageId);
|
removeMessageFromUI(messageId);
|
||||||
UIControls.showToast('Message deleted');
|
UIControls.showToast('Message deleted');
|
||||||
|
ScreenReader.systemMessage('Message deleted by admin', 'normal');
|
||||||
} else {
|
} else {
|
||||||
UIControls.showToast('Failed to delete message');
|
UIControls.showToast('Failed to delete message');
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +271,7 @@
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
UIControls.showToast('User banned');
|
UIControls.showToast('User banned');
|
||||||
|
ScreenReader.systemMessage('User banned from chat by admin', 'normal');
|
||||||
} else {
|
} else {
|
||||||
UIControls.showToast('Failed to ban user');
|
UIControls.showToast('Failed to ban user');
|
||||||
}
|
}
|
||||||
|
|
@ -273,6 +293,7 @@
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
clearMessagesUI();
|
clearMessagesUI();
|
||||||
UIControls.showToast('Chat cleared');
|
UIControls.showToast('Chat cleared');
|
||||||
|
ScreenReader.systemMessage('Chat was cleared by admin', 'high');
|
||||||
AppState.lastMessageId = '';
|
AppState.lastMessageId = '';
|
||||||
API.sendHeartbeat(); // Force refresh all users
|
API.sendHeartbeat(); // Force refresh all users
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -333,6 +354,12 @@
|
||||||
// Handle initial message load
|
// Handle initial message load
|
||||||
function handleInitialMessageLoad(data) {
|
function handleInitialMessageLoad(data) {
|
||||||
AppState.allMessages = data.all_messages || [];
|
AppState.allMessages = data.all_messages || [];
|
||||||
|
|
||||||
|
// Announce message group if loading multiple messages
|
||||||
|
if (AppState.allMessages.length > 0) {
|
||||||
|
ScreenReader.messageGroup(AppState.allMessages, 'added');
|
||||||
|
}
|
||||||
|
|
||||||
displayAllMessages();
|
displayAllMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
340
assets/js/screen-reader.js
Normal file
340
assets/js/screen-reader.js
Normal file
|
|
@ -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');
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
AppModules.require('api');
|
AppModules.require('api', 'screen-reader');
|
||||||
AppModules.register('ui-controls');
|
AppModules.register('ui-controls');
|
||||||
|
|
||||||
// Toast notification system
|
// Toast notification system
|
||||||
|
|
@ -288,6 +288,9 @@
|
||||||
if (viewerElement) {
|
if (viewerElement) {
|
||||||
viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
|
viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce viewer count changes to screen readers
|
||||||
|
ScreenReader.viewerCount(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection status dot
|
// Connection status dot
|
||||||
|
|
|
||||||
10
index.php
10
index.php
|
|
@ -516,6 +516,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||||
<!-- Legacy Toast Notification (deprecated) -->
|
<!-- Legacy Toast Notification (deprecated) -->
|
||||||
<div class="toast" id="toast" style="display: none;"></div>
|
<div class="toast" id="toast" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Screen Reader Announcement Regions -->
|
||||||
|
<div id="sr-stream-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Stream status announcements"></div>
|
||||||
|
<div id="sr-connection-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Connection status announcements"></div>
|
||||||
|
<div id="sr-system-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="System announcements"></div>
|
||||||
|
<div id="sr-message-group-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Message group announcements"></div>
|
||||||
|
<div id="sr-viewer-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Viewer count announcements"></div>
|
||||||
|
<div id="sr-activity-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Activity announcements"></div>
|
||||||
|
<div id="sr-form-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Form validation announcements"></div>
|
||||||
|
|
||||||
<!-- Mobile Bottom Navigation -->
|
<!-- Mobile Bottom Navigation -->
|
||||||
<nav class="mobile-nav" id="mobileNav" role="navigation" aria-label="Mobile navigation">
|
<nav class="mobile-nav" id="mobileNav" role="navigation" aria-label="Mobile navigation">
|
||||||
<button class="mobile-nav-btn" data-action="toggle-chat" aria-label="Toggle chat" aria-expanded="false">
|
<button class="mobile-nav-btn" data-action="toggle-chat" aria-label="Toggle chat" aria-expanded="false">
|
||||||
|
|
@ -548,6 +557,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||||
<script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
|
<script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
|
||||||
<script defer src="assets/js/app.js?v=1.4.4"></script>
|
<script defer src="assets/js/app.js?v=1.4.4"></script>
|
||||||
<script defer src="assets/js/api.js?v=1.4.4"></script>
|
<script defer src="assets/js/api.js?v=1.4.4"></script>
|
||||||
|
<script defer src="assets/js/screen-reader.js?v=1.4.4"></script>
|
||||||
<script defer src="assets/js/ui-controls.js?v=1.4.4"></script>
|
<script defer src="assets/js/ui-controls.js?v=1.4.4"></script>
|
||||||
<script defer src="assets/js/chat.js?v=1.4.4"></script>
|
<script defer src="assets/js/chat.js?v=1.4.4"></script>
|
||||||
<script defer src="assets/js/video-player.js?v=1.4.4"></script>
|
<script defer src="assets/js/video-player.js?v=1.4.4"></script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue