Initial commit - OBS Source Switcher Plugin UI
Some checks failed
Lint and Build / build (20) (push) Has been cancelled
Lint and Build / build (22) (push) Has been cancelled

Complete Next.js application for managing OBS Source Switcher
- Stream management with multiple screen layouts
- Team management CRUD operations
- SQLite database integration
- OBS WebSocket API integration
- Updated to latest versions (Next.js 15.4.1, React 19.1.0, Tailwind CSS 4.0.0)
- Enhanced .gitignore for privacy and development
This commit is contained in:
Decobus 2025-07-15 22:15:57 -04:00
commit 1d4b1eefba
43 changed files with 9596 additions and 0 deletions

6
.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals", "next/typescript","prettier"],
"rules": {
"@typescript-eslint/no-require-imports": "off"
}
}

47
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Lint and Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 20, 22 ]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Clean NextJS cache
run: rm -rf .next
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Run linter
run: npm run lint
- name: Build project
run: npm run build
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: obs-ss-${{ matrix.node-version }}
include-hidden-files: 'true'
path: ./.next/*

104
.gitignore vendored Normal file
View file

@ -0,0 +1,104 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
.idea_modules/
# VS Code
.vscode/
*.code-workspace
# Local environment variables
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Logs
logs
*.log
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Database files (exclude local development databases)
files/*.db
files/*.sqlite
files/*.sqlite3
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
# Claude Code settings (if you want to keep them private)
.claude/settings.local.json

82
CLAUDE.md Normal file
View file

@ -0,0 +1,82 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Next.js web application that controls multiple OBS Source Switchers. It provides a UI for managing stream sources across different screen layouts (large, left, right, topLeft, topRight, bottomLeft, bottomRight) and communicates with OBS WebSocket API to control streaming sources.
## Key Commands
### Development
- `npm run dev` - Start the development server
- `npm run build` - Build the production application
- `npm start` - Start the production server
- `npm run lint` - Run ESLint to check code quality
- `npm run type-check` - Run TypeScript type checking without emitting files
## Architecture Overview
### Technology Stack
- **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and Tailwind CSS
- **Backend**: Next.js API routes
- **Database**: SQLite with sqlite3 driver
- **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio
- **Styling**: Tailwind CSS with @tailwindcss/forms plugin
### Project Structure
- `/app` - Next.js App Router pages and API routes
- `/api` - Backend API endpoints for stream management
- `/add` - Page for adding new stream clients
- `/components` - Reusable React components (e.g., Dropdown)
- `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client for communicating with OBS Studio
- `constants.ts` - Shared constants (table names, etc.)
- `/types` - TypeScript type definitions
- `/files` - Default directory for SQLite database and text files (configurable via .env.local)
### Key Concepts
1. **Stream Management**: The app manages stream sources that can be assigned to different screen positions. Each stream has:
- name: Display name
- obs_source_name: Name of the source in OBS
- url: Stream URL
- team_id: Associated team identifier
2. **Screen Types**: Seven different screen positions are supported: large, left, right, topLeft, topRight, bottomLeft, bottomRight
3. **Text File Integration**: The app writes the active source name to text files that OBS Source Switcher reads to switch sources automatically
4. **Environment Configuration**:
- `FILE_DIRECTORY`: Directory for database and text files (default: ./files)
- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1)
- `OBS_WEBSOCKET_PORT`: OBS WebSocket port (default: 4455)
- `OBS_WEBSOCKET_PASSWORD`: OBS WebSocket password (optional)
### API Endpoints
- `POST /api/addStream` - Add new stream to database and OBS
- `GET /api/streams` - Get all available streams
- `GET /api/teams` - Get all teams
- `GET /api/getActive` - Get currently active sources for all screens
- `POST /api/setActive` - Set active stream for a specific screen
- `GET /api/getTeamName` - Get team name by ID
### Database Schema
Two main tables:
- `streams`: id, name, obs_source_name, url, team_id
- `teams`: team_id, team_name
### OBS Integration Pattern
The app communicates with OBS through:
1. WebSocket connection using obs-websocket-js
2. Text files that OBS Source Switcher monitors for source changes
3. Direct source management through OBS WebSocket API
When setting an active source:
1. User selects stream in UI
2. API writes source name to corresponding text file (e.g., largeScreen.txt)
3. OBS Source Switcher detects file change and switches to that source

28
README.md Normal file
View file

@ -0,0 +1,28 @@
This is a [Next.js](https://nextjs.org) app to control multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/)
## Configuration
The application uses a configurable directory for storing files and the database. To update the directory: create `.env.local` in the root of the project.
`.env.local` should look like:
```
FILE_DIRECTORY=C:\\OBS\\source-switching
```
If no `.env.local` file is created, it will default to `./files`, as seen in `config.js`
```javascript
const config = {
FILE_DIRECTORY: path.resolve('./files'),
};
```
In the "Source Switcher" properties in OBS, at the bottom, is a setting called `Current Source File`. Enable that, point it to the location of one of the text files, and put the read interval to 1000ms. Your source will change whenever the text file changes to a source _that is defined in the Source Switcher properties_
The list of available sources is defined in a SQLite3 DB, location set in the `api/setActive.ts` route.
`npm install` and
`npm run dev` to run it.
This is my first [Next.js](https://nextjs.org) app and I am not a Javascript Developer professionally, use at your own risk.

144
app/add/AddStreamClient.tsx Normal file
View file

@ -0,0 +1,144 @@
'use client';
import { useState, useEffect } from 'react';
import Dropdown from '../../components/Dropdown'; // Adjust the import path as needed
import { Team } from '@/types';
export default function AddStreamClient() {
const [formData, setFormData] = useState({
name: '',
obs_source_name: '',
url: '',
team_id: null, // Include team_id in the form data
});
const [teams, setTeams] = useState([]); // State to store teams
const [message, setMessage] = useState('');
// Fetch teams on component mount
useEffect(() => {
async function fetchTeams() {
try {
const response = await fetch('/api/teams');
const data = await response.json();
// Map the API data to the format required by the Dropdown
setTeams(
data.map((team:Team) => ({
id: team.team_id,
name: team.team_name,
}))
);
} catch (error) {
console.error('Failed to fetch teams:', error);
}
}
fetchTeams();
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleTeamSelect = (teamId:number) => {
// @ts-expect-error - team_id can be null or number in formData, but TypeScript expects only number
setFormData((prev) => ({ ...prev, team_id: teamId }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage('');
try {
const response = await fetch('/api/addStream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (response.ok) {
setMessage(data.message);
setFormData({ name: '', obs_source_name: '', url: '', team_id: null }); // Reset form
} else {
setMessage(data.error || 'Something went wrong.');
}
} catch (error) {
console.error('Error adding stream:', error);
setMessage('Failed to add stream.');
}
};
return (
<div style={{ maxWidth: '600px', margin: '50px auto', padding: '20px', border: '1px solid #ccc' }}>
<h2>Add New Stream</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '10px' }}>
<label>
Name:
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>
OBS Source Name:
<input
type="text"
name="obs_source_name"
value={formData.obs_source_name}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>
URL:
<input
type="url"
name="url"
value={formData.url}
onChange={handleInputChange}
required
style={{ display: 'block', width: '100%', padding: '8px', margin: '5px 0' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>Team:</label>
<Dropdown
options={teams}
activeId={formData.team_id}
onSelect={handleTeamSelect}
label="Select a Team"
/>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
background: '#0070f3',
color: '#fff',
border: 'none',
cursor: 'pointer',
}}
>
Add Stream
</button>
</form>
{message && (
<p style={{ marginTop: '20px', color: message.includes('successfully') ? 'green' : 'red' }}>
{message}
</p>
)}
</div>
);
}

10
app/add/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import AddStreamClient from './AddStreamClient';
export default function AddStream() {
return (
<div>
<h1>Add a New Stream</h1>
<AddStreamClient />
</div>
);
}

196
app/api/addStream/route.ts Normal file
View file

@ -0,0 +1,196 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher } from '../../../lib/obsClient';
interface OBSClient {
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
interface OBSScene {
sceneName: string;
}
interface OBSInput {
inputName: string;
}
interface GetSceneListResponse {
currentProgramSceneName: string;
currentPreviewSceneName: string;
scenes: OBSScene[];
}
interface GetInputListResponse {
inputs: OBSInput[];
}
const screens = [
'ss_large',
'ss_left',
'ss_right',
'ss_top_left',
'ss_top_right',
'ss_bottom_left',
'ss_bottom_right',
];
async function fetchTeamName(teamId: number) {
try {
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`);
if (!response.ok) {
throw new Error('Failed to fetch team name');
}
const data = await response.json();
return data.team_name;
} catch (error) {
if (error instanceof Error) {
console.error('Error:', error.message);
} else {
console.error('An unknown error occurred:', error);
}
return null;
}
}
async function addBrowserSourceWithAudioControl(obs: OBSClient, sceneName: string, inputName: string, url: string) {
try {
// Step 1: Create the browser source input
await obs.call('CreateInput', {
sceneName,
inputName,
inputKind: 'browser_source',
inputSettings: {
width: 1600,
height: 900,
url,
},
});
console.log(`Browser source "${inputName}" created successfully.`);
// Step 2: Wait for the input to initialize
let inputReady = false;
for (let i = 0; i < 10; i++) {
try {
await obs.call('GetInputSettings', { inputName });
inputReady = true;
break;
} catch {
console.log(`Waiting for input "${inputName}" to initialize...`);
await new Promise((resolve) => setTimeout(resolve, 500)); // Wait 500ms before retrying
}
}
if (!inputReady) {
throw new Error(`Input "${inputName}" did not initialize in time.`);
}
// Step 3: Enable "Reroute audio"
await obs.call('SetInputSettings', {
inputName,
inputSettings: {
reroute_audio: true,
},
overlay: true, // Keep existing settings and apply changes
});
console.log(`Audio rerouted for "${inputName}".`);
// Step 4: Mute the input
await obs.call('SetInputMute', {
inputName,
inputMuted: true,
});
console.log(`Audio muted for "${inputName}".`);
} catch (error) {
if (error instanceof Error) {
console.error('Error adding browser source with audio control:', error.message);
} else {
console.error('An unknown error occurred while adding browser source:', error);
}
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, obs_source_name, url, team_id } = body;
if (!name || !obs_source_name || !url) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Connect to OBS WebSocket
console.log("Pre-connect")
await connectToOBS();
console.log('Pre client')
const obs: OBSClient = await getOBSClient();
// obs.on('message', (msg) => {
// console.log('Message from OBS:', msg);
// });
let inputs;
try {
const response = await obs.call('GetInputList');
const inputListResponse = response as unknown as GetInputListResponse;
inputs = inputListResponse.inputs;
// console.log('Inputs:', inputs);
} catch (err) {
if (err instanceof Error) {
console.error('Failed to fetch inputs:', err.message);
} else {
console.error('Failed to fetch inputs:', err);
}
throw new Error('GetInputList failed.');
}
const teamName = await fetchTeamName(team_id);
console.log('Team Name:', teamName)
const response = await obs.call('GetSceneList');
const sceneListResponse = response as unknown as GetSceneListResponse;
const { scenes } = sceneListResponse;
const groupExists = scenes.some((scene: OBSScene) => scene.sceneName === teamName);
if (!groupExists) {
await obs.call('CreateScene', { sceneName: teamName });
}
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
if (!sourceExists) {
await addBrowserSourceWithAudioControl(obs, teamName, obs_source_name, url)
console.log(`OBS source "${obs_source_name}" created.`);
for (const screen of screens) {
try {
await addSourceToSwitcher(screen, [
{ hidden: false, selected: false, value: obs_source_name },
]);
} catch (error) {
if (error instanceof Error) {
console.error(`Failed to add source to ${screen}:`, error.message);
} else {
console.error(`Failed to add source to ${screen}:`, error);
}
}
}
} else {
console.log(`OBS source "${obs_source_name}" already exists.`);
}
const db = await getDatabase();
const query = `INSERT INTO streams_2025_spring_adr (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`;
db.run(query, [name, obs_source_name, url, team_id])
await disconnectFromOBS();
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
} catch (error) {
if (error instanceof Error) {
console.error('Error adding stream:', error.message);
} else {
console.error('An unknown error occurred while adding stream:', error);
}
await disconnectFromOBS();
return NextResponse.json({ error: 'Failed to add stream' }, { status: 500 });
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
// import config from '../../../config';
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
// Ensure directory exists
if (!fs.existsSync(FILE_DIRECTORY)) {
fs.mkdirSync(FILE_DIRECTORY, { recursive: true });
}
console.log('using', FILE_DIRECTORY)
export async function GET() {
try {
const largePath = path.join(FILE_DIRECTORY, 'large.txt');
const leftPath = path.join(FILE_DIRECTORY, 'left.txt');
const rightPath = path.join(FILE_DIRECTORY, 'right.txt');
const topLeftPath = path.join(FILE_DIRECTORY, 'topLeft.txt');
const topRightPath = path.join(FILE_DIRECTORY, 'topRight.txt');
const bottomLeftPath = path.join(FILE_DIRECTORY, 'bottomLeft.txt');
const bottomRightPath = path.join(FILE_DIRECTORY, 'bottomRight.txt');
const tankPath = path.join(FILE_DIRECTORY, 'tank.txt');
const treePath = path.join(FILE_DIRECTORY, 'tree.txt');
const kittyPath = path.join(FILE_DIRECTORY, 'kitty.txt');
const chickenPath = path.join(FILE_DIRECTORY, 'chicken.txt');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const topLeft = fs.existsSync(topLeftPath) ? fs.readFileSync(topLeftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const topRight = fs.existsSync(topRightPath) ? fs.readFileSync(topRightPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const bottomLeft = fs.existsSync(bottomLeftPath) ? fs.readFileSync(bottomLeftPath, 'utf-8') : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const bottomRight = fs.existsSync(bottomRightPath) ? fs.readFileSync(bottomRightPath, 'utf-8') : null;
const tank = fs.existsSync(tankPath) ? fs.readFileSync(tankPath, 'utf-8') : null;
const tree = fs.existsSync(treePath) ? fs.readFileSync(treePath, 'utf-8') : null;
const kitty = fs.existsSync(kittyPath) ? fs.readFileSync(kittyPath, 'utf-8') : null;
const chicken = fs.existsSync(chickenPath) ? fs.readFileSync(chickenPath, 'utf-8') : null;
// For SaT
// return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201})
return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201})
} catch (error) {
console.error('Error reading active sources:', error);
return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500});
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
export async function GET(request: NextRequest) {
try {
// Extract the team_id from the query string
const { searchParams } = new URL(request.url);
const teamId = searchParams.get('team_id');
if (!teamId) {
return NextResponse.json(
{ error: 'Missing team_id' },
{ status: 400 }
);
}
const db = await getDatabase();
const team = await db.get(
'SELECT team_name FROM teams_2025_spring_adr WHERE team_id = ?',
[teamId]
);
if (!team) {
return NextResponse.json(
{ error: 'Team not found' },
{ status: 404 }
);
}
return NextResponse.json({ team_name: team.team_name });
} catch (error) {
console.error('Error fetching team name:', error instanceof Error ? error.message : String(error));
return NextResponse.json(
{ error: 'Failed to fetch team name' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { FILE_DIRECTORY } from '../../../config';
import { getDatabase } from '../../../lib/database';
import { Stream, Screen } from '@/types';
export async function POST(request: NextRequest) {
const body: Screen = await request.json();
const { screen, id } = body;
const validScreens = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
if (!validScreens.includes(screen)) {
return NextResponse.json({ error: 'Invalid screen name' }, { status: 400 });
}
console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`));
const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`);
try {
const db = await getDatabase();
const stream: Stream | undefined = await db.get<Stream>(
'SELECT * FROM streams_2025_spring_adr WHERE id = ?',
[id]
);
console.log('Stream:', stream);
if (!stream) {
return NextResponse.json({ error: 'Stream not found' }, { status: 400 });
}
fs.writeFileSync(filePath, stream.obs_source_name);
return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 });
} catch (error) {
console.error('Error updating active source:', error);
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
return NextResponse.json(
{ error: 'Failed to update active source', details: errorMessage },
{ status: 500 }
);
}
}

18
app/api/streams/route.ts Normal file
View file

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Stream } from '@/types';
import { TABLE_NAMES } from '../../../lib/constants';
export async function GET() {
try {
const db = await getDatabase();
const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`);
return NextResponse.json(streams);
} catch (error) {
console.error('Error fetching streams:', error);
return NextResponse.json(
{ error: 'Failed to fetch streams' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '@/lib/database';
import { TABLE_NAMES } from '@/lib/constants';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId: teamIdParam } = await params;
const teamId = parseInt(teamIdParam);
const { team_name } = await request.json();
if (!team_name) {
return NextResponse.json({ error: 'Team name is required' }, { status: 400 });
}
const db = await getDatabase();
const result = await db.run(
`UPDATE ${TABLE_NAMES.TEAMS} SET team_name = ? WHERE team_id = ?`,
[team_name, teamId]
);
if (result.changes === 0) {
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Team updated successfully' });
} catch (error) {
console.error('Error updating team:', error);
return NextResponse.json({ error: 'Failed to update team' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId: teamIdParam } = await params;
const teamId = parseInt(teamIdParam);
const db = await getDatabase();
await db.run('BEGIN TRANSACTION');
try {
await db.run(
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE team_id = ?`,
[teamId]
);
const result = await db.run(
`DELETE FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
[teamId]
);
if (result.changes === 0) {
await db.run('ROLLBACK');
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
}
await db.run('COMMIT');
return NextResponse.json({ message: 'Team and associated streams deleted successfully' });
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error deleting team:', error);
return NextResponse.json({ error: 'Failed to delete team' }, { status: 500 });
}
}

37
app/api/teams/route.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Team } from '@/types';
import { TABLE_NAMES } from '@/lib/constants';
export async function GET() {
const db = await getDatabase();
const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS}`);
return NextResponse.json(teams);
}
export async function POST(request: Request) {
try {
const { team_name } = await request.json();
if (!team_name) {
return NextResponse.json({ error: 'Team name is required' }, { status: 400 });
}
const db = await getDatabase();
const result = await db.run(
`INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`,
[team_name]
);
const newTeam: Team = {
team_id: result.lastID!,
team_name: team_name
};
return NextResponse.json(newTeam, { status: 201 });
} catch (error) {
console.error('Error creating team:', error);
return NextResponse.json({ error: 'Failed to create team' }, { status: 500 });
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
app/fonts/GeistMonoVF.woff Normal file

Binary file not shown.

BIN
app/fonts/GeistVF.woff Normal file

Binary file not shown.

23
app/globals.css Normal file
View file

@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Input styles removed - using explicit Tailwind classes on components instead */
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

14
app/layout.tsx Normal file
View file

@ -0,0 +1,14 @@
import './globals.css';
export const metadata = {
title: 'OBS Source Switcher',
description: 'A tool to manage OBS sources dynamically',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

207
app/page.tsx Normal file
View file

@ -0,0 +1,207 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Dropdown from '@/components/Dropdown';
type Stream = {
id: number;
name: string;
obs_source_name: string;
url: string;
};
export default function Home() {
const [streams, setStreams] = useState<Stream[]>([]);
type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
large: null,
left: null,
right: null,
topLeft: null,
topRight: null,
bottomLeft: null,
bottomRight: null,
});
const [isLoadingStreams, setIsLoadingStreams] = useState(true);
const [isLoadingActiveSources, setIsLoadingActiveSources] = useState(true);
const [openDropdown, setOpenDropdown] = useState<string | null>(null); // Manage open dropdown
useEffect(() => {
// Fetch available streams from the database
async function fetchStreams() {
setIsLoadingStreams(true);
try {
const res = await fetch('/api/streams');
const data = await res.json();
setStreams(data);
} catch (error) {
console.error('Error fetching streams:', error);
} finally {
setIsLoadingStreams(false);
}
}
// Fetch current active sources from files
async function fetchActiveSources() {
setIsLoadingActiveSources(true);
try {
const res = await fetch('/api/getActive');
const data = await res.json();
console.log('Fetched activeSources:', data); // Debug log
setActiveSources(data);
} catch (error) {
console.error('Error fetching active sources:', error);
} finally {
setIsLoadingActiveSources(false);
}
}
fetchStreams();
fetchActiveSources();
}, []);
const handleSetActive = async (screen: ScreenType, id: number | null) => {
const selectedStream = streams.find((stream) => stream.id === id);
// Update local state
setActiveSources((prev) => ({
...prev,
[screen]: selectedStream?.obs_source_name || null,
}));
// Update the backend
try {
if (id) {
console.log('Setting screen ', screen);
const response = await fetch('/api/setActive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ screen, id }),
});
if (!response.ok) {
throw new Error('Failed to set active stream');
}
const data = await response.json();
console.log(data.message);
}
} catch (error) {
console.error('Error setting active stream:', error);
}
};
const handleToggleDropdown = (screen: string) => {
setOpenDropdown((prev) => (prev === screen ? null : screen)); // Toggle dropdown open/close
};
return (
<div>
{/* Navigation Links */}
<div className="text-center mb-5 space-x-4">
<Link
href="/add"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Add New Stream
</Link>
<span className="text-gray-400">|</span>
<Link
href="/teams"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Manage Teams
</Link>
</div>
<div className="text-center mb-5">
<h1 className="text-2xl font-bold">Manage Streams</h1>
</div>
{/* Display loading indicator if either streams or active sources are loading */}
{isLoadingStreams || isLoadingActiveSources ? (
<div className="text-center text-gray-500">Loading...</div>
) : (
<>
{/* Large Screen on its own line */}
<div className="flex justify-center p-5">
<div className="text-center border border-gray-400 p-4 rounded-lg shadow w-full max-w-md">
<h2 className="text-lg font-semibold mb-2">Large</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.large)?.id || null
}
onSelect={(id) => handleSetActive('large', id)}
label="Select a Stream..."
isOpen={openDropdown === 'large'}
onToggle={() => handleToggleDropdown('large')}
/>
</div>
</div>
{/* Row for Left and Right Screens */}
<div className="flex justify-around p-5">
{/* Left Screen */}
<div className="flex-1 text-center border border-gray-400 p-4 rounded-lg shadow mx-2">
<h2 className="text-lg font-semibold mb-2">Left</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.left)?.id || null
}
onSelect={(id) => handleSetActive('left', id)}
label="Select a Stream..."
isOpen={openDropdown === 'left'}
onToggle={() => handleToggleDropdown('left')}
/>
</div>
{/* Right Screen */}
<div className="flex-1 text-center border border-gray-400 p-4 rounded-lg shadow mx-2">
<h2 className="text-lg font-semibold mb-2">Right</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources.right)?.id || null
}
onSelect={(id) => handleSetActive('right', id)}
label="Select a Stream..."
isOpen={openDropdown === 'right'}
onToggle={() => handleToggleDropdown('right')}
/>
</div>
</div>
{/* 2x2 Square for Additional Sources */}
<div className="grid grid-cols-2 gap-4 p-5">
{[
{ screen: 'topLeft' as const, label: 'Top Left' },
{ screen: 'topRight' as const, label: 'Top Right' },
{ screen: 'bottomLeft' as const, label: 'Bottom Left' },
{ screen: 'bottomRight' as const, label: 'Bottom Right' },
].map(({ screen, label }) => (
<div key={screen} className="text-center border border-gray-400 p-4 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-2">{label}</h2>
<Dropdown
options={streams}
activeId={
streams.find((stream) => stream.obs_source_name === activeSources[screen])?.id ||
null
}
onSelect={(id) => handleSetActive(screen, id)}
label="Select a Stream..."
isOpen={openDropdown === screen}
onToggle={() => handleToggleDropdown(screen)}
/>
</div>
))}
</div>
</>
)}
</div>
);
}

204
app/teams/TeamsClient.tsx Normal file
View file

@ -0,0 +1,204 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Team } from '@/types';
export default function TeamsClient() {
const [teams, setTeams] = useState<Team[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newTeamName, setNewTeamName] = useState('');
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const [editingName, setEditingName] = useState('');
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/teams');
const data = await res.json();
setTeams(data);
} catch (error) {
console.error('Error fetching teams:', error);
} finally {
setIsLoading(false);
}
};
const handleAddTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTeamName.trim()) return;
try {
const res = await fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team_name: newTeamName }),
});
if (res.ok) {
setNewTeamName('');
fetchTeams();
} else {
const error = await res.json();
alert(`Error adding team: ${error.error}`);
}
} catch (error) {
console.error('Error adding team:', error);
alert('Failed to add team');
}
};
const handleUpdateTeam = async (teamId: number) => {
if (!editingName.trim()) return;
try {
const res = await fetch(`/api/teams/${teamId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team_name: editingName }),
});
if (res.ok) {
setEditingTeam(null);
setEditingName('');
fetchTeams();
} else {
const error = await res.json();
alert(`Error updating team: ${error.error}`);
}
} catch (error) {
console.error('Error updating team:', error);
alert('Failed to update team');
}
};
const handleDeleteTeam = async (teamId: number) => {
if (!confirm('Are you sure you want to delete this team? This will also delete all associated streams.')) {
return;
}
try {
const res = await fetch(`/api/teams/${teamId}`, {
method: 'DELETE',
});
if (res.ok) {
fetchTeams();
} else {
const error = await res.json();
alert(`Error deleting team: ${error.error}`);
}
} catch (error) {
console.error('Error deleting team:', error);
alert('Failed to delete team');
}
};
const startEditing = (team: Team) => {
setEditingTeam(team);
setEditingName(team.team_name);
};
const cancelEditing = () => {
setEditingTeam(null);
setEditingName('');
};
return (
<div className="max-w-4xl mx-auto p-5">
<div className="text-center mb-5">
<Link
href="/"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
>
Back to Stream Management
</Link>
</div>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Add New Team</h2>
<form onSubmit={handleAddTeam} className="flex gap-3">
<input
type="text"
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder="Team name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Add Team
</button>
</form>
</div>
<div className="bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold p-6 border-b">Existing Teams</h2>
{isLoading ? (
<div className="p-6 text-center text-gray-500">Loading...</div>
) : teams.length === 0 ? (
<div className="p-6 text-center text-gray-500">No teams found. Add one above!</div>
) : (
<div className="divide-y">
{teams.map((team) => (
<div key={team.team_id} className="p-6 flex items-center justify-between">
{editingTeam?.team_id === team.team_id ? (
<>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mr-3"
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateTeam(team.team_id)}
className="px-3 py-1 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Save
</button>
<button
onClick={cancelEditing}
className="px-3 py-1 bg-gray-600 text-white rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancel
</button>
</div>
</>
) : (
<>
<div>
<span className="font-medium">{team.team_name}</span>
<span className="text-gray-500 text-sm ml-2">(ID: {team.team_id})</span>
</div>
<div className="flex gap-2">
<button
onClick={() => startEditing(team)}
className="px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Edit
</button>
<button
onClick={() => handleDeleteTeam(team.team_id)}
className="px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Delete
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

10
app/teams/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import TeamsClient from './TeamsClient';
export default function Teams() {
return (
<div>
<h1 className="text-2xl font-bold text-center mb-5">Team Management</h1>
<TeamsClient />
</div>
);
}

101
components/Dropdown.tsx Normal file
View file

@ -0,0 +1,101 @@
'use client';
import { useRef, useEffect, useState } from 'react';
type DropdownProps = {
options: Array<{ id: number; name: string }>;
activeId: number | null;
onSelect: (id: number) => void;
label: string;
isOpen?: boolean;
onToggle?: (isOpen: boolean) => void;
};
export default function Dropdown({
options,
activeId,
onSelect,
label,
isOpen: controlledIsOpen,
onToggle,
}: DropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(controlledIsOpen ?? false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { if (!dropdownRef.current || !(event.target instanceof Node)) return;
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
if (onToggle) onToggle(false);
else setIsOpen(false);
}
};
if (controlledIsOpen || isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [controlledIsOpen, isOpen, onToggle]);
const activeOption = options.find((option) => option.id === activeId) || null;
const handleSelect = (option: { id: number }) => {
onSelect(option.id);
if (onToggle) onToggle(false);
else setIsOpen(false);
};
const toggleDropdown = () => {
if (onToggle) onToggle(!isOpen);
else setIsOpen((prev) => !prev);
};
return (
<div className="relative inline-block text-left" ref={dropdownRef}>
<button
type="button"
onClick={toggleDropdown}
className="inline-flex justify-between items-center w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{activeOption ? activeOption.name : label}
<svg
xmlns="http://www.w3.org/2000/svg"
className="ml-2 h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a 1 1 0 01-1.414 0l-4-4a 1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{(controlledIsOpen ?? isOpen) && (
<div
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
style={{ zIndex: 50 }} // Set a high z-index value
>
<div className="py-1">
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => handleSelect(option)}
className={`block w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
activeOption?.id === option.id ? 'font-bold text-blue-500' : ''
}`}
>
{option.name}
</button>
))}
</div>
</div>
)}
</div>
);
}

13
config.js Normal file
View file

@ -0,0 +1,13 @@
// import path from 'path';
const path = require('path')
// let FILE_DIRECTORY = null;
const config = {
FILE_DIRECTORY: path.resolve(process.env.FILE_DIRECTORY || './files'),
};
export function FILE_DIRECTORY() {
return path.resolve(process.env.FILE_DIRECTORY || './files');
}
// export default config;

42
lib/constants.ts Normal file
View file

@ -0,0 +1,42 @@
// Base table names
export const BASE_TABLE_NAMES = {
STREAMS: 'streams',
TEAMS: 'teams',
} as const;
// Table configuration interface
export interface TableConfig {
year: number;
season: 'spring' | 'summer' | 'fall' | 'winter';
suffix?: string;
}
// Default configuration
export const DEFAULT_TABLE_CONFIG: TableConfig = {
year: 2025,
season: 'spring',
suffix: 'adr'
};
/**
* Generates a full table name using the provided configuration
* @param baseTableName - The base table name (e.g., 'streams' or 'teams')
* @param config - Optional configuration object. If not provided, uses DEFAULT_TABLE_CONFIG
* @returns The full table name with year, season, and suffix
*/
export function getTableName(
baseTableName: typeof BASE_TABLE_NAMES[keyof typeof BASE_TABLE_NAMES],
config: Partial<TableConfig> = {}
): string {
const finalConfig = {...DEFAULT_TABLE_CONFIG, ...config};
const suffix = finalConfig.suffix ? `_${finalConfig.suffix}` : '';
return `${baseTableName}_${finalConfig.year}_${finalConfig.season}${suffix}`;
}
// Export commonly used full table names with default configuration
export const TABLE_NAMES = {
STREAMS: getTableName(BASE_TABLE_NAMES.STREAMS),
TEAMS: getTableName(BASE_TABLE_NAMES.TEAMS),
} as const;

60
lib/database.ts Normal file
View file

@ -0,0 +1,60 @@
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
import path from 'path';
import fs from 'fs';
import { TABLE_NAMES } from './constants';
let db: Database<sqlite3.Database, sqlite3.Statement> | null = null;
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
const ensureDirectoryExists = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
console.log(`Created directory: ${dirPath}`);
}
};
const initializeDatabase = async (database: Database<sqlite3.Database, sqlite3.Statement>) => {
// Create streams table
await database.exec(`
CREATE TABLE IF NOT EXISTS ${TABLE_NAMES.STREAMS} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
obs_source_name TEXT NOT NULL,
url TEXT NOT NULL,
team_id INTEGER NOT NULL
)
`);
// Create teams table
await database.exec(`
CREATE TABLE IF NOT EXISTS ${TABLE_NAMES.TEAMS} (
team_id INTEGER PRIMARY KEY,
team_name TEXT NOT NULL
)
`);
console.log('Database tables initialized.');
};
export const getDatabase = async () => {
if (!db) {
// Ensure the files directory exists
ensureDirectoryExists(FILE_DIRECTORY);
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
console.log('Database connection established.');
// Initialize database tables
await initializeDatabase(db);
}
return db;
}
// export default getDatabase

125
lib/obsClient.js Normal file
View file

@ -0,0 +1,125 @@
// const config = require('../config');
const { OBSWebSocket } = require('obs-websocket-js');
let obs = null;
async function connectToOBS() {
if (!obs) {
obs = new OBSWebSocket();
}
try {
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
console.log('Connecting to OBS WebSocket...');
console.log('Host:', OBS_HOST);
console.log('Port:', OBS_PORT);
console.log('Password:', OBS_PASSWORD ? '***' : '(none)');
await obs.connect(`ws://${OBS_HOST}:${OBS_PORT}`, OBS_PASSWORD);
console.log('Connected to OBS WebSocket.');
} catch (err) {
console.error('Failed to connect to OBS WebSocket:', err.message);
throw err;
}
}
function getOBSClient() {
if (!obs) {
throw new Error('OBS WebSocket client is not initialized. Call connectToOBS() first.');
}
// console.log('client', obs)
return obs;
}
async function disconnectFromOBS() {
if (obs) {
await obs.disconnect();
console.log('Disconnected from OBS WebSocket.');
obs = null;
}
}
async function addSourceToSwitcher(inputName, newSources) {
if (!obs) {
obs = new OBSWebSocket();
}
try {
const OBS_HOST = process.env.OBS_WEBSOCKET_HOST || '127.0.0.1';
const OBS_PORT = process.env.OBS_WEBSOCKET_PORT || '4455';
const OBS_PASSWORD = process.env.OBS_WEBSOCKET_PASSWORD || '';
await obs.connect(`ws://${OBS_HOST}:${OBS_PORT}`, OBS_PASSWORD);
// Step 1: Get current input settings
const { inputSettings } = await obs.call('GetInputSettings', { inputName });
// console.log('Current Settings:', inputSettings);
// Step 2: Add new sources to the sources array
const updatedSources = [...inputSettings.sources, ...newSources];
// Step 3: Update the settings with the new sources array
await obs.call('SetInputSettings', {
inputName,
inputSettings: {
...inputSettings,
sources: updatedSources,
},
});
console.log('Updated settings successfully for', inputName);
obs.disconnect();
} catch (error) {
console.error('Error updating settings:', error.message);
}
}
// async function addSourceToGroup(obs, teamName, obs_source_name, url) {
// try {
// // Step 1: Check if the group exists
// const { scenes } = await obs.call('GetSceneList');
// const groupExists = scenes.some((scene) => scene.sceneName === teamName);
// // Step 2: Create the group if it doesn't exist
// if (!groupExists) {
// console.log(`Group "${teamName}" does not exist. Creating it.`);
// await obs.call('CreateScene', { sceneName: teamName });
// } else {
// console.log(`Group "${teamName}" already exists.`);
// }
// // Step 3: Add the source to the group
// console.log(`Adding source "${obs_source_name}" to group "${teamName}".`);
// await obs.call('CreateInput', {
// sceneName: teamName,
// inputName: obs_source_name,
// inputKind: 'browser_source',
// inputSettings: {
// width: 1600,
// height: 900,
// url,
// control_audio: true,
// },
// });
// // Step 4: Enable "Control audio via OBS"
// await obs.call('SetInputSettings', {
// inputName: obs_source_name,
// inputSettings: {
// control_audio: true, // Enable audio control
// },
// overlay: true, // Keep existing settings and apply changes
// });
// console.log(`Source "${obs_source_name}" successfully added to group "${teamName}".`);
// } catch (error) {
// console.error('Error adding source to group:', error.message);
// }
// }
// Export all functions
module.exports = { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher};

11
next.config.ts Normal file
View file

@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
eslint: {
// Warning: This allows production builds to successfully complete even if
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
};
export default nextConfig;

7741
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "obs-source-switcher",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.11",
"bufferutil": "^4.0.8",
"next": "^15.4.1",
"obs-websocket-js": "^5.0.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"ws": "^8.18.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.13.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-config-next": "^15.4.1",
"eslint-config-prettier": "^10.0.1",
"postcss": "^8.5.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
}
}

8
postcss.config.mjs Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

10
styles/Home.module.css Normal file
View file

@ -0,0 +1,10 @@
.linkButton {
padding: 10px 20px;
background: #0070f3;
color: #fff;
text-decoration: none;
border-radius: 5px;
display: inline-block;
text-align: center;
}

13
tailwind.config.js Normal file
View file

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/forms')],
}

18
tailwind.config.ts Normal file
View file

@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
darkMode: 'class',
content: [
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

20
testObs.js Normal file
View file

@ -0,0 +1,20 @@
const { connectToOBS, getOBSClient, disconnectFromOBS } = require('./lib/obsClient');
async function testOBS() {
try {
await connectToOBS();
console.log('OBS WebSocket connected successfully.');
// Perform additional OBS calls here, if needed.
const obs = getOBSClient();
const { inputs } = await obs.call('GetInputList');
console.log(inputs)
const { inputSettings } = await obs.call('GetInputSettings', { inputName:'ss_left' });
console.log('Source Switcher Settings:', inputSettings);
// console.log(obs)
await disconnectFromOBS();
} catch (error) {
console.error('Error:', error.message);
}
}
testOBS();

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
// "@config": ["./config.js"],
"@lib/*": ["./lib/*"],
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js"],
"exclude": ["node_modules"]
}

17
types/index.ts Normal file
View file

@ -0,0 +1,17 @@
export type Stream = {
id: number;
name: string;
obs_source_name: string;
url: string;
team_id: number;
};
export type Screen = {
screen: string;
id: number;
};
export type Team = {
team_id: number;
team_name: string;
};