diff --git a/UI_UPDATE.MD b/UI_UPDATE.MD index 3bff8cb..7e8623e 100644 --- a/UI_UPDATE.MD +++ b/UI_UPDATE.MD @@ -54,40 +54,40 @@ Always come back and update UI_UPDATE.MD once complete with task and task item. ### Sub-task 1.2: JavaScript Separation and Organization #### 1.2.1: JavaScript Code Identification -- [ ] Locate all JavaScript code within ` - + + + + + 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'); + +})();