1644 lines
62 KiB
PHP
1644 lines
62 KiB
PHP
<?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">
|
||
<link href="reset.css" rel="stylesheet">
|
||
<link href="variables.css" rel="stylesheet">
|
||
<link href="layout.css" rel="stylesheet">
|
||
<link href="components.css" rel="stylesheet">
|
||
<link href="utilities.css" rel="stylesheet">
|
||
</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>
|