diff --git a/api.js b/api.js
new file mode 100644
index 0000000..bcd7b0e
--- /dev/null
+++ b/api.js
@@ -0,0 +1,222 @@
+// API Communication Module
+// Handles all AJAX/Fetch calls and API interactions
+
+(function() {
+ 'use strict';
+
+ AppModules.register('api');
+
+ const API_BASE_URL = '';
+
+ // Generic fetch wrapper with error handling and retries
+ function apiRequest(endpoint, options = {}) {
+ const url = endpoint.startsWith('?') ? endpoint : `${API_BASE_URL}${endpoint}`;
+
+ return fetch(url, {
+ method: options.method || 'GET',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ ...options.headers
+ },
+ body: options.body
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ return response.json();
+ })
+ .catch(error => {
+ AppLogger.error(`API request to ${endpoint} failed:`, error);
+ throw error;
+ });
+ }
+
+ // Stream status checking
+ function checkStreamStatus() {
+ return apiRequest('?api=stream_status')
+ .then(data => {
+ AppLogger.log('Stream status check:', data);
+ return data.online === true;
+ })
+ .catch(error => {
+ AppLogger.error('Stream status check failed:', error);
+ return false; // Assume offline if check fails
+ });
+ }
+
+ // User management
+ function getUserId() {
+ return apiRequest('', {
+ method: 'POST',
+ body: 'action=get_user_id'
+ })
+ .then(data => {
+ if (data.success) {
+ AppState.userId = data.user_id;
+ AppState.isAdmin = data.is_admin || false;
+
+ const userIdElement = document.getElementById('userId');
+ if (userIdElement) {
+ userIdElement.textContent = '#' + AppState.userId;
+
+ if (AppState.isAdmin) {
+ userIdElement.classList.add('admin');
+ userIdElement.textContent = 'đ #' + AppState.userId;
+ const adminControls = document.getElementById('adminControls');
+ if (adminControls) adminControls.style.display = 'flex';
+ }
+ }
+
+ return data;
+ } else {
+ throw new Error('Failed to get user ID');
+ }
+ });
+ }
+
+ // Heartbeat functionality
+ function sendHeartbeat() {
+ const nicknameValue = document.getElementById('nickname')?.value || 'Anonymous';
+
+ return apiRequest('', {
+ method: 'POST',
+ body: `action=heartbeat&nickname=${encodeURIComponent(nicknameValue)}`
+ })
+ .then(data => {
+ if (data.success && data.viewer_count !== undefined) {
+ AppState.viewerCount = data.viewer_count;
+ updateViewerCount(data.viewer_count);
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Heartbeat failed:', error);
+ });
+ }
+
+ // Chat message operations
+ function sendMessage(nickname, message) {
+ if (!nickname || !message) {
+ throw new Error('Nickname and message are required');
+ }
+
+ const body = `action=send&nickname=${encodeURIComponent(nickname)}&message=${encodeURIComponent(message)}`;
+
+ return apiRequest('', {
+ method: 'POST',
+ body: body
+ });
+ }
+
+ function fetchMessages() {
+ return apiRequest('', {
+ method: 'POST',
+ body: `action=fetch&last_id=${encodeURIComponent(AppState.lastMessageId)}`
+ })
+ .then(data => {
+ if (data.success) {
+ // Update viewer count
+ if (data.viewer_count !== undefined) {
+ AppState.viewerCount = data.viewer_count;
+ updateViewerCount(data.viewer_count);
+ }
+
+ return data;
+ } else {
+ throw new Error('Failed to fetch messages');
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Fetch messages failed:', error);
+ updateConnectionStatus(false);
+ throw error;
+ });
+ }
+
+ // Admin functions
+ function deleteMessage(messageId) {
+ if (!AppState.isAdmin) return Promise.reject(new Error('Admin access required'));
+
+ return apiRequest('', {
+ method: 'POST',
+ body: `action=delete_message&message_id=${encodeURIComponent(messageId)}`
+ })
+ .then(data => {
+ if (data.success) {
+ return data;
+ } else {
+ throw new Error('Failed to delete message');
+ }
+ });
+ }
+
+ function banUser(userId) {
+ if (!AppState.isAdmin) return Promise.reject(new Error('Admin access required'));
+
+ return apiRequest('', {
+ method: 'POST',
+ body: `action=ban_user&user_id=${encodeURIComponent(userId)}`
+ })
+ .then(data => {
+ if (data.success) {
+ return data;
+ } else {
+ throw new Error('Failed to ban user');
+ }
+ });
+ }
+
+ function clearChat() {
+ if (!AppState.isAdmin) return Promise.reject(new Error('Admin access required'));
+
+ return apiRequest('', {
+ method: 'POST',
+ body: 'action=clear_chat'
+ })
+ .then(data => {
+ if (data.success) {
+ return data;
+ } else {
+ throw new Error('Failed to clear chat');
+ }
+ });
+ }
+
+ // UI feedback functions (will be moved to ui-controls later, but needed here for now)
+ function updateViewerCount(count) {
+ const viewerElement = document.getElementById('viewerCount');
+ if (viewerElement) {
+ viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
+ }
+ }
+
+ function updateConnectionStatus(online) {
+ const statusDot = document.getElementById('statusDot');
+ const statusText = document.getElementById('statusText');
+
+ if (statusDot && statusText) {
+ if (online) {
+ statusDot.classList.remove('offline');
+ statusText.textContent = 'Connected';
+ } else {
+ statusDot.classList.add('offline');
+ statusText.textContent = 'Reconnecting...';
+ }
+ }
+ }
+
+ // Public API
+ window.API = {
+ checkStreamStatus: checkStreamStatus,
+ getUserId: getUserId,
+ sendHeartbeat: sendHeartbeat,
+ sendMessage: sendMessage,
+ fetchMessages: fetchMessages,
+ deleteMessage: deleteMessage,
+ banUser: banUser,
+ clearChat: clearChat
+ };
+
+ AppLogger.log('API module loaded');
+
+})();
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..ff8b882
--- /dev/null
+++ b/app.js
@@ -0,0 +1,116 @@
+// Core Dodgers Stream Application
+// Configuration and module loading
+
+const AppConfig = {
+ // API configuration
+ api: {
+ baseUrl: '',
+ heartbeatInterval: 5000,
+ chatPollInterval: 2000,
+ maxRetries: 3,
+ retryDelay: 1000
+ },
+ // Video player configuration
+ video: {
+ defaultVolume: 0.3,
+ maxRetries: 3,
+ recoveryTimeout: 10000,
+ streamMonitoringInterval: 10000,
+ recoveryCheckCount: 5,
+ recoveryCheckInterval: 2000
+ },
+ // UI configuration
+ ui: {
+ animationDuration: 300,
+ mobileBreakpoint: 768,
+ toastDuration: 3000
+ },
+ // Chat configuration
+ chat: {
+ maxMessages: 100,
+ maxMessageLength: 1000,
+ maxNicknameLength: 20,
+ typingTimeout: 1000
+ }
+};
+
+// Global application state
+const AppState = {
+ player: null,
+ userId: '',
+ nickname: '',
+ isAdmin: false,
+ streamMode: 'live', // 'live' or 'placeholder'
+ chatCollapsed: false,
+ unreadCount: 0,
+ soundEnabled: true,
+ lastMessageId: '',
+ allMessages: [],
+ viewerCount: 0,
+ errorRetryCount: 0,
+ isRetrying: false,
+ isRecovering: false,
+ recoveryInterval: null,
+ typingTimer: null,
+ isTyping: false
+};
+
+// Global error handling and logging
+const AppLogger = {
+ log: (message, ...args) => {
+ console.log(`[${new Date().toLocaleTimeString()}] ${message}`, ...args);
+ },
+ error: (message, ...args) => {
+ console.error(`[${new Date().toLocaleTimeString()}] ERROR: ${message}`, ...args);
+ },
+ warn: (message, ...args) => {
+ console.warn(`[${new Date().toLocaleTimeString()}] WARNING: ${message}`, ...args);
+ }
+};
+
+// Module registry for dependencies
+const AppModules = {
+ loaded: new Set(),
+
+ register: function(name) {
+ this.loaded.add(name);
+ AppLogger.log(`Module '${name}' registered`);
+ },
+
+ isLoaded: function(name) {
+ return this.loaded.has(name);
+ },
+
+ require: function(...names) {
+ for (const name of names) {
+ if (!this.isLoaded(name)) {
+ throw new Error(`Required module '${name}' is not loaded`);
+ }
+ }
+ }
+};
+
+// Global error handler
+window.addEventListener('unhandledrejection', (event) => {
+ AppLogger.error('Unhandled promise rejection:', event.reason);
+});
+
+window.addEventListener('error', (event) => {
+ AppLogger.error('Unhandled error:', event.error);
+});
+
+// Initialize application
+document.addEventListener('DOMContentLoaded', () => {
+ AppLogger.log('Dodgers Stream Theater initializing...');
+
+ // Start with basic UI setup
+ const nicknameInput = document.getElementById('nickname');
+ if (nicknameInput && localStorage.getItem('chatNickname')) {
+ nicknameInput.value = localStorage.getItem('chatNickname');
+ AppState.nickname = nicknameInput.value;
+ }
+
+ AppLogger.log('Core application initialized');
+});
+
+AppLogger.log('Core app.js loaded');
diff --git a/chat.js b/chat.js
new file mode 100644
index 0000000..ec289b4
--- /dev/null
+++ b/chat.js
@@ -0,0 +1,414 @@
+// Chat System Module
+// Handles chat functionality, message handling, and user authentication
+
+(function() {
+ 'use strict';
+
+ AppModules.require('api', 'ui-controls');
+ AppModules.register('chat');
+
+ // Initialize chat system
+ function initializeChat() {
+ AppLogger.log('Initializing chat system...');
+
+ // Get user ID first
+ API.getUserId()
+ .then(data => {
+ AppLogger.log('Chat initialized with user:', AppState.userId, AppState.isAdmin ? '(admin)' : '');
+
+ // Show admin toast if applicable
+ if (AppState.isAdmin) {
+ UIControls.showToast('Admin mode activated');
+ }
+
+ // Set up nickname handling
+ const nicknameInput = document.getElementById('nickname');
+ if (nicknameInput) {
+ // Load saved nickname
+ const savedNickname = localStorage.getItem('chatNickname');
+ if (savedNickname) {
+ nicknameInput.value = savedNickname;
+ }
+
+ // Handle nickname changes
+ UIControls.DOMUtils.addEvent(nicknameInput, 'change', function() {
+ const nickname = this.value.trim();
+ localStorage.setItem('chatNickname', nickname);
+ AppState.nickname = nickname;
+ });
+ }
+
+ // Set up message input handling
+ const messageInput = document.getElementById('messageInput');
+ if (messageInput) {
+ // Enter key to send message
+ UIControls.DOMUtils.addEvent(messageInput, 'keypress', function(e) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ });
+
+ // Typing indicator
+ UIControls.DOMUtils.addEvent(messageInput, 'input', function() {
+ handleTypingIndicator();
+ });
+ }
+
+ // Set up send button
+ const sendButton = document.querySelector('.chat-input button') ||
+ document.querySelector('#messageInput').nextElementSibling;
+ if (sendButton && sendButton.tagName === 'BUTTON') {
+ UIControls.DOMUtils.addEvent(sendButton, 'click', sendMessage);
+ }
+
+ // Start polling for messages
+ startMessagePolling();
+
+ // Send initial heartbeat
+ API.sendHeartbeat();
+
+ AppLogger.log('Chat system initialized successfully');
+ })
+ .catch(error => {
+ AppLogger.error('Failed to initialize chat:', error);
+ UIControls.showToast('Failed to initialize chat');
+ });
+ }
+
+ // Send message functionality
+ function sendMessage() {
+ const messageInput = document.getElementById('messageInput');
+ const nicknameInput = document.getElementById('nickname');
+ const message = messageInput?.value?.trim() || '';
+ const nickname = nicknameInput?.value?.trim() || '';
+
+ if (!nickname) {
+ if (nicknameInput) {
+ nicknameInput.style.borderColor = 'var(--dodgers-red)';
+ nicknameInput.focus();
+ }
+ UIControls.showToast('Please enter a nickname');
+ setTimeout(() => {
+ if (nicknameInput) nicknameInput.style.borderColor = '#e0e0e0';
+ }, 2000);
+ return;
+ }
+
+ if (!message) {
+ return; // Just return quietly for empty message
+ }
+
+ // Send message via API
+ API.sendMessage(nickname, message)
+ .then(data => {
+ if (data.success) {
+ if (messageInput) messageInput.value = '';
+ AppState.nickname = nickname;
+ // Message will be added automatically by polling
+ } else if (data.error) {
+ UIControls.showToast(data.error);
+ UIControls.updateConnectionStatus(false);
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Error sending message:', error);
+ UIControls.showToast('Failed to send message');
+ UIControls.updateConnectionStatus(false);
+ });
+ }
+
+ // Delete message (admin only)
+ function deleteMessage(messageId) {
+ if (!AppState.isAdmin) return;
+
+ if (!confirm('Delete this message?')) return;
+
+ API.deleteMessage(messageId)
+ .then(data => {
+ if (data.success) {
+ removeMessageFromUI(messageId);
+ UIControls.showToast('Message deleted');
+ } else {
+ UIControls.showToast('Failed to delete message');
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Error deleting message:', error);
+ UIControls.showToast('Failed to delete message');
+ });
+ }
+
+ // Ban user (admin only)
+ function banUser(userId) {
+ if (!AppState.isAdmin) return;
+
+ if (!confirm('Ban this user from chat?')) return;
+
+ API.banUser(userId)
+ .then(data => {
+ if (data.success) {
+ UIControls.showToast('User banned');
+ } else {
+ UIControls.showToast('Failed to ban user');
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Error banning user:', error);
+ UIControls.showToast('Failed to ban user');
+ });
+ }
+
+ // Clear chat (admin only)
+ function clearChat() {
+ if (!AppState.isAdmin) return;
+
+ if (!confirm('Clear all chat messages?')) return;
+
+ API.clearChat()
+ .then(data => {
+ if (data.success) {
+ clearMessagesUI();
+ UIControls.showToast('Chat cleared');
+ AppState.lastMessageId = '';
+ API.sendHeartbeat(); // Force refresh all users
+ } else {
+ UIControls.showToast('Failed to clear chat');
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Error clearing chat:', error);
+ UIControls.showToast('Failed to clear chat');
+ });
+ }
+
+ // Message polling system
+ function startMessagePolling() {
+ // Poll for new messages
+ setInterval(fetchMessages, AppConfig.api.chatPollInterval);
+
+ // Send heartbeat periodically
+ setInterval(API.sendHeartbeat, AppConfig.api.heartbeatInterval);
+
+ // Initial fetch
+ fetchMessages();
+ }
+
+ // Fetch and display messages
+ function fetchMessages() {
+ API.fetchMessages()
+ .then(data => {
+ if (data.success) {
+ // Handle initial load or message updates
+ if (data.all_messages !== null) {
+ // Initial load or forced refresh (like after admin clear)
+ handleInitialMessageLoad(data);
+ } else {
+ // Incremental updates
+ handleNewMessages(data.messages);
+ }
+
+ // Check for admin clear
+ if (data.message_count === 0 && AppState.allMessages.length > 0) {
+ handleChatCleared();
+ }
+
+ // Update last message ID
+ if (AppState.allMessages.length > 0) {
+ AppState.lastMessageId = AppState.allMessages[AppState.allMessages.length - 1].id;
+ }
+
+ UIControls.updateConnectionStatus(true);
+ }
+ })
+ .catch(error => {
+ AppLogger.error('Error fetching messages:', error);
+ UIControls.updateConnectionStatus(false);
+ });
+ }
+
+ // Handle initial message load
+ function handleInitialMessageLoad(data) {
+ AppState.allMessages = data.all_messages || [];
+ displayAllMessages();
+ }
+
+ // Handle new messages
+ function handleNewMessages(messages) {
+ if (!messages || messages.length === 0) return;
+
+ messages.forEach(msg => {
+ appendMessage(msg);
+
+ // Show notification if chat is collapsed and message is not from current user
+ if (AppState.chatCollapsed && msg.user_id !== AppState.userId) {
+ AppState.unreadCount++;
+ UIControls.updateNotificationBadge();
+ UIControls.playNotificationSound();
+ }
+ });
+ }
+
+ // Handle chat cleared by admin
+ function handleChatCleared() {
+ const chatMessages = document.getElementById('chatMessages');
+ if (chatMessages) {
+ chatMessages.innerHTML = '
Chat was cleared by admin
';
+ }
+ AppState.allMessages = [];
+ AppState.lastMessageId = '';
+ }
+
+ // Typing indicator system
+ function handleTypingIndicator() {
+ const messageInput = document.getElementById('messageInput');
+ if (!messageInput) return;
+
+ const hasContent = messageInput.value.length > 0;
+
+ if (hasContent && !AppState.isTyping) {
+ AppState.isTyping = true;
+ }
+
+ clearTimeout(AppState.typingTimer);
+ AppState.typingTimer = setTimeout(() => {
+ AppState.isTyping = false;
+ }, AppConfig.chat.typingTimeout);
+ }
+
+ // UI manipulation functions
+ function displayAllMessages() {
+ const chatMessages = document.getElementById('chatMessages');
+
+ if (!chatMessages) return;
+
+ if (AppState.allMessages.length === 0) {
+ chatMessages.innerHTML = 'No messages yet. Be the first to say hello! đ
';
+ AppState.lastMessageCount = 0;
+ return;
+ }
+
+ chatMessages.innerHTML = '';
+
+ AppState.allMessages.forEach(msg => {
+ appendMessageElement(msg, false);
+ });
+
+ // Scroll to bottom
+ chatMessages.scrollTop = chatMessages.scrollHeight;
+ AppState.lastMessageCount = AppState.allMessages.length;
+ }
+
+ function appendMessage(msg) {
+ AppState.allMessages.push(msg);
+ appendMessageElement(msg, true);
+ AppState.lastMessageCount = AppState.allMessages.length;
+ }
+
+ function appendMessageElement(msg, animate) {
+ const chatMessages = document.getElementById('chatMessages');
+
+ if (!chatMessages) return;
+
+ // 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);
+
+ // Own message styling
+ if (msg.user_id === AppState.userId) {
+ messageDiv.className += ' own-message';
+ }
+
+ // Admin message styling
+ if (msg.is_admin) {
+ messageDiv.className += ' admin-message';
+ }
+
+ // Disable animation for initial load
+ if (!animate) {
+ messageDiv.style.animation = 'none';
+ }
+
+ // Create admin actions if user is admin
+ const adminActions = (AppState.isAdmin && msg.user_id !== AppState.userId) ?
+ `
+
+
+
` : '';
+
+ // Create admin badge
+ const adminBadge = msg.is_admin ? 'đ ' : '';
+
+ messageDiv.innerHTML = `
+
+ ${escapeHtml(msg.message)}
+ ${adminActions}
+ `;
+
+ chatMessages.appendChild(messageDiv);
+
+ // Auto-scroll to bottom on new messages
+ if (animate) {
+ chatMessages.scrollTop = chatMessages.scrollHeight;
+ }
+ }
+
+ function removeMessageFromUI(messageId) {
+ const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
+ if (messageElement) {
+ messageElement.style.animation = 'slideOut 0.3s';
+ setTimeout(() => {
+ messageElement.remove();
+ }, 300);
+ }
+
+ // Remove from internal array
+ AppState.allMessages = AppState.allMessages.filter(msg => msg.id !== messageId);
+ }
+
+ function clearMessagesUI() {
+ const chatMessages = document.getElementById('chatMessages');
+ if (chatMessages) {
+ chatMessages.innerHTML = 'Chat cleared by admin
';
+ }
+ AppState.allMessages = [];
+ AppState.lastMessageId = '';
+ }
+
+ // Utility function for HTML escaping
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // Public API
+ window.Chat = {
+ initializeChat: initializeChat,
+ sendMessage: sendMessage,
+ deleteMessage: deleteMessage,
+ banUser: banUser,
+ clearChat: clearChat,
+ displayAllMessages: displayAllMessages,
+ appendMessage: appendMessage
+ };
+
+ // Initialize when DOM is ready and modules are loaded
+ document.addEventListener('DOMContentLoaded', function() {
+ // Small delay to ensure all modules are loaded
+ setTimeout(initializeChat, 100);
+ });
+
+ AppLogger.log('Chat module loaded');
+
+})();
diff --git a/ui-controls.js b/ui-controls.js
new file mode 100644
index 0000000..9161393
--- /dev/null
+++ b/ui-controls.js
@@ -0,0 +1,282 @@
+// UI Controls Module
+// Handles UI toggle functions, keyboard shortcuts, notifications, and DOM utilities
+
+(function() {
+ 'use strict';
+
+ AppModules.require('api');
+ AppModules.register('ui-controls');
+
+ // Toast notification system
+ function showToast(message, duration = AppConfig.ui.toastDuration) {
+ const toast = document.getElementById('toast');
+ if (!toast) return;
+
+ toast.textContent = message;
+ toast.classList.add('show');
+
+ if (duration > 0) {
+ setTimeout(() => {
+ toast.classList.remove('show');
+ }, duration);
+ }
+ }
+
+ function hideToast() {
+ const toast = document.getElementById('toast');
+ if (toast) {
+ toast.classList.remove('show');
+ }
+ }
+
+ // Notification badge management
+ function updateNotificationBadge() {
+ const badge = document.getElementById('notificationBadge');
+
+ if (badge) {
+ if (AppState.unreadCount > 0) {
+ badge.textContent = AppState.unreadCount > 99 ? '99+' : AppState.unreadCount;
+ badge.classList.add('show');
+ } else {
+ badge.classList.remove('show');
+ }
+ }
+ }
+
+ // Chat toggle functionality
+ function toggleChat() {
+ const chatSection = document.getElementById('chatSection');
+ const videoSection = document.getElementById('videoSection');
+ const toggleBtn = document.getElementById('chatToggleText');
+
+ if (!chatSection || !videoSection || !toggleBtn) return;
+
+ AppState.chatCollapsed = !AppState.chatCollapsed;
+
+ if (AppState.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 when reopening
+ AppState.unreadCount = 0;
+ updateNotificationBadge();
+ }
+ }
+
+ // Viewer count update
+ function updateViewerCount(count) {
+ const viewerElement = document.getElementById('viewerCount');
+ if (viewerElement) {
+ viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
+ }
+ }
+
+ // Connection status dot
+ function updateConnectionStatus(online) {
+ const statusDot = document.getElementById('statusDot');
+ const statusText = document.getElementById('statusText');
+
+ if (statusDot && statusText) {
+ if (online) {
+ statusDot.classList.remove('offline');
+ statusText.textContent = 'Connected';
+ } else {
+ statusDot.classList.add('offline');
+ statusText.textContent = 'Reconnecting...';
+ }
+ }
+ }
+
+ // Keyboard shortcuts handler
+ function handleKeyboardShortcuts(event) {
+ // Skip if typing in input field
+ if (event.target.matches('input, textarea')) {
+ return;
+ }
+
+ switch (event.key) {
+ case 'c':
+ case 'C':
+ // Toggle chat
+ event.preventDefault();
+ toggleChat();
+ break;
+ case 'f':
+ case 'F':
+ // Toggle fullscreen
+ event.preventDefault();
+ toggleFullscreen();
+ break;
+ case '/':
+ // Focus chat input
+ event.preventDefault();
+ const messageInput = document.getElementById('messageInput');
+ if (messageInput) {
+ messageInput.focus();
+ }
+ break;
+ case 'p':
+ case 'P':
+ // Toggle picture-in-picture
+ event.preventDefault();
+ togglePictureInPicture();
+ break;
+ }
+ }
+
+ // Fullscreen functionality
+ function toggleFullscreen() {
+ if (!AppState.player) return;
+
+ if (AppState.player.isFullscreen()) {
+ AppState.player.exitFullscreen();
+ } else {
+ AppState.player.requestFullscreen();
+ }
+ }
+
+ // Picture-in-picture functionality
+ function togglePictureInPicture() {
+ const video = document.getElementById('video-player');
+ if (!video || !video.requestPictureInPicture) return;
+
+ if (document.pictureInPictureElement) {
+ document.exitPictureInPicture();
+ } else {
+ video.requestPictureInPicture();
+ }
+ }
+
+ // Sound notification for new messages
+ function playNotificationSound() {
+ if (!AppState.soundEnabled || !document.hidden) return;
+
+ 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) {
+ AppLogger.log('Audio notification failed:', e);
+ }
+ }
+
+ // Mobile responsive behavior
+ function handleWindowResize() {
+ const isMobile = window.innerWidth <= AppConfig.ui.mobileBreakpoint;
+
+ if (isMobile && !AppState.chatCollapsed) {
+ // Auto-collapse chat on mobile for better viewing
+ toggleChat();
+ }
+ }
+
+ // Page visibility API handler
+ function handleVisibilityChange() {
+ if (!document.hidden && !AppState.chatCollapsed) {
+ // Clear unread count when page becomes visible
+ AppState.unreadCount = 0;
+ updateNotificationBadge();
+ }
+ }
+
+ // DOM utility functions
+ const DOMUtils = {
+ // Add event listener with proper cleanup tracking
+ addEvent: function(element, event, handler) {
+ if (!element) return;
+
+ element.addEventListener(event, handler);
+
+ // Store handler for potential cleanup
+ if (!element._handlers) {
+ element._handlers = new Map();
+ }
+ if (!element._handlers.has(event)) {
+ element._handlers.set(event, []);
+ }
+ element._handlers.get(event).push(handler);
+ },
+
+ // Remove event listeners (for cleanup)
+ removeEvent: function(element, event, handler) {
+ if (!element || !element._handlers || !element._handlers.has(event)) return;
+
+ const handlers = element._handlers.get(event);
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ element.removeEventListener(event, handler);
+ }
+ },
+
+ // Get element with optional error logging
+ getElement: function(id, required = false) {
+ const element = document.getElementById(id);
+ if (required && !element) {
+ AppLogger.error(`Required element with id '${id}' not found`);
+ }
+ return element;
+ },
+
+ // Add/remove classes safely
+ addClass: function(element, className) {
+ if (element) element.classList.add(className);
+ },
+
+ removeClass: function(element, className) {
+ if (element) element.classList.remove(className);
+ },
+
+ toggleClass: function(element, className) {
+ if (element) element.classList.toggle(className);
+ }
+ };
+
+ // Initialize event listeners
+ function initializeEventListeners() {
+ // Keyboard shortcuts
+ DOMUtils.addEvent(document, 'keydown', handleKeyboardShortcuts);
+
+ // Window resize for mobile responsiveness
+ DOMUtils.addEvent(window, 'resize', handleWindowResize);
+
+ // Page visibility for notification clearing
+ DOMUtils.addEvent(document, 'visibilitychange', handleVisibilityChange);
+
+ AppLogger.log('UI controls event listeners initialized');
+ }
+
+ // Public API
+ window.UIControls = {
+ showToast: showToast,
+ hideToast: hideToast,
+ toggleChat: toggleChat,
+ toggleFullscreen: toggleFullscreen,
+ togglePictureInPicture: togglePictureInPicture,
+ updateViewerCount: updateViewerCount,
+ updateConnectionStatus: updateConnectionStatus,
+ updateNotificationBadge: updateNotificationBadge,
+ playNotificationSound: playNotificationSound,
+ DOMUtils: DOMUtils
+ };
+
+ // Initialize when DOM is ready
+ document.addEventListener('DOMContentLoaded', initializeEventListeners);
+
+ AppLogger.log('UI Controls module loaded');
+
+})();
diff --git a/video-player.js b/video-player.js
new file mode 100644
index 0000000..085564a
--- /dev/null
+++ b/video-player.js
@@ -0,0 +1,540 @@
+// Video Player Module
+// Handles Video.js initialization, stream management, and player controls
+
+(function() {
+ 'use strict';
+
+ AppModules.require('api', 'ui-controls');
+ AppModules.register('video-player');
+
+ // Stream status management
+ function updateStreamStatus(status, color) {
+ const badge = document.querySelector('.stream-badge');
+ if (badge) {
+ badge.textContent = status;
+ badge.style.background = color || 'var(--dodgers-red)';
+ AppLogger.log(`đ Stream status updated: ${status} (${color || 'red'})`);
+ }
+ }
+
+ // Video.js player initialization
+ function initializePlayer() {
+ AppLogger.log('đŦ Starting Video.js initialization...');
+
+ AppState.player = videojs('video-player', {
+ html5: {
+ vhs: {
+ overrideNative: true,
+ withCredentials: false,
+ handlePartialData: true,
+ smoothQualityChange: true,
+ allowSeeksWithinUnsafeLiveWindow: true,
+ handleManifestRedirects: true,
+ useBandwidthFromLocalStorage: true,
+ maxPlaylistRetries: 10,
+ blacklistDuration: 5,
+ bandwidth: 4194304
+ }
+ },
+ liveui: true,
+ liveTracker: {
+ trackingThreshold: 30,
+ liveTolerance: 15
+ },
+ errorDisplay: false,
+ responsive: false,
+ controlBar: {
+ volumePanel: {
+ inline: false
+ },
+ pictureInPictureToggle: true
+ }
+ });
+
+ // Volume enforcement and restoration
+ setupVolumeManagement();
+
+ // Stream status checking
+ setupStreamMonitoring();
+
+ // Error handling
+ setupPlayerErrorHandling();
+
+ // Event handlers
+ setupPlayerEvents();
+
+ AppLogger.log('Video.js player initialized successfully');
+ }
+
+ // Volume management system
+ function setupVolumeManagement() {
+ AppState.player.ready(function() {
+ AppLogger.log('Player is ready');
+
+ // Immediate volume fix
+ enforceSavedVolume();
+
+ // Load saved volume or set default
+ const savedVolume = localStorage.getItem('playerVolume');
+ if (savedVolume !== null) {
+ const volumeValue = parseFloat(savedVolume);
+ AppState.player.volume(volumeValue);
+ AppLogger.log('đ Restored saved volume:', volumeValue);
+ } else {
+ AppState.player.volume(AppConfig.video.defaultVolume);
+ AppLogger.log('đ Set default volume:', AppConfig.video.defaultVolume);
+ }
+
+ // Volume monitoring and enforcement
+ AppState.player.on('volumechange', function() {
+ if (!enforceSavedVolume()) {
+ // User's intentional change - save it
+ if (!AppState.player.muted()) {
+ const newVolume = AppState.player.volume();
+ localStorage.setItem('playerVolume', newVolume);
+ AppLogger.log('đž Saved volume setting:', newVolume);
+ }
+ }
+ });
+
+ // PROACTIVE STREAM STATUS CHECK
+ checkInitialStreamStatus();
+ });
+ }
+
+ // Volume enforcement function
+ function enforceSavedVolume() {
+ const currentVolume = AppState.player.volume();
+ if (currentVolume === 1.0) {
+ const savedVolume = parseFloat(localStorage.getItem('playerVolume') || AppConfig.video.defaultVolume.toString());
+ if (savedVolume !== 1.0) {
+ AppLogger.log('đĢ BLOCKED: Volume reset to 100% - forcing back to:', savedVolume);
+ AppState.player.volume(savedVolume);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Stream status and monitoring
+ function checkInitialStreamStatus() {
+ AppLogger.log('đ Checking initial stream status...');
+
+ API.checkStreamStatus()
+ .then(isOnline => {
+ if (!isOnline) {
+ AppLogger.log('đ Stream offline on load - switching to placeholder immediately');
+ switchToPlaceholder();
+ } else {
+ AppLogger.log('â
Stream online on load - starting HLS playback');
+ attemptHLSPlayback();
+ startStreamMonitoring();
+ }
+ });
+ }
+
+ function startStreamMonitoring() {
+ if (!AppState.recoveryInterval) {
+ AppLogger.log('đī¸ Starting stream monitoring...');
+ AppState.recoveryInterval = setInterval(() => {
+ if (AppState.streamMode !== 'placeholder') {
+ API.checkStreamStatus()
+ .then(data => {
+ if (!data.online) {
+ AppLogger.log('â ī¸ Stream went offline during playback - switching to placeholder');
+ switchToPlaceholder();
+ }
+ })
+ .catch(error => {
+ AppLogger.error('â Stream monitoring check failed:', error);
+ });
+ }
+ }, AppConfig.video.streamMonitoringInterval);
+ }
+ }
+
+ // HLS playback attempt
+ function attemptHLSPlayback() {
+ AppLogger.log('đŦ Attempting HLS stream playback...');
+
+ var playPromise = AppState.player.play();
+ if (playPromise !== undefined) {
+ playPromise.catch(function(error) {
+ AppLogger.log('Auto-play was prevented:', error);
+ UIControls.showToast('Click play to start stream');
+ });
+ }
+ }
+
+ // Player error handling
+ function setupPlayerErrorHandling() {
+ // Warning events from Video.js
+ AppState.player.on('warning', function(event) {
+ AppLogger.log('VideoJS warning:', event.detail);
+ if (!AppState.isRetrying && !AppState.isRecovering && AppState.streamMode === 'live') {
+ AppState.errorRetryCount++;
+ AppLogger.log('Warning count now:', AppState.errorRetryCount);
+ if (AppState.errorRetryCount >= 3) {
+ AppLogger.log('Threshold reached, switching to placeholder');
+ switchToPlaceholder();
+ }
+ }
+ });
+
+ // Error handling
+ AppState.player.on('error', function() {
+ var error = AppState.player.error();
+ AppLogger.log('Player error:', error);
+
+ if (AppState.isRetrying || AppState.streamMode === 'placeholder') return;
+
+ if (error && (error.code === 2 || error.code === 4)) {
+ if (AppState.errorRetryCount < AppConfig.video.maxRetries) {
+ AppState.isRetrying = true;
+ AppState.errorRetryCount++;
+
+ AppLogger.log(`Attempting recovery (${AppState.errorRetryCount}/${AppConfig.video.maxRetries})...`);
+ updateStreamStatus('RECONNECTING...', '#ffc107');
+
+ setTimeout(() => {
+ AppState.player.error(null);
+ AppState.player.pause();
+ AppState.player.reset();
+
+ // Restore volume
+ enforceSavedVolume();
+
+ // Try playing again
+ if (AppState.player.paused()) {
+ AppState.player.play().catch(e => AppLogger.log('Play attempt failed:', e));
+ }
+
+ AppState.isRetrying = false;
+
+ // Soft reload fallback
+ setTimeout(() => {
+ if (AppState.player.error()) {
+ AppLogger.log('Soft reloading source...');
+ const currentTime = AppState.player.currentTime();
+ AppState.player.src(AppState.player.currentSrc());
+ AppState.player.play().catch(e => AppLogger.log('Play after reload failed:', e));
+ }
+ }, 3000);
+
+ }, 1000 * Math.min(AppState.errorRetryCount, 3));
+
+ } else {
+ AppLogger.error('Max retries exceeded, switching to placeholder video');
+ switchToPlaceholder();
+ }
+ } else if (error && error.code === 3) {
+ AppLogger.error('Decode error, switching to placeholder:', error);
+ switchToPlaceholder();
+ }
+ });
+ }
+
+ // Player event setup
+ function setupPlayerEvents() {
+ // Reset error count on successful playback
+ AppState.player.on('loadeddata', function() {
+ AppState.errorRetryCount = 0;
+ AppState.isRetrying = false;
+ if (AppState.streamMode === 'live') {
+ updateStreamStatus('LIVE', 'var(--dodgers-red)');
+ }
+ });
+
+ AppState.player.on('playing', function() {
+ AppState.errorRetryCount = 0;
+ AppState.isRetrying = false;
+ if (AppState.streamMode === 'live') {
+ updateStreamStatus('LIVE', 'var(--dodgers-red)');
+ }
+ });
+
+ AppState.player.on('waiting', function() {
+ AppLogger.log('Player is buffering...');
+ if (!AppState.isRetrying && AppState.errorRetryCount === 0 && AppState.streamMode === 'live') {
+ updateStreamStatus('BUFFERING...', '#ffc107');
+ }
+
+ // Extended buffering detection
+ const waitingStartTime = Date.now();
+ let offlineConfirmCount = 0;
+
+ function checkBufferingStatus(attempt = 1, maxAttempts = 5) {
+ API.checkStreamStatus()
+ .then(data => {
+ if (!data.online) {
+ offlineConfirmCount++;
+ AppLogger.log(`â ī¸ Confirmed offline - Attempts: ${offlineConfirmCount}/${maxAttempts}`);
+
+ if (offlineConfirmCount >= 2 || attempt >= maxAttempts) {
+ AppLogger.log('â Stream confirmed offline - switching to placeholder');
+ switchToPlaceholder();
+ return;
+ }
+ } else {
+ offlineConfirmCount = 0;
+ }
+
+ if (offlineConfirmCount < 2 && attempt < maxAttempts) {
+ setTimeout(() => checkBufferingStatus(attempt + 1, maxAttempts), 1500);
+ }
+ })
+ .catch(apiError => {
+ AppLogger.log('â API check failed:', apiError);
+ if (attempt < maxAttempts) {
+ setTimeout(() => checkBufferingStatus(attempt + 1, maxAttempts), 1500);
+ }
+ });
+ }
+
+ checkBufferingStatus();
+ });
+
+ // Context menu prevention
+ document.getElementById('video-player').addEventListener('contextmenu', function(e) {
+ e.preventDefault();
+ return false;
+ });
+
+ AppState.player.ready(function() {
+ const videoElement = AppState.player.el().querySelector('video');
+ if (videoElement) {
+ videoElement.addEventListener('contextmenu', function(e) {
+ e.preventDefault();
+ return false;
+ });
+ }
+ });
+ }
+
+ // Stream switching functions
+ function switchToPlaceholder() {
+ if (AppState.streamMode === 'placeholder') {
+ AppLogger.log('â ī¸ Already in placeholder mode, ignoring switch request');
+ return;
+ }
+
+ AppLogger.log('đ START: Switching to placeholder video');
+ AppLogger.log('đ State before switch:', {
+ streamMode: AppState.streamMode,
+ errorRetryCount: AppState.errorRetryCount,
+ isRecovering: AppState.isRecovering
+ });
+
+ AppState.streamMode = 'placeholder';
+
+ if (AppState.recoveryInterval) {
+ clearInterval(AppState.recoveryInterval);
+ AppState.recoveryInterval = null;
+ }
+ if (AppState.recoveryTimeout) {
+ clearTimeout(AppState.recoveryTimeout);
+ AppState.recoveryTimeout = null;
+ }
+
+ updateStreamStatus('SWITCHING...', '#ffc107');
+
+ try {
+ AppState.player.pause();
+ AppState.player.reset();
+ enforceSavedVolume();
+
+ setTimeout(() => {
+ AppState.player.src({
+ src: 'placeholder.mp4',
+ type: 'video/mp4'
+ });
+
+ AppState.player.load();
+ setTimeout(() => enforceSavedVolume(), 200);
+
+ AppState.player.play()
+ .then(() => {
+ AppLogger.log('Placeholder video started successfully');
+ const savedVolume = localStorage.getItem('playerVolume') || AppConfig.video.defaultVolume.toString();
+ const targetVolume = parseFloat(savedVolume);
+ AppState.player.volume(targetVolume);
+ updateStreamStatus('PLACEHOLDER', '#17a2b8');
+ UIControls.showToast('Stream offline - showing placeholder video');
+ startRecoveryChecks();
+ })
+ .catch(e => {
+ AppLogger.log('Placeholder play failed:', e);
+ updateStreamStatus('PLACEHOLDER', '#17a2b8');
+ UIControls.showToast('Stream offline - showing placeholder video');
+ startRecoveryChecks();
+ });
+
+ }, 1000);
+
+ } catch (e) {
+ AppLogger.log('Error switching to placeholder:', e);
+ updateStreamStatus('PLACEHOLDER', '#17a2b8');
+ startRecoveryChecks();
+ }
+ }
+
+ function switchToLive() {
+ if (AppState.streamMode === 'live') {
+ AppLogger.log('â ī¸ Already in live mode, ignoring switch request');
+ return;
+ }
+
+ AppLogger.log('đ START: Switching back to live stream');
+ AppLogger.log('đ State before switch:', {
+ streamMode: AppState.streamMode,
+ isRecovering: AppState.isRecovering
+ });
+
+ AppState.streamMode = 'live';
+ AppState.isRecovering = false;
+
+ if (AppState.recoveryInterval) {
+ clearInterval(AppState.recoveryInterval);
+ AppState.recoveryInterval = null;
+ }
+
+ updateStreamStatus('SWITCHING...', '#ffc107');
+
+ try {
+ AppState.player.pause();
+ AppState.player.reset();
+ enforceSavedVolume();
+
+ setTimeout(() => {
+ AppState.player.src({
+ src: '?proxy=stream',
+ type: 'application/x-mpegURL'
+ });
+
+ AppState.errorRetryCount = 0;
+ AppState.isRetrying = false;
+
+ AppState.player.load();
+ setTimeout(() => enforceSavedVolume(), 200);
+
+ AppState.player.play()
+ .then(() => {
+ AppLogger.log('Live stream started successfully');
+ const savedVolume = localStorage.getItem('playerVolume') || AppConfig.video.defaultVolume.toString();
+ const targetVolume = parseFloat(savedVolume);
+ AppState.player.volume(targetVolume);
+ updateStreamStatus('RECONNECTING...', '#ffc107');
+ UIControls.showToast('Stream back online - switching to live');
+
+ setTimeout(() => {
+ if (AppState.streamMode === 'live') {
+ updateStreamStatus('LIVE', 'var(--dodgers-red)');
+ }
+ }, 2000);
+ })
+ .catch(e => {
+ AppLogger.log('Live stream play failed:', e);
+ const savedVolume = localStorage.getItem('playerVolume') || AppConfig.video.defaultVolume.toString();
+ const targetVolume = parseFloat(savedVolume);
+ AppState.player.volume(targetVolume);
+ updateStreamStatus('LIVE', 'var(--dodgers-red)');
+ UIControls.showToast('Stream back online - switching to live');
+ });
+
+ }, 1000);
+
+ } catch (e) {
+ AppLogger.log('Error switching to live:', e);
+ AppState.player.src({ src: '?proxy=stream', type: 'application/x-mpegURL' });
+ AppState.player.load();
+ }
+ }
+
+ // Recovery check system
+ function startRecoveryChecks() {
+ AppLogger.log('đ Starting fast recovery checks every 2 seconds for 5 checks');
+
+ let recoveryCheckCount = 0;
+ let recoveryTimeout;
+
+ function checkRecovery() {
+ if (AppState.streamMode !== 'placeholder' || recoveryCheckCount >= AppConfig.video.recoveryCheckCount) {
+ if (recoveryCheckCount >= AppConfig.video.recoveryCheckCount) {
+ AppLogger.log('âšī¸ Fast recovery checks complete - stream still offline');
+ }
+ return;
+ }
+
+ recoveryCheckCount++;
+ AppLogger.log(`đ Recovery check ${recoveryCheckCount}/${AppConfig.video.recoveryCheckCount}...`);
+
+ API.checkStreamStatus()
+ .then(data => {
+ if (data.online && AppState.streamMode === 'placeholder') {
+ AppLogger.log('â
Stream back online during recovery check - switching');
+ AppState.isRecovering = true;
+ switchToLive();
+ } else {
+ if (recoveryCheckCount < AppConfig.video.recoveryCheckCount) {
+ recoveryTimeout = setTimeout(checkRecovery, AppConfig.video.recoveryCheckInterval);
+ }
+ }
+ })
+ .catch(error => {
+ AppLogger.log(`â Recovery check ${recoveryCheckCount} failed:`, error);
+ if (recoveryCheckCount < AppConfig.video.recoveryCheckCount) {
+ recoveryTimeout = setTimeout(checkRecovery, AppConfig.video.recoveryCheckInterval);
+ }
+ });
+ }
+
+ checkRecovery();
+ }
+
+ // Manual recovery function
+ function manualRecovery() {
+ AppLogger.log('đ Manual recovery triggered by user');
+ AppState.errorRetryCount = 0;
+ AppState.isRetrying = false;
+
+ UIControls.showToast('đ Manual stream refresh triggered');
+
+ AppState.player.reset();
+ AppState.player.error(null);
+
+ AppState.player.src({
+ src: '?proxy=stream',
+ type: 'application/x-mpegURL'
+ });
+
+ AppState.player.load();
+
+ setTimeout(() => {
+ AppState.player.play().catch(e => AppLogger.log('Manual recovery play failed:', e));
+ }, 500);
+ }
+
+ // Public API
+ window.VideoPlayer = {
+ initializePlayer: initializePlayer,
+ switchToPlaceholder: switchToPlaceholder,
+ switchToLive: switchToLive,
+ manualRecovery: manualRecovery,
+ updateStreamStatus: updateStreamStatus
+ };
+
+ // Initialize player when DOM and Video.js are ready
+ document.addEventListener('DOMContentLoaded', function() {
+ // Check if Video.js is loaded
+ if (typeof videojs === 'undefined') {
+ AppLogger.error('Video.js not found - video player will not initialize');
+ return;
+ }
+
+ // Small delay to ensure DOM is fully ready
+ setTimeout(initializePlayer, 50);
+ });
+
+ AppLogger.log('Video Player module loaded');
+
+})();