Reorganize assets into static/css and static/js folders for better file organization

This commit is contained in:
VinnyNC 2025-09-28 22:29:30 -04:00
parent 7d2931b7c9
commit 14bc8e6f44
11 changed files with 10 additions and 10 deletions

581
static/css/components.css Normal file
View file

@ -0,0 +1,581 @@
/* Component styles - Reusable UI components */
/* =================================================================
VIDEO HEADER - Top section of video area
================================================================= */
.video-header {
background: linear-gradient(135deg, var(--dodgers-blue-700) 0%, var(--dodgers-blue-500) 100%);
padding: var(--spacing-3) var(--spacing-5);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-dodgers-md);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: var(--spacing-5);
}
.logo {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.logo::before {
content: "⚾";
font-size: var(--font-size-2xl);
}
/* =================================================================
STREAM STATS - Viewer count and badges
================================================================= */
.stream-stats {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.viewer-count {
background: rgba(255,255,255,0.2);
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--border-radius-2xl);
color: white;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
display: flex;
align-items: center;
gap: var(--spacing-2);
backdrop-filter: blur(10px);
}
.viewer-count::before {
content: "👥";
}
.stream-badge {
background: var(--dodgers-red);
color: white;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--border-radius-2xl);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
animation: pulse 2s infinite;
}
/* =================================================================
HEADER CONTROLS - Buttons and selectors
================================================================= */
.header-controls {
display: flex;
gap: var(--spacing-3);
align-items: center;
}
/* Ensure all header control buttons have identical dimensions */
.header-controls > * {
height: 35px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
vertical-align: top;
line-height: 1;
}
.quality-selector {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--border-radius-2xl);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
backdrop-filter: blur(10px);
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
min-height: auto;
}
.toggle-chat-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--border-radius-2xl);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
transition: all var(--transition-base);
backdrop-filter: blur(10px);
position: relative;
}
.toggle-chat-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-1px);
}
.manual-refresh-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--border-radius-2xl);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
transition: all var(--transition-base);
backdrop-filter: blur(10px);
position: relative;
}
.manual-refresh-btn:hover {
background: rgba(255,255,255,0.3);
transform: translateY(-1px);
}
/* =================================================================
CHAT HEADER
================================================================= */
.chat-header {
background: linear-gradient(135deg, var(--dodgers-blue-500) 0%, var(--dodgers-blue-300) 100%);
padding: var(--spacing-4) var(--spacing-5);
color: white;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-header-left {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.chat-header-left::before {
content: "💬";
font-size: var(--font-size-lg);
}
.admin-controls {
display: flex;
gap: var(--spacing-2);
}
.admin-btn {
background: rgba(255,215,0,0.3);
border: 1px solid var(--dodgers-gold);
color: white;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--border-radius-xl);
font-size: var(--font-size-xs);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-btn:hover {
background: rgba(255,215,0,0.5);
}
/* =================================================================
USER INFO SECTION
================================================================= */
.user-info {
padding: var(--spacing-3) var(--spacing-4);
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.user-id-display {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: var(--spacing-2);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.id-badge {
background: var(--dodgers-blue-500);
color: white;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-xl);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
}
.id-badge.admin {
background: linear-gradient(135deg, var(--dodgers-gold), #FFB700);
color: #333;
}
.nickname-input {
position: relative;
}
.nickname-input input {
width: 100%;
padding: var(--spacing-3) var(--spacing-3);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
transition: all var(--transition-base);
background: var(--input-bg);
color: var(--text-primary);
}
.nickname-input input::placeholder {
color: var(--text-muted);
}
.nickname-input input:focus {
outline: none;
border-color: var(--dodgers-blue-500);
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* =================================================================
CHAT MESSAGES CONTAINER
================================================================= */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
background: var(--bg-dark);
}
/* =================================================================
MESSAGE COMPONENTS
================================================================= */
.message {
margin-bottom: var(--spacing-4);
animation: slideIn var(--transition-slow);
position: relative;
}
.message:hover .message-actions {
opacity: 1;
}
.message-actions {
position: absolute;
right: 0;
top: 0;
opacity: 0;
transition: opacity var(--transition-fast);
display: flex;
gap: var(--spacing-1);
}
.delete-btn, .ban-btn {
background: rgba(239,62,66,0.9);
color: white;
border: none;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
cursor: pointer;
}
.ban-btn {
background: rgba(255,152,0,0.9);
}
.message-header {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
margin-bottom: var(--spacing-1);
flex-wrap: wrap;
}
.message-nickname {
font-weight: var(--font-weight-semibold);
color: var(--dodgers-blue-500);
font-size: var(--font-size-sm);
}
.message-nickname.admin {
color: var(--dodgers-gold);
text-shadow: 0 0 2px rgba(255,215,0,0.5);
}
.message-id {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-family: var(--font-family-mono);
background: var(--border-color);
padding: 1px var(--spacing-1);
border-radius: var(--border-radius-xl);
}
.message-time {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-left: auto;
}
.message-text {
padding: var(--spacing-2) var(--spacing-3);
background: var(--card-bg);
border-radius: var(--border-radius-lg);
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
box-shadow: var(--shadow-sm);
border-left: 3px solid transparent;
color: var(--text-primary);
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
.own-message .message-nickname {
color: var(--dodgers-red-500);
}
.own-message .message-text {
background: rgba(0,90,156,0.2);
border-left-color: var(--dodgers-blue-500);
}
.admin-message .message-text {
background: var(--admin-bg);
border-left-color: var(--dodgers-gold);
}
.system-message {
text-align: center;
color: var(--text-muted);
font-size: var(--font-size-xs);
font-style: italic;
margin: var(--spacing-3) 0;
padding: var(--spacing-2);
background: rgba(0,90,156,0.1);
border-radius: var(--border-radius-md);
}
.typing-indicator {
padding: 0 var(--spacing-4) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--text-muted);
font-style: italic;
display: none;
}
.typing-indicator.active {
display: block;
}
.typing-indicator span {
animation: blink 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
/* =================================================================
CHAT INPUT AREA
================================================================= */
.chat-input {
padding: var(--spacing-3) var(--spacing-4);
background: var(--card-bg);
border-top: 1px solid var(--border-color);
}
.input-group {
display: flex;
gap: var(--spacing-2);
}
.chat-input input {
flex: 1;
padding: var(--spacing-3) var(--spacing-4);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-2xl);
font-size: var(--font-size-sm);
transition: all var(--transition-base);
background: var(--input-bg);
color: var(--text-primary);
}
.chat-input input::placeholder {
color: var(--text-muted);
}
.chat-input input:focus {
outline: none;
border-color: var(--dodgers-blue-500);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.chat-input button {
padding: var(--spacing-3) var(--spacing-5);
background: linear-gradient(135deg, var(--dodgers-blue-500), var(--dodgers-blue-300));
color: white;
border: none;
border-radius: var(--border-radius-2xl);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-dodgers-sm);
}
.chat-input button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-dodgers-md);
}
.chat-input button:active {
transform: translateY(0);
}
.empty-chat {
text-align: center;
color: var(--text-muted);
padding: var(--spacing-12) var(--spacing-5);
font-size: var(--font-size-sm);
}
/* =================================================================
CONNECTION STATUS
================================================================= */
.connection-status {
padding: var(--spacing-2);
background: var(--bg-darker);
text-align: center;
font-size: var(--font-size-xs);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
border-top: 1px solid var(--border-color);
}
.status-dot {
width: var(--spacing-2);
height: var(--spacing-2);
border-radius: 50%;
background: #4caf50;
animation: blink 2s infinite;
}
.status-dot.offline {
background: #f44336;
animation: none;
}
/* =================================================================
NOTIFICATIONS & TOASTS
================================================================= */
.toast {
position: fixed;
bottom: var(--spacing-5);
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: white;
padding: var(--spacing-3) var(--spacing-5);
border-radius: var(--border-radius-2xl);
font-size: var(--font-size-sm);
z-index: var(--z-toast);
opacity: 0;
transition: opacity var(--transition-fast);
}
.toast.show {
opacity: 1;
}
.notification-badge {
position: absolute;
top: calc(var(--spacing-2) * -1);
right: calc(var(--spacing-2) * -1);
background: var(--dodgers-red);
color: white;
border-radius: var(--border-radius-xl);
padding: var(--spacing-1) var(--spacing-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
display: none;
animation: bounce 0.5s;
}
.notification-badge.show {
display: block;
}
/* =================================================================
FLOATING ACTION BUTTONS
================================================================= */
.fab-container {
position: fixed;
bottom: var(--spacing-5);
right: var(--spacing-5);
display: flex;
flex-direction: column;
gap: var(--spacing-3);
z-index: var(--z-fixed);
}
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--dodgers-blue-500), var(--dodgers-blue-300));
color: white;
border: none;
cursor: pointer;
box-shadow: var(--shadow-dodgers-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all var(--transition-base);
}
.fab:hover {
transform: scale(1.1);
box-shadow: var(--shadow-dodgers-lg);
}
.fab.secondary {
width: 48px;
height: 48px;
font-size: 20px;
background: rgba(0,90,156,0.9);
}

64
static/css/layout.css Normal file
View file

@ -0,0 +1,64 @@
/* Layout styles - Main structural elements */
/* =================================================================
THEATER CONTAINER - Main layout wrapper
================================================================= */
.theater-container {
display: flex;
height: 100vh;
position: relative;
transition: all var(--transition-slow);
}
/* =================================================================
VIDEO SECTION - Left side containing video player
================================================================= */
.video-section {
flex: 1;
background: #000;
position: relative;
display: flex;
flex-direction: column;
transition: all var(--transition-slow);
}
.video-section.expanded {
flex: 1;
width: 100%;
}
/* =================================================================
VIDEO WRAPPER - Contains the video element
================================================================= */
.video-wrapper {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
/* =================================================================
CHAT SECTION - Right side chat panel
================================================================= */
.chat-section {
width: 350px;
background: var(--bg-dark);
color: var(--text-primary);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-dodgers-lg);
transition: transform var(--transition-slow);
position: relative;
border-left: 1px solid var(--border-color);
}
.chat-section.collapsed {
transform: translateX(100%);
position: absolute;
right: 0;
height: 100%;
}

16
static/css/reset.css Normal file
View file

@ -0,0 +1,16 @@
/* Reset and normalize styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Base body styles */
body {
font-family: var(--font-family-primary);
background: var(--bg-darkest);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
position: relative;
}

341
static/css/utilities.css Normal file
View file

@ -0,0 +1,341 @@
/* Utility classes and animations */
/* =================================================================
KEYFRAME ANIMATIONS
================================================================= */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
@keyframes slideOut {
to {
opacity: 0;
transform: translateX(100%);
}
}
/* =================================================================
SCROLLBAR STYLING
================================================================= */
.chat-messages::-webkit-scrollbar {
width: var(--spacing-2);
}
.chat-messages::-webkit-scrollbar-track {
background: var(--bg-darker);
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--dodgers-blue-500);
border-radius: var(--border-radius-sm);
opacity: 0.6;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
opacity: 1;
}
/* =================================================================
VIDEO.JS OVERRIDES
================================================================= */
/* Video player container */
#video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Video.js skin overrides */
.video-js {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
/* Override VideoJS fluid behavior to prevent overflow */
.video-js.vjs-fluid {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
padding-top: 0 !important;
}
/* Ensure the actual video element is properly constrained */
.video-js video {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
object-fit: contain !important;
}
/* Control bar styling */
.video-js .vjs-control-bar {
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
}
/* =================================================================
FOCUSABLE ELEMENTS (Accessibility)
================================================================= */
/* Focus ring utility - can be used on any focusable element */
.focus-ring:focus {
outline: 2px solid var(--dodgers-blue-500);
outline-offset: 2px;
}
/* =================================================================
VISIBLE ONLY TO SCREEN READERS
================================================================= */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* =================================================================
FLEXBOX UTILITIES
================================================================= */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: var(--spacing-2);
}
.gap-4 {
gap: var(--spacing-4);
}
/* =================================================================
SPACING UTILITIES
================================================================= */
.p-4 {
padding: var(--spacing-4);
}
.px-4 {
padding-left: var(--spacing-4);
padding-right: var(--spacing-4);
}
.py-4 {
padding-top: var(--spacing-4);
padding-bottom: var(--spacing-4);
}
.m-4 {
margin: var(--spacing-4);
}
.mx-4 {
margin-left: var(--spacing-4);
margin-right: var(--spacing-4);
}
.my-4 {
margin-top: var(--spacing-4);
margin-bottom: var(--spacing-4);
}
/* =================================================================
TEXT UTILITIES
================================================================= */
.text-sm {
font-size: var(--font-size-sm);
}
.text-base {
font-size: var(--font-size-base);
}
.text-lg {
font-size: var(--font-size-lg);
}
.text-center {
text-align: center;
}
.font-semibold {
font-weight: var(--font-weight-semibold);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
/* =================================================================
BACKGROUND UTILITIES
================================================================= */
.bg-primary {
background-color: var(--dodgers-blue-500);
}
.bg-secondary {
background-color: var(--dodgers-red-500);
}
.bg-accent {
background-color: var(--dodgers-gold-500);
}
/* =================================================================
BORDER UTILITIES
================================================================= */
.rounded {
border-radius: var(--border-radius-base);
}
.rounded-full {
border-radius: var(--border-radius-full);
}
/* =================================================================
TRANSITIONS UTILITIES
================================================================= */
.transition {
transition: all var(--transition-base);
}
.transition-fast {
transition: all var(--transition-fast);
}
.transition-slow {
transition: all var(--transition-slow);
}
/* =================================================================
RESPONSIVE UTILITIES
================================================================= */
/* Hide elements at different breakpoints */
@media (max-width: 767px) {
.hidden-mobile {
display: none !important;
}
}
@media (min-width: 768px) {
.hidden-tablet {
display: none !important;
}
}
@media (max-width: 1023px) {
.hidden-tablet-up {
display: none !important;
}
}
@media (min-width: 1024px) {
.hidden-desktop {
display: none !important;
}
}
/* =================================================================
MOBILE RESPONSIVE OVERRIDES
================================================================= */
@media (max-width: 768px) {
.chat-section {
width: 100%;
position: absolute;
right: 0;
top: 0;
height: 100%;
z-index: var(--z-modal);
}
.video-header {
padding: var(--spacing-3) var(--spacing-4);
}
.logo {
font-size: var(--font-size-base);
}
.stream-stats {
gap: var(--spacing-3);
}
.quality-selector {
display: none;
}
}

233
static/css/variables.css Normal file
View file

@ -0,0 +1,233 @@
/* CSS Custom Properties - Dodgers Theme */
/* =================================================================
COLOR PALETTE - DODGERS BRAND COLORS
================================================================= */
:root {
/* Primary Dodgers Colors */
--dodgers-blue: #005A9C;
--dodgers-blue-50: #e6f2ff;
--dodgers-blue-100: #b3d9ff;
--dodgers-blue-200: #80bfff;
--dodgers-blue-300: #4da6ff;
--dodgers-blue-400: #1a8dff;
--dodgers-blue-500: #005A9C; /* Base */
--dodgers-blue-600: #004d85;
--dodgers-blue-700: #003d6b;
--dodgers-blue-800: #002d52;
--dodgers-blue-900: #001d38;
--dodgers-red: #EF3E42;
--dodgers-red-50: #fef2f2;
--dodgers-red-100: #fce2e2;
--dodgers-red-200: #f9c9c9;
--dodgers-red-300: #f4a8a9;
--dodgers-red-400: #ef6e70;
--dodgers-red-500: #EF3E42; /* Base */
--dodgers-red-600: #e92e31;
--dodgers-red-700: #d91c1f;
--dodgers-red-800: #c0171a;
--dodgers-red-900: #a00d10;
--dodgers-gold: #FFD700;
--dodgers-gold-50: #fffbeb;
--dodgers-gold-100: #fef3c7;
--dodgers-gold-200: #fde68a;
--dodgers-gold-300: #fcd34d;
--dodgers-gold-400: #fbbf24;
--dodgers-gold-500: #FFD700; /* Base */
--dodgers-gold-600: #f59e0b;
--dodgers-gold-700: #d97706;
--dodgers-gold-800: #b45309;
--dodgers-gold-900: #92400e;
/* Semantic Color Mappings */
--primary-color: var(--dodgers-blue);
--secondary-color: var(--dodgers-red);
--accent-color: var(--dodgers-gold);
/* Background Colors */
--bg-darkest: #050810;
--bg-darker: #0a0d14;
--bg-dark: #0f1419;
--bg-light: #15202b;
--bg-lighter: #1a2332;
--bg-lightest: #232d3a;
/* Text Colors */
--text-primary: #ffffff;
--text-secondary: #b8c5d6;
--text-muted: #8590a0;
--text-placeholder: #6b7785;
/* Interactive States */
--hover-overlay: rgba(255, 255, 255, 0.1);
--active-overlay: rgba(255, 255, 255, 0.2);
--focus-ring: rgba(0, 90, 156, 0.5);
/* Borders & Dividers */
--border-color: #1a2332;
--border-color-light: #232d3a;
--divider-color: rgba(255, 255, 255, 0.1);
/* Input & Form Elements */
--input-bg: #1a2332;
--input-border: #232d3a;
--input-focus: var(--dodgers-blue);
--input-error: var(--dodgers-red);
--input-disabled: #2a3441;
/* Cards & Surfaces */
--card-bg: #15202b;
--card-border: #1a2332;
--card-shadow: rgba(0, 0, 0, 0.3);
/* Admin Elements */
--admin-bg: rgba(255, 215, 0, 0.1);
--admin-border: var(--dodgers-gold);
--admin-text: var(--dodgers-gold);
/* Overlay & Modal */
--overlay-bg: rgba(0, 0, 0, 0.7);
--modal-bg: #15202b;
--tooltip-bg: #232d3a;
/* =================================================================
TYPOGRAPHY SCALE
================================================================= */
/* Font Families */
--font-family-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-family-mono: 'Courier New', monospace;
/* Font Sizes - Modular Scale (1.125 ratio) */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 1.875rem; /* 30px */
--font-size-4xl: 2.25rem; /* 36px */
/* Font Weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.4;
--line-height-relaxed: 1.6;
--line-height-loose: 1.8;
/* Letter Spacing */
--letter-spacing-tight: -0.025em;
--letter-spacing-normal: 0;
--letter-spacing-wide: 0.025em;
/* =================================================================
SPACING SCALE
================================================================= */
/* Spacing - Based on 4px grid */
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-5: 1.25rem; /* 20px */
--spacing-6: 1.5rem; /* 24px */
--spacing-8: 2rem; /* 32px */
--spacing-10: 2.5rem; /* 40px */
--spacing-12: 3rem; /* 48px */
--spacing-16: 4rem; /* 64px */
/* =================================================================
BORDER RADIUS SCALE
================================================================= */
--border-radius-none: 0;
--border-radius-sm: 0.25rem; /* 4px */
--border-radius-base: 0.5rem; /* 8px */
--border-radius-md: 0.75rem; /* 12px */
--border-radius-lg: 1rem; /* 16px */
--border-radius-xl: 1.5rem; /* 24px */
--border-radius-2xl: 2rem; /* 32px */
--border-radius-full: 9999px;
/* =================================================================
SHADOW SCALE
================================================================= */
--shadow-none: none;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
/* Elevated shadows for Dodgers theme */
--shadow-dodgers-sm: 0 2px 8px rgba(0, 90, 156, 0.15);
--shadow-dodgers-md: 0 4px 16px rgba(0, 90, 156, 0.2);
--shadow-dodgers-lg: 0 8px 32px rgba(0, 90, 156, 0.25);
--shadow-dodgers-xl: 0 16px 48px rgba(0, 90, 156, 0.3);
/* =================================================================
BREAKPOINT VARIABLES
================================================================= */
/* Mobile-first breakpoints */
--breakpoint-xs: 320px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
/* Media query mixins (these will be used in component CSS) */
/* xs: 0px and up */
/* sm: 640px and up */
/* md: 768px and up */
/* lg: 1024px and up */
/* xl: 1280px and up */
/* 2xl: 1536px and up */
/* =================================================================
Z-INDEX SCALE
================================================================= */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
/* =================================================================
TRANSITIONS & ANIMATIONS
================================================================= */
--transition-fast: 0.15s ease-in-out;
--transition-base: 0.2s ease-in-out;
--transition-slow: 0.3s ease-in-out;
/* Easing functions */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* =================================================================
DARK THEME OVERRIDE (Future Enhancement)
================================================================= */
/* .theme-light {
--bg-primary: var(--dodgers-blue-50);
--text-primary: #1e293b;
} */

222
static/js/api.js Normal file
View file

@ -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');
})();

116
static/js/app.js Normal file
View file

@ -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');

414
static/js/chat.js Normal file
View file

@ -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 = '<div class="system-message">Chat was cleared by admin</div>';
}
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 = '<div class="empty-chat">No messages yet. Be the first to say hello! 👋</div>';
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) ?
`<div class="message-actions">
<button class="delete-btn" onclick="Chat.deleteMessage('${msg.id}')">Delete</button>
<button class="ban-btn" onclick="Chat.banUser('${msg.user_id}')">Ban</button>
</div>` : '';
// Create admin badge
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">${escapeHtml(msg.message)}</div>
${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 = '<div class="system-message">Chat cleared by admin</div>';
}
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');
})();

282
static/js/ui-controls.js Normal file
View file

@ -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');
})();

540
static/js/video-player.js Normal file
View file

@ -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');
})();