diff --git a/CLAUDE.md b/CLAUDE.md index ad09d70..faca2d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,6 @@ 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 - **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility - **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 - **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs @@ -214,13 +213,12 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio - Shared team text source - All associated stream scenes and sources - All browser sources with team prefix -- **Sync Verification**: Real-time verification of database-OBS group synchronization with system scene bypass +- **Sync Verification**: Real-time verification of database-OBS group synchronization - **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes) - **Visual Indicators**: Clear status indicators for group linking and sync problems - 🆔 "Linked by UUID" - Group tracked by reliable UUID - 📝 "Name changed in OBS" - Group renamed in OBS, database needs update - ⚠️ "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 - **Toast Notifications**: Real-time feedback for all operations (success/error/info) @@ -229,22 +227,13 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio - **Responsive Design**: Mobile-friendly interface with glass morphism styling - **Loading States**: Clear indicators during API operations - **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 - **Type Safety**: Comprehensive TypeScript definitions throughout -- **API Standardization**: Consistent response formats across all endpoints with proper error handling +- **API Documentation**: Well-documented endpoints with clear parameter validation - **Migration Scripts**: Database migration tools for schema updates - **Security**: Input validation, sanitization, and secure API design -- **Performance Monitoring**: Smart polling with visibility detection and performance tracking -- **Code Optimization**: Eliminated redundancies and consolidated common patterns +- **Testing**: Comprehensive error handling and edge case management ## Known Issues diff --git a/README.md b/README.md index f375aa9..f83b714 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ A professional [Next.js](https://nextjs.org) web application for managing live s - **Professional Broadcasting**: Audio routing, scene management, and live status indicators - **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility - **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 diff --git a/app/api/counts/route.ts b/app/api/counts/route.ts deleted file mode 100644 index 86a638d..0000000 --- a/app/api/counts/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -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); \ No newline at end of file diff --git a/app/api/getActive/route.ts b/app/api/getActive/route.ts index a47825b..a54b4b4 100644 --- a/app/api/getActive/route.ts +++ b/app/api/getActive/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; -import { createSuccessResponse, createErrorResponse, withErrorHandling } from '../../../lib/apiHelpers'; +// import config from '../../../config'; const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files') // Ensure directory exists @@ -10,7 +10,7 @@ if (!fs.existsSync(FILE_DIRECTORY)) { } console.log('using', FILE_DIRECTORY) -async function getActiveHandler() { +export async function GET() { try { const largePath = path.join(FILE_DIRECTORY, 'large.txt'); const leftPath = path.join(FILE_DIRECTORY, 'left.txt'); @@ -20,27 +20,38 @@ async function getActiveHandler() { const bottomLeftPath = path.join(FILE_DIRECTORY, 'bottomLeft.txt'); const bottomRightPath = path.join(FILE_DIRECTORY, 'bottomRight.txt'); - const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8').trim() : null; - const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8').trim() : null; - const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8').trim() : null; - 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; + const tankPath = path.join(FILE_DIRECTORY, 'tank.txt'); + const treePath = path.join(FILE_DIRECTORY, 'tree.txt'); + const kittyPath = path.join(FILE_DIRECTORY, 'kitty.txt'); + const chickenPath = path.join(FILE_DIRECTORY, 'chicken.txt'); - return createSuccessResponse({ - large, - left, - right, - topLeft, - topRight, - bottomLeft, - bottomRight - }); - } catch (error) { + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8') : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8') : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8') : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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) { console.error('Error reading active sources:', error); - return createErrorResponse('Failed to read active sources', 500, 'Could not read source files', error); - } -} + return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500}); + } -export const GET = withErrorHandling(getActiveHandler); \ No newline at end of file +} \ No newline at end of file diff --git a/app/api/streams/route.ts b/app/api/streams/route.ts index b52a74e..ab368e3 100644 --- a/app/api/streams/route.ts +++ b/app/api/streams/route.ts @@ -1,20 +1,13 @@ import { NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; -import { StreamWithTeam } from '@/types'; +import { Stream } from '@/types'; import { TABLE_NAMES } from '../../../lib/constants'; import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers'; async function getStreamsHandler() { try { const db = await getDatabase(); - 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 - `); + const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`); return createSuccessResponse(streams); } catch (error) { return createDatabaseError('fetch streams', error); diff --git a/app/api/verifyGroups/route.ts b/app/api/verifyGroups/route.ts index 1ed5515..40b6df0 100644 --- a/app/api/verifyGroups/route.ts +++ b/app/api/verifyGroups/route.ts @@ -12,8 +12,7 @@ const SYSTEM_SCENES: string[] = [ 'Starting', 'Ending', 'Audio', - 'Movies', - 'Resources' + 'Movies' ]; interface OBSScene { diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 706fc12..4422a66 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -65,7 +65,8 @@ export default function EditStream() { team_id: streamData.team_id, }); - const teams = teamsData.data; + // Handle both old and new API response formats + const teams = teamsData.success ? teamsData.data : teamsData; // Map teams for dropdown setTeams( diff --git a/app/globals.css b/app/globals.css index e1107df..288b6ab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -40,30 +40,6 @@ body { 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 { background: rgba(7, 54, 66, 0.4); diff --git a/app/page.tsx b/app/page.tsx index 2f768aa..0e3ff66 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,12 +7,17 @@ import { useToast } from '@/lib/useToast'; import { ToastContainer } from '@/components/Toast'; import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance'; -import { StreamWithTeam } from '@/types'; +type Stream = { + id: number; + name: string; + obs_source_name: string; + url: string; +}; type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; export default function Home() { - const [streams, setStreams] = useState([]); + const [streams, setStreams] = useState([]); const [activeSources, setActiveSources] = useState>({ large: null, left: null, @@ -75,8 +80,8 @@ export default function Home() { activeRes.json() ]); - setStreams(streamsData.data); - setActiveSources(activeData.data); + setStreams(streamsData); + setActiveSources(activeData); } catch (error) { console.error('Error fetching data:', error); showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.'); @@ -93,9 +98,9 @@ export default function Home() { const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => { const selectedStream = streams.find((stream) => stream.id === id); - // Generate stream group name for optimistic updates - must match obsClient.js format + // Generate stream group name for optimistic updates const streamGroupName = selectedStream - ? `${selectedStream.team_name?.toLowerCase().replace(/\s+/g, '_') || 'unknown'}_${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream` + ? `${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream` : null; // Update local state immediately for optimistic updates diff --git a/app/streams/page.tsx b/app/streams/page.tsx index 2f23e7c..8520c19 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -40,8 +40,9 @@ export default function AddStream() { const teamsData = await teamsResponse.json(); const streamsData = await streamsResponse.json(); - const teams = teamsData.data; - const streams = streamsData.data; + // Handle both old and new API response formats + const teams = teamsData.success ? teamsData.data : teamsData; + const streams = streamsData.success ? streamsData.data : streamsData; // Map the API data to the format required by the Dropdown setTeams( diff --git a/app/teams/page.tsx b/app/teams/page.tsx index bb8684a..7b877e4 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -41,7 +41,9 @@ export default function Teams() { try { const res = await fetch('/api/teams'); const data = await res.json(); - setTeams(data.data); + // Handle both old and new API response formats + const teams = data.success ? data.data : data; + setTeams(teams); } catch (error) { console.error('Error fetching teams:', error); } finally { diff --git a/components/Footer.tsx b/components/Footer.tsx index b3f4b2f..c43a131 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -19,14 +19,8 @@ type OBSStatus = { error?: string; }; -type Counts = { - streams: number; - teams: number; -}; - export default function Footer() { const [obsStatus, setObsStatus] = useState(null); - const [counts, setCounts] = useState(null); const [isLoading, setIsLoading] = useState(true); // Smart polling with performance monitoring and visibility detection @@ -46,22 +40,9 @@ 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 const pollingInterval = obsStatus?.connected ? 15000 : 30000; // Poll faster when 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) { return ( @@ -79,58 +60,29 @@ export default function Footer() {
{/* Connection Status */}
-

OBS Studio

-
-
- - {obsStatus?.connected ? 'Connected' : 'Disconnected'} - +
+
+
+

OBS Studio

+

+ {obsStatus?.connected ? 'Connected' : 'Disconnected'} +

+
{obsStatus && (
{obsStatus.host}:{obsStatus.port}
{obsStatus.hasPassword &&
🔒 Authenticated
} - - {/* Streaming/Recording Status */} - {obsStatus.connected && ( -
-
-
- {obsStatus.streaming ? 'LIVE' : 'OFFLINE'} -
- -
-
- {obsStatus.recording ? 'REC' : 'IDLE'} -
-
- )}
)}
- {/* Live Status or Database Stats */} -
-

- {obsStatus?.connected ? 'Live Status' : 'Database Stats'} -

- - {/* Show counts when OBS is disconnected */} - {!obsStatus?.connected && counts && ( -
-
- Teams: - {counts.teams} -
-
- Streams: - {counts.streams} -
-
- )} - - {obsStatus?.connected && ( + {/* Live Status */} + {obsStatus?.connected && ( +
+

Live Status

+
{obsStatus.currentScene && (
@@ -146,22 +98,20 @@ export default function Footer() {
)} - {/* Database counts */} - {counts && ( - <> -
- Teams: - {counts.teams} -
-
- Streams: - {counts.streams} -
- - )} +
+
+
+ {obsStatus.streaming ? 'LIVE' : 'OFFLINE'} +
+ +
+
+ {obsStatus.recording ? 'REC' : 'IDLE'} +
+
- )} -
+
+ )}
{/* Error Message */} diff --git a/lib/obsClient.js b/lib/obsClient.js index 4402708..9adfe80 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -323,7 +323,7 @@ async function createTextSource(sceneName, textSourceName, text) { text, font: { face: 'Arial', - size: 96, + size: 72, style: 'Bold' }, color: 0xFFFFFFFF, // White text @@ -352,7 +352,7 @@ async function createTextSource(sceneName, textSourceName, text) { text, font: { face: 'Arial', - size: 96, + size: 72, style: 'Bold' }, color: 0xFFFFFFFF, // White text diff --git a/lib/performance.ts b/lib/performance.ts index 8e71fca..4355393 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -38,17 +38,13 @@ export function useThrottle unknown>( } // Memoized stream lookup utilities -export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) { +export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) { const sourceToIdMap = new Map(); - const idToStreamMap = new Map(); + const idToStreamMap = new Map(); streams.forEach(stream => { // Generate stream group name to match what's written to files - // 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`; - + const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`; sourceToIdMap.set(streamGroupName, stream.id); idToStreamMap.set(stream.id, stream); }); @@ -57,7 +53,7 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n } // Hook version for React components -export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) { +export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) { return useMemo(() => { return createStreamLookupMaps(streams); }, [streams]); @@ -65,7 +61,7 @@ export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name // Efficient active source lookup export function useActiveSourceLookup( - streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>, + streams: Array<{ id: number; obs_source_name: string; name: string }>, activeSources: Record ) { const { sourceToIdMap } = useStreamLookupMaps(streams);