diff --git a/CLAUDE.md b/CLAUDE.md
index 2bda7ca..17bd6d4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,10 +23,11 @@ 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
+- **Backend**: Next.js API routes with authentication middleware
- **Database**: SQLite with sqlite3 driver
- **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio
-- **Styling**: Solarized Dark theme with CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components
+- **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
### Project Structure
- `/app` - Next.js App Router pages and API routes
@@ -34,7 +35,8 @@ 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)
+- `/components` - Reusable React components (Header, Footer, Dropdown, Toast, CollapsibleGroup)
+- `/middleware.ts` - API authentication middleware for security
- `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client with persistent connection management
@@ -76,6 +78,10 @@ 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)
@@ -117,9 +123,10 @@ 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 and streaming status
+- `GET /api/obsStatus` - Real-time OBS connection, streaming, recording, and studio mode status
### Database Schema
@@ -188,6 +195,9 @@ 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
@@ -212,6 +222,8 @@ 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
@@ -247,12 +259,15 @@ 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 d44ace2..ce5b8e9 100644
--- a/README.md
+++ b/README.md
@@ -7,19 +7,22 @@ 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 and OBS connection status
-- **Optimized Performance**: Reduced code duplication and standardized API responses
+- **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
## Quick Start
@@ -109,54 +112,22 @@ npm run type-check # TypeScript validation
- **Styling**: Custom CSS with glass morphism and Tailwind utilities
- **CI/CD**: Forgejo workflows with self-hosted runners
-## API Endpoints
+## API Documentation
-### 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
+The application provides a comprehensive REST API for managing streams, teams, and OBS integration.
-### 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
+**đ [Complete API Documentation](docs/API.md)**
-### 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
+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
-### 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
+All endpoints support API key authentication for production deployments.
-### 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.
+See [`CLAUDE.md`](CLAUDE.md) for detailed architecture documentation and [`docs/API.md`](docs/API.md) for complete endpoint specifications.
## Known Issues
@@ -167,7 +138,7 @@ See `CLAUDE.md` for detailed architecture documentation and implementation detai
### System Scene Exclusion
Infrastructure scenes containing source switchers are excluded from orphaned group detection:
-- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies
+- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies, Resources
- 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 41f6389..1e1eea1 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 bd09053..d7e431b 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, error) => ({
+ createDatabaseError: jest.fn((operation) => ({
error: 'Database Error',
status: 500,
json: async () => ({
diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts
index 9347106..75a62a3 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;
+ let name: string, url: string, team_id: number, obs_source_name: string, lockSources: boolean;
// Parse and validate request body
try {
@@ -78,6 +78,7 @@ 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 });
@@ -125,7 +126,7 @@ export async function POST(request: NextRequest) {
if (!sourceExists) {
// Create stream group with text overlay
- await createStreamGroup(groupName, name, teamInfo.team_name, url);
+ await createStreamGroup(groupName, name, teamInfo.team_name, url, lockSources);
// 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 13e8dc4..91c85f6 100644
--- a/app/api/obsStatus/route.ts
+++ b/app/api/obsStatus/route.ts
@@ -19,9 +19,11 @@ export async function GET() {
obsWebSocketVersion: string;
};
currentScene?: string;
+ currentPreviewScene?: string;
sceneCount?: number;
streaming?: boolean;
recording?: boolean;
+ studioModeEnabled?: boolean;
error?: string;
} = {
host: OBS_HOST,
@@ -58,15 +60,31 @@ 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 548e003..2363d32 100644
--- a/app/api/setScene/route.ts
+++ b/app/api/setScene/route.ts
@@ -32,16 +32,30 @@ export async function POST(request: NextRequest) {
try {
const obsClient = await getOBSClient();
- // Switch to the requested scene
- await obsClient.call('SetCurrentProgramScene', { sceneName });
+ // Check if studio mode is active
+ const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled');
- console.log(`Successfully switched to scene: ${sceneName}`);
-
- return NextResponse.json({
- success: true,
- data: { sceneName },
- message: `Switched to ${sceneName} layout`
- });
+ 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`
+ });
+ }
} 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
new file mode 100644
index 0000000..7136863
--- /dev/null
+++ b/app/api/triggerTransition/route.ts
@@ -0,0 +1,62 @@
+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 e1107df..dac679e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -20,6 +20,14 @@
--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 */
@@ -34,8 +42,8 @@ html {
}
body {
- background: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%);
- color: #93a1a1;
+ background: var(--gradient-body);
+ color: var(--solarized-base1);
min-height: 100vh;
line-height: 1.6;
}
@@ -65,7 +73,8 @@ body {
}
/* Glass Card Component */
-.glass {
+.glass,
+.glass-panel {
background: rgba(7, 54, 66, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(88, 110, 117, 0.3);
@@ -75,8 +84,8 @@ body {
/* Modern Button System */
.btn {
- background: linear-gradient(135deg, #268bd2, #2aa198);
- color: #fdf6e3;
+ background: var(--gradient-primary);
+ color: var(--solarized-base3);
border: none;
padding: 12px 24px;
border-radius: 12px;
@@ -93,9 +102,10 @@ body {
text-decoration: none;
}
-.btn.active {
- background: linear-gradient(135deg, #859900, #b58900);
- color: #fdf6e3;
+.btn.active,
+.btn-success {
+ background: var(--gradient-active);
+ color: var(--solarized-base3);
box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5);
transform: translateY(-1px);
font-weight: 700;
@@ -117,7 +127,7 @@ body {
.btn-secondary {
background: rgba(88, 110, 117, 0.3);
border: 1px solid rgba(131, 148, 150, 0.4);
- color: #93a1a1;
+ color: var(--solarized-base1);
backdrop-filter: blur(10px);
}
@@ -127,25 +137,14 @@ 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: linear-gradient(135deg, #dc322f, #cb4b16);
- color: #fdf6e3;
+ background: var(--gradient-danger);
+ color: var(--solarized-base3);
}
.btn-danger:hover {
- background: linear-gradient(135deg, #cb4b16, #dc322f);
+ background: linear-gradient(135deg, var(--solarized-orange), var(--solarized-red));
box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4);
}
@@ -169,6 +168,18 @@ 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;
@@ -194,7 +205,7 @@ body {
border: 1px solid rgba(88, 110, 117, 0.4);
border-radius: 12px;
padding: 12px 16px;
- color: #93a1a1;
+ color: var(--solarized-base1);
width: 100%;
transition: all 0.3s ease;
}
@@ -205,7 +216,7 @@ body {
.input:focus {
outline: none;
- border-color: #268bd2;
+ border-color: var(--solarized-blue);
box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2);
}
@@ -215,7 +226,7 @@ body {
border: 1px solid rgba(88, 110, 117, 0.4);
border-radius: 12px;
padding: 12px 16px;
- color: #93a1a1;
+ color: var(--solarized-base1);
width: 100%;
text-align: left;
cursor: pointer;
@@ -241,6 +252,28 @@ 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 {
@@ -248,7 +281,7 @@ body {
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid rgba(88, 110, 117, 0.2);
- color: #93a1a1;
+ color: var(--solarized-base1);
}
.dropdown-item:last-child {
@@ -261,7 +294,7 @@ body {
.dropdown-item.active {
background: rgba(38, 139, 210, 0.3);
- color: #fdf6e3;
+ color: var(--solarized-base3);
}
/* Icon Sizes */
@@ -356,6 +389,22 @@ 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;
}
@@ -366,4 +415,134 @@ body {
.p-8 {
padding: 32px;
-}
\ No newline at end of file
+}
+
+/* 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
diff --git a/app/layout.tsx b/app/layout.tsx
index c3ab1bc..4b3dbbc 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 PerformanceDashboard from '@/components/PerformanceDashboard';
+import { ApiKeyProvider } from '@/contexts/ApiKeyContext';
export const metadata = {
title: 'Live Stream Manager',
@@ -13,14 +13,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
-
-
-
- {children}
-
-
-
-
+
+
+
+
+ {children}
+
+
+
+
);
diff --git a/app/page.tsx b/app/page.tsx
index 7094cd5..3ed4197 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -19,6 +19,8 @@ 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
@@ -59,17 +61,19 @@ export default function Home() {
const fetchData = useCallback(async () => {
const endTimer = PerformanceMonitor.startTimer('fetchData');
try {
- // Fetch streams, active sources, and current scene in parallel
- const [streamsRes, activeRes, sceneRes] = await Promise.all([
+ // Fetch streams, active sources, current scene, and OBS status in parallel
+ const [streamsRes, activeRes, sceneRes, obsStatusRes] = await Promise.all([
fetch('/api/streams'),
fetch('/api/getActive'),
- fetch('/api/getCurrentScene')
+ fetch('/api/getCurrentScene'),
+ fetch('/api/obsStatus')
]);
- const [streamsData, activeData, sceneData] = await Promise.all([
+ const [streamsData, activeData, sceneData, obsStatusData] = await Promise.all([
streamsRes.json(),
activeRes.json(),
- sceneRes.json()
+ sceneRes.json(),
+ obsStatusRes.json()
]);
// Handle both old and new API response formats
@@ -80,6 +84,15 @@ 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.');
@@ -126,9 +139,16 @@ export default function Home() {
const result = await response.json();
if (result.success) {
- // Update local state immediately for responsive UI
- setCurrentScene(sceneName);
- showSuccess('Scene Changed', `Switched to ${sceneName} layout`);
+ // 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`);
+ }
} else {
throw new Error(result.error || 'Failed to switch scene');
}
@@ -138,6 +158,86 @@ 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' },
@@ -182,17 +282,29 @@ export default function Home() {
Primary Display
-
+
+ {(() => {
+ const buttonState = getSceneButtonState('1-Screen');
+ return (
+ <>
+
+ {buttonState.showTransition && (
+
+ )}
+ >
+ );
+ })()}
+
Side Displays
-
+
+ {(() => {
+ const buttonState = getSceneButtonState('2-Screen');
+ return (
+ <>
+
+ {buttonState.showTransition && (
+
+ )}
+ >
+ );
+ })()}
+
@@ -252,17 +376,29 @@ 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
new file mode 100644
index 0000000..4db20db
--- /dev/null
+++ b/app/performance/page.tsx
@@ -0,0 +1,125 @@
+'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
new file mode 100644
index 0000000..13d2692
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,158 @@
+'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 2e4f52f..fb1896c 100644
--- a/app/streams/page.tsx
+++ b/app/streams/page.tsx
@@ -1,7 +1,8 @@
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } 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';
@@ -14,6 +15,172 @@ 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: '',
@@ -312,61 +479,11 @@ export default function AddStream() {
Create your first stream above!
) : (
-
- {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
-
-
-
-
-
-
- );
- })}
-
+
setDeleteConfirm({ id: stream.id, name: stream.name })}
+ />
)}
diff --git a/components/ApiKeyPrompt.tsx b/components/ApiKeyPrompt.tsx
new file mode 100644
index 0000000..a42611a
--- /dev/null
+++ b/components/ApiKeyPrompt.tsx
@@ -0,0 +1,152 @@
+'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
new file mode 100644
index 0000000..0ea3c54
--- /dev/null
+++ b/components/CollapsibleGroup.tsx
@@ -0,0 +1,64 @@
+'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 edbcb26..d7c4f77 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -13,9 +13,11 @@ type OBSStatus = {
obsWebSocketVersion: string;
};
currentScene?: string;
+ currentPreviewScene?: string;
sceneCount?: number;
streaming?: boolean;
recording?: boolean;
+ studioModeEnabled?: boolean;
error?: string;
};
@@ -83,8 +85,8 @@ export default function Footer() {
OBS Studio
-
-
+
+
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
@@ -96,17 +98,22 @@ export default function Footer() {
{obsStatus.host}:{obsStatus.port}
{obsStatus.hasPassword &&
đ Authenticated
}
- {/* Streaming/Recording Status */}
+ {/* Streaming/Recording/Studio Mode Status */}
{obsStatus.connected && (
-
-
-
-
{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
+
+
+
+
{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
-
-
-
{obsStatus.recording ? 'REC' : 'IDLE'}
+
+
+
{obsStatus.recording ? 'REC' : 'IDLE'}
+
+
+
+
+
{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}
)}
@@ -138,11 +145,18 @@ export default function Footer() {
{obsStatus.currentScene && (
- Scene:
+ {obsStatus.studioModeEnabled ? 'Program:' : '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 44f8cc6..5c4d9ec 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -50,6 +50,24 @@ export default function Header() {
đĨ
Teams
+
+
+ âī¸
+ Settings
+
+
+ {process.env.NODE_ENV === 'development' && (
+
+ đ
+ Perf
+
+ )}
diff --git a/components/PerformanceDashboard.tsx b/components/PerformanceDashboard.tsx
index cfb96ed..b18c95c 100644
--- a/components/PerformanceDashboard.tsx
+++ b/components/PerformanceDashboard.tsx
@@ -36,22 +36,27 @@ 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-400' : 'text-yellow-400'}>
+ Max:{' '}
+ 100 ? 'text-red' : 'text-yellow'}>
{metric.max.toFixed(2)}ms
@@ -96,11 +101,11 @@ export default function PerformanceDashboard() {
-
+
{metric.avg < 50 ? 'Excellent' :
metric.avg < 100 ? 'Good' : 'Needs Optimization'}
@@ -111,11 +116,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
@@ -124,8 +129,9 @@ export default function PerformanceDashboard() {
)}
+
)}
-
+ >
);
}
\ No newline at end of file
diff --git a/contexts/ApiKeyContext.tsx b/contexts/ApiKeyContext.tsx
new file mode 100644
index 0000000..829d266
--- /dev/null
+++ b/contexts/ApiKeyContext.tsx
@@ -0,0 +1,57 @@
+'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
new file mode 100644
index 0000000..be61781
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,288 @@
+# 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 5d386c7..41170b6 100644
--- a/lib/apiClient.ts
+++ b/lib/apiClient.ts
@@ -1,14 +1,13 @@
// API client utility for making authenticated requests
-// Get API key from environment (client-side will need to be provided differently)
+// Get API key from environment or localStorage
function getApiKey(): string | null {
if (typeof window === 'undefined') {
// Server-side
return process.env.API_KEY || null;
} else {
- // Client-side - for now, return null to bypass auth in development
- // In production, this would come from a secure storage or context
- return null;
+ // Client-side - get from localStorage
+ return localStorage.getItem('obs-api-key') || null;
}
}
diff --git a/lib/obsClient.js b/lib/obsClient.js
index 1232244..9910fb5 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) {
+async function createStreamGroup(groupName, streamName, teamName, url, lockSources = true) {
try {
const obsClient = await getOBSClient();
@@ -380,7 +380,7 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
try {
await obsClient.call('CreateScene', { sceneName: streamGroupName });
console.log(`Created nested scene "${streamGroupName}" for stream grouping`);
- } catch (error) {
+ } catch {
console.log(`Nested scene "${streamGroupName}" might already exist`);
}
@@ -443,14 +443,48 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
} 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
- await obsClient.call('CreateSceneItem', {
+ const { sceneItemId } = 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', {
@@ -482,22 +516,50 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
const colorSourceName = `${textSourceName}_bg`;
try {
- await obsClient.call('CreateSceneItem', {
+ const { sceneItemId: colorItemId } = await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: colorSourceName
});
console.log(`Added color source background "${colorSourceName}" to nested scene`);
- } catch (error) {
+
+ // 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 {
console.log('Color source background might already be in nested scene');
}
try {
- await obsClient.call('CreateSceneItem', {
+ const { sceneItemId: textItemId } = await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: textSourceName
});
console.log(`Added text source "${textSourceName}" to nested scene`);
- } catch (error) {
+
+ // 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 {
console.log('Text source might already be in nested scene');
}
diff --git a/middleware.ts b/middleware.ts
index 8361919..a20b5db 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
- const apiKey = request.headers.get('x-api-key');
+ // Check for API key in header or URL parameter
+ const apiKey = request.headers.get('x-api-key') || request.nextUrl.searchParams.get('apikey');
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
deleted file mode 100644
index c890880..0000000
--- a/styles/Home.module.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.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