Merge pull request 'Add studio mode support and consolidate CSS architecture' (#13) from studio-mode-support into main
All checks were successful
Lint and Build / build (push) Successful in 2m50s
All checks were successful
Lint and Build / build (push) Successful in 2m50s
Reviewed-on: #13
This commit is contained in:
commit
e4418a1e12
25 changed files with 1711 additions and 264 deletions
23
CLAUDE.md
23
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
|
||||
|
|
63
README.md
63
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`
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { GET } from '../streams/route';
|
||||
// import { GET } from '../streams/route';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@/lib/database', () => ({
|
||||
|
|
|
@ -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 () => ({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
62
app/api/triggerTransition/route.ts
Normal file
62
app/api/triggerTransition/route.ts
Normal file
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
237
app/globals.css
237
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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); }
|
|
@ -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 (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<Footer />
|
||||
<PerformanceDashboard />
|
||||
<ApiKeyProvider>
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<Footer />
|
||||
</ApiKeyProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
218
app/page.tsx
218
app/page.tsx
|
@ -19,6 +19,8 @@ export default function Home() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const [currentScene, setCurrentScene] = useState<string | null>(null);
|
||||
const [currentPreviewScene, setCurrentPreviewScene] = useState<string | null>(null);
|
||||
const [studioModeEnabled, setStudioModeEnabled] = useState<boolean>(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() {
|
|||
<div className="glass p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Primary Display</h2>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('1-Screen')}
|
||||
className={`btn ${currentScene === '1-Screen' ? 'active' : ''}`}
|
||||
style={{
|
||||
background: currentScene === '1-Screen'
|
||||
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||
}}
|
||||
>
|
||||
{currentScene === '1-Screen' ? 'Active: 1-Screen' : 'Switch to 1-Screen'}
|
||||
</button>
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('1-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('1-Screen')}
|
||||
className={`btn ${buttonState.className}`}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn btn-scene-transition ml-3"
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<Dropdown
|
||||
|
@ -210,17 +322,29 @@ export default function Home() {
|
|||
<div className="glass p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Side Displays</h2>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('2-Screen')}
|
||||
className={`btn ${currentScene === '2-Screen' ? 'active' : ''}`}
|
||||
style={{
|
||||
background: currentScene === '2-Screen'
|
||||
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||
}}
|
||||
>
|
||||
{currentScene === '2-Screen' ? 'Active: 2-Screen' : 'Switch to 2-Screen'}
|
||||
</button>
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('2-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('2-Screen')}
|
||||
className={`btn ${buttonState.className}`}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn btn-scene-transition ml-3"
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
|
@ -252,17 +376,29 @@ export default function Home() {
|
|||
<div className="glass p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="card-title mb-0">Corner Displays</h2>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('4-Screen')}
|
||||
className={`btn ${currentScene === '4-Screen' ? 'active' : ''}`}
|
||||
style={{
|
||||
background: currentScene === '4-Screen'
|
||||
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||
}}
|
||||
>
|
||||
{currentScene === '4-Screen' ? 'Active: 4-Screen' : 'Switch to 4-Screen'}
|
||||
</button>
|
||||
<div className="flex">
|
||||
{(() => {
|
||||
const buttonState = getSceneButtonState('4-Screen');
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSceneSwitch('4-Screen')}
|
||||
className={`btn ${buttonState.className}`}
|
||||
>
|
||||
{buttonState.text}
|
||||
</button>
|
||||
{buttonState.showTransition && (
|
||||
<button
|
||||
onClick={handleTransition}
|
||||
className="btn btn-scene-transition ml-3"
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-4">
|
||||
{cornerDisplays.map(({ screen, label }) => (
|
||||
|
|
125
app/performance/page.tsx
Normal file
125
app/performance/page.tsx
Normal file
|
@ -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<PerformanceMetrics>({});
|
||||
|
||||
useEffect(() => {
|
||||
const updateMetrics = () => {
|
||||
setMetrics(PerformanceMonitor.getAllMetrics());
|
||||
};
|
||||
|
||||
// Update metrics every 2 seconds
|
||||
updateMetrics();
|
||||
const interval = setInterval(updateMetrics, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl" style={{ paddingBottom: '48px' }}>
|
||||
<div className="glass-panel p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Performance Metrics</h1>
|
||||
|
||||
{Object.keys(metrics).length === 0 ? (
|
||||
<div className="glass-panel p-6 border border-base01 text-center">
|
||||
<p className="text-base1">No metrics collected yet. Navigate around the app to see performance data.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid" style={{ gap: '24px' }}>
|
||||
{Object.entries(metrics).map(([label, metric]) => {
|
||||
if (!metric) return null;
|
||||
|
||||
return (
|
||||
<div key={label} className="glass-panel p-6 border border-base01">
|
||||
<h2 className="font-semibold text-blue text-lg mb-4">
|
||||
{label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4" style={{ gap: '16px' }}>
|
||||
<div className="glass-panel p-4 border border-base01">
|
||||
<span className="text-base1 text-sm block mb-2">Average</span>
|
||||
<span className="text-green text-xl font-semibold">{metric.avg.toFixed(2)}ms</span>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 border border-base01">
|
||||
<span className="text-base1 text-sm block mb-2">Min</span>
|
||||
<span className="text-green text-xl font-semibold">{metric.min.toFixed(2)}ms</span>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 border border-base01">
|
||||
<span className="text-base1 text-sm block mb-2">Max</span>
|
||||
<span className={`text-xl font-semibold ${metric.max > 100 ? 'text-red' : 'text-yellow'}`}>
|
||||
{metric.max.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 border border-base01">
|
||||
<span className="text-base1 text-sm block mb-2">Count</span>
|
||||
<span className="text-blue text-xl font-semibold">{metric.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance indicator bar */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center" style={{ gap: '16px' }}>
|
||||
<div className="flex-1 h-2 bg-base02 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
metric.avg < 50 ? 'bg-green' :
|
||||
metric.avg < 100 ? 'bg-yellow' : 'bg-red'
|
||||
}`}
|
||||
style={{ width: `${Math.min((metric.avg / 200) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-base1 min-w-[100px] text-right">
|
||||
{metric.avg < 50 ? 'Excellent' :
|
||||
metric.avg < 100 ? 'Good' : 'Needs Optimization'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Performance tips */}
|
||||
<div className="glass-panel p-6 border border-yellow/30">
|
||||
<h2 className="font-semibold text-yellow text-lg mb-4">💡 Performance Tips</h2>
|
||||
<div className="grid md:grid-cols-2 text-base1" style={{ gap: '24px' }}>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Response Times</h3>
|
||||
<ul className="text-sm space-y-2" style={{ paddingLeft: '8px' }}>
|
||||
<li>< 50ms - Excellent user experience</li>
|
||||
<li>50-100ms - Good, barely noticeable</li>
|
||||
<li>100-300ms - Noticeable delay</li>
|
||||
<li>> 300ms - Frustrating for users</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Optimization Strategies</h3>
|
||||
<ul className="text-sm space-y-2" style={{ paddingLeft: '8px' }}>
|
||||
<li>Monitor fetchData and setActive timings</li>
|
||||
<li>High max values indicate performance spikes</li>
|
||||
<li>Consider caching for frequently called APIs</li>
|
||||
<li>Batch multiple requests when possible</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
158
app/settings/page.tsx
Normal file
158
app/settings/page.tsx
Normal file
|
@ -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 (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl" style={{ paddingBottom: '48px' }}>
|
||||
<div className="glass-panel p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Settings</h1>
|
||||
|
||||
{/* API Key Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">API Key Authentication</h2>
|
||||
<p className="text-base1 mb-6">
|
||||
API keys are required when accessing this application from external networks.
|
||||
The key is stored securely in your browser's local storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
<div className="glass-panel p-4 border border-base01">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-1">Current Status</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-green rounded-full"></div>
|
||||
<span className="text-green text-sm">Authenticated</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-yellow rounded-full"></div>
|
||||
<span className="text-yellow text-sm">No API key set</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={handleClearKey}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Clear Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4" style={{ marginTop: '24px' }}>
|
||||
<div>
|
||||
<label htmlFor="apiKey" className="block text-sm font-medium text-base1 mb-2">
|
||||
{isAuthenticated ? 'Update API Key' : 'Enter API Key'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="w-full text-white focus:outline-none focus:ring-2 focus:ring-blue transition-all"
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'rgba(7, 54, 66, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(88, 110, 117, 0.3)',
|
||||
borderRadius: '12px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel p-3 border border-red/30">
|
||||
<p className="text-red text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="glass-panel p-3 border border-green/30">
|
||||
<p className="text-green text-sm">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn w-full"
|
||||
>
|
||||
{isLoading ? 'Validating...' : (isAuthenticated ? 'Update API Key' : 'Save API Key')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Information Section */}
|
||||
<div className="glass-panel p-6 border border-blue/30" style={{ marginTop: '24px' }}>
|
||||
<h3 className="font-medium text-blue text-sm mb-3">ℹ️ Information</h3>
|
||||
<ul className="text-xs text-base1 space-y-1" style={{ paddingLeft: '8px' }}>
|
||||
<li>API keys are only required for external network access</li>
|
||||
<li>Local network access bypasses authentication automatically</li>
|
||||
<li>Keys are validated against the server before saving</li>
|
||||
<li>Your API key is stored locally and never transmitted unnecessarily</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<Set<number>>(new Set());
|
||||
const [useCustomExpanded, setUseCustomExpanded] = useState(false);
|
||||
|
||||
// Group streams by team
|
||||
const streamsByTeam = useMemo(() => {
|
||||
const grouped = new Map<number, Stream[]>();
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
{streamsByTeam.length > 0 && (
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleExpandAll}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCollapseAll}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{streamsByTeam.map(({ teamId, teamName, streams: teamStreams }) => (
|
||||
<CollapsibleGroup
|
||||
key={teamId}
|
||||
title={teamName}
|
||||
itemCount={teamStreams.length}
|
||||
defaultOpen={teamStreams.length <= 10}
|
||||
isOpen={useCustomExpanded ? expandedGroups.has(teamId) : undefined}
|
||||
onToggle={() => handleToggleGroup(teamId)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{teamStreams.map((stream) => (
|
||||
<div key={stream.id} className="glass p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0 mr-4"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
fontSize: '24px'
|
||||
}}
|
||||
>
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white">{stream.name}</div>
|
||||
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={stream.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary text-sm mr-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
View Stream
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onDelete(stream)}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddStream() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
|
@ -312,61 +479,11 @@ export default function AddStream() {
|
|||
<div className="text-white/40 text-sm">Create your first stream above!</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream) => {
|
||||
const team = teams.find(t => t.id === stream.team_id);
|
||||
return (
|
||||
<div key={stream.id} className="glass p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
fontSize: '24px',
|
||||
marginRight: '16px'
|
||||
}}
|
||||
>
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white">{stream.name}</div>
|
||||
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
|
||||
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={stream.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
View Stream
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<StreamsByTeam
|
||||
streams={streams}
|
||||
teams={teams}
|
||||
onDelete={(stream) => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
152
components/ApiKeyPrompt.tsx
Normal file
152
components/ApiKeyPrompt.tsx
Normal file
|
@ -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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="glass-panel p-6 max-w-md w-full mx-4">
|
||||
<h2 className="text-xl font-bold text-white mb-4">API Key Required</h2>
|
||||
<p className="text-base1 mb-4">
|
||||
This application requires an API key for access. Please enter your API key to continue.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="apiKey" className="block text-sm font-medium text-base1 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="w-full text-white focus:outline-none focus:ring-2 focus:ring-blue transition-all"
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'rgba(7, 54, 66, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(88, 110, 117, 0.3)',
|
||||
borderRadius: '12px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn flex-1"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary px-4 py-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeyBanner() {
|
||||
const { isAuthenticated, clearApiKey } = useApiKey();
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="glass-panel mx-4 mt-4 px-4 py-2 text-sm flex justify-between items-center border border-green/30">
|
||||
<span className="text-green flex items-center gap-2">
|
||||
<span className="text-green">✓</span>
|
||||
Authenticated
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowPrompt(true)}
|
||||
className="text-base1 hover:text-white underline transition-colors"
|
||||
>
|
||||
Change Key
|
||||
</button>
|
||||
<button
|
||||
onClick={clearApiKey}
|
||||
className="text-base1 hover:text-white underline transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<ApiKeyPrompt show={showPrompt} onClose={() => setShowPrompt(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="glass-panel mx-4 mt-4 px-4 py-2 text-sm flex justify-between items-center border border-yellow/30">
|
||||
<span className="text-yellow flex items-center gap-2">
|
||||
<span className="text-yellow">⚠️</span>
|
||||
API key required for full access
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowPrompt(true)}
|
||||
className="text-base1 hover:text-white underline transition-colors"
|
||||
>
|
||||
Enter API Key
|
||||
</button>
|
||||
</div>
|
||||
<ApiKeyPrompt show={showPrompt} onClose={() => setShowPrompt(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
64
components/CollapsibleGroup.tsx
Normal file
64
components/CollapsibleGroup.tsx
Normal file
|
@ -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 (
|
||||
<div className="collapsible-group">
|
||||
<button
|
||||
className="collapsible-header"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="collapsible-header-content">
|
||||
<svg
|
||||
className={`collapsible-icon ${isOpen ? 'open' : ''}`}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<h3 className="collapsible-title">{title}</h3>
|
||||
<span className="collapsible-count">{itemCount}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={`collapsible-content ${isOpen ? 'open' : ''}`}>
|
||||
<div className="collapsible-content-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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() {
|
|||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold mb-2">OBS Studio</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`}></div>
|
||||
<div className="flex items-center">
|
||||
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'} mr-1`}></div>
|
||||
<p className="text-sm opacity-60">
|
||||
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
|
@ -96,17 +98,22 @@ export default function Footer() {
|
|||
<div>{obsStatus.host}:{obsStatus.port}</div>
|
||||
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
||||
|
||||
{/* Streaming/Recording Status */}
|
||||
{/* Streaming/Recording/Studio Mode Status */}
|
||||
{obsStatus.connected && (
|
||||
<div className="flex gap-4 mt-4">
|
||||
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
||||
<div className="flex flex-wrap gap-6 mt-4">
|
||||
<div className={`flex items-center ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
||||
<span className="text-sm font-medium mr-2">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
||||
<div className={`flex items-center ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
||||
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
||||
<span className="text-sm font-medium mr-2">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${obsStatus.studioModeEnabled ? 'text-yellow-400' : 'opacity-60'}`}>
|
||||
<div className={`status-dot ${obsStatus.studioModeEnabled ? 'connected' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
||||
<span className="text-sm font-medium">{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -138,11 +145,18 @@ export default function Footer() {
|
|||
<div className="space-y-2 text-sm">
|
||||
{obsStatus.currentScene && (
|
||||
<div className="flex justify-between">
|
||||
<span>Scene:</span>
|
||||
<span>{obsStatus.studioModeEnabled ? 'Program:' : 'Scene:'}</span>
|
||||
<span className="font-medium">{obsStatus.currentScene}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obsStatus.studioModeEnabled && obsStatus.currentPreviewScene && (
|
||||
<div className="flex justify-between">
|
||||
<span>Preview:</span>
|
||||
<span className="font-medium text-yellow-400">{obsStatus.currentPreviewScene}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obsStatus.sceneCount !== null && (
|
||||
<div className="flex justify-between">
|
||||
<span>Total Scenes:</span>
|
||||
|
|
|
@ -50,6 +50,24 @@ export default function Header() {
|
|||
<span className="icon">👥</span>
|
||||
Teams
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`btn ${isActive('/settings') ? 'active' : ''}`}
|
||||
>
|
||||
<span className="icon">⚙️</span>
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Link
|
||||
href="/performance"
|
||||
className={`btn ${isActive('/performance') ? 'active' : ''}`}
|
||||
>
|
||||
<span className="icon">📊</span>
|
||||
Perf
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -36,22 +36,27 @@ export default function PerformanceDashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
{!isVisible ? (
|
||||
<button
|
||||
onClick={() => setIsVisible(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium shadow-lg"
|
||||
title="Show Performance Metrics"
|
||||
>
|
||||
📊 Perf
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-black/90 backdrop-blur-sm text-white rounded-lg p-4 max-w-md max-h-96 overflow-y-auto shadow-xl border border-white/20">
|
||||
<>
|
||||
{!isVisible && (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => setIsVisible(true)}
|
||||
className="btn text-sm"
|
||||
title="Show Performance Metrics"
|
||||
>
|
||||
📊 Perf
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="glass-panel p-6 max-w-md max-h-96 overflow-y-auto border border-base01 shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-lg font-semibold">Performance Metrics</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Performance Metrics</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-white/60 hover:text-white text-xl leading-none"
|
||||
className="text-base1 hover:text-white text-xl leading-none transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
|
@ -59,33 +64,33 @@ export default function PerformanceDashboard() {
|
|||
</div>
|
||||
|
||||
{Object.keys(metrics).length === 0 ? (
|
||||
<p className="text-white/60 text-sm">No metrics collected yet.</p>
|
||||
<p className="text-base1 text-sm">No metrics collected yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(metrics).map(([label, metric]) => {
|
||||
if (!metric) return null;
|
||||
|
||||
return (
|
||||
<div key={label} className="bg-white/5 rounded p-3">
|
||||
<h4 className="font-medium text-blue-300 text-sm mb-2">
|
||||
<div key={label} className="glass-panel p-3 border border-base01">
|
||||
<h4 className="font-medium text-blue text-sm mb-2">
|
||||
{label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-white/60">Avg:</span>{' '}
|
||||
<span className="text-green-400">{metric.avg.toFixed(2)}ms</span>
|
||||
<span className="text-base1">Avg:</span>{' '}
|
||||
<span className="text-green">{metric.avg.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">Count:</span>{' '}
|
||||
<span className="text-blue-400">{metric.count}</span>
|
||||
<span className="text-base1">Count:</span>{' '}
|
||||
<span className="text-blue">{metric.count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">Min:</span>{' '}
|
||||
<span className="text-green-400">{metric.min.toFixed(2)}ms</span>
|
||||
<span className="text-base1">Min:</span>{' '}
|
||||
<span className="text-green">{metric.min.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">Max:</span>{' '}
|
||||
<span className={metric.max > 100 ? 'text-red-400' : 'text-yellow-400'}>
|
||||
<span className="text-base1">Max:</span>{' '}
|
||||
<span className={metric.max > 100 ? 'text-red' : 'text-yellow'}>
|
||||
{metric.max.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
|
@ -96,11 +101,11 @@ export default function PerformanceDashboard() {
|
|||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
metric.avg < 50 ? 'bg-green-500' :
|
||||
metric.avg < 100 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
metric.avg < 50 ? 'bg-green' :
|
||||
metric.avg < 100 ? 'bg-yellow' : 'bg-red'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-white/60">
|
||||
<span className="text-xs text-base1">
|
||||
{metric.avg < 50 ? 'Excellent' :
|
||||
metric.avg < 100 ? 'Good' : 'Needs Optimization'}
|
||||
</span>
|
||||
|
@ -111,11 +116,11 @@ export default function PerformanceDashboard() {
|
|||
})}
|
||||
|
||||
{/* Performance tips */}
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded p-3 mt-4">
|
||||
<h4 className="font-medium text-yellow-300 text-sm mb-2">
|
||||
<div className="glass-panel p-3 mt-4 border border-yellow/30">
|
||||
<h4 className="font-medium text-yellow text-sm mb-2">
|
||||
💡 Performance Tips
|
||||
</h4>
|
||||
<ul className="text-xs text-white/80 space-y-1">
|
||||
<ul className="text-xs text-base1 space-y-1">
|
||||
<li>• Keep API calls under 100ms for optimal UX</li>
|
||||
<li>• Monitor fetchData and setActive timings</li>
|
||||
<li>• High max values indicate performance spikes</li>
|
||||
|
@ -124,8 +129,9 @@ export default function PerformanceDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
57
contexts/ApiKeyContext.tsx
Normal file
57
contexts/ApiKeyContext.tsx
Normal file
|
@ -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<ApiKeyContextType | undefined>(undefined);
|
||||
|
||||
export function ApiKeyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [apiKey, setApiKeyState] = useState<string | null>(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 <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiKeyContext.Provider value={{ apiKey, setApiKey, clearApiKey, isAuthenticated }}>
|
||||
{children}
|
||||
</ApiKeyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useApiKey() {
|
||||
const context = useContext(ApiKeyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useApiKey must be used within an ApiKeyProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
288
docs/API.md
Normal file
288
docs/API.md
Normal file
|
@ -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.
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
.linkButton {
|
||||
padding: 10px 20px;
|
||||
background: #0070f3;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue