diff --git a/CLAUDE.md b/CLAUDE.md
index 17bd6d4..2bda7ca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,11 +23,10 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro
### Technology Stack
- **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and custom CSS with glass morphism design
-- **Backend**: Next.js API routes with authentication middleware
+- **Backend**: Next.js API routes
- **Database**: SQLite with sqlite3 driver
- **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio
-- **Styling**: Consolidated CSS architecture with Solarized Dark theme, CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components
-- **Security**: API key authentication middleware for production deployments
+- **Styling**: Solarized Dark theme with CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components
### Project Structure
- `/app` - Next.js App Router pages and API routes
@@ -35,8 +34,7 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro
- `/streams` - Streams management page (add new streams and view existing)
- `/teams` - Team management page
- `/edit/[id]` - Individual stream editing
-- `/components` - Reusable React components (Header, Footer, Dropdown, Toast, CollapsibleGroup)
-- `/middleware.ts` - API authentication middleware for security
+- `/components` - Reusable React components (Header, Footer, Dropdown, Toast)
- `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client with persistent connection management
@@ -78,10 +76,6 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro
10. **OBS Scene Control**: Direct scene switching controls with dynamic state tracking and real-time synchronization between UI and OBS
-11. **Studio Mode Support**: Full preview/program scene management with transition controls for professional broadcasting
-
-12. **Collapsible Stream Groups**: Organized stream display with expandable team groups for better UI management
-
### Environment Configuration
- `FILE_DIRECTORY`: Directory for database and text files (default: ./files)
- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1)
@@ -123,10 +117,9 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro
#### OBS Scene Control
- `POST /api/setScene` - Switch OBS to specified scene layout (1-Screen, 2-Screen, 4-Screen)
- `GET /api/getCurrentScene` - Get currently active OBS scene for state synchronization
-- `POST /api/triggerTransition` - Trigger studio mode transition from preview to program (requires studio mode enabled)
#### System Status
-- `GET /api/obsStatus` - Real-time OBS connection, streaming, recording, and studio mode status
+- `GET /api/obsStatus` - Real-time OBS connection and streaming status
### Database Schema
@@ -195,9 +188,6 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
### Security Architecture
**Authentication**: API key-based authentication protects all API endpoints through Next.js middleware
-- Middleware intercepts all API requests when `API_KEY` is set
-- Bypasses authentication for localhost in development
-- Returns 401 for unauthorized requests
**Input Validation**: Comprehensive validation using centralized security utilities in `/lib/security.ts`:
- Screen parameter allowlisting prevents path traversal attacks
@@ -222,8 +212,6 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
- **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
-- **Collapsible Organization**: Streams grouped by team in expandable sections for cleaner UI
-- **Enhanced Stream Display**: Shows stream status with preview/program indicators in studio mode
### Team & Group Management
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
@@ -259,15 +247,12 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
- **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
-- **Studio Mode Status**: Footer displays studio mode state with preview/program scene information
-- **Transition Controls**: "Cut to Preview" button available when studio mode is active
### 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
-- **CSS Consolidation**: Eliminated repetitive styles, centralized theming in globals.css
### Developer Experience
- **Type Safety**: Comprehensive TypeScript definitions throughout
diff --git a/README.md b/README.md
index ce5b8e9..d44ace2 100644
--- a/README.md
+++ b/README.md
@@ -7,22 +7,19 @@ A professional [Next.js](https://nextjs.org) web application for managing live s
## Features
-- **Studio Mode Support**: Full preview/program scene management with transition controls for professional broadcasting
- **OBS Scene Control**: Switch between OBS layouts (1-Screen, 2-Screen, 4-Screen) with dynamic button states
- **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners)
- **Real-time OBS Integration**: WebSocket connection with live status monitoring
- **Enhanced Stream Management**: Create, edit, and delete streams with comprehensive OBS cleanup
- **Team Organization**: Organize streams by teams with full CRUD operations and scene synchronization
-- **Collapsible Stream Groups**: Organized stream display with expandable team groups for better UI management
- **Comprehensive Deletion**: Remove streams/teams with complete OBS component cleanup (scenes, sources, text files)
- **Audio Control**: Browser sources created with muted audio and OBS control enabled
- **Modern UI**: Glass morphism design with responsive layout and accessibility features
- **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, OBS connection status, and studio mode indicators
-- **API Security**: Optional API key authentication for production deployments
-- **Optimized Performance**: Consolidated CSS architecture and standardized API responses
+- **Enhanced Footer**: Real-time team/stream counts and OBS connection status
+- **Optimized Performance**: Reduced code duplication and standardized API responses
## Quick Start
@@ -112,22 +109,54 @@ npm run type-check # TypeScript validation
- **Styling**: Custom CSS with glass morphism and Tailwind utilities
- **CI/CD**: Forgejo workflows with self-hosted runners
-## API Documentation
+## API Endpoints
-The application provides a comprehensive REST API for managing streams, teams, and OBS integration.
+### Stream Management
+- `GET /api/streams` - List all streams with team information
+- `GET /api/streams/[id]` - Get individual stream details
+- `POST /api/addStream` - Create new stream with browser source and team association
+- `PUT /api/streams/[id]` - Update stream information
+- `DELETE /api/streams/[id]` - Delete stream with comprehensive OBS cleanup:
+ - Removes stream's nested scene
+ - Deletes browser source
+ - Removes from all source switchers
+ - Clears text files referencing the stream
-**đ [Complete API Documentation](docs/API.md)**
+### Source Control
+- `POST /api/setActive` - Set active stream for screen position (writes team-prefixed name to text file)
+- `GET /api/getActive` - Get currently active sources for all screen positions
-Key endpoints include:
-- Stream management (CRUD operations)
-- Source control for 7 screen positions
-- Team and OBS group management
-- Scene switching and studio mode controls
-- Real-time status monitoring
+### Team Management
+- `GET /api/teams` - Get all teams with group information and sync status
+- `POST /api/teams` - Create new team with optional OBS scene creation
+- `PUT /api/teams/[teamId]` - Update team name, group_name, or group_uuid
+- `DELETE /api/teams/[teamId]` - Delete team with comprehensive OBS cleanup:
+ - Deletes team scene/group
+ - Removes team text source
+ - Deletes all associated stream scenes
+ - Removes all browser sources with team prefix
+ - Clears all related text files
+- `GET /api/getTeamName` - Get team name by ID
-All endpoints support API key authentication for production deployments.
+### OBS Group/Scene Management
+- `POST /api/createGroup` - Create OBS scene from team and store UUID
+- `POST /api/syncGroups` - Synchronize all teams with OBS groups
+- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking
+ - Detects orphaned groups (excludes system scenes)
+ - Identifies name mismatches
+ - Shows sync status for all teams
-See [`CLAUDE.md`](CLAUDE.md) for detailed architecture documentation and [`docs/API.md`](docs/API.md) for complete endpoint specifications.
+### OBS Scene Control
+- `POST /api/setScene` - Switch OBS to specified scene (1-Screen, 2-Screen, 4-Screen)
+- `GET /api/getCurrentScene` - Get currently active OBS scene
+
+### System Status
+- `GET /api/obsStatus` - Real-time OBS connection, streaming, and recording status
+
+### Authentication
+All endpoints require API key authentication when `API_KEY` environment variable is set.
+
+See `CLAUDE.md` for detailed architecture documentation and implementation details.
## Known Issues
@@ -138,7 +167,7 @@ See [`CLAUDE.md`](CLAUDE.md) for detailed architecture documentation and [`docs/
### System Scene Exclusion
Infrastructure scenes containing source switchers are excluded from orphaned group detection:
-- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies, Resources
+- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies
- Additional scenes can be added to the `SYSTEM_SCENES` array in `/app/api/verifyGroups/route.ts`
diff --git a/app/api/__tests__/streams.test.ts b/app/api/__tests__/streams.test.ts
index 1e1eea1..41f6389 100644
--- a/app/api/__tests__/streams.test.ts
+++ b/app/api/__tests__/streams.test.ts
@@ -1,4 +1,4 @@
-// import { GET } from '../streams/route';
+import { GET } from '../streams/route';
// Mock the database module
jest.mock('@/lib/database', () => ({
diff --git a/app/api/__tests__/teams.test.ts b/app/api/__tests__/teams.test.ts
index d7e431b..bd09053 100644
--- a/app/api/__tests__/teams.test.ts
+++ b/app/api/__tests__/teams.test.ts
@@ -1,4 +1,4 @@
-// import { GET } from '../teams/route';
+import { GET } from '../teams/route';
// Mock the database module
jest.mock('@/lib/database', () => ({
@@ -13,7 +13,7 @@ jest.mock('@/lib/apiHelpers', () => ({
status,
json: async () => ({ success: true, data }),
})),
- createDatabaseError: jest.fn((operation) => ({
+ createDatabaseError: jest.fn((operation, error) => ({
error: 'Database Error',
status: 500,
json: async () => ({
diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts
index 75a62a3..9347106 100644
--- a/app/api/addStream/route.ts
+++ b/app/api/addStream/route.ts
@@ -63,7 +63,7 @@ function generateOBSSourceName(teamSceneName: string, streamName: string): strin
}
export async function POST(request: NextRequest) {
- let name: string, url: string, team_id: number, obs_source_name: string, lockSources: boolean;
+ let name: string, url: string, team_id: number, obs_source_name: string;
// Parse and validate request body
try {
@@ -78,7 +78,6 @@ export async function POST(request: NextRequest) {
}
({ name, url, team_id } = validation.data!);
- lockSources = body.lockSources !== false; // Default to true if not specified
} catch {
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 });
@@ -126,7 +125,7 @@ export async function POST(request: NextRequest) {
if (!sourceExists) {
// Create stream group with text overlay
- await createStreamGroup(groupName, name, teamInfo.team_name, url, lockSources);
+ await createStreamGroup(groupName, name, teamInfo.team_name, url);
// Update team with group UUID if not set
if (!teamInfo.group_uuid) {
diff --git a/app/api/obsStatus/route.ts b/app/api/obsStatus/route.ts
index 91c85f6..13e8dc4 100644
--- a/app/api/obsStatus/route.ts
+++ b/app/api/obsStatus/route.ts
@@ -19,11 +19,9 @@ export async function GET() {
obsWebSocketVersion: string;
};
currentScene?: string;
- currentPreviewScene?: string;
sceneCount?: number;
streaming?: boolean;
recording?: boolean;
- studioModeEnabled?: boolean;
error?: string;
} = {
host: OBS_HOST,
@@ -60,31 +58,15 @@ export async function GET() {
// Get recording status
const recordStatus = await obs.call('GetRecordStatus');
- // Get studio mode status
- const studioModeStatus = await obs.call('GetStudioModeEnabled');
-
- // Get preview scene if studio mode is enabled
- let currentPreviewScene;
- if (studioModeStatus.studioModeEnabled) {
- try {
- const previewSceneInfo = await obs.call('GetCurrentPreviewScene');
- currentPreviewScene = previewSceneInfo.sceneName;
- } catch (previewError) {
- console.log('Could not get preview scene:', previewError);
- }
- }
-
connectionStatus.connected = true;
connectionStatus.version = {
obsVersion: versionInfo.obsVersion,
obsWebSocketVersion: versionInfo.obsWebSocketVersion
};
connectionStatus.currentScene = currentSceneInfo.sceneName;
- connectionStatus.currentPreviewScene = currentPreviewScene;
connectionStatus.sceneCount = sceneList.scenes.length;
connectionStatus.streaming = streamStatus.outputActive;
connectionStatus.recording = recordStatus.outputActive;
- connectionStatus.studioModeEnabled = studioModeStatus.studioModeEnabled;
} catch (err) {
connectionStatus.error = err instanceof Error ? err.message : 'Unknown error occurred';
diff --git a/app/api/setScene/route.ts b/app/api/setScene/route.ts
index 2363d32..548e003 100644
--- a/app/api/setScene/route.ts
+++ b/app/api/setScene/route.ts
@@ -32,30 +32,16 @@ export async function POST(request: NextRequest) {
try {
const obsClient = await getOBSClient();
- // Check if studio mode is active
- const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled');
+ // Switch to the requested scene
+ await obsClient.call('SetCurrentProgramScene', { sceneName });
- if (studioModeEnabled) {
- // In studio mode, switch the preview scene
- await obsClient.call('SetCurrentPreviewScene', { sceneName });
- console.log(`Successfully switched preview to scene: ${sceneName} (Studio Mode)`);
-
- return NextResponse.json({
- success: true,
- data: { sceneName, studioMode: true },
- message: `Preview set to ${sceneName} layout (Studio Mode) - ready to transition`
- });
- } else {
- // Normal mode, switch program scene directly
- await obsClient.call('SetCurrentProgramScene', { sceneName });
- console.log(`Successfully switched to scene: ${sceneName}`);
-
- return NextResponse.json({
- success: true,
- data: { sceneName, studioMode: false },
- message: `Switched to ${sceneName} layout`
- });
- }
+ console.log(`Successfully switched to scene: ${sceneName}`);
+
+ return NextResponse.json({
+ success: true,
+ data: { sceneName },
+ message: `Switched to ${sceneName} layout`
+ });
} catch (obsError) {
console.error('OBS WebSocket error:', obsError);
return NextResponse.json(
diff --git a/app/api/triggerTransition/route.ts b/app/api/triggerTransition/route.ts
deleted file mode 100644
index 7136863..0000000
--- a/app/api/triggerTransition/route.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { NextResponse } from 'next/server';
-import { getOBSClient } from '../../../lib/obsClient';
-
-export async function POST() {
- try {
- const obsClient = await getOBSClient();
-
- // Check if studio mode is active
- const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled');
-
- if (!studioModeEnabled) {
- return NextResponse.json(
- {
- success: false,
- error: 'Studio mode is not enabled',
- message: 'Studio mode must be enabled to trigger transitions'
- },
- { status: 400 }
- );
- }
-
- try {
- // Trigger the studio mode transition (preview to program)
- await obsClient.call('TriggerStudioModeTransition');
- console.log('Successfully triggered studio mode transition');
-
- // Get the updated scene information after transition
- const [programResponse, previewResponse] = await Promise.all([
- obsClient.call('GetCurrentProgramScene'),
- obsClient.call('GetCurrentPreviewScene')
- ]);
-
- return NextResponse.json({
- success: true,
- data: {
- programScene: programResponse.currentProgramSceneName,
- previewScene: previewResponse.currentPreviewSceneName
- },
- message: 'Successfully transitioned preview to program'
- });
- } catch (obsError) {
- console.error('OBS WebSocket error during transition:', obsError);
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to trigger transition in OBS',
- details: obsError instanceof Error ? obsError.message : 'Unknown error'
- },
- { status: 500 }
- );
- }
- } catch (error) {
- console.error('Error triggering transition:', error);
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to connect to OBS or trigger transition'
- },
- { status: 500 }
- );
- }
-}
\ No newline at end of file
diff --git a/app/globals.css b/app/globals.css
index dac679e..e1107df 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -20,14 +20,6 @@
--solarized-red: #dc322f;
--solarized-magenta: #d33682;
--solarized-violet: #6c71c4;
-
- /* Gradient Custom Properties */
- --gradient-primary: linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan));
- --gradient-active: linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow));
- --gradient-danger: linear-gradient(135deg, var(--solarized-red), var(--solarized-orange));
- --gradient-preview: linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange));
- --gradient-transition: linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta));
- --gradient-body: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%);
}
/* Modern CSS Foundation */
@@ -42,8 +34,8 @@ html {
}
body {
- background: var(--gradient-body);
- color: var(--solarized-base1);
+ background: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%);
+ color: #93a1a1;
min-height: 100vh;
line-height: 1.6;
}
@@ -73,8 +65,7 @@ body {
}
/* Glass Card Component */
-.glass,
-.glass-panel {
+.glass {
background: rgba(7, 54, 66, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(88, 110, 117, 0.3);
@@ -84,8 +75,8 @@ body {
/* Modern Button System */
.btn {
- background: var(--gradient-primary);
- color: var(--solarized-base3);
+ background: linear-gradient(135deg, #268bd2, #2aa198);
+ color: #fdf6e3;
border: none;
padding: 12px 24px;
border-radius: 12px;
@@ -102,10 +93,9 @@ body {
text-decoration: none;
}
-.btn.active,
-.btn-success {
- background: var(--gradient-active);
- color: var(--solarized-base3);
+.btn.active {
+ background: linear-gradient(135deg, #859900, #b58900);
+ color: #fdf6e3;
box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5);
transform: translateY(-1px);
font-weight: 700;
@@ -127,7 +117,7 @@ body {
.btn-secondary {
background: rgba(88, 110, 117, 0.3);
border: 1px solid rgba(131, 148, 150, 0.4);
- color: var(--solarized-base1);
+ color: #93a1a1;
backdrop-filter: blur(10px);
}
@@ -137,14 +127,25 @@ body {
box-shadow: 0 6px 20px rgba(88, 110, 117, 0.3);
}
+/* Success Button */
+.btn-success {
+ background: linear-gradient(135deg, #859900, #b58900);
+ color: #fdf6e3;
+}
+
+.btn-success:hover {
+ background: linear-gradient(135deg, #b58900, #859900);
+ box-shadow: 0 6px 20px rgba(133, 153, 0, 0.4);
+}
+
/* Danger Button */
.btn-danger {
- background: var(--gradient-danger);
- color: var(--solarized-base3);
+ background: linear-gradient(135deg, #dc322f, #cb4b16);
+ color: #fdf6e3;
}
.btn-danger:hover {
- background: linear-gradient(135deg, var(--solarized-orange), var(--solarized-red));
+ background: linear-gradient(135deg, #cb4b16, #dc322f);
box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4);
}
@@ -168,18 +169,6 @@ body {
flex-shrink: 0;
}
-/* Scene Button Variants */
-.btn-scene-preview {
- background: var(--gradient-preview);
- color: var(--solarized-base3);
-}
-
-.btn-scene-transition {
- background: var(--gradient-transition);
- color: var(--solarized-base3);
- min-width: 120px;
-}
-
/* Form spacing fixes since Tailwind gap classes aren't working */
.form-row {
display: flex;
@@ -205,7 +194,7 @@ body {
border: 1px solid rgba(88, 110, 117, 0.4);
border-radius: 12px;
padding: 12px 16px;
- color: var(--solarized-base1);
+ color: #93a1a1;
width: 100%;
transition: all 0.3s ease;
}
@@ -216,7 +205,7 @@ body {
.input:focus {
outline: none;
- border-color: var(--solarized-blue);
+ border-color: #268bd2;
box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2);
}
@@ -226,7 +215,7 @@ body {
border: 1px solid rgba(88, 110, 117, 0.4);
border-radius: 12px;
padding: 12px 16px;
- color: var(--solarized-base1);
+ color: #93a1a1;
width: 100%;
text-align: left;
cursor: pointer;
@@ -252,28 +241,6 @@ body {
position: absolute;
transform: translateZ(0);
will-change: transform;
- max-height: 400px;
- overflow-y: auto;
-}
-
-/* Custom scrollbar for dropdown */
-.dropdown-menu::-webkit-scrollbar {
- width: 8px;
-}
-
-.dropdown-menu::-webkit-scrollbar-track {
- background: rgba(7, 54, 66, 0.5);
- border-radius: 4px;
-}
-
-.dropdown-menu::-webkit-scrollbar-thumb {
- background: rgba(88, 110, 117, 0.6);
- border-radius: 4px;
- transition: background 0.2s ease;
-}
-
-.dropdown-menu::-webkit-scrollbar-thumb:hover {
- background: rgba(88, 110, 117, 0.8);
}
.dropdown-item {
@@ -281,7 +248,7 @@ body {
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid rgba(88, 110, 117, 0.2);
- color: var(--solarized-base1);
+ color: #93a1a1;
}
.dropdown-item:last-child {
@@ -294,7 +261,7 @@ body {
.dropdown-item.active {
background: rgba(38, 139, 210, 0.3);
- color: var(--solarized-base3);
+ color: #fdf6e3;
}
/* Icon Sizes */
@@ -389,22 +356,6 @@ body {
margin-bottom: 32px;
}
-.ml-3 {
- margin-left: 12px;
-}
-
-.mr-1 {
- margin-right: 4px;
-}
-
-.mr-2 {
- margin-right: 8px;
-}
-
-.mr-4 {
- margin-right: 16px;
-}
-
.p-4 {
padding: 16px;
}
@@ -415,134 +366,4 @@ body {
.p-8 {
padding: 32px;
-}
-
-/* Collapsible Group Styles */
-.collapsible-group {
- margin-bottom: 16px;
-}
-
-.collapsible-header {
- width: 100%;
- background: rgba(7, 54, 66, 0.3);
- border: 1px solid rgba(88, 110, 117, 0.3);
- border-radius: 12px;
- padding: 16px 20px;
- cursor: pointer;
- transition: all 0.3s ease;
- margin-bottom: 1px;
-}
-
-.collapsible-header:hover {
- background: rgba(7, 54, 66, 0.5);
- border-color: rgba(131, 148, 150, 0.4);
-}
-
-.collapsible-header-content {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.collapsible-icon {
- flex-shrink: 0;
- transition: transform 0.3s ease;
- color: var(--solarized-base1);
-}
-
-.collapsible-icon.open {
- transform: rotate(90deg);
-}
-
-.collapsible-title {
- flex: 1;
- font-size: 18px;
- font-weight: 600;
- color: var(--solarized-base3);
- text-align: left;
- margin: 0;
-}
-
-.collapsible-count {
- background: rgba(38, 139, 210, 0.2);
- color: var(--solarized-blue);
- padding: 4px 12px;
- border-radius: 20px;
- font-size: 14px;
- font-weight: 600;
-}
-
-.collapsible-content {
- overflow: hidden;
- transition: all 0.3s ease;
- max-height: 0;
- opacity: 0;
-}
-
-.collapsible-content.open {
- max-height: 5000px;
- opacity: 1;
- margin-top: 12px;
-}
-
-.collapsible-content-inner {
- padding: 20px;
- background: rgba(7, 54, 66, 0.2);
- border: 1px solid rgba(88, 110, 117, 0.2);
- border-radius: 12px;
-}
-
-/* Solarized Color Utilities */
-.text-base03 { color: var(--solarized-base03); }
-.text-base02 { color: var(--solarized-base02); }
-.text-base01 { color: var(--solarized-base01); }
-.text-base00 { color: var(--solarized-base00); }
-.text-base0 { color: var(--solarized-base0); }
-.text-base1 { color: var(--solarized-base1); }
-.text-base2 { color: var(--solarized-base2); }
-.text-base3 { color: var(--solarized-base3); }
-.text-blue { color: var(--solarized-blue); }
-.text-cyan { color: var(--solarized-cyan); }
-.text-green { color: var(--solarized-green); }
-.text-yellow { color: var(--solarized-yellow); }
-.text-orange { color: var(--solarized-orange); }
-.text-red { color: var(--solarized-red); }
-.text-magenta { color: var(--solarized-magenta); }
-.text-violet { color: var(--solarized-violet); }
-
-.bg-base03 { background-color: var(--solarized-base03); }
-.bg-base02 { background-color: var(--solarized-base02); }
-.bg-base01 { background-color: var(--solarized-base01); }
-.bg-base00 { background-color: var(--solarized-base00); }
-.bg-base0 { background-color: var(--solarized-base0); }
-.bg-base1 { background-color: var(--solarized-base1); }
-.bg-base2 { background-color: var(--solarized-base2); }
-.bg-base3 { background-color: var(--solarized-base3); }
-.bg-blue { background-color: var(--solarized-blue); }
-.bg-cyan { background-color: var(--solarized-cyan); }
-.bg-green { background-color: var(--solarized-green); }
-.bg-yellow { background-color: var(--solarized-yellow); }
-.bg-orange { background-color: var(--solarized-orange); }
-.bg-red { background-color: var(--solarized-red); }
-.bg-magenta { background-color: var(--solarized-magenta); }
-.bg-violet { background-color: var(--solarized-violet); }
-
-.border-base01 { border-color: var(--solarized-base01); }
-.border-base02 { border-color: var(--solarized-base02); }
-.border-blue { border-color: var(--solarized-blue); }
-.border-cyan { border-color: var(--solarized-cyan); }
-.border-green { border-color: var(--solarized-green); }
-.border-yellow { border-color: var(--solarized-yellow); }
-.border-orange { border-color: var(--solarized-orange); }
-.border-red { border-color: var(--solarized-red); }
-
-/* Border opacity utilities */
-.border-green\/30 { border-color: rgba(133, 153, 0, 0.3); }
-.border-yellow\/30 { border-color: rgba(181, 137, 0, 0.3); }
-.border-blue\/30 { border-color: rgba(38, 139, 210, 0.3); }
-.border-red\/30 { border-color: rgba(220, 50, 47, 0.3); }
-
-/* Focus utilities */
-.focus\:outline-none:focus { outline: none; }
-.focus\:ring-2:focus { box-shadow: 0 0 0 2px var(--solarized-blue); }
-.focus\:ring-blue:focus { box-shadow: 0 0 0 2px var(--solarized-blue); }
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index 4b3dbbc..c3ab1bc 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,7 +2,7 @@ import './globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
-import { ApiKeyProvider } from '@/contexts/ApiKeyContext';
+import PerformanceDashboard from '@/components/PerformanceDashboard';
export const metadata = {
title: 'Live Stream Manager',
@@ -13,15 +13,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
-
-
-
-
- {children}
-
-
-
-
+
+
+
+ {children}
+
+
+
+
);
diff --git a/app/page.tsx b/app/page.tsx
index 3ed4197..7094cd5 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -19,8 +19,6 @@ export default function Home() {
const [isLoading, setIsLoading] = useState(true);
const [openDropdown, setOpenDropdown] = useState(null);
const [currentScene, setCurrentScene] = useState(null);
- const [currentPreviewScene, setCurrentPreviewScene] = useState(null);
- const [studioModeEnabled, setStudioModeEnabled] = useState(false);
const { toasts, removeToast, showSuccess, showError } = useToast();
// Memoized active source lookup for performance
@@ -61,19 +59,17 @@ export default function Home() {
const fetchData = useCallback(async () => {
const endTimer = PerformanceMonitor.startTimer('fetchData');
try {
- // Fetch streams, active sources, current scene, and OBS status in parallel
- const [streamsRes, activeRes, sceneRes, obsStatusRes] = await Promise.all([
+ // Fetch streams, active sources, and current scene in parallel
+ const [streamsRes, activeRes, sceneRes] = await Promise.all([
fetch('/api/streams'),
fetch('/api/getActive'),
- fetch('/api/getCurrentScene'),
- fetch('/api/obsStatus')
+ fetch('/api/getCurrentScene')
]);
- const [streamsData, activeData, sceneData, obsStatusData] = await Promise.all([
+ const [streamsData, activeData, sceneData] = await Promise.all([
streamsRes.json(),
activeRes.json(),
- sceneRes.json(),
- obsStatusRes.json()
+ sceneRes.json()
]);
// Handle both old and new API response formats
@@ -84,15 +80,6 @@ export default function Home() {
setStreams(streams);
setActiveSources(activeSources);
setCurrentScene(sceneName);
-
- // Update studio mode and preview scene from OBS status
- if (obsStatusData.connected) {
- setStudioModeEnabled(obsStatusData.studioModeEnabled || false);
- setCurrentPreviewScene(obsStatusData.currentPreviewScene || null);
- } else {
- setStudioModeEnabled(false);
- setCurrentPreviewScene(null);
- }
} catch (error) {
console.error('Error fetching data:', error);
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
@@ -139,16 +126,9 @@ export default function Home() {
const result = await response.json();
if (result.success) {
- // Update local state based on studio mode
- if (result.data.studioMode) {
- // In studio mode, update preview scene
- setCurrentPreviewScene(sceneName);
- showSuccess('Preview Set', result.message);
- } else {
- // In normal mode, update program scene
- setCurrentScene(sceneName);
- showSuccess('Scene Changed', `Switched to ${sceneName} layout`);
- }
+ // Update local state immediately for responsive UI
+ setCurrentScene(sceneName);
+ showSuccess('Scene Changed', `Switched to ${sceneName} layout`);
} else {
throw new Error(result.error || 'Failed to switch scene');
}
@@ -158,86 +138,6 @@ export default function Home() {
}
}, [showSuccess, showError]);
- const handleTransition = useCallback(async () => {
- try {
- const response = await fetch('/api/triggerTransition', {
- method: 'POST',
- });
-
- const result = await response.json();
-
- if (result.success) {
- // Update local state after successful transition
- setCurrentScene(result.data.programScene);
- setCurrentPreviewScene(result.data.previewScene);
- showSuccess('Transition Complete', 'Successfully transitioned preview to program');
-
- // Refresh data to ensure UI is in sync
- fetchData();
- } else {
- throw new Error(result.error || 'Failed to trigger transition');
- }
- } catch (error) {
- console.error('Error triggering transition:', error);
- showError('Transition Failed', error instanceof Error ? error.message : 'Could not trigger transition. Please try again.');
- }
- }, [showSuccess, showError, fetchData]);
-
- // Helper function to get scene button state and styling
- const getSceneButtonState = useCallback((sceneName: string) => {
- const isProgram = currentScene === sceneName;
- const isPreview = studioModeEnabled && currentPreviewScene === sceneName;
-
- if (studioModeEnabled) {
- if (isProgram && isPreview) {
- return {
- isActive: true,
- text: `Program & Preview: ${sceneName}`,
- className: 'active',
- showTransition: false
- };
- } else if (isProgram) {
- return {
- isActive: true,
- text: `Program: ${sceneName}`,
- className: 'active',
- showTransition: false
- };
- } else if (isPreview) {
- return {
- isActive: true,
- text: `Preview: ${sceneName}`,
- className: 'btn-scene-preview',
- showTransition: true
- };
- } else {
- return {
- isActive: false,
- text: `Set Preview: ${sceneName}`,
- className: '',
- showTransition: false
- };
- }
- } else {
- // Normal mode
- if (isProgram) {
- return {
- isActive: true,
- text: `Active: ${sceneName}`,
- className: 'active',
- showTransition: false
- };
- } else {
- return {
- isActive: false,
- text: `Switch to ${sceneName}`,
- className: '',
- showTransition: false
- };
- }
- }
- }, [currentScene, currentPreviewScene, studioModeEnabled]);
-
// Memoized corner displays to prevent re-renders
const cornerDisplays = useMemo(() => [
{ screen: 'top_left' as const, label: 'Top Left' },
@@ -282,29 +182,17 @@ export default function Home() {
Primary Display
-
- {(() => {
- const buttonState = getSceneButtonState('1-Screen');
- return (
- <>
-
- {buttonState.showTransition && (
-
- )}
- >
- );
- })()}
-
+
Side Displays
-
- {(() => {
- const buttonState = getSceneButtonState('2-Screen');
- return (
- <>
-
- {buttonState.showTransition && (
-
- )}
- >
- );
- })()}
-
+
@@ -376,29 +252,17 @@ export default function Home() {
Corner Displays
-
- {(() => {
- const buttonState = getSceneButtonState('4-Screen');
- return (
- <>
-
- {buttonState.showTransition && (
-
- )}
- >
- );
- })()}
-
+
{cornerDisplays.map(({ screen, label }) => (
diff --git a/app/performance/page.tsx b/app/performance/page.tsx
deleted file mode 100644
index 4db20db..0000000
--- a/app/performance/page.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { PerformanceMonitor } from '@/lib/performance';
-
-interface PerformanceMetrics {
- [key: string]: {
- avg: number;
- min: number;
- max: number;
- count: number;
- } | null;
-}
-
-export default function PerformancePage() {
- const [metrics, setMetrics] = useState
({});
-
- useEffect(() => {
- const updateMetrics = () => {
- setMetrics(PerformanceMonitor.getAllMetrics());
- };
-
- // Update metrics every 2 seconds
- updateMetrics();
- const interval = setInterval(updateMetrics, 2000);
-
- return () => clearInterval(interval);
- }, []);
-
- return (
-
-
-
Performance Metrics
-
- {Object.keys(metrics).length === 0 ? (
-
-
No metrics collected yet. Navigate around the app to see performance data.
-
- ) : (
-
- {Object.entries(metrics).map(([label, metric]) => {
- if (!metric) return null;
-
- return (
-
-
- {label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
-
-
-
-
- Average
- {metric.avg.toFixed(2)}ms
-
-
-
- Min
- {metric.min.toFixed(2)}ms
-
-
-
- Max
- 100 ? 'text-red' : 'text-yellow'}`}>
- {metric.max.toFixed(2)}ms
-
-
-
-
- Count
- {metric.count}
-
-
-
- {/* Performance indicator bar */}
-
-
-
-
- {metric.avg < 50 ? 'Excellent' :
- metric.avg < 100 ? 'Good' : 'Needs Optimization'}
-
-
-
-
- );
- })}
-
- {/* Performance tips */}
-
-
đĄ Performance Tips
-
-
-
Response Times
-
- - < 50ms - Excellent user experience
- - 50-100ms - Good, barely noticeable
- - 100-300ms - Noticeable delay
- - > 300ms - Frustrating for users
-
-
-
-
Optimization Strategies
-
- - Monitor fetchData and setActive timings
- - High max values indicate performance spikes
- - Consider caching for frequently called APIs
- - Batch multiple requests when possible
-
-
-
-
-
- )}
-
-
- );
-}
\ No newline at end of file
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
deleted file mode 100644
index 13d2692..0000000
--- a/app/settings/page.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useApiKey } from '@/contexts/ApiKeyContext';
-
-export default function SettingsPage() {
- const { setApiKey, clearApiKey, isAuthenticated } = useApiKey();
- const [inputValue, setInputValue] = useState('');
- const [error, setError] = useState('');
- const [success, setSuccess] = useState('');
- const [isLoading, setIsLoading] = useState(false);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
- setSuccess('');
- setIsLoading(true);
-
- if (!inputValue.trim()) {
- setError('API key is required');
- setIsLoading(false);
- return;
- }
-
- // Test the API key by making a simple request
- try {
- const response = await fetch('/api/obsStatus', {
- headers: {
- 'x-api-key': inputValue.trim()
- }
- });
-
- if (response.ok) {
- setApiKey(inputValue.trim());
- setInputValue('');
- setSuccess('API key saved successfully!');
- } else {
- setError('Invalid API key');
- }
- } catch {
- setError('Failed to validate API key');
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleClearKey = () => {
- clearApiKey();
- setInputValue('');
- setError('');
- setSuccess('API key cleared');
- };
-
- return (
-
-
-
Settings
-
- {/* API Key Section */}
-
-
-
API Key Authentication
-
- API keys are required when accessing this application from external networks.
- The key is stored securely in your browser's local storage.
-
-
-
- {/* Current Status */}
-
-
-
-
Current Status
-
- {isAuthenticated ? (
- <>
-
-
Authenticated
- >
- ) : (
- <>
-
-
No API key set
- >
- )}
-
-
- {isAuthenticated && (
-
- )}
-
-
-
- {/* API Key Form */}
-
-
- {/* Information Section */}
-
-
âšī¸ Information
-
- - API keys are only required for external network access
- - Local network access bypasses authentication automatically
- - Keys are validated against the server before saving
- - Your API key is stored locally and never transmitted unnecessarily
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/streams/page.tsx b/app/streams/page.tsx
index fb1896c..2e4f52f 100644
--- a/app/streams/page.tsx
+++ b/app/streams/page.tsx
@@ -1,8 +1,7 @@
'use client';
-import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import Dropdown from '@/components/Dropdown';
-import CollapsibleGroup from '@/components/CollapsibleGroup';
import { Team } from '@/types';
import { useToast } from '@/lib/useToast';
import { ToastContainer } from '@/components/Toast';
@@ -15,172 +14,6 @@ interface Stream {
team_id: number;
}
-interface StreamsByTeamProps {
- streams: Stream[];
- teams: {id: number; name: string}[];
- onDelete: (stream: Stream) => void;
-}
-
-function StreamsByTeam({ streams, teams, onDelete }: StreamsByTeamProps) {
- const [expandedGroups, setExpandedGroups] = useState>(new Set());
- const [useCustomExpanded, setUseCustomExpanded] = useState(false);
-
- // Group streams by team
- const streamsByTeam = useMemo(() => {
- const grouped = new Map();
-
- // Initialize with all teams
- teams.forEach(team => {
- grouped.set(team.id, []);
- });
-
- // Add "No Team" group for streams without a team
- grouped.set(-1, []);
-
- // Group streams
- streams.forEach(stream => {
- const teamId = stream.team_id || -1;
- const teamStreams = grouped.get(teamId) || [];
- teamStreams.push(stream);
- grouped.set(teamId, teamStreams);
- });
-
- // Only include groups that have streams
- const result: Array<{teamId: number; teamName: string; streams: Stream[]}> = [];
-
- grouped.forEach((streamList, teamId) => {
- if (streamList.length > 0) {
- const team = teams.find(t => t.id === teamId);
- result.push({
- teamId,
- teamName: teamId === -1 ? 'No Team' : (team?.name || 'Unknown Team'),
- streams: streamList
- });
- }
- });
-
- // Sort by team name, with "No Team" at the end
- result.sort((a, b) => {
- if (a.teamId === -1) return 1;
- if (b.teamId === -1) return -1;
- return a.teamName.localeCompare(b.teamName);
- });
-
- return result;
- }, [streams, teams]);
-
- const handleExpandAll = () => {
- const allIds = streamsByTeam.map(group => group.teamId);
- setExpandedGroups(new Set(allIds));
- setUseCustomExpanded(true);
- };
-
- const handleCollapseAll = () => {
- setExpandedGroups(new Set());
- setUseCustomExpanded(true);
- };
-
- const handleToggleGroup = (teamId: number) => {
- const newExpanded = new Set(expandedGroups);
- if (newExpanded.has(teamId)) {
- newExpanded.delete(teamId);
- } else {
- newExpanded.add(teamId);
- }
- setExpandedGroups(newExpanded);
- setUseCustomExpanded(true);
- };
-
- return (
-
- {streamsByTeam.length > 0 && (
-
-
-
-
- )}
-
- {streamsByTeam.map(({ teamId, teamName, streams: teamStreams }) => (
-
handleToggleGroup(teamId)}
- >
-
- {teamStreams.map((stream) => (
-
-
-
-
- {stream.name.charAt(0).toUpperCase()}
-
-
-
{stream.name}
-
OBS: {stream.obs_source_name}
-
-
-
-
-
- ))}
-
-
- ))}
-
-
- );
-}
-
export default function AddStream() {
const [formData, setFormData] = useState({
name: '',
@@ -479,11 +312,61 @@ export default function AddStream() {
Create your first stream above!
) : (
-
setDeleteConfirm({ id: stream.id, name: stream.name })}
- />
+
+ {streams.map((stream) => {
+ const team = teams.find(t => t.id === stream.team_id);
+ return (
+
+
+
+
+ {stream.name.charAt(0).toUpperCase()}
+
+
+
{stream.name}
+
OBS: {stream.obs_source_name}
+
Team: {team?.name || 'Unknown'}
+
+
+
+
ID: {stream.id}
+
+
+
+ View Stream
+
+
+
+
+
+
+ );
+ })}
+
)}
diff --git a/components/ApiKeyPrompt.tsx b/components/ApiKeyPrompt.tsx
deleted file mode 100644
index a42611a..0000000
--- a/components/ApiKeyPrompt.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { useApiKey } from '../contexts/ApiKeyContext';
-
-interface ApiKeyPromptProps {
- show: boolean;
- onClose?: () => void;
-}
-
-export function ApiKeyPrompt({ show, onClose }: ApiKeyPromptProps) {
- const { setApiKey } = useApiKey();
- const [inputValue, setInputValue] = useState('');
- const [error, setError] = useState('');
-
- if (!show) return null;
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
-
- if (!inputValue.trim()) {
- setError('API key is required');
- return;
- }
-
- // Test the API key by making a simple request
- try {
- const response = await fetch('/api/obsStatus', {
- headers: {
- 'x-api-key': inputValue.trim()
- }
- });
-
- if (response.ok) {
- setApiKey(inputValue.trim());
- setInputValue('');
- onClose?.();
- } else {
- setError('Invalid API key');
- }
- } catch {
- setError('Failed to validate API key');
- }
- };
-
- return (
-
-
-
API Key Required
-
- This application requires an API key for access. Please enter your API key to continue.
-
-
-
-
-
- );
-}
-
-export function ApiKeyBanner() {
- const { isAuthenticated, clearApiKey } = useApiKey();
- const [showPrompt, setShowPrompt] = useState(false);
-
- if (isAuthenticated) {
- return (
-
-
- â
- Authenticated
-
-
-
-
-
-
setShowPrompt(false)} />
-
- );
- }
-
- return (
- <>
-
-
- â ī¸
- API key required for full access
-
-
-
-
setShowPrompt(false)} />
- >
- );
-}
\ No newline at end of file
diff --git a/components/CollapsibleGroup.tsx b/components/CollapsibleGroup.tsx
deleted file mode 100644
index 0ea3c54..0000000
--- a/components/CollapsibleGroup.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-'use client';
-
-import { useState, ReactNode } from 'react';
-
-interface CollapsibleGroupProps {
- title: string;
- itemCount: number;
- children: ReactNode;
- defaultOpen?: boolean;
- isOpen?: boolean;
- onToggle?: () => void;
-}
-
-export default function CollapsibleGroup({
- title,
- itemCount,
- children,
- defaultOpen = true,
- isOpen: controlledIsOpen,
- onToggle
-}: CollapsibleGroupProps) {
- const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
-
- // Use controlled state if provided, otherwise use internal state
- const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
-
- const handleToggle = () => {
- if (onToggle) {
- onToggle();
- } else {
- setInternalIsOpen(!internalIsOpen);
- }
- };
-
- return (
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/components/Footer.tsx b/components/Footer.tsx
index d7c4f77..edbcb26 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -13,11 +13,9 @@ type OBSStatus = {
obsWebSocketVersion: string;
};
currentScene?: string;
- currentPreviewScene?: string;
sceneCount?: number;
streaming?: boolean;
recording?: boolean;
- studioModeEnabled?: boolean;
error?: string;
};
@@ -85,8 +83,8 @@ export default function Footer() {
OBS Studio
-
-
+
+
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
@@ -98,22 +96,17 @@ export default function Footer() {
{obsStatus.host}:{obsStatus.port}
{obsStatus.hasPassword &&
đ Authenticated
}
- {/* Streaming/Recording/Studio Mode Status */}
+ {/* Streaming/Recording Status */}
{obsStatus.connected && (
-
-
-
-
{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
+
+
+
+
{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
-
-
-
{obsStatus.recording ? 'REC' : 'IDLE'}
-
-
-
-
-
{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}
+
+
+
{obsStatus.recording ? 'REC' : 'IDLE'}
)}
@@ -145,18 +138,11 @@ export default function Footer() {
{obsStatus.currentScene && (
- {obsStatus.studioModeEnabled ? 'Program:' : 'Scene:'}
+ Scene:
{obsStatus.currentScene}
)}
- {obsStatus.studioModeEnabled && obsStatus.currentPreviewScene && (
-
- Preview:
- {obsStatus.currentPreviewScene}
-
- )}
-
{obsStatus.sceneCount !== null && (
Total Scenes:
diff --git a/components/Header.tsx b/components/Header.tsx
index 5c4d9ec..44f8cc6 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -50,24 +50,6 @@ export default function Header() {
đĨ
Teams
-
-
- âī¸
- Settings
-
-
- {process.env.NODE_ENV === 'development' && (
-
- đ
- Perf
-
- )}
diff --git a/components/PerformanceDashboard.tsx b/components/PerformanceDashboard.tsx
index b18c95c..cfb96ed 100644
--- a/components/PerformanceDashboard.tsx
+++ b/components/PerformanceDashboard.tsx
@@ -36,27 +36,22 @@ export default function PerformanceDashboard() {
}
return (
- <>
- {!isVisible && (
-
-
-
- )}
-
- {isVisible && (
-
-
+
+ {!isVisible ? (
+
+ ) : (
+
-
Performance Metrics
+ Performance Metrics
{Object.keys(metrics).length === 0 ? (
-
No metrics collected yet.
+
No metrics collected yet.
) : (
{Object.entries(metrics).map(([label, metric]) => {
if (!metric) return null;
return (
-
-
+
+
{label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
- Avg:{' '}
- {metric.avg.toFixed(2)}ms
+ Avg:{' '}
+ {metric.avg.toFixed(2)}ms
- Count:{' '}
- {metric.count}
+ Count:{' '}
+ {metric.count}
- Min:{' '}
- {metric.min.toFixed(2)}ms
+ Min:{' '}
+ {metric.min.toFixed(2)}ms
- Max:{' '}
- 100 ? 'text-red' : 'text-yellow'}>
+ Max:{' '}
+ 100 ? 'text-red-400' : 'text-yellow-400'}>
{metric.max.toFixed(2)}ms
@@ -101,11 +96,11 @@ export default function PerformanceDashboard() {
-
+
{metric.avg < 50 ? 'Excellent' :
metric.avg < 100 ? 'Good' : 'Needs Optimization'}
@@ -116,11 +111,11 @@ export default function PerformanceDashboard() {
})}
{/* Performance tips */}
-
-
+
+
đĄ Performance Tips
-
+
- âĸ Keep API calls under 100ms for optimal UX
- âĸ Monitor fetchData and setActive timings
- âĸ High max values indicate performance spikes
@@ -129,9 +124,8 @@ export default function PerformanceDashboard() {
)}
-
)}
- >
+
);
}
\ No newline at end of file
diff --git a/contexts/ApiKeyContext.tsx b/contexts/ApiKeyContext.tsx
deleted file mode 100644
index 829d266..0000000
--- a/contexts/ApiKeyContext.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-'use client';
-
-import React, { createContext, useContext, useState, useEffect } from 'react';
-
-interface ApiKeyContextType {
- apiKey: string | null;
- setApiKey: (key: string) => void;
- clearApiKey: () => void;
- isAuthenticated: boolean;
-}
-
-const ApiKeyContext = createContext(undefined);
-
-export function ApiKeyProvider({ children }: { children: React.ReactNode }) {
- const [apiKey, setApiKeyState] = useState(null);
- const [isLoaded, setIsLoaded] = useState(false);
-
- // Load API key from localStorage on mount
- useEffect(() => {
- const stored = localStorage.getItem('obs-api-key');
- if (stored) {
- setApiKeyState(stored);
- }
- setIsLoaded(true);
- }, []);
-
- const setApiKey = (key: string) => {
- localStorage.setItem('obs-api-key', key);
- setApiKeyState(key);
- };
-
- const clearApiKey = () => {
- localStorage.removeItem('obs-api-key');
- setApiKeyState(null);
- };
-
- const isAuthenticated = Boolean(apiKey);
-
- // Don't render children until we've loaded the API key from storage
- if (!isLoaded) {
- return Loading...
;
- }
-
- return (
-
- {children}
-
- );
-}
-
-export function useApiKey() {
- const context = useContext(ApiKeyContext);
- if (context === undefined) {
- throw new Error('useApiKey must be used within an ApiKeyProvider');
- }
- return context;
-}
\ No newline at end of file
diff --git a/docs/API.md b/docs/API.md
deleted file mode 100644
index be61781..0000000
--- a/docs/API.md
+++ /dev/null
@@ -1,288 +0,0 @@
-# API Documentation
-
-This document provides detailed information about all API endpoints available in the Live Stream Manager application.
-
-## Base URL
-All API endpoints are available at `/api/*` relative to your application's base URL.
-
-## Authentication
-All endpoints require API key authentication when the `API_KEY` environment variable is set. Include the API key in the `Authorization` header:
-
-```
-Authorization: Bearer your_api_key_here
-```
-
-Authentication is bypassed for localhost requests in development mode.
-
-## Response Format
-All endpoints return JSON responses in the following format:
-
-```json
-{
- "success": true,
- "data": { /* response data */ },
- "message": "Optional success message"
-}
-```
-
-Error responses:
-```json
-{
- "success": false,
- "error": "Error description",
- "message": "User-friendly error message"
-}
-```
-
-## Stream Management
-
-### GET /api/streams
-List all streams with team information.
-
-**Response:**
-```json
-{
- "success": true,
- "data": [
- {
- "id": 1,
- "name": "StreamerName",
- "obs_source_name": "TeamName_StreamerName",
- "url": "https://twitch.tv/streamername",
- "team_id": 1,
- "team_name": "Team Alpha"
- }
- ]
-}
-```
-
-### GET /api/streams/[id]
-Get individual stream details by ID.
-
-### POST /api/addStream
-Create new stream with browser source and team association.
-
-**Request Body:**
-```json
-{
- "name": "StreamerName",
- "url": "https://twitch.tv/streamername", // or just "streamername"
- "team_id": 1
-}
-```
-
-### PUT /api/streams/[id]
-Update stream information.
-
-### DELETE /api/streams/[id]
-Delete stream with comprehensive OBS cleanup:
-- Removes stream's nested scene
-- Deletes browser source
-- Removes from all source switchers
-- Clears text files referencing the stream
-
-## Source Control
-
-### POST /api/setActive
-Set active stream for screen position (writes team-prefixed name to text file).
-
-**Request Body:**
-```json
-{
- "screen": "large", // large, left, right, top_left, top_right, bottom_left, bottom_right
- "source": "TeamName_StreamerName"
-}
-```
-
-### GET /api/getActive
-Get currently active sources for all screen positions.
-
-**Response:**
-```json
-{
- "success": true,
- "data": {
- "large": "TeamName_StreamerName",
- "left": "TeamName_StreamerName2",
- "right": "",
- // ... other positions
- }
-}
-```
-
-## Team Management
-
-### GET /api/teams
-Get all teams with group information and sync status.
-
-**Response:**
-```json
-{
- "success": true,
- "data": [
- {
- "team_id": 1,
- "team_name": "Team Alpha",
- "group_name": "Team Alpha",
- "group_uuid": "abc123-def456-ghi789"
- }
- ]
-}
-```
-
-### POST /api/teams
-Create new team with optional OBS scene creation.
-
-**Request Body:**
-```json
-{
- "team_name": "New Team",
- "create_group": true // optional
-}
-```
-
-### PUT /api/teams/[teamId]
-Update team name, group_name, or group_uuid.
-
-### DELETE /api/teams/[teamId]
-Delete team with comprehensive OBS cleanup:
-- Deletes team scene/group
-- Removes team text source
-- Deletes all associated stream scenes
-- Removes all browser sources with team prefix
-- Clears all related text files
-
-### GET /api/getTeamName
-Get team name by ID.
-
-**Query Parameters:**
-- `teamId`: Team ID to lookup
-
-## OBS Group/Scene Management
-
-### POST /api/createGroup
-Create OBS scene from team and store UUID.
-
-**Request Body:**
-```json
-{
- "team_id": 1
-}
-```
-
-### POST /api/syncGroups
-Synchronize all teams with OBS groups. Updates database with current OBS scene information.
-
-### GET /api/verifyGroups
-Verify database groups exist in OBS with UUID tracking.
-
-**Response:**
-```json
-{
- "success": true,
- "data": {
- "teams": [
- {
- "team_id": 1,
- "team_name": "Team Alpha",
- "group_name": "Team Alpha",
- "group_uuid": "abc123",
- "status": "linked", // linked, name_changed, not_found
- "obs_name": "Team Alpha Modified" // if name changed in OBS
- }
- ],
- "orphanedGroups": [
- {
- "sceneName": "Orphaned Scene",
- "sceneUuid": "orphan123"
- }
- ]
- }
-}
-```
-
-Features:
-- Detects orphaned groups (excludes system scenes)
-- Identifies name mismatches
-- Shows sync status for all teams
-
-## OBS Scene Control
-
-### POST /api/setScene
-Switch OBS to specified scene (1-Screen, 2-Screen, 4-Screen).
-
-**Request Body:**
-```json
-{
- "scene": "2-Screen" // 1-Screen, 2-Screen, or 4-Screen
-}
-```
-
-### GET /api/getCurrentScene
-Get currently active OBS scene.
-
-**Response:**
-```json
-{
- "success": true,
- "data": {
- "currentScene": "2-Screen"
- }
-}
-```
-
-### POST /api/triggerTransition
-Trigger studio mode transition from preview to program (requires studio mode enabled).
-
-**Response:**
-```json
-{
- "success": true,
- "data": {
- "programScene": "2-Screen",
- "previewScene": "1-Screen"
- },
- "message": "Successfully transitioned preview to program"
-}
-```
-
-**Error Conditions:**
-- Studio mode not enabled (400 error)
-- OBS connection issues (500 error)
-
-## System Status
-
-### GET /api/obsStatus
-Real-time OBS connection, streaming, recording, and studio mode status.
-
-**Response:**
-```json
-{
- "success": true,
- "data": {
- "connected": true,
- "streaming": false,
- "recording": true,
- "studioMode": {
- "enabled": true,
- "previewScene": "1-Screen",
- "programScene": "2-Screen"
- }
- }
-}
-```
-
-## Error Codes
-
-- **400**: Bad Request - Invalid parameters or studio mode not enabled
-- **401**: Unauthorized - Missing or invalid API key
-- **404**: Not Found - Resource doesn't exist
-- **500**: Internal Server Error - OBS connection issues or server errors
-
-## Rate Limiting
-
-Currently no rate limiting is implemented, but consider implementing it for production deployments to prevent abuse.
-
-## WebSocket Integration
-
-The application maintains a persistent WebSocket connection to OBS Studio for real-time communication. All API endpoints use this shared connection for optimal performance.
\ No newline at end of file
diff --git a/lib/apiClient.ts b/lib/apiClient.ts
index 41170b6..5d386c7 100644
--- a/lib/apiClient.ts
+++ b/lib/apiClient.ts
@@ -1,13 +1,14 @@
// API client utility for making authenticated requests
-// Get API key from environment or localStorage
+// Get API key from environment (client-side will need to be provided differently)
function getApiKey(): string | null {
if (typeof window === 'undefined') {
// Server-side
return process.env.API_KEY || null;
} else {
- // Client-side - get from localStorage
- return localStorage.getItem('obs-api-key') || null;
+ // Client-side - for now, return null to bypass auth in development
+ // In production, this would come from a secure storage or context
+ return null;
}
}
diff --git a/lib/obsClient.js b/lib/obsClient.js
index 9910fb5..1232244 100644
--- a/lib/obsClient.js
+++ b/lib/obsClient.js
@@ -363,7 +363,7 @@ async function createTextSource(sceneName, textSourceName, text) {
}
}
-async function createStreamGroup(groupName, streamName, teamName, url, lockSources = true) {
+async function createStreamGroup(groupName, streamName, teamName, url) {
try {
const obsClient = await getOBSClient();
@@ -380,7 +380,7 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc
try {
await obsClient.call('CreateScene', { sceneName: streamGroupName });
console.log(`Created nested scene "${streamGroupName}" for stream grouping`);
- } catch {
+ } catch (error) {
console.log(`Nested scene "${streamGroupName}" might already exist`);
}
@@ -443,48 +443,14 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc
} catch (muteError) {
console.error(`Failed to mute browser source audio for "${sourceName}":`, muteError.message);
}
-
- // Lock the newly created browser source if requested
- if (lockSources) {
- try {
- // Get the scene items to find the browser source's ID
- const { sceneItems } = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName });
- const browserItem = sceneItems.find(item => item.sourceName === sourceName);
-
- if (browserItem) {
- await obsClient.call('SetSceneItemLocked', {
- sceneName: streamGroupName,
- sceneItemId: browserItem.sceneItemId,
- sceneItemLocked: true
- });
- console.log(`Locked browser source "${sourceName}" in nested scene`);
- }
- } catch (lockError) {
- console.error(`Failed to lock browser source "${sourceName}":`, lockError.message);
- }
- }
} else {
// Add existing source to nested scene
- const { sceneItemId } = await obsClient.call('CreateSceneItem', {
+ await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: sourceName
});
console.log(`Added existing browser source "${sourceName}" to nested scene`);
- // Lock the scene item if requested
- if (lockSources) {
- try {
- await obsClient.call('SetSceneItemLocked', {
- sceneName: streamGroupName,
- sceneItemId: sceneItemId,
- sceneItemLocked: true
- });
- console.log(`Locked browser source "${sourceName}" in nested scene`);
- } catch (lockError) {
- console.error(`Failed to lock browser source "${sourceName}":`, lockError.message);
- }
- }
-
// Ensure existing browser source has audio control enabled and correct URL
try {
await obsClient.call('SetInputSettings', {
@@ -516,50 +482,22 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc
const colorSourceName = `${textSourceName}_bg`;
try {
- const { sceneItemId: colorItemId } = await obsClient.call('CreateSceneItem', {
+ await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: colorSourceName
});
console.log(`Added color source background "${colorSourceName}" to nested scene`);
-
- // Lock the color source if requested
- if (lockSources) {
- try {
- await obsClient.call('SetSceneItemLocked', {
- sceneName: streamGroupName,
- sceneItemId: colorItemId,
- sceneItemLocked: true
- });
- console.log(`Locked color source background "${colorSourceName}"`);
- } catch (lockError) {
- console.error(`Failed to lock color source:`, lockError.message);
- }
- }
- } catch {
+ } catch (error) {
console.log('Color source background might already be in nested scene');
}
try {
- const { sceneItemId: textItemId } = await obsClient.call('CreateSceneItem', {
+ await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: textSourceName
});
console.log(`Added text source "${textSourceName}" to nested scene`);
-
- // Lock the text source if requested
- if (lockSources) {
- try {
- await obsClient.call('SetSceneItemLocked', {
- sceneName: streamGroupName,
- sceneItemId: textItemId,
- sceneItemLocked: true
- });
- console.log(`Locked text source "${textSourceName}"`);
- } catch (lockError) {
- console.error(`Failed to lock text source:`, lockError.message);
- }
- }
- } catch {
+ } catch (error) {
console.log('Text source might already be in nested scene');
}
diff --git a/middleware.ts b/middleware.ts
index a20b5db..8361919 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -8,8 +8,8 @@ export function middleware(request: NextRequest) {
return NextResponse.next();
}
- // Check for API key in header or URL parameter
- const apiKey = request.headers.get('x-api-key') || request.nextUrl.searchParams.get('apikey');
+ // Check for API key in header
+ const apiKey = request.headers.get('x-api-key');
const validKey = process.env.API_KEY;
// If API_KEY is not set in environment, skip authentication (development mode)
diff --git a/styles/Home.module.css b/styles/Home.module.css
new file mode 100644
index 0000000..c890880
--- /dev/null
+++ b/styles/Home.module.css
@@ -0,0 +1,10 @@
+.linkButton {
+ padding: 10px 20px;
+ background: #0070f3;
+ color: #fff;
+ text-decoration: none;
+ border-radius: 5px;
+ display: inline-block;
+ text-align: center;
+ }
+
\ No newline at end of file