Footer enhancements and performance optimizations #9
14 changed files with 196 additions and 92 deletions
17
CLAUDE.md
17
CLAUDE.md
|
@ -205,6 +205,7 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
|
||||||
- **Audio Control**: Browser sources created with "Control Audio via OBS" enabled and auto-muted
|
- **Audio Control**: Browser sources created with "Control Audio via OBS" enabled and auto-muted
|
||||||
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
|
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
|
||||||
- **Team Association**: Streams organized under teams with proper naming conventions
|
- **Team Association**: Streams organized under teams with proper naming conventions
|
||||||
|
- **Active Source Detection**: Properly reads current active sources from text files on page load and navigation
|
||||||
|
|
||||||
### Team & Group Management
|
### Team & Group Management
|
||||||
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
|
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
|
||||||
|
@ -213,12 +214,13 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
|
||||||
- Shared team text source
|
- Shared team text source
|
||||||
- All associated stream scenes and sources
|
- All associated stream scenes and sources
|
||||||
- All browser sources with team prefix
|
- All browser sources with team prefix
|
||||||
- **Sync Verification**: Real-time verification of database-OBS group synchronization
|
- **Sync Verification**: Real-time verification of database-OBS group synchronization with system scene bypass
|
||||||
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
|
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
|
||||||
- **Visual Indicators**: Clear status indicators for group linking and sync problems
|
- **Visual Indicators**: Clear status indicators for group linking and sync problems
|
||||||
- 🆔 "Linked by UUID" - Group tracked by reliable UUID
|
- 🆔 "Linked by UUID" - Group tracked by reliable UUID
|
||||||
- 📝 "Name changed in OBS" - Group renamed in OBS, database needs update
|
- 📝 "Name changed in OBS" - Group renamed in OBS, database needs update
|
||||||
- ⚠️ "Not found in OBS" - Group in database but missing from OBS
|
- ⚠️ "Not found in OBS" - Group in database but missing from OBS
|
||||||
|
- **System Scene Protection**: Infrastructure scenes (1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies, Resources) excluded from orphaned cleanup
|
||||||
|
|
||||||
### User Experience Improvements
|
### User Experience Improvements
|
||||||
- **Toast Notifications**: Real-time feedback for all operations (success/error/info)
|
- **Toast Notifications**: Real-time feedback for all operations (success/error/info)
|
||||||
|
@ -227,13 +229,22 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
|
||||||
- **Responsive Design**: Mobile-friendly interface with glass morphism styling
|
- **Responsive Design**: Mobile-friendly interface with glass morphism styling
|
||||||
- **Loading States**: Clear indicators during API operations
|
- **Loading States**: Clear indicators during API operations
|
||||||
- **Error Recovery**: Graceful error handling with user-friendly messages
|
- **Error Recovery**: Graceful error handling with user-friendly messages
|
||||||
|
- **Enhanced Footer**: Real-time team/stream counts, OBS connection status with visual indicators
|
||||||
|
- **Optimistic Updates**: Immediate UI feedback with proper stream group name matching
|
||||||
|
|
||||||
|
### OBS Integration Improvements
|
||||||
|
- **Text Size**: Team name overlays use 96pt font for better visibility
|
||||||
|
- **Color Display**: Fixed background color display (#002b4b) using proper ABGR format
|
||||||
|
- **Standardized APIs**: All endpoints use consistent `{ success: true, data: [...] }` response format
|
||||||
|
- **Performance Optimization**: Reduced code duplication and improved API response handling
|
||||||
|
|
||||||
### Developer Experience
|
### Developer Experience
|
||||||
- **Type Safety**: Comprehensive TypeScript definitions throughout
|
- **Type Safety**: Comprehensive TypeScript definitions throughout
|
||||||
- **API Documentation**: Well-documented endpoints with clear parameter validation
|
- **API Standardization**: Consistent response formats across all endpoints with proper error handling
|
||||||
- **Migration Scripts**: Database migration tools for schema updates
|
- **Migration Scripts**: Database migration tools for schema updates
|
||||||
- **Security**: Input validation, sanitization, and secure API design
|
- **Security**: Input validation, sanitization, and secure API design
|
||||||
- **Testing**: Comprehensive error handling and edge case management
|
- **Performance Monitoring**: Smart polling with visibility detection and performance tracking
|
||||||
|
- **Code Optimization**: Eliminated redundancies and consolidated common patterns
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ A professional [Next.js](https://nextjs.org) web application for managing live s
|
||||||
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
|
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
|
||||||
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
|
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
|
||||||
- **UUID-based Tracking**: Robust OBS group synchronization with rename-safe tracking
|
- **UUID-based Tracking**: Robust OBS group synchronization with rename-safe tracking
|
||||||
|
- **Enhanced Footer**: Real-time team/stream counts and OBS connection status
|
||||||
|
- **Optimized Performance**: Reduced code duplication and standardized API responses
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
25
app/api/counts/route.ts
Normal file
25
app/api/counts/route.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '../../../lib/database';
|
||||||
|
import { TABLE_NAMES } from '../../../lib/constants';
|
||||||
|
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
|
||||||
|
|
||||||
|
async function getCountsHandler() {
|
||||||
|
try {
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Get counts in parallel
|
||||||
|
const [streamsResult, teamsResult] = await Promise.all([
|
||||||
|
db.get(`SELECT COUNT(*) as count FROM ${TABLE_NAMES.STREAMS}`),
|
||||||
|
db.get(`SELECT COUNT(*) as count FROM ${TABLE_NAMES.TEAMS}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
streams: streamsResult.count,
|
||||||
|
teams: teamsResult.count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createDatabaseError('fetch counts', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withErrorHandling(getCountsHandler);
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
// import config from '../../../config';
|
import { createSuccessResponse, createErrorResponse, withErrorHandling } from '../../../lib/apiHelpers';
|
||||||
|
|
||||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
|
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
|
@ -10,7 +10,7 @@ if (!fs.existsSync(FILE_DIRECTORY)) {
|
||||||
}
|
}
|
||||||
console.log('using', FILE_DIRECTORY)
|
console.log('using', FILE_DIRECTORY)
|
||||||
|
|
||||||
export async function GET() {
|
async function getActiveHandler() {
|
||||||
try {
|
try {
|
||||||
const largePath = path.join(FILE_DIRECTORY, 'large.txt');
|
const largePath = path.join(FILE_DIRECTORY, 'large.txt');
|
||||||
const leftPath = path.join(FILE_DIRECTORY, 'left.txt');
|
const leftPath = path.join(FILE_DIRECTORY, 'left.txt');
|
||||||
|
@ -20,38 +20,27 @@ export async function GET() {
|
||||||
const bottomLeftPath = path.join(FILE_DIRECTORY, 'bottomLeft.txt');
|
const bottomLeftPath = path.join(FILE_DIRECTORY, 'bottomLeft.txt');
|
||||||
const bottomRightPath = path.join(FILE_DIRECTORY, 'bottomRight.txt');
|
const bottomRightPath = path.join(FILE_DIRECTORY, 'bottomRight.txt');
|
||||||
|
|
||||||
const tankPath = path.join(FILE_DIRECTORY, 'tank.txt');
|
const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8').trim() : null;
|
||||||
const treePath = path.join(FILE_DIRECTORY, 'tree.txt');
|
const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8').trim() : null;
|
||||||
const kittyPath = path.join(FILE_DIRECTORY, 'kitty.txt');
|
const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8').trim() : null;
|
||||||
const chickenPath = path.join(FILE_DIRECTORY, 'chicken.txt');
|
const topLeft = fs.existsSync(topLeftPath) ? fs.readFileSync(topLeftPath, 'utf-8').trim() : null;
|
||||||
|
const topRight = fs.existsSync(topRightPath) ? fs.readFileSync(topRightPath, 'utf-8').trim() : null;
|
||||||
|
const bottomLeft = fs.existsSync(bottomLeftPath) ? fs.readFileSync(bottomLeftPath, 'utf-8').trim() : null;
|
||||||
|
const bottomRight = fs.existsSync(bottomRightPath) ? fs.readFileSync(bottomRightPath, 'utf-8').trim() : null;
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
large,
|
||||||
const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8') : null;
|
left,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
right,
|
||||||
const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8') : null;
|
topLeft,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
topRight,
|
||||||
const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8') : null;
|
bottomLeft,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
bottomRight
|
||||||
const topLeft = fs.existsSync(topLeftPath) ? fs.readFileSync(topLeftPath, 'utf-8') : null;
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const topRight = fs.existsSync(topRightPath) ? fs.readFileSync(topRightPath, 'utf-8') : null;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const bottomLeft = fs.existsSync(bottomLeftPath) ? fs.readFileSync(bottomLeftPath, 'utf-8') : null;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const bottomRight = fs.existsSync(bottomRightPath) ? fs.readFileSync(bottomRightPath, 'utf-8') : null;
|
|
||||||
|
|
||||||
const tank = fs.existsSync(tankPath) ? fs.readFileSync(tankPath, 'utf-8') : null;
|
|
||||||
const tree = fs.existsSync(treePath) ? fs.readFileSync(treePath, 'utf-8') : null;
|
|
||||||
const kitty = fs.existsSync(kittyPath) ? fs.readFileSync(kittyPath, 'utf-8') : null;
|
|
||||||
const chicken = fs.existsSync(chickenPath) ? fs.readFileSync(chickenPath, 'utf-8') : null;
|
|
||||||
|
|
||||||
// For SaT
|
|
||||||
return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201})
|
|
||||||
// return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading active sources:', error);
|
console.error('Error reading active sources:', error);
|
||||||
return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500});
|
return createErrorResponse('Failed to read active sources', 500, 'Could not read source files', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
export const GET = withErrorHandling(getActiveHandler);
|
|
@ -1,13 +1,20 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../lib/database';
|
import { getDatabase } from '../../../lib/database';
|
||||||
import { Stream } from '@/types';
|
import { StreamWithTeam } from '@/types';
|
||||||
import { TABLE_NAMES } from '../../../lib/constants';
|
import { TABLE_NAMES } from '../../../lib/constants';
|
||||||
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
|
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
|
||||||
|
|
||||||
async function getStreamsHandler() {
|
async function getStreamsHandler() {
|
||||||
try {
|
try {
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`);
|
const streams: StreamWithTeam[] = await db.all(`
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
t.team_name,
|
||||||
|
t.group_name
|
||||||
|
FROM ${TABLE_NAMES.STREAMS} s
|
||||||
|
LEFT JOIN ${TABLE_NAMES.TEAMS} t ON s.team_id = t.team_id
|
||||||
|
`);
|
||||||
return createSuccessResponse(streams);
|
return createSuccessResponse(streams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createDatabaseError('fetch streams', error);
|
return createDatabaseError('fetch streams', error);
|
||||||
|
|
|
@ -12,7 +12,8 @@ const SYSTEM_SCENES: string[] = [
|
||||||
'Starting',
|
'Starting',
|
||||||
'Ending',
|
'Ending',
|
||||||
'Audio',
|
'Audio',
|
||||||
'Movies'
|
'Movies',
|
||||||
|
'Resources'
|
||||||
];
|
];
|
||||||
|
|
||||||
interface OBSScene {
|
interface OBSScene {
|
||||||
|
|
|
@ -65,8 +65,7 @@ export default function EditStream() {
|
||||||
team_id: streamData.team_id,
|
team_id: streamData.team_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle both old and new API response formats
|
const teams = teamsData.data;
|
||||||
const teams = teamsData.success ? teamsData.data : teamsData;
|
|
||||||
|
|
||||||
// Map teams for dropdown
|
// Map teams for dropdown
|
||||||
setTeams(
|
setTeams(
|
||||||
|
|
|
@ -40,6 +40,30 @@ body {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Indicator Dots */
|
||||||
|
.status-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background-color: #859900; /* Solarized green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background-color: #dc322f; /* Solarized red */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.streaming {
|
||||||
|
background-color: #dc322f; /* Solarized red */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.idle {
|
||||||
|
background-color: #586e75; /* Solarized base01 */
|
||||||
|
}
|
||||||
|
|
||||||
/* Glass Card Component */
|
/* Glass Card Component */
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(7, 54, 66, 0.4);
|
background: rgba(7, 54, 66, 0.4);
|
||||||
|
|
17
app/page.tsx
17
app/page.tsx
|
@ -7,17 +7,12 @@ import { useToast } from '@/lib/useToast';
|
||||||
import { ToastContainer } from '@/components/Toast';
|
import { ToastContainer } from '@/components/Toast';
|
||||||
import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance';
|
import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance';
|
||||||
|
|
||||||
type Stream = {
|
import { StreamWithTeam } from '@/types';
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
obs_source_name: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [streams, setStreams] = useState<Stream[]>([]);
|
const [streams, setStreams] = useState<StreamWithTeam[]>([]);
|
||||||
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
|
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
|
||||||
large: null,
|
large: null,
|
||||||
left: null,
|
left: null,
|
||||||
|
@ -80,8 +75,8 @@ export default function Home() {
|
||||||
activeRes.json()
|
activeRes.json()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setStreams(streamsData);
|
setStreams(streamsData.data);
|
||||||
setActiveSources(activeData);
|
setActiveSources(activeData.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error('Error fetching data:', error);
|
||||||
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
||||||
|
@ -98,9 +93,9 @@ export default function Home() {
|
||||||
const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => {
|
const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => {
|
||||||
const selectedStream = streams.find((stream) => stream.id === id);
|
const selectedStream = streams.find((stream) => stream.id === id);
|
||||||
|
|
||||||
// Generate stream group name for optimistic updates
|
// Generate stream group name for optimistic updates - must match obsClient.js format
|
||||||
const streamGroupName = selectedStream
|
const streamGroupName = selectedStream
|
||||||
? `${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream`
|
? `${selectedStream.team_name?.toLowerCase().replace(/\s+/g, '_') || 'unknown'}_${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Update local state immediately for optimistic updates
|
// Update local state immediately for optimistic updates
|
||||||
|
|
|
@ -40,9 +40,8 @@ export default function AddStream() {
|
||||||
const teamsData = await teamsResponse.json();
|
const teamsData = await teamsResponse.json();
|
||||||
const streamsData = await streamsResponse.json();
|
const streamsData = await streamsResponse.json();
|
||||||
|
|
||||||
// Handle both old and new API response formats
|
const teams = teamsData.data;
|
||||||
const teams = teamsData.success ? teamsData.data : teamsData;
|
const streams = streamsData.data;
|
||||||
const streams = streamsData.success ? streamsData.data : streamsData;
|
|
||||||
|
|
||||||
// Map the API data to the format required by the Dropdown
|
// Map the API data to the format required by the Dropdown
|
||||||
setTeams(
|
setTeams(
|
||||||
|
|
|
@ -41,9 +41,7 @@ export default function Teams() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams');
|
const res = await fetch('/api/teams');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Handle both old and new API response formats
|
setTeams(data.data);
|
||||||
const teams = data.success ? data.data : data;
|
|
||||||
setTeams(teams);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching teams:', error);
|
console.error('Error fetching teams:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -19,8 +19,14 @@ type OBSStatus = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Counts = {
|
||||||
|
streams: number;
|
||||||
|
teams: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const [obsStatus, setObsStatus] = useState<OBSStatus | null>(null);
|
const [obsStatus, setObsStatus] = useState<OBSStatus | null>(null);
|
||||||
|
const [counts, setCounts] = useState<Counts | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Smart polling with performance monitoring and visibility detection
|
// Smart polling with performance monitoring and visibility detection
|
||||||
|
@ -40,10 +46,23 @@ export default function Footer() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCounts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/counts');
|
||||||
|
const data = await response.json();
|
||||||
|
setCounts(data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch counts:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Use smart polling that respects page visibility and adapts interval based on connection status
|
// Use smart polling that respects page visibility and adapts interval based on connection status
|
||||||
const pollingInterval = obsStatus?.connected ? 15000 : 30000; // Poll faster when connected
|
const pollingInterval = obsStatus?.connected ? 15000 : 30000; // Poll faster when connected
|
||||||
useSmartPolling(fetchOBSStatus, pollingInterval, [obsStatus?.connected]);
|
useSmartPolling(fetchOBSStatus, pollingInterval, [obsStatus?.connected]);
|
||||||
|
|
||||||
|
// Poll counts less frequently (every 60 seconds) since they don't change as often
|
||||||
|
useSmartPolling(fetchCounts, 60000, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<footer className="glass p-6 mt-8">
|
<footer className="glass p-6 mt-8">
|
||||||
|
@ -60,29 +79,58 @@ export default function Footer() {
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
{/* Connection Status */}
|
{/* Connection Status */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<h3 className="font-semibold mb-4">OBS Studio</h3>
|
||||||
<div className={`w-3 h-3 rounded-full ${obsStatus?.connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div>
|
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`}></div>
|
||||||
<h3 className="font-semibold">OBS Studio</h3>
|
<span className="text-sm">
|
||||||
<p className="text-sm opacity-60">
|
|
||||||
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{obsStatus && (
|
{obsStatus && (
|
||||||
<div className="text-sm opacity-80">
|
<div className="text-sm opacity-80">
|
||||||
<div>{obsStatus.host}:{obsStatus.port}</div>
|
<div>{obsStatus.host}:{obsStatus.port}</div>
|
||||||
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
||||||
|
|
||||||
|
{/* Streaming/Recording Status */}
|
||||||
|
{obsStatus.connected && (
|
||||||
|
<div className="flex gap-4 mt-4">
|
||||||
|
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
||||||
|
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||||
|
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
||||||
|
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||||
|
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Status */}
|
{/* Live Status or Database Stats */}
|
||||||
{obsStatus?.connected && (
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-4">Live Status</h3>
|
<h3 className="font-semibold mb-4">
|
||||||
|
{obsStatus?.connected ? 'Live Status' : 'Database Stats'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Show counts when OBS is disconnected */}
|
||||||
|
{!obsStatus?.connected && counts && (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Teams:</span>
|
||||||
|
<span className="font-medium">{counts.teams}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Streams:</span>
|
||||||
|
<span className="font-medium">{counts.streams}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{obsStatus?.connected && (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{obsStatus.currentScene && (
|
{obsStatus.currentScene && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
@ -98,21 +146,23 @@ export default function Footer() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4 mt-4">
|
{/* Database counts */}
|
||||||
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
{counts && (
|
||||||
<div className={`w-2 h-2 rounded-full ${obsStatus.streaming ? 'bg-red-500' : 'bg-gray-500'}`}></div>
|
<>
|
||||||
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
<div className="flex justify-between">
|
||||||
</div>
|
<span>Teams:</span>
|
||||||
|
<span className="font-medium">{counts.teams}</span>
|
||||||
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
|
||||||
<div className={`w-2 h-2 rounded-full ${obsStatus.recording ? 'bg-red-500' : 'bg-gray-500'}`}></div>
|
|
||||||
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Streams:</span>
|
||||||
|
<span className="font-medium">{counts.streams}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{obsStatus?.error && (
|
{obsStatus?.error && (
|
||||||
|
|
|
@ -323,7 +323,7 @@ async function createTextSource(sceneName, textSourceName, text) {
|
||||||
text,
|
text,
|
||||||
font: {
|
font: {
|
||||||
face: 'Arial',
|
face: 'Arial',
|
||||||
size: 72,
|
size: 96,
|
||||||
style: 'Bold'
|
style: 'Bold'
|
||||||
},
|
},
|
||||||
color: 0xFFFFFFFF, // White text
|
color: 0xFFFFFFFF, // White text
|
||||||
|
@ -352,7 +352,7 @@ async function createTextSource(sceneName, textSourceName, text) {
|
||||||
text,
|
text,
|
||||||
font: {
|
font: {
|
||||||
face: 'Arial',
|
face: 'Arial',
|
||||||
size: 72,
|
size: 96,
|
||||||
style: 'Bold'
|
style: 'Bold'
|
||||||
},
|
},
|
||||||
color: 0xFFFFFFFF, // White text
|
color: 0xFFFFFFFF, // White text
|
||||||
|
|
|
@ -38,13 +38,17 @@ export function useThrottle<T extends (...args: unknown[]) => unknown>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized stream lookup utilities
|
// Memoized stream lookup utilities
|
||||||
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) {
|
||||||
const sourceToIdMap = new Map<string, number>();
|
const sourceToIdMap = new Map<string, number>();
|
||||||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>();
|
||||||
|
|
||||||
streams.forEach(stream => {
|
streams.forEach(stream => {
|
||||||
// Generate stream group name to match what's written to files
|
// Generate stream group name to match what's written to files
|
||||||
const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`;
|
// Format: {team_name}_{stream_name}_stream (matching obsClient.js logic)
|
||||||
|
const cleanTeamName = stream.team_name ? stream.team_name.toLowerCase().replace(/\s+/g, '_') : 'unknown';
|
||||||
|
const cleanStreamName = stream.name.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const streamGroupName = `${cleanTeamName}_${cleanStreamName}_stream`;
|
||||||
|
|
||||||
sourceToIdMap.set(streamGroupName, stream.id);
|
sourceToIdMap.set(streamGroupName, stream.id);
|
||||||
idToStreamMap.set(stream.id, stream);
|
idToStreamMap.set(stream.id, stream);
|
||||||
});
|
});
|
||||||
|
@ -53,7 +57,7 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook version for React components
|
// Hook version for React components
|
||||||
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return createStreamLookupMaps(streams);
|
return createStreamLookupMaps(streams);
|
||||||
}, [streams]);
|
}, [streams]);
|
||||||
|
@ -61,7 +65,7 @@ export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name
|
||||||
|
|
||||||
// Efficient active source lookup
|
// Efficient active source lookup
|
||||||
export function useActiveSourceLookup(
|
export function useActiveSourceLookup(
|
||||||
streams: Array<{ id: number; obs_source_name: string; name: string }>,
|
streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>,
|
||||||
activeSources: Record<string, string | null>
|
activeSources: Record<string, string | null>
|
||||||
) {
|
) {
|
||||||
const { sourceToIdMap } = useStreamLookupMaps(streams);
|
const { sourceToIdMap } = useStreamLookupMaps(streams);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue