iptv-stream-web/index.php
Vincent da1528c529 Add comprehensive styles for video streaming and chat interface
- Implement base styles for the application layout and components.
- Define color variables for consistent theming.
- Style the video player, chat section, and user interface elements.
- Add responsive design adjustments for mobile devices.
- Include animations for buttons and notifications for enhanced user experience.
2025-09-28 22:03:28 -04:00

2377 lines
82 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
session_start();
// Admin configuration - Change this to a secure random string
define('ADMIN_CODE', 'dodgers2024streamAdm1nC0d3!xyz789'); // Change this!
// Check if user is admin
$isAdmin = false;
if (isset($_GET['admin']) && $_GET['admin'] === ADMIN_CODE) {
$_SESSION['is_admin'] = true;
}
$isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
// Generate or retrieve user ID
if (!isset($_SESSION['user_id'])) {
$_SESSION['user_id'] = substr(uniqid(), -6); // 6 character unique ID
}
// Simple file-based storage
$chatFile = 'chat_messages.json';
$viewersFile = 'active_viewers.json';
$bannedFile = 'banned_users.json';
$maxMessages = 100; // Keep last 100 messages
// 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('Access-Control-Allow-Origin: *');
$streamUrl = 'http://38.64.28.91:23456/stream.m3u8';
$online = false;
$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);
if ($content !== false && !empty($content)) {
// Check if it looks like a valid m3u8
if (str_starts_with($content, '#EXTM3U')) {
$online = true;
}
}
echo json_encode(['online' => $online]);
exit;
}
// Handle proxy requests for the stream
if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
$streamUrl = 'http://38.64.28.91:23456/stream.m3u8';
// Set appropriate headers for m3u8 content
header('Content-Type: application/vnd.apple.mpegurl');
header('Access-Control-Allow-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
$updatedContent[] = '?proxy=segment&url=' . urlencode($line);
} else {
// Relative URL
$baseUrl = 'http://38.64.28.91:23456/';
$updatedContent[] = '?proxy=segment&url=' . urlencode($baseUrl . $line);
}
} else {
$updatedContent[] = $line;
}
}
echo implode("\n", $updatedContent);
} else {
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'])) {
$segmentUrl = urldecode($_GET['url']);
// Validate URL to prevent abuse
if (strpos($segmentUrl, 'http://38.64.28.91:23456/') !== 0) {
http_response_code(403);
exit;
}
header('Content-Type: video/mp2t');
header('Access-Control-Allow-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) {
echo $content;
} else {
http_response_code(500);
}
exit;
}
// Handle chat actions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
// Admin actions
if ($_POST['action'] === 'delete_message' && $isAdmin && isset($_POST['message_id'])) {
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
$messages = array_filter($messages, function($msg) {
return $msg['id'] !== $_POST['message_id'];
});
$messages = array_values($messages); // Re-index array
file_put_contents($chatFile, json_encode($messages));
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'clear_chat' && $isAdmin) {
file_put_contents($chatFile, json_encode([]));
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'ban_user' && $isAdmin && isset($_POST['user_id'])) {
$banned = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
if (!in_array($_POST['user_id'], $banned)) {
$banned[] = $_POST['user_id'];
file_put_contents($bannedFile, json_encode($banned));
}
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'heartbeat') {
$userId = $_SESSION['user_id'];
$nickname = isset($_POST['nickname']) ? htmlspecialchars(substr($_POST['nickname'], 0, 20)) : '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;
}
if ($_POST['action'] === 'send' && isset($_POST['message']) && isset($_POST['nickname'])) {
$nickname = htmlspecialchars(substr($_POST['nickname'], 0, 20));
$message = htmlspecialchars(substr($_POST['message'], 0, 1000));
$userId = $_SESSION['user_id'];
// Check if user is banned
$banned = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
if (in_array($userId, $banned)) {
echo json_encode(['success' => false, 'error' => 'You are banned from chat']);
exit;
}
if (!empty($nickname) && !empty($message)) {
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
$newMessage = [
'id' => uniqid(),
'user_id' => $userId,
'nickname' => $nickname,
'message' => $message,
'timestamp' => time(),
'time' => date('M j, H:i'),
'is_admin' => $isAdmin
];
array_push($messages, $newMessage);
// Keep only last N messages
if (count($messages) > $maxMessages) {
$messages = array_slice($messages, -$maxMessages);
}
file_put_contents($chatFile, json_encode($messages));
echo json_encode(['success' => true, 'message' => $newMessage]);
} else {
echo json_encode(['success' => false, 'error' => 'Invalid input']);
}
exit;
}
if ($_POST['action'] === 'fetch') {
$lastId = isset($_POST['last_id']) ? $_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;
}
if ($_POST['action'] === 'get_user_id') {
echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]);
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>
<link href="https://vjs.zencdn.net/8.6.1/video-js.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--dodgers-blue: #005A9C;
--dodgers-dark: #003d6b;
--dodgers-light: #1e7ec8;
--dodgers-red: #EF3E42;
--bg-dark: #0f1419;
--bg-darker: #0a0d14;
--bg-darkest: #050810;
--admin-gold: #FFD700;
--text-primary: #ffffff;
--text-secondary: #b8c5d6;
--text-muted: #8590a0;
--border-color: #1a2332;
--input-bg: #1a2332;
--card-bg: #15202b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-darkest);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
position: relative;
}
.theater-container {
display: flex;
height: 100vh;
position: relative;
transition: all 0.3s ease;
}
.video-section {
flex: 1;
background: #000;
position: relative;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.video-section.expanded {
flex: 1;
width: 100%;
}
.video-header {
background: linear-gradient(135deg, var(--dodgers-dark) 0%, var(--dodgers-blue) 100%);
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.logo {
font-size: 20px;
font-weight: bold;
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 8px;
}
.logo::before {
content: "⚾";
font-size: 24px;
}
.stream-stats {
display: flex;
align-items: center;
gap: 15px;
}
.viewer-count {
background: rgba(255,255,255,0.2);
padding: 6px 14px;
border-radius: 20px;
color: white;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
backdrop-filter: blur(10px);
}
.viewer-count::before {
content: "👥";
}
.stream-badge {
background: var(--dodgers-red);
color: white;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.header-controls {
display: flex;
gap: 10px;
align-items: center;
}
/* Ensure all header control buttons have identical dimensions */
.header-controls > * {
height: 35px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
vertical-align: top;
line-height: 1;
}
.quality-selector {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(10px);
/* Standardize select appearance to match buttons */
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
min-height: auto;
}
.toggle-chat-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
backdrop-filter: blur(10px);
position: relative;
}
.toggle-chat-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-1px);
}
.manual-refresh-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
backdrop-filter: blur(10px);
position: relative;
}
.manual-refresh-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-1px);
}
.video-wrapper {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
#video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.video-js {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
/* Override VideoJS fluid behavior to prevent overflow */
.video-js.vjs-fluid {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
padding-top: 0 !important;
}
/* Ensure the actual video element is properly constrained */
.video-js video {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
object-fit: contain !important;
}
.video-js .vjs-control-bar {
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
}
.chat-section {
width: 350px;
background: var(--bg-dark);
color: var(--text-primary);
display: flex;
flex-direction: column;
box-shadow: -5px 0 20px rgba(0,0,0,0.5);
transition: transform 0.3s ease;
position: relative;
border-left: 1px solid var(--border-color);
}
.chat-section.collapsed {
transform: translateX(100%);
position: absolute;
right: 0;
height: 100%;
}
.chat-header {
background: linear-gradient(135deg, var(--dodgers-blue) 0%, var(--dodgers-light) 100%);
padding: 16px 20px;
color: white;
font-size: 16px;
font-weight: 600;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.chat-header-left::before {
content: "💬";
font-size: 18px;
}
.admin-controls {
display: flex;
gap: 8px;
}
.admin-btn {
background: rgba(255,215,0,0.3);
border: 1px solid var(--admin-gold);
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.admin-btn:hover {
background: rgba(255,215,0,0.5);
}
.user-info {
padding: 12px 16px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.user-id-display {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.id-badge {
background: var(--dodgers-blue);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-weight: 600;
font-family: monospace;
}
.id-badge.admin {
background: linear-gradient(135deg, var(--admin-gold), #FFB700);
color: #333;
}
.nickname-input {
position: relative;
}
.nickname-input input {
width: 100%;
padding: 10px 12px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
transition: all 0.3s;
background: var(--input-bg);
color: var(--text-primary);
}
.nickname-input input::placeholder {
color: var(--text-muted);
}
.nickname-input input:focus {
outline: none;
border-color: var(--dodgers-blue);
box-shadow: 0 0 0 3px rgba(0,90,156,0.2);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--bg-dark);
}
.message {
margin-bottom: 14px;
animation: slideIn 0.3s ease;
position: relative;
}
.message:hover .message-actions {
opacity: 1;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.message-header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.message-nickname {
font-weight: 600;
color: var(--dodgers-blue);
font-size: 13px;
}
.message-nickname.admin {
color: var(--admin-gold);
text-shadow: 0 0 2px rgba(255,215,0,0.5);
}
.message-id {
font-size: 10px;
color: var(--text-muted);
font-family: monospace;
background: var(--border-color);
padding: 1px 5px;
border-radius: 10px;
}
.message-time {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.message-actions {
position: absolute;
right: 0;
top: 0;
opacity: 0;
transition: opacity 0.2s;
display: flex;
gap: 4px;
}
.delete-btn, .ban-btn {
background: rgba(239,62,66,0.9);
color: white;
border: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.ban-btn {
background: rgba(255,152,0,0.9);
}
.message-text {
padding: 8px 12px;
background: var(--card-bg);
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-left: 3px solid transparent;
color: var(--text-primary);
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
.own-message .message-nickname {
color: var(--dodgers-red);
}
.own-message .message-text {
background: rgba(0,90,156,0.2);
border-left-color: var(--dodgers-blue);
}
.admin-message .message-text {
background: rgba(255,215,0,0.15);
border-left-color: var(--admin-gold);
}
.system-message {
text-align: center;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
margin: 10px 0;
padding: 8px;
background: rgba(0,90,156,0.1);
border-radius: 8px;
}
.typing-indicator {
padding: 0 16px 8px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
display: none;
}
.typing-indicator.active {
display: block;
}
.typing-indicator span {
animation: blink 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
.chat-input {
padding: 12px 16px;
background: var(--card-bg);
border-top: 1px solid var(--border-color);
}
.input-group {
display: flex;
gap: 8px;
}
.chat-input input {
flex: 1;
padding: 10px 14px;
border: 2px solid var(--border-color);
border-radius: 24px;
font-size: 14px;
transition: all 0.3s;
background: var(--input-bg);
color: var(--text-primary);
}
.chat-input input::placeholder {
color: var(--text-muted);
}
.chat-input input:focus {
outline: none;
border-color: var(--dodgers-blue);
box-shadow: 0 0 0 3px rgba(0,90,156,0.2);
}
.chat-input button {
padding: 10px 20px;
background: linear-gradient(135deg, var(--dodgers-blue), var(--dodgers-light));
color: white;
border: none;
border-radius: 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0,90,156,0.3);
}
.chat-input button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,90,156,0.4);
}
.chat-input button:active {
transform: translateY(0);
}
.empty-chat {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
font-size: 13px;
}
.connection-status {
padding: 6px;
background: var(--bg-darker);
text-align: center;
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-top: 1px solid var(--border-color);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4caf50;
animation: blink 2s infinite;
}
.status-dot.offline {
background: #f44336;
animation: none;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.notification-badge {
position: absolute;
top: -8px;
right: -8px;
background: var(--dodgers-red);
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
display: none;
animation: bounce 0.5s;
}
.notification-badge.show {
display: block;
}
@keyframes bounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
@keyframes slideOut {
to {
opacity: 0;
transform: translateX(100%);
}
}
/* Floating action buttons */
.fab-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 100;
}
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--dodgers-blue), var(--dodgers-light));
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all 0.3s;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0,0,0,0.4);
}
.fab.secondary {
width: 48px;
height: 48px;
font-size: 20px;
background: rgba(0,90,156,0.9);
}
/* Toast notifications */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: white;
padding: 12px 24px;
border-radius: 24px;
font-size: 14px;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
}
.toast.show {
opacity: 1;
}
@media (max-width: 768px) {
.chat-section {
width: 100%;
position: absolute;
right: 0;
top: 0;
height: 100%;
z-index: 100;
}
.video-header {
padding: 10px 15px;
}
.logo {
font-size: 16px;
}
.stream-stats {
gap: 10px;
}
.quality-selector {
display: none;
}
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: var(--bg-darker);
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--dodgers-blue);
border-radius: 3px;
opacity: 0.6;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
opacity: 1;
}
</style>
</head>
<body>
<div class="theater-container">
<div class="video-section" id="videoSection">
<div class="video-header">
<div class="header-left">
<div class="logo">DODGERS STREAM</div>
<div class="stream-stats">
<div class="stream-badge">WAITING...</div>
<div class="viewer-count" id="viewerCount">0 viewers</div>
</div>
</div>
<div class="header-controls">
<button class="manual-refresh-btn" onclick="manualRecovery()" title="Manual Stream Refresh">
🔄 Refresh
</button>
<select class="quality-selector" id="qualitySelector" style="display:none;">
<option value="auto">Auto Quality</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
<button class="toggle-chat-btn" onclick="toggleChat()">
<span id="chatToggleText">Hide Chat</span>
<span class="notification-badge" id="notificationBadge">0</span>
</button>
</div>
</div>
<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>
<div class="chat-section" id="chatSection">
<div class="chat-header">
<div class="chat-header-left">Live Chat</div>
<div class="admin-controls" id="adminControls" style="display:none;">
<button class="admin-btn" onclick="clearChat()">Clear</button>
</div>
</div>
<div class="user-info">
<div class="user-id-display">
Your ID: <span class="id-badge" id="userId">Loading...</span>
</div>
<div class="nickname-input">
<input type="text" id="nickname" placeholder="Choose a nickname..." maxlength="20" autocomplete="off">
</div>
</div>
<div class="connection-status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Connected</span>
</div>
<div class="chat-messages" id="chatMessages">
<div class="empty-chat">No messages yet. Be the first to say hello! 👋</div>
</div>
<div class="typing-indicator" id="typingIndicator">
Someone is typing<span>.</span><span>.</span><span>.</span>
</div>
<div class="chat-input">
<div class="input-group">
<input type="text" id="messageInput" placeholder="Type a message..." maxlength="1000" autocomplete="off">
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
<script>
// Global variables
let player;
let nickname = localStorage.getItem('chatNickname') || '';
let userId = '';
let isAdmin = false;
let lastMessageId = '';
let allMessages = [];
let lastKnownMessageCount = 0;
let chatCollapsed = false;
let unreadCount = 0;
let soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
let typingTimer;
let isTyping = false;
// Stream state variables
let streamMode = 'live'; // 'live' or 'placeholder'
let recoveryInterval;
let isRecovering = false;
// CENTRALIZED STATUS MANAGEMENT - ENSURES 100% ACCURACY
function updateStreamStatus(status, color) {
const badge = document.querySelector('.stream-badge');
if (badge) {
badge.textContent = status;
badge.style.background = color || 'var(--dodgers-red)'; // Default to live color
console.log(`📊 Stream status updated: ${status} (${color || 'red'})`);
}
}
// SYNC STATUS WITH STREAM MODE - PREVENTS INCORRECT DISPLAY
function syncStatusWithStreamMode() {
if (streamMode === 'placeholder') {
updateStreamStatus('PLACEHOLDER', '#17a2b8');
} else if (streamMode === 'live') {
updateStreamStatus('LIVE', 'var(--dodgers-red)');
} else {
updateStreamStatus('WAITING...', '#ffc107');
}
}
console.log('🔄 Initializing Dodgers Stream Player');
console.log('📊 Initial state: streamMode=live, errorRetryCount=0, isRecovering=false');
console.log('🎬 Starting Video.js initialization...');
// Initialize Video.js player
player = videojs('video-player', {
html5: {
vhs: {
overrideNative: true,
withCredentials: false,
// Add these for better resilience
handlePartialData: true,
smoothQualityChange: true,
allowSeeksWithinUnsafeLiveWindow: true,
handleManifestRedirects: true,
useBandwidthFromLocalStorage: true,
// Increase tolerance for errors
maxPlaylistRetries: 10, // Retry playlist fetches
blacklistDuration: 5, // Only blacklist for 5 seconds
bandwidth: 4194304 // Start with reasonable bandwidth estimate
}
},
liveui: true,
liveTracker: {
trackingThreshold: 30, // Seconds behind live before seeking
liveTolerance: 15 // Seconds from live to be considered live
},
errorDisplay: false, // We'll handle errors ourselves
responsive: false,
controlBar: {
volumePanel: {
inline: false
},
pictureInPictureToggle: true
}
});
// Track error state to prevent spam
let errorRetryCount = 0;
let isRetrying = false;
const MAX_RETRIES = 3; // Reduced for faster fallback
// AGGRESSIVE VOLUME ENFORCEMENT - BLOCKS VIDEO.JS FROM RESETTING VOLUME TO 100%
function enforceSavedVolume() {
const currentVolume = player.volume();
if (currentVolume === 1.0) { // Video.js evil reset to 100% detected
const savedVolume = parseFloat(localStorage.getItem('playerVolume') || '0.3');
if (savedVolume !== 1.0) {
console.log('🚫 BLOCKED: Volume reset to 100% - forcing back to:', savedVolume);
player.volume(savedVolume);
return true; // Volume was forced back
}
}
return false; // Volume is OK
}
// TIMED VOLUME RESTORATION FUNCTION
function restoreVolume() {
const savedVolume = localStorage.getItem('playerVolume');
if (savedVolume) {
const volume = parseFloat(savedVolume);
console.log('🔊 RESTORING VOLUME TO:', volume);
// Force immediately, then reinforce with delay
player.volume(volume);
setTimeout(() => player.volume(volume), 100);
setTimeout(() => player.volume(volume), 500);
// Also reinforce on key events that Video.js might use to reset volume
setTimeout(() => {
['loadeddata', 'loadedmetadata', 'canplay'].forEach(event => {
player.one(event, () => {
setTimeout(() => {
const current = player.volume();
if (current === 1.0 || (current !== volume && !player.muted())) {
console.log('🔄 Reinforcing volume after', event + ':', volume);
player.volume(volume);
}
}, 50);
});
});
}, 100);
}
}
// Seperate function for HLS playback (used when stream is confirmed online)
function attemptHLSPlayback() {
console.log('🎬 Attempting HLS stream playback...');
// Try to play automatically
var playPromise = player.play();
if (playPromise !== undefined) {
playPromise.catch(function(error) {
console.log('Auto-play was prevented:', error);
showToast('Click play to start stream');
});
}
}
// Check stream status on page load
function checkInitialStreamStatus() {
return fetch('?api=stream_status')
.then(response => response.json())
.then(data => {
console.log('📡 Initial stream status check:', data);
return data.online === true; // Strict boolean check
})
.catch(error => {
console.log('❗ Initial stream status check failed:', error);
return false; // Assume offline if check fails
});
}
// Set default volume or restore saved volume
player.ready(function() {
console.log('Player is ready');
// IMMEDIATELY FIX ANY EVIL VIDEO.JS 100% VOLUME
if (player.volume() === 1.0) {
const savedVolume = localStorage.getItem('playerVolume') || '0.3';
player.volume(parseFloat(savedVolume));
console.log('🚫 FIXED: Initial 100% volume reset to:', savedVolume);
}
// Restore volume or set default
const savedVolume = localStorage.getItem('playerVolume');
if (savedVolume !== null) {
const volumeValue = parseFloat(savedVolume);
player.volume(volumeValue);
console.log('🔊 Restored saved volume:', volumeValue);
} else {
player.volume(0.3); // Default 30%
console.log('🔊 Set default volume: 0.3');
}
// AGGRESSIVE VOLUME MONITOR - BLOCKS 100% RESETS
player.on('volumechange', function() {
if (!enforceSavedVolume()) {
// User's intentional change - save it
if (!player.muted()) {
const newVolume = player.volume();
localStorage.setItem('playerVolume', newVolume);
console.log('💾 Saved volume setting:', newVolume);
}
}
});
// PROACTIVE STREAM STATUS CHECK - START WITH APPROPRIATE SOURCE
console.log('🔍 Checking stream status on page load...');
checkInitialStreamStatus().then(isOnline => {
if (!isOnline) {
console.log('🔄 Stream offline on load - switching to placeholder immediately');
switchToPlaceholder();
} else {
console.log('✅ Stream online on load - starting HLS playback');
// Stream is online, proceed with normal HLS loading
attemptHLSPlayback();
// Also start monitoring in case it goes offline soon
startStreamMonitoring();
}
});
});
// Start periodic stream monitoring
function startStreamMonitoring() {
if (!recoveryInterval) {
console.log('👁️ Starting stream monitoring...');
recoveryInterval = setInterval(() => {
if (streamMode !== 'placeholder') { // Only check if not already in placeholder mode
fetch('?api=stream_status')
.then(response => response.json())
.then(data => {
if (!data.online) {
console.log('⚠️ Stream went offline during playback - switching to placeholder');
switchToPlaceholder();
}
})
.catch(error => {
console.log('❗ Stream monitoring check failed:', error);
});
}
}, 10000); // Check every 10 seconds during normal operation
}
}
// Listen for VideoJS warnings to trigger fallback faster
player.on('warning', function(event) {
console.log('VideoJS warning:', event.detail);
// If we're getting playlist warnings and not already in recovery
if (!isRetrying && !isRecovering && streamMode === 'live') {
console.log('Playlist warning detected, increasing error count');
errorRetryCount += 1; // Full error increment per warning
console.log('Warning count now:', errorRetryCount);
if (errorRetryCount >= 3) { // Trigger after 3 consecutive warnings
console.log('Threshold reached, switching to placeholder');
switchToPlaceholder();
}
}
});
// Enhanced error handling with placeholder fallback
player.on('error', function() {
var error = player.error();
console.log('Player error:', error);
// Don't retry if we're already retrying or in placeholder mode
if (isRetrying || streamMode === 'placeholder') return;
// Handle different error types
if (error && (error.code === 2 || error.code === 4)) {
// Network errors (2) or media errors (4) - these are recoverable
if (errorRetryCount < MAX_RETRIES) {
isRetrying = true;
errorRetryCount++;
console.log(`Attempting recovery (${errorRetryCount}/${MAX_RETRIES})...`);
// Update UI to show attempting recovery
updateStreamStatus('RECONNECTING...', '#ffc107');
// Clear the error and attempt to recover
setTimeout(() => {
player.error(null); // Clear error state
// For segment/playlist errors, just continue playing
// The player will automatically try to fetch new segments
if (player.paused()) {
player.play().catch(e => console.log('Play attempt failed:', e));
}
isRetrying = false;
// If still having issues after a bit, try a soft reload
setTimeout(() => {
if (player.error()) {
console.log('Soft reloading source...');
const currentTime = player.currentTime();
player.src(player.currentSrc());
player.play().catch(e => console.log('Play after reload failed:', e));
}
}, 3000);
}, 1000 * Math.min(errorRetryCount, 3)); // Exponential backoff, max 3 seconds
} else {
// Max retries exceeded - switch to placeholder
console.error('Max retries exceeded, switching to placeholder video');
switchToPlaceholder();
}
} else if (error && error.code === 3) {
// Decode error - might need different handling
console.error('Decode error, switching to placeholder:', error);
switchToPlaceholder();
}
});
// Reset error count on successful playback
player.on('loadeddata', function() {
errorRetryCount = 0; // Reset retry count on success
isRetrying = false;
if (streamMode === 'live') {
updateStreamStatus('LIVE', 'var(--dodgers-red)');
}
});
// Add playing event to ensure we show correct status
player.on('playing', function() {
errorRetryCount = 0; // Reset retry count
isRetrying = false;
if (streamMode === 'live') {
updateStreamStatus('LIVE', 'var(--dodgers-red)');
}
});
// Handle stalling (buffering)
player.on('waiting', function() {
console.log('Player is buffering...');
// Only show buffering state if we're not in error recovery
if (!isRetrying && errorRetryCount === 0 && streamMode === 'live') {
updateStreamStatus('BUFFERING...', '#ffc107');
}
});
// Monitor for extended stalls and reload stream
let stallTimeout;
let waitingStartTime = null;
let offlineConfirmCount = 0;
player.on('waiting', function() {
waitingStartTime = Date.now();
offlineConfirmCount = 0;
console.log('⏸️ Player entered buffering state at:', new Date(waitingStartTime).toLocaleTimeString());
// Do multiple API status checks to confirm stream is actually offline
// First check is immediate, then 2 more over 3 seconds to confirm
function checkStreamStatus(attempt = 1, maxAttempts = 5) {
console.log(`📡 Stream status check ${attempt}/${maxAttempts}...`);
fetch('?api=stream_status')
.then(response => response.json())
.then(data => {
console.log(`📡 Check ${attempt}:`, data);
if (!data.online) {
offlineConfirmCount++;
console.log(`⚠️ Confirmed offline - Attempts: ${offlineConfirmCount}/${maxAttempts}`);
if (offlineConfirmCount >= 2 || attempt >= maxAttempts) {
// Got 2+ offline confirmations or max attempts - definitely offline
console.log('❌ Stream confirmed offline after multiple checks - switching to placeholder');
switchToPlaceholder();
return;
}
} else {
// Stream still shows online - reset offline count
offlineConfirmCount = 0;
console.log('✅ Stream still shows online - normal buffering');
}
// If not confirmed offline yet and attempts remain, check again in 1.5s
if (offlineConfirmCount < 2 && attempt < maxAttempts) {
setTimeout(() => checkStreamStatus(attempt + 1, maxAttempts), 1500);
} else if (offlineConfirmCount < 2 && attempt >= maxAttempts) {
// All attempts done and stream still shows online - set long buffer timeout
console.log('🌟 Stream appears online after multiple checks - normal buffering');
if (stallTimeout) clearTimeout(stallTimeout);
stallTimeout = setTimeout(() => {
const stallDuration = Date.now() - waitingStartTime;
console.log(`🔄 LONG STALL DETECTED: ${stallDuration/1000} seconds - checking if still buffered...`);
// Double-check if we're still waiting after this timeout
if (player.paused() || player.currentTime() === 0 && waitingStartTime) {
// Still stalled - attempt recovery
console.log('✅ Still stalled after 3 minutes - attempting recovery...');
player.error(null); // Clear any error state
player.pause(); // Stop current playback
player.reset(); // Reset player state completely
player.src({
src: '?proxy=stream',
type: 'application/x-mpegURL'
}); // Use fresh URL instead of currentSrc()
player.load();
// Restore volume after reset, but before play
const savedVolume = localStorage.getItem('playerVolume');
const targetVolume = savedVolume !== null ? parseFloat(savedVolume) : 0.3;
player.volume(targetVolume);
console.log('🔊 Volume restored during recovery to:', targetVolume);
player.play().then(() => {
console.log('✅ Stream recovery successful after long stall');
}).catch(e => {
console.log('❌ Recovery failed, might switch to placeholder');
});
} else {
console.log('✨ Stall resolved - no action needed');
}
}, 180000); // 3 minutes for confirmed online streams
}
})
.catch(apiError => {
console.log('❗ API check failed:', apiError);
// If API fails, be conservative - assume online and set long timeout
if (stallTimeout) clearTimeout(stallTimeout);
stallTimeout = setTimeout(() => {
console.log('🔄 Conservative timeout reached (API failed) - attempting recovery...');
player.error(null);
player.src({
src: '?proxy=stream',
type: 'application/x-mpegURL'
});
player.load();
player.play().catch(e => console.log('Recover play failed:', e));
}, 60000); // 1 minute when API fails
});
}
// Start the multi-check process
checkStreamStatus();
});
// Cancel stall timeout when playback resumes
player.on('playing', function() {
if (waitingStartTime) {
const totalStallTime = Date.now() - waitingStartTime;
if (totalStallTime > 5000) { // Only log significant stalls
console.log(`✨ Playback resumed after ${totalStallTime/1000}s stall - clearing timeouts`);
}
waitingStartTime = null;
}
if (stallTimeout) {
clearTimeout(stallTimeout);
stallTimeout = null;
}
});
// Disable right-click context menu on video player
document.getElementById('video-player').addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
// Also disable context menu on the video element itself
player.ready(function() {
const videoElement = player.el().querySelector('video');
if (videoElement) {
videoElement.addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
}
});
// Optional: Add a manual recovery button for users
function manualRecovery() {
console.log('🔄 Manual recovery triggered by user');
errorRetryCount = 0;
isRetrying = false;
showToast('🔄 Manual stream refresh triggered');
// Force hard reset FIRST (clears all state)
player.reset();
// Clear any errors after reset
player.error(null);
// Now set fresh proxy URL
player.src({
src: '?proxy=stream',
type: 'application/x-mpegURL'
});
// Load and play
player.load();
setTimeout(() => {
player.play().catch(e => console.log('Manual recovery play failed:', e));
}, 500); // Short delay to ensure load completes
}
// Chat functionality
function initializeChat() {
// Get user ID on load
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'action=get_user_id'
})
.then(response => response.json())
.then(data => {
if (data.success) {
userId = data.user_id;
isAdmin = data.is_admin || false;
const userIdElement = document.getElementById('userId');
userIdElement.textContent = '#' + userId;
if (isAdmin) {
userIdElement.classList.add('admin');
userIdElement.textContent = '👑 #' + userId;
document.getElementById('adminControls').style.display = 'flex';
showToast('Admin mode activated');
}
}
});
if (nickname) {
document.getElementById('nickname').value = nickname;
}
document.getElementById('nickname').addEventListener('change', function() {
nickname = this.value.trim();
localStorage.setItem('chatNickname', nickname);
});
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Typing indicator
document.getElementById('messageInput').addEventListener('input', function() {
if (this.value.length > 0 && !isTyping) {
isTyping = true;
// In a real implementation, you'd send this to the server
}
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
isTyping = false;
}, 1000);
});
}
function sendMessage() {
const messageInput = document.getElementById('messageInput');
const nicknameInput = document.getElementById('nickname');
const message = messageInput.value.trim();
nickname = nicknameInput.value.trim();
if (!nickname) {
nicknameInput.style.borderColor = 'var(--dodgers-red)';
nicknameInput.focus();
showToast('Please enter a nickname');
setTimeout(() => {
nicknameInput.style.borderColor = '#e0e0e0';
}, 2000);
return;
}
if (!message) {
return;
}
// Send message via AJAX
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `action=send&nickname=${encodeURIComponent(nickname)}&message=${encodeURIComponent(message)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
messageInput.value = '';
// Add message immediately to avoid delay
appendMessage(data.message);
} else if (data.error) {
showToast(data.error);
}
})
.catch(error => {
console.error('Error sending message:', error);
updateConnectionStatus(false);
});
}
function deleteMessage(messageId) {
if (!isAdmin) return;
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `action=delete_message&message_id=${encodeURIComponent(messageId)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove message from DOM
const msgElement = document.querySelector(`[data-message-id="${messageId}"]`);
if (msgElement) {
msgElement.style.animation = 'slideOut 0.3s';
setTimeout(() => msgElement.remove(), 300);
}
// Remove message from allMessages array to prevent it from reappearing
allMessages = allMessages.filter(msg => msg.id !== messageId);
showToast('Message deleted');
}
});
}
function banUser(userId) {
if (!isAdmin) return;
if (confirm('Ban this user from chat?')) {
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `action=ban_user&user_id=${encodeURIComponent(userId)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('User banned');
}
});
}
}
function clearChat() {
if (!isAdmin) return;
if (confirm('Clear all chat messages?')) {
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'action=clear_chat'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Clear local messages and reset state
allMessages = [];
lastMessageId = '';
document.getElementById('chatMessages').innerHTML = '<div class="system-message">Chat cleared by admin</div>';
showToast('Chat cleared');
// Force all users to refresh their chat by fetching all messages again
setTimeout(() => {
fetchMessages();
}, 500);
}
});
}
}
function fetchMessages() {
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `action=fetch&last_id=${encodeURIComponent(lastMessageId)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update viewer count
if (data.viewer_count !== undefined) {
updateViewerCount(data.viewer_count);
}
const serverMessageCount = data.message_count || 0;
if (data.all_messages !== null) {
// Initial load or forced refresh
allMessages = data.all_messages;
lastKnownMessageCount = serverMessageCount;
displayAllMessages();
if (serverMessageCount === 0 && lastKnownMessageCount > 0) {
// Chat was cleared
document.getElementById('chatMessages').innerHTML = '<div class="system-message">Chat was cleared by admin</div>';
}
} else {
// Check if message count decreased (admin deleted messages or cleared chat)
if (serverMessageCount < lastKnownMessageCount) {
// Something was deleted, refresh all messages
lastMessageId = ''; // Reset to fetch all messages
fetchMessages();
return;
}
// Normal case: append new messages
if (data.messages && data.messages.length > 0) {
data.messages.forEach(msg => {
appendMessage(msg);
// Show notification if chat is collapsed and message is not from current user
if (chatCollapsed && msg.user_id !== userId) {
unreadCount++;
updateNotificationBadge();
playNotificationSound();
}
});
}
lastKnownMessageCount = serverMessageCount;
}
updateConnectionStatus(true);
}
})
.catch(error => {
console.error('Error fetching messages:', error);
updateConnectionStatus(false);
});
}
function sendHeartbeat() {
const nicknameValue = document.getElementById('nickname').value || 'Anonymous';
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `action=heartbeat&nickname=${encodeURIComponent(nicknameValue)}`
})
.then(response => response.json())
.then(data => {
if (data.success && data.viewer_count !== undefined) {
updateViewerCount(data.viewer_count);
}
})
.catch(error => {
console.error('Error sending heartbeat:', error);
});
}
function updateViewerCount(count) {
const viewerElement = document.getElementById('viewerCount');
viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
}
function displayAllMessages() {
const chatMessages = document.getElementById('chatMessages');
if (allMessages.length === 0) {
chatMessages.innerHTML = '<div class="empty-chat">No messages yet. Be the first to say hello! 👋</div>';
lastKnownMessageCount = 0;
return;
}
chatMessages.innerHTML = '';
allMessages.forEach(msg => {
appendMessageElement(msg, false);
});
if (allMessages.length > 0) {
lastMessageId = allMessages[allMessages.length - 1].id;
chatMessages.scrollTop = chatMessages.scrollHeight;
}
lastKnownMessageCount = allMessages.length;
}
function appendMessage(msg) {
allMessages.push(msg);
appendMessageElement(msg, true);
lastMessageId = msg.id;
}
function appendMessageElement(msg, animate) {
const chatMessages = document.getElementById('chatMessages');
// Remove empty chat message if it exists
const emptyChat = chatMessages.querySelector('.empty-chat');
if (emptyChat) {
emptyChat.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
messageDiv.setAttribute('data-message-id', msg.id);
if (msg.user_id === userId) {
messageDiv.className += ' own-message';
}
if (msg.is_admin) {
messageDiv.className += ' admin-message';
}
if (!animate) {
messageDiv.style.animation = 'none';
}
const adminActions = isAdmin && msg.user_id !== userId ? `
<div class="message-actions">
<button class="delete-btn" onclick="deleteMessage('${msg.id}')">Delete</button>
<button class="ban-btn" onclick="banUser('${msg.user_id}')">Ban</button>
</div>
` : '';
const adminBadge = msg.is_admin ? '👑 ' : '';
messageDiv.innerHTML = `
<div class="message-header">
<span class="message-nickname${msg.is_admin ? ' admin' : ''}">${adminBadge}${msg.nickname}</span>
<span class="message-id">#${msg.user_id}</span>
<span class="message-time">${msg.time}</span>
</div>
<div class="message-text">${msg.message}</div>
${adminActions}
`;
chatMessages.appendChild(messageDiv);
// Auto-scroll to bottom
if (animate) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
function updateConnectionStatus(online) {
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
if (online) {
statusDot.classList.remove('offline');
statusText.textContent = 'Connected';
} else {
statusDot.classList.add('offline');
statusText.textContent = 'Reconnecting...';
}
}
function toggleChat() {
const chatSection = document.getElementById('chatSection');
const videoSection = document.getElementById('videoSection');
const toggleBtn = document.getElementById('chatToggleText');
chatCollapsed = !chatCollapsed;
if (chatCollapsed) {
chatSection.classList.add('collapsed');
videoSection.classList.add('expanded');
toggleBtn.textContent = 'Show Chat';
} else {
chatSection.classList.remove('collapsed');
videoSection.classList.remove('expanded');
toggleBtn.textContent = 'Hide Chat';
// Clear unread count
unreadCount = 0;
updateNotificationBadge();
}
}
function updateNotificationBadge() {
const badge = document.getElementById('notificationBadge');
if (unreadCount > 0) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
badge.classList.add('show');
} else {
badge.classList.remove('show');
}
}
function playNotificationSound() {
if (soundEnabled && document.hidden) {
// Create a simple beep sound using Web Audio API
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.1;
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
} catch(e) {
console.log('Audio notification failed:', e);
}
}
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Fast recovery checks when in placeholder mode (every 2 seconds for 5 checks)
let recoveryCheckCount = 0;
let recoveryTimeout;
function startRecoveryChecks() {
console.log('🔍 Starting fast recovery checks (every 2s for 5 checks = 10s total)');
recoveryCheckCount = 0;
// Clear any existing recovery timeout
if (recoveryTimeout) {
clearTimeout(recoveryTimeout);
}
function checkRecovery() {
if (streamMode !== 'placeholder' || recoveryCheckCount >= 5) {
if (recoveryCheckCount >= 5) {
console.log('⏹️ Fast recovery checks complete - stream still offline, staying in placeholder mode');
}
return;
}
recoveryCheckCount++;
console.log(`🔍 Recovery check ${recoveryCheckCount}/5...`);
fetch('?api=stream_status')
.then(response => response.json())
.then(data => {
console.log(`📡 Recovery check ${recoveryCheckCount}:`, data);
if (data.online && streamMode === 'placeholder') {
console.log('✅ Stream back online during recovery check - switching back');
isRecovering = true;
switchToLive();
} else {
// Schedule next check in 2 seconds (unless we've reached the limit)
if (recoveryCheckCount < 5 && streamMode === 'placeholder') {
recoveryTimeout = setTimeout(checkRecovery, 2000);
}
}
})
.catch(error => {
console.log(`❗ Recovery check ${recoveryCheckCount} failed:`, error);
// Still schedule next check even on failure
if (recoveryCheckCount < 5 && streamMode === 'placeholder') {
recoveryTimeout = setTimeout(checkRecovery, 2000);
}
});
}
// Start first check immediately
checkRecovery();
}
// Stream management functions
function switchToPlaceholder() {
if (streamMode === 'placeholder') {
console.log('⚠️ Already in placeholder mode, ignoring switch request');
return;
}
console.log('🔄 START: Switching to placeholder video');
console.log('📊 State before switch - streamMode:', streamMode, 'errorRetryCount:', errorRetryCount, 'isRecovering:', isRecovering);
streamMode = 'placeholder';
console.log('✅ State after switch - streamMode:', streamMode);
// Clear any existing recovery interval/timeout
if (recoveryInterval) {
clearInterval(recoveryInterval);
}
if (recoveryTimeout) {
clearTimeout(recoveryTimeout);
}
// Update UI first to give immediate feedback
updateStreamStatus('SWITCHING...', '#ffc107');
try {
// Stop playback and reset to clear all HLS state
player.pause();
player.reset();
// ENFORCE VOLUME AFTER RESET
restoreVolume();
// Wait longer for cleanup
setTimeout(() => {
// Switch to placeholder MP4
player.src({
src: 'placeholder.mp4',
type: 'video/mp4'
});
player.load();
// ENFORCE VOLUME AFTER LOAD
setTimeout(() => restoreVolume(), 200);
// Start playing after load
player.play().then(() => {
console.log('Placeholder video started successfully');
// Restore volume after switching
const savedVolume = localStorage.getItem('playerVolume');
const targetVolume = savedVolume !== null ? parseFloat(savedVolume) : 0.3;
player.volume(targetVolume);
console.log('🔊 Volume restored to:', targetVolume, '(saved:', savedVolume !== null, ')');
// Update UI to show placeholder mode
updateStreamStatus('PLACEHOLDER', '#17a2b8');
showToast('Stream offline - showing placeholder video');
// Start fast recovery checks (every 2 seconds for 5 checks = 10 seconds total)
startRecoveryChecks();
}).catch(e => {
console.log('Placeholder play failed:', e);
// Still update UI even if play fails
updateStreamStatus('PLACEHOLDER', '#17a2b8');
showToast('Stream offline - showing placeholder video');
// Start fast recovery checks even if play failed
startRecoveryChecks();
});
}, 1000); // Give HLS more time to clean up
} catch (e) {
console.log('Error switching to placeholder:', e);
// Fallback approach
try {
player.src({
src: 'placeholder.mp4',
type: 'video/mp4'
});
player.load();
startRecoveryChecks();
} catch (e2) {
console.log('Fallback also failed:', e2);
startRecoveryChecks();
}
}
}
function switchToLive() {
if (streamMode === 'live') {
console.log('⚠️ Already in live mode, ignoring switch request');
return;
}
console.log('🔄 START: Switching back to live stream');
console.log('📊 State before switch - streamMode:', streamMode, 'isRecovering:', isRecovering);
streamMode = 'live';
isRecovering = false;
console.log('✅ State after switch - streamMode:', streamMode, 'isRecovering:', isRecovering);
// Clear recovery interval
if (recoveryInterval) {
clearInterval(recoveryInterval);
recoveryInterval = null;
}
// Update UI first
updateStreamStatus('SWITCHING...', '#ffc107');
try {
// Stop current playback and reset player state
player.pause();
player.reset();
// ENFORCE VOLUME AFTER RESET
restoreVolume();
// Wait for cleanup
setTimeout(() => {
// Switch back to HLS stream
player.src({
src: '?proxy=stream',
type: 'application/x-mpegURL'
});
// Reset error state
errorRetryCount = 0;
isRetrying = false;
player.load();
// ENFORCE VOLUME AFTER LOAD
setTimeout(() => restoreVolume(), 200);
player.play().then(() => {
console.log('Live stream started successfully');
// Restore volume after switching
const savedVolume = localStorage.getItem('playerVolume');
const targetVolume = savedVolume !== null ? parseFloat(savedVolume) : 0.3;
player.volume(targetVolume);
console.log('🔊 Volume restored to:', targetVolume, '(saved:', savedVolume !== null, ')');
// Update UI to show reconnecting then live
updateStreamStatus('RECONNECTING...', '#ffc107');
showToast('Stream back online - switching to live');
// After a moment, show live status
setTimeout(() => {
if (streamMode === 'live') {
updateStreamStatus('LIVE', 'var(--dodgers-red)');
}
}, 2000);
}).catch(e => {
console.log('Live stream play failed:', e);
// Restore volume after switching even if play fails
const savedVolume = localStorage.getItem('playerVolume');
const targetVolume = savedVolume !== null ? parseFloat(savedVolume) : 0.3;
player.volume(targetVolume);
console.log('🔊 Volume restored to:', targetVolume, '(saved:', savedVolume !== null, ')');
// Still show as live even if initial play fails
updateStreamStatus('LIVE', 'var(--dodgers-red)');
showToast('Stream back online - switching to live');
});
}, 1000); // Give more time for cleanup
} catch (e) {
console.log('Error switching to live:', e);
try {
player.src({
src: '?proxy=stream',
type: 'application/x-mpegURL'
});
player.load();
} catch (e2) {
console.log('Fallback also failed:', e2);
}
}
}
function checkStreamRecovery() {
if (isRecovering) {
console.log('⏳ Already recovering, skipping recovery check');
return;
}
console.log('🔍 Checking stream recovery status...');
console.log('📊 Current state - streamMode:', streamMode, 'isRecovering:', isRecovering);
fetch('?api=stream_status')
.then(response => response.json())
.then(data => {
console.log('📡 Stream status response:', data);
if (data.online && streamMode === 'placeholder') {
console.log('✅ Live stream detected, switching back');
isRecovering = true;
console.log('🔄 Set isRecovering=true, will switch to live in 1 second');
// Give it a moment then switch back
setTimeout(() => {
console.log('🚀 Executing switch to live stream');
switchToLive();
}, 1000);
} else if (data.online) {
console.log(' Stream is online but not in placeholder mode');
} else {
console.log('❌ Stream is still offline, will check again in 5 seconds');
}
})
.catch(error => {
console.log('❗ Error checking stream status:', error);
});
}
function toggleFullscreen() {
if (player.isFullscreen()) {
player.exitFullscreen();
document.getElementById('fullscreenIcon').textContent = '⛶';
} else {
player.requestFullscreen();
document.getElementById('fullscreenIcon').textContent = '⛶';
}
}
function togglePictureInPicture() {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
const video = document.getElementById('video-player');
if (video.requestPictureInPicture) {
video.requestPictureInPicture();
}
}
}
// Initialize
initializeChat();
// Poll for new messages every 2 seconds
setInterval(fetchMessages, 2000);
// Send heartbeat every 5 seconds
setInterval(sendHeartbeat, 5000);
// Initial fetch
fetchMessages();
sendHeartbeat();
// Handle window resize
window.addEventListener('resize', function() {
if (window.innerWidth <= 768 && !chatCollapsed) {
// Auto-collapse chat on mobile for better video viewing
toggleChat();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Toggle chat with 'C' key
if (e.key === 'c' && !e.target.matches('input')) {
toggleChat();
}
// Toggle fullscreen with 'F' key
if (e.key === 'f' && !e.target.matches('input')) {
toggleFullscreen();
}
// Focus chat with '/' key
if (e.key === '/' && !e.target.matches('input')) {
e.preventDefault();
document.getElementById('messageInput').focus();
}
});
// Page visibility API for notifications
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// Clear unread when page becomes visible
if (!chatCollapsed) {
unreadCount = 0;
updateNotificationBadge();
}
}
});
</script>
</body>
</html>