- Implemented SecurityTest to validate token generation, CSRF protection, input sanitization, and rate limiting. - Created UserModelTest to ensure correct database operations for user management, including creation, updating, banning, and fetching active users. - Developed ValidationTest to verify input validation and sanitization for user IDs, nicknames, messages, and API requests. - Introduced Security and Validation utility classes with methods for secure token generation, input sanitization, and comprehensive validation rules.
699 lines
31 KiB
PHP
699 lines
31 KiB
PHP
<?php
|
|
/**
|
|
* Dodgers Stream Theater
|
|
* Main application entry point with secure authentication and API handling
|
|
*/
|
|
|
|
// Initialize application with security framework
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
|
|
// Get configuration and security objects
|
|
// $isAdmin is now set by bootstrap.php
|
|
|
|
// Load models and services
|
|
$userModel = new UserModel();
|
|
$chatMessageModel = new ChatMessageModel();
|
|
$activeViewerModel = new ActiveViewerModel();
|
|
|
|
// File-based storage (to be migrated to database later)
|
|
$chatFile = 'chat_messages.json';
|
|
$viewersFile = 'active_viewers.json';
|
|
$bannedFile = 'banned_users.json';
|
|
$maxMessages = Config::get('chat.max_messages', 100);
|
|
|
|
// Get stream base URL from configuration
|
|
$streamBaseUrl = Config::get('stream.base_url', 'http://38.64.28.91:23456');
|
|
|
|
// Handle SSE (Server-Sent Events) connections for real-time chat
|
|
if (isset($_GET['sse']) && $_GET['sse'] === '1' && isset($_GET['user_id'])) {
|
|
$chatServer = new ChatServer();
|
|
$chatServer->handleSSE($_GET['user_id']);
|
|
exit;
|
|
}
|
|
|
|
// Clean up old viewers (inactive for more than 10 seconds)
|
|
function cleanupViewers() {
|
|
global $viewersFile;
|
|
$viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : [];
|
|
$currentTime = time();
|
|
$activeViewers = [];
|
|
|
|
foreach ($viewers as $viewer) {
|
|
if ($currentTime - $viewer['last_seen'] < 10) {
|
|
$activeViewers[] = $viewer;
|
|
}
|
|
}
|
|
|
|
file_put_contents($viewersFile, json_encode($activeViewers));
|
|
return count($activeViewers);
|
|
}
|
|
|
|
// Handle API requests for stream status
|
|
if (isset($_GET['api']) && $_GET['api'] === 'stream_status') {
|
|
header('Content-Type: application/json');
|
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
|
header('Pragma: no-cache');
|
|
header('Expires: 0');
|
|
|
|
$corsOrigins = Config::get('cors.allowed_origins', []);
|
|
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
}
|
|
|
|
$streamUrl = $streamBaseUrl . '/stream.m3u8';
|
|
$online = false;
|
|
|
|
if (Security::checkRateLimit(Security::getClientIP(), 'stream_status', 10, 60)) {
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' => "User-Agent: Mozilla/5.0\r\n",
|
|
'timeout' => 5 // Quick check
|
|
]
|
|
]);
|
|
|
|
$content = @file_get_contents($streamUrl, false, $context);
|
|
Security::logSecurityEvent('stream_status_check', ['online' => $online]);
|
|
|
|
if ($content !== false && !empty($content)) {
|
|
// Check if it looks like a valid m3u8
|
|
if (str_starts_with($content, '#EXTM3U')) {
|
|
$online = true;
|
|
}
|
|
}
|
|
} else {
|
|
Security::logSecurityEvent('stream_status_rate_limited');
|
|
}
|
|
|
|
echo json_encode(['online' => $online]);
|
|
exit;
|
|
}
|
|
|
|
// Handle proxy requests for the stream
|
|
if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
|
|
// Check rate limiting
|
|
if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_stream')) {
|
|
Security::logSecurityEvent('proxy_stream_rate_limited');
|
|
http_response_code(429);
|
|
echo json_encode(['error' => 'Too many requests. Please try again later.']);
|
|
exit;
|
|
}
|
|
|
|
$streamUrl = $streamBaseUrl . '/stream.m3u8';
|
|
|
|
// Set appropriate headers for m3u8 content
|
|
header('Content-Type: application/vnd.apple.mpegurl');
|
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
|
header('Pragma: no-cache');
|
|
header('Expires: 0');
|
|
|
|
$corsOrigins = Config::get('cors.allowed_origins', []);
|
|
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Range');
|
|
}
|
|
|
|
// Fetch and output the m3u8 content
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' => "User-Agent: Mozilla/5.0\r\n",
|
|
'timeout' => 10
|
|
]
|
|
]);
|
|
|
|
$content = @file_get_contents($streamUrl, false, $context);
|
|
|
|
if ($content !== false) {
|
|
// Parse and update the m3u8 content to use our proxy for .ts segments
|
|
$lines = explode("\n", $content);
|
|
$updatedContent = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if (!empty($line) && !str_starts_with($line, '#')) {
|
|
// This is a .ts segment URL
|
|
if (strpos($line, 'http') === 0) {
|
|
// Absolute URL - validate it first
|
|
if (Security::isValidStreamUrl($line)) {
|
|
$updatedContent[] = '?proxy=segment&url=' . urlencode($line);
|
|
}
|
|
} else {
|
|
// Relative URL
|
|
$segmentUrl = $streamBaseUrl . '/' . $line;
|
|
if (Security::isValidStreamUrl($segmentUrl)) {
|
|
$updatedContent[] = '?proxy=segment&url=' . urlencode($segmentUrl);
|
|
}
|
|
}
|
|
} else {
|
|
$updatedContent[] = $line;
|
|
}
|
|
}
|
|
|
|
Security::logSecurityEvent('proxy_stream_success');
|
|
echo implode("\n", $updatedContent);
|
|
} else {
|
|
Security::logSecurityEvent('proxy_stream_failed', ['url' => $streamUrl]);
|
|
http_response_code(500);
|
|
echo "Failed to fetch stream";
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Handle proxy requests for .ts segments
|
|
if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])) {
|
|
// Check rate limiting
|
|
if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_segment')) {
|
|
Security::logSecurityEvent('proxy_segment_rate_limited');
|
|
http_response_code(429);
|
|
exit;
|
|
}
|
|
|
|
$segmentUrl = Security::sanitizeInput($_GET['url'], 'url');
|
|
|
|
// Validate URL to prevent SSRF attacks
|
|
if (!$segmentUrl || !Security::isValidStreamUrl($segmentUrl)) {
|
|
Security::logSecurityEvent('proxy_segment_invalid_url', ['url' => $_GET['url'] ?? '']);
|
|
http_response_code(403);
|
|
echo "Invalid segment URL";
|
|
exit;
|
|
}
|
|
|
|
header('Content-Type: video/mp2t');
|
|
header('Cache-Control: public, max-age=3600'); // Cache segments for 1 hour
|
|
|
|
$corsOrigins = Config::get('cors.allowed_origins', []);
|
|
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
}
|
|
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' => "User-Agent: Mozilla/5.0\r\n",
|
|
'timeout' => 10
|
|
]
|
|
]);
|
|
|
|
$content = @file_get_contents($segmentUrl, false, $context);
|
|
|
|
if ($content !== false) {
|
|
Security::logSecurityEvent('proxy_segment_success');
|
|
echo $content;
|
|
} else {
|
|
Security::logSecurityEvent('proxy_segment_failed', ['url' => $segmentUrl]);
|
|
http_response_code(500);
|
|
echo "Failed to fetch segment";
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Handle chat actions
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|
header('Content-Type: application/json');
|
|
|
|
$action = $_POST['action'];
|
|
|
|
// Validate the action parameter
|
|
if (!preg_match('/^[a-z_]+$/', $action)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid action parameter']);
|
|
Security::logSecurityEvent('invalid_chat_action', ['action' => $action]);
|
|
exit;
|
|
}
|
|
|
|
// Admin-only actions
|
|
if (in_array($action, ['delete_message', 'clear_chat', 'ban_user'])) {
|
|
if (!$isAdmin) {
|
|
http_response_code(403);
|
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
|
Security::logSecurityEvent('unauthorized_admin_action', ['action' => $action, 'ip' => Security::getClientIP()]);
|
|
exit;
|
|
}
|
|
|
|
// Rate limiting for admin actions
|
|
if (!Security::checkRateLimit(Security::getClientIP(), 'admin_actions', 10, 60)) {
|
|
http_response_code(429);
|
|
echo json_encode(['success' => false, 'error' => 'Too many admin actions. Please wait.']);
|
|
Security::logSecurityEvent('admin_rate_limited');
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Handle different actions
|
|
switch ($action) {
|
|
case 'delete_message':
|
|
if (!isset($_POST['message_id']) || !preg_match('/^[a-zA-Z0-9]+$/', $_POST['message_id'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid message ID']);
|
|
exit;
|
|
}
|
|
|
|
$messageIdToDelete = $_POST['message_id'];
|
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
|
$filteredMessages = array_filter($messages, function($msg) use ($messageIdToDelete) {
|
|
return $msg['id'] !== $messageIdToDelete;
|
|
});
|
|
$filteredMessages = array_values($filteredMessages);
|
|
file_put_contents($chatFile, json_encode($filteredMessages));
|
|
|
|
Security::logSecurityEvent('message_deleted', ['message_id' => $messageIdToDelete]);
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
|
|
case 'clear_chat':
|
|
file_put_contents($chatFile, json_encode([]));
|
|
Security::logSecurityEvent('chat_cleared');
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
|
|
case 'ban_user':
|
|
if (!isset($_POST['user_id'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'User ID required']);
|
|
exit;
|
|
}
|
|
|
|
$bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
|
if (!in_array($_POST['user_id'], $bannedUsers)) {
|
|
$bannedUsers[] = $_POST['user_id'];
|
|
file_put_contents($bannedFile, json_encode($bannedUsers));
|
|
}
|
|
|
|
Security::logSecurityEvent('user_banned', ['user_id' => $_POST['user_id']]);
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
|
|
case 'heartbeat':
|
|
// Validate heartbeat data
|
|
$heartbeatValidation = Validation::validateHeartbeat($_POST);
|
|
if (!$heartbeatValidation['valid']) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid heartbeat data']);
|
|
exit;
|
|
}
|
|
|
|
$userId = $_SESSION['user_id'];
|
|
$nickname = $heartbeatValidation['validated']['nickname'] ?? 'Anonymous';
|
|
|
|
$viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : [];
|
|
|
|
// Update or add viewer
|
|
$found = false;
|
|
foreach ($viewers as &$viewer) {
|
|
if ($viewer['user_id'] === $userId) {
|
|
$viewer['last_seen'] = time();
|
|
$viewer['nickname'] = $nickname;
|
|
$viewer['is_admin'] = $isAdmin;
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$found) {
|
|
$viewers[] = [
|
|
'user_id' => $userId,
|
|
'nickname' => $nickname,
|
|
'last_seen' => time(),
|
|
'is_admin' => $isAdmin
|
|
];
|
|
}
|
|
|
|
file_put_contents($viewersFile, json_encode($viewers));
|
|
$viewerCount = cleanupViewers();
|
|
|
|
echo json_encode(['success' => true, 'viewer_count' => $viewerCount]);
|
|
exit;
|
|
|
|
case 'send':
|
|
if (!isset($_POST['message']) || !isset($_POST['nickname'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Message and nickname required']);
|
|
exit;
|
|
}
|
|
|
|
// Rate limiting for message sending
|
|
if (!Security::checkRateLimit($_SESSION['user_id'], 'send_message', 5, 60)) {
|
|
echo json_encode(['success' => false, 'error' => 'Too many messages. Please wait before sending another.']);
|
|
exit;
|
|
}
|
|
|
|
// Validate message data
|
|
$messageValidation = Validation::validateMessageSend($_POST);
|
|
if (!$messageValidation['valid']) {
|
|
echo json_encode(['success' => false, 'error' => 'Validation failed: ' . implode(', ', array_values($messageValidation['errors']))]);
|
|
exit;
|
|
}
|
|
|
|
$userId = $_SESSION['user_id'];
|
|
$nickname = $messageValidation['validated']['nickname'];
|
|
$message = $messageValidation['validated']['message'];
|
|
|
|
// Check if user is banned
|
|
$bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
|
if (in_array($userId, $bannedUsers)) {
|
|
echo json_encode(['success' => false, 'error' => 'You are banned from chat']);
|
|
exit;
|
|
}
|
|
|
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
|
|
|
$newMessage = [
|
|
'id' => Security::generateSecureToken(8), // More secure than uniqid()
|
|
'user_id' => $userId,
|
|
'nickname' => htmlspecialchars($nickname, ENT_QUOTES, 'UTF-8'),
|
|
'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'),
|
|
'timestamp' => time(),
|
|
'time' => date('M j, H:i'),
|
|
'is_admin' => $isAdmin
|
|
];
|
|
|
|
$messages[] = $newMessage;
|
|
|
|
// Keep only last N messages
|
|
if (count($messages) > $maxMessages) {
|
|
$messages = array_slice($messages, -$maxMessages);
|
|
}
|
|
|
|
file_put_contents($chatFile, json_encode($messages));
|
|
|
|
Security::logSecurityEvent('message_sent', ['message_id' => $newMessage['id']]);
|
|
echo json_encode(['success' => true, 'message' => $newMessage]);
|
|
exit;
|
|
|
|
case 'fetch':
|
|
$lastId = isset($_POST['last_id']) ? trim($_POST['last_id']) : '';
|
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
|
|
|
// Find new messages only
|
|
$newMessages = [];
|
|
$foundLast = empty($lastId);
|
|
|
|
foreach ($messages as $msg) {
|
|
if ($foundLast) {
|
|
$newMessages[] = $msg;
|
|
}
|
|
if ($msg['id'] === $lastId) {
|
|
$foundLast = true;
|
|
}
|
|
}
|
|
|
|
// If lastId wasn't found, return all messages (initial load or refresh)
|
|
if (!$foundLast && !empty($lastId)) {
|
|
$newMessages = $messages;
|
|
}
|
|
|
|
// Get viewer count
|
|
$viewerCount = cleanupViewers();
|
|
|
|
// Determine if we should send all messages (for initial load or after admin actions)
|
|
$sendAllMessages = empty($lastId) || !$foundLast;
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'messages' => $newMessages,
|
|
'all_messages' => $sendAllMessages ? $messages : null,
|
|
'message_count' => count($messages),
|
|
'viewer_count' => $viewerCount,
|
|
'is_admin' => $isAdmin
|
|
]);
|
|
exit;
|
|
|
|
case 'get_user_id':
|
|
echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]);
|
|
exit;
|
|
|
|
default:
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Unknown action']);
|
|
Security::logSecurityEvent('unknown_chat_action', ['action' => $action]);
|
|
exit;
|
|
}
|
|
}
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dodgers Stream Theater</title>
|
|
<meta name="csrf-token" content="<?php echo htmlspecialchars(Security::generateCSRFToken()); ?>">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
<link href="https://vjs.zencdn.net/8.6.1/video-js.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="assets/css/main.css?v=1.4.2">
|
|
</head>
|
|
<body>
|
|
<!-- Skip to main content links for accessibility -->
|
|
<a href="#videoSection" class="skip-link sr-only focus-visible" tabindex="1">Skip to main content</a>
|
|
<a href="#chatSection" class="skip-link sr-only focus-visible" tabindex="1">Skip to chat</a>
|
|
<a href="#mobileNav" class="skip-link sr-only focus-visible sr-only-mobile" tabindex="1">Skip to navigation</a>
|
|
|
|
<main class="theater" role="main">
|
|
<!-- Dashboard Sidebar for Desktop -->
|
|
<aside class="theater__dashboard-section" id="dashboardSection" aria-label="Dashboard">
|
|
<header class="dashboard__header">
|
|
<h2 class="dashboard__title">Dashboard</h2>
|
|
<div class="dashboard__controls">
|
|
<button class="dashboard__toggle-btn" data-action="toggle-dashboard" aria-expanded="true" aria-label="Toggle dashboard">
|
|
<span class="icon-dashboard-toggle icon-sm">«</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="dashboard__stats-grid">
|
|
<div class="stats-card">
|
|
<div class="stats-card__icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
|
<circle cx="9" cy="7" r="4"></circle>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="stats-card__content">
|
|
<div class="stats-card__value" id="statsViewersCount" aria-live="polite" aria-label="Active viewers count">0</div>
|
|
<div class="stats-card__label">Active Viewers</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-card">
|
|
<div class="stats-card__icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
|
<line x1="8" y1="21" x2="16" y2="21"></line>
|
|
<line x1="12" y1="17" x2="12" y2="21"></line>
|
|
</svg>
|
|
</div>
|
|
<div class="stats-card__content">
|
|
<div class="stats-card__value" id="statsStreamQuality" aria-live="polite" aria-label="Current stream quality">HD</div>
|
|
<div class="stats-card__label">Stream Quality</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-card">
|
|
<div class="stats-card__icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<polyline points="12,6 12,12 16,14"></polyline>
|
|
</svg>
|
|
</div>
|
|
<div class="stats-card__content">
|
|
<div class="stats-card__value" id="statsUptime" aria-live="polite" aria-label="Stream uptime">00:00</div>
|
|
<div class="stats-card__label">Stream Uptime</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dashboard__widgets">
|
|
<div class="widget">
|
|
<header class="widget__header">
|
|
<h3 class="widget__title">Recent Activity</h3>
|
|
</header>
|
|
<div class="widget__content" id="recentActivity" aria-live="polite" aria-label="Recent activity updates">
|
|
<div class="activity-item">
|
|
<div class="activity-item__avatar">🎥</div>
|
|
<div class="activity-item__content">
|
|
<div class="activity-item__text">Stream started</div>
|
|
<div class="activity-item__time">Just now</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="widget">
|
|
<header class="widget__header">
|
|
<h3 class="widget__title">Active Users</h3>
|
|
</header>
|
|
<div class="widget__content" id="activeUsers" aria-live="polite" aria-label="Active users list">
|
|
<div class="user-item">
|
|
<div class="user-item__status online"></div>
|
|
<div class="user-item__nickname">Welcome to chat!</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Dashboard resize handle -->
|
|
<div class="resize-handle dashboard-resize" data-target="dashboard"></div>
|
|
|
|
<section class="theater__video-section" id="videoSection" aria-label="Video Player">
|
|
<header class="video-player__header">
|
|
<div class="video-player__header-left">
|
|
<h1 class="stream-logo">DODGERS STREAM</h1>
|
|
<div class="stream-stats">
|
|
<span class="stream-stats__status" role="status">WAITING...</span>
|
|
<span class="stream-stats__viewer-count" id="viewerCount" aria-live="polite">0 viewers</span>
|
|
</div>
|
|
</div>
|
|
<div class="video-player__header-controls">
|
|
<button class="video-player__toggle-dashboard-btn" data-action="toggle-dashboard" aria-expanded="false" aria-label="Toggle dashboard" title="Toggle Dashboard">
|
|
<span class="icon-dashboard icon-sm"></span> Dashboard
|
|
</button>
|
|
<button class="stream-stats__refresh-btn" id="refreshBtn" data-action="manual-refresh" title="Manual Stream Refresh" aria-label="Refresh stream">
|
|
<span class="icon-refresh icon-sm"></span> Refresh
|
|
</button>
|
|
<select class="video-player__quality-selector" id="qualitySelector" style="display:none;" aria-label="Video quality">
|
|
<option value="auto">Auto Quality</option>
|
|
<option value="1080p">1080p</option>
|
|
<option value="720p">720p</option>
|
|
<option value="480p">480p</option>
|
|
</select>
|
|
<button class="video-player__toggle-chat-btn" data-action="toggle-chat" aria-expanded="false" aria-controls="chatSection">
|
|
<span id="chatToggleText">Hide Chat</span>
|
|
<span class="video-player__notification-badge" id="notificationBadge" aria-label="New messages">0</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div class="video-wrapper">
|
|
<video
|
|
id="video-player"
|
|
class="video-js vjs-default-skin vjs-big-play-centered"
|
|
controls
|
|
preload="auto"
|
|
loop
|
|
>
|
|
<source src="?proxy=stream" type="application/x-mpegURL">
|
|
<p class="vjs-no-js">
|
|
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
|
supports HTML5 video.
|
|
</p>
|
|
</video>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="theater__chat-section" id="chatSection" aria-label="Live Chat">
|
|
<header class="chat__header">
|
|
<h2 class="chat__header-title">Live Chat</h2>
|
|
<div class="chat__admin-controls" id="adminControls" style="display:none;">
|
|
<button class="chat__admin-btn" data-action="clear-chat" aria-label="Clear all chat messages">Clear</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="chat__user-info" aria-labelledby="user-info-heading">
|
|
<h3 id="user-info-heading" class="sr-only">User Information</h3>
|
|
<div class="chat__user-id-display">
|
|
Your ID: <span class="chat__user-id-badge" id="userId">Loading...</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="chat__nickname-input">
|
|
<input type="text" id="nickname" placeholder="Choose a nickname..." maxlength="20" autocomplete="off" aria-label="Enter your nickname" aria-describedby="nicknameError">
|
|
</div>
|
|
<div class="form-error" id="nicknameError" style="display: none;"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="chat__connection-status" aria-live="assertive">
|
|
<span class="chat__connection-indicator" id="statusDot" role="status" aria-label="Connection status"></span>
|
|
<span id="statusText">Connected</span>
|
|
</div>
|
|
|
|
<section class="chat__messages" id="chatMessages" aria-live="polite" aria-label="Chat messages" role="log" aria-atomic="false">
|
|
<div class="chat__empty-state">No messages yet. Be the first to say hello! <span class="icon-wave icon-md"></span></div>
|
|
</section>
|
|
|
|
<div class="chat__typing-indicator" id="typingIndicator" aria-live="assertive">
|
|
Someone is typing<span>.</span><span>.</span><span>.</span>
|
|
</div>
|
|
|
|
<section class="chat__input" aria-labelledby="chat-input-heading">
|
|
<h3 id="chat-input-heading" class="sr-only">Send Message</h3>
|
|
<div class="form-group">
|
|
<div class="chat__input-group">
|
|
<input type="text" id="messageInput" placeholder="Type a message..." maxlength="1000" autocomplete="off" aria-label="Type your message" aria-describedby="messageError">
|
|
<button data-action="send-message" aria-label="Send message">Send</button>
|
|
</div>
|
|
<div class="form-error" id="messageError" style="display: none;"></div>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</main>
|
|
|
|
<!-- Pull-to-refresh indicator for mobile -->
|
|
<div id="pullRefreshIndicator" class="pull-refresh-indicator" aria-hidden="true" style="display: none;">
|
|
<div class="pull-refresh-content">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
|
</svg>
|
|
<span>Pull to refresh</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Enhanced Notification System -->
|
|
<div class="notifications-container" role="region" aria-live="assertive" aria-label="Notifications" id="notificationsContainer"></div>
|
|
|
|
<!-- Legacy Toast Notification (deprecated) -->
|
|
<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 -->
|
|
<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">
|
|
<span class="icon icon-chat icon-lg"></span>
|
|
<span>Chat</span>
|
|
</button>
|
|
<button class="mobile-nav-btn" data-action="refresh-stream" aria-label="Refresh stream">
|
|
<span class="icon icon-refresh icon-lg"></span>
|
|
<span>Refresh</span>
|
|
</button>
|
|
<button class="mobile-nav-btn" data-action="toggle-fullscreen" aria-label="Toggle fullscreen">
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="24" height="24">
|
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path>
|
|
</svg>
|
|
<span>Fullscreen</span>
|
|
</button>
|
|
<button class="mobile-nav-btn" data-action="toggle-picture-in-picture" aria-label="Picture in picture mode">
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="24" height="24">
|
|
<path d="M21 3H3v7h18V3z"></path>
|
|
<rect x="7" y="13" width="10" height="7" rx="2"></rect>
|
|
</svg>
|
|
<span>PiP</span>
|
|
</button>
|
|
<button class="mobile-nav-btn" data-action="toggle-quality" aria-label="Video quality settings" style="display:none;">
|
|
<span class="icon icon-settings icon-lg"></span>
|
|
<span>Quality</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<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/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/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/notifications.js?v=1.4.4"></script>
|
|
<script defer src="assets/js/loading-states.js?v=1.4.4"></script>
|
|
</body>
|
|
</html>
|