Compare commits

...

14 commits

Author SHA1 Message Date
Decobus
d75f599711 Allow Twitch URL or username input in add stream form
All checks were successful
Lint and Build / build (pull_request) Successful in 2m46s
- Add extractTwitchUsername() function to parse various URL formats
- Support https://twitch.tv/username, www.twitch.tv/username, and plain usernames
- Real-time URL parsing - automatically extracts username as user types
- Updated UI labels and placeholder to indicate both input options
- Maintains existing validation and backend compatibility
- Seamless UX - users can paste full URLs or type usernames directly
2025-07-22 16:11:49 -04:00
Decobus
f80f496db5 Resolve merge conflicts in page.tsx and Footer.tsx
- Unified API response format handling to support both old and new formats
- Maintained enhanced UI layout from HEAD branch
- Preserved performance optimizations and smart polling features
- Ensured consistent error handling across components
2025-07-22 16:08:19 -04:00
Decobus
78410124a5 Merge pull request 'Footer enhancements and performance optimizations' (#9) from footer-enhancements into main
All checks were successful
Lint and Build / build (push) Successful in 2m48s
Reviewed-on: #9
2025-07-22 23:01:23 +03:00
Decobus
c1f1c17763 Update documentation to reflect recent enhancements
All checks were successful
Lint and Build / build (pull_request) Successful in 2m47s
- Updated CLAUDE.md with comprehensive recent changes:
  - Enhanced footer with team/stream counts and status indicators
  - Fixed active source detection and dropdown optimization
  - OBS text size improvements (96pt font)
  - API standardization and performance optimizations
  - System scene protection (Resources added to bypass list)
  - Improved error handling and user experience

- Updated README.md with key feature additions:
  - Enhanced footer functionality
  - Performance optimizations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:55:55 -04:00
Decobus
8687d19702 Add Resources scene to orphaned themes bypass check
- Added "Resources" to SYSTEM_SCENES array in verifyGroups API
- Resources scene will no longer be flagged as orphaned in OBS verification
- Prevents system scenes from showing up as cleanup candidates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:54:02 -04:00
Decobus
0dbdc52fd3 Increase OBS text font size from 84 to 96
- Further increased font size for better visibility of team name text overlays

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:52:05 -04:00
Decobus
c8a7b83fc4 Fix dropdown optimistic updates to use correct stream group names
- Include team name prefix in optimistic update stream group generation
- Format: {team_name}_{stream_name}_stream to match backend file writes
- Prevents dropdown from reverting to wrong value after selection
- Now dropdown shows correct selection immediately without requiring page refresh

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:48:10 -04:00
Decobus
b3679d6642 Increase OBS text font size from 72 to 84
- Bumped font size for better visibility of team name text overlays in OBS
- Applied to both text source creation and update scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:45:02 -04:00
Decobus
f9363eac20 Fix active source detection by including team names in lookup
- Update streams API to join with teams table and return StreamWithTeam data
- Modify stream lookup maps to generate proper stream group names with team prefixes
- Format: {team_name}_{stream_name}_stream to match obsClient.js logic
- Update type signatures throughout to support team_name and group_name fields
- Now properly matches text file contents with database streams for dropdown selection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:06:21 -04:00
Decobus
5dd9707f13 Fix getActive API to use standardized response format
- Update /api/getActive to return { success: true, data: {...} } format
- Add proper error handling with standardized error responses
- Update main page to handle new response format for active sources
- Remove unused variables and clean up code
- Add trim() to file reads to handle whitespace properly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:04:08 -04:00
Decobus
8c2de2c66b Remove backwards compatibility for API responses
- Clean up all backward compatibility checks for old API format
- All endpoints now consistently return { success: true, data: [...] }
- Simplify response handling across all components and pages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:53:57 -04:00
Decobus
423897d1bf Fix API response handling on main page
- Handle new standardized API response format for streams endpoint
- Extract data from { success: true, data: [...] } wrapper
- Maintain backward compatibility with old API format
- Fixes TypeError: streams.forEach is not a function

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:49:55 -04:00
Decobus
ec6ff1b570 Add team and stream counts to footer with improved layout
- Added team and stream counts displayed when OBS disconnected
- Added counts display alongside live status when OBS connected
- Moved OFFLINE/IDLE status indicators to left column for better balance
- Fixed green dot positioning to be properly next to "Connected" text
- Added custom CSS for status dots since Tailwind classes weren't applying
- Enhanced footer layout with better visual hierarchy

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 14:42:18 -04:00
Decobus
7f475680ae Merge pull request 'text-background-color-source' (#8) from text-background-color-source into main
All checks were successful
Lint and Build / build (push) Successful in 2m46s
Reviewed-on: #8
2025-07-22 21:11:30 +03:00
12 changed files with 102 additions and 69 deletions

View file

@ -205,6 +205,7 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
- **Audio Control**: Browser sources created with "Control Audio via OBS" enabled and auto-muted
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
- **Team Association**: Streams organized under teams with proper naming conventions
- **Active Source Detection**: Properly reads current active sources from text files on page load and navigation
### Team & Group Management
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
@ -213,12 +214,13 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
- Shared team text source
- All associated stream scenes and sources
- All browser sources with team prefix
- **Sync Verification**: Real-time verification of database-OBS group synchronization
- **Sync Verification**: Real-time verification of database-OBS group synchronization with system scene bypass
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
- **Visual Indicators**: Clear status indicators for group linking and sync problems
- 🆔 "Linked by UUID" - Group tracked by reliable UUID
- 📝 "Name changed in OBS" - Group renamed in OBS, database needs update
- ⚠️ "Not found in OBS" - Group in database but missing from OBS
- **System Scene Protection**: Infrastructure scenes (1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies, Resources) excluded from orphaned cleanup
### User Experience Improvements
- **Toast Notifications**: Real-time feedback for all operations (success/error/info)
@ -227,13 +229,22 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
- **Responsive Design**: Mobile-friendly interface with glass morphism styling
- **Loading States**: Clear indicators during API operations
- **Error Recovery**: Graceful error handling with user-friendly messages
- **Enhanced Footer**: Real-time team/stream counts, OBS connection status with visual indicators
- **Optimistic Updates**: Immediate UI feedback with proper stream group name matching
### OBS Integration Improvements
- **Text Size**: Team name overlays use 96pt font for better visibility
- **Color Display**: Fixed background color display (#002b4b) using proper ABGR format
- **Standardized APIs**: All endpoints use consistent `{ success: true, data: [...] }` response format
- **Performance Optimization**: Reduced code duplication and improved API response handling
### Developer Experience
- **Type Safety**: Comprehensive TypeScript definitions throughout
- **API Documentation**: Well-documented endpoints with clear parameter validation
- **API Standardization**: Consistent response formats across all endpoints with proper error handling
- **Migration Scripts**: Database migration tools for schema updates
- **Security**: Input validation, sanitization, and secure API design
- **Testing**: Comprehensive error handling and edge case management
- **Performance Monitoring**: Smart polling with visibility detection and performance tracking
- **Code Optimization**: Eliminated redundancies and consolidated common patterns
## Known Issues

View file

@ -17,6 +17,8 @@ A professional [Next.js](https://nextjs.org) web application for managing live s
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
- **UUID-based Tracking**: Robust OBS group synchronization with rename-safe tracking
- **Enhanced Footer**: Real-time team/stream counts and OBS connection status
- **Optimized Performance**: Reduced code duplication and standardized API responses
## Quick Start

View file

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
// import config from '../../../config';
import { createSuccessResponse, createErrorResponse, withErrorHandling } from '../../../lib/apiHelpers';
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files')
// Ensure directory exists
@ -10,7 +10,7 @@ if (!fs.existsSync(FILE_DIRECTORY)) {
}
console.log('using', FILE_DIRECTORY)
export async function GET() {
async function getActiveHandler() {
try {
const largePath = path.join(FILE_DIRECTORY, 'large.txt');
const leftPath = path.join(FILE_DIRECTORY, 'left.txt');
@ -20,38 +20,27 @@ export async function GET() {
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');
const large = fs.existsSync(largePath) ? fs.readFileSync(largePath, 'utf-8').trim() : null;
const left = fs.existsSync(leftPath) ? fs.readFileSync(leftPath, 'utf-8').trim() : null;
const right = fs.existsSync(rightPath) ? fs.readFileSync(rightPath, 'utf-8').trim() : null;
const topLeft = fs.existsSync(topLeftPath) ? fs.readFileSync(topLeftPath, 'utf-8').trim() : null;
const topRight = fs.existsSync(topRightPath) ? fs.readFileSync(topRightPath, 'utf-8').trim() : null;
const bottomLeft = fs.existsSync(bottomLeftPath) ? fs.readFileSync(bottomLeftPath, 'utf-8').trim() : null;
const bottomRight = fs.existsSync(bottomRightPath) ? fs.readFileSync(bottomRightPath, 'utf-8').trim() : null;
// 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) {
return createSuccessResponse({
large,
left,
right,
topLeft,
topRight,
bottomLeft,
bottomRight
});
} catch (error) {
console.error('Error reading active sources:', error);
return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500});
}
return createErrorResponse('Failed to read active sources', 500, 'Could not read source files', error);
}
}
}
export const GET = withErrorHandling(getActiveHandler);

View file

@ -1,13 +1,20 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Stream } from '@/types';
import { StreamWithTeam } from '@/types';
import { TABLE_NAMES } from '../../../lib/constants';
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
async function getStreamsHandler() {
try {
const db = await getDatabase();
const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`);
const streams: StreamWithTeam[] = await db.all(`
SELECT
s.*,
t.team_name,
t.group_name
FROM ${TABLE_NAMES.STREAMS} s
LEFT JOIN ${TABLE_NAMES.TEAMS} t ON s.team_id = t.team_id
`);
return createSuccessResponse(streams);
} catch (error) {
return createDatabaseError('fetch streams', error);

View file

@ -12,7 +12,8 @@ const SYSTEM_SCENES: string[] = [
'Starting',
'Ending',
'Audio',
'Movies'
'Movies',
'Resources'
];
interface OBSScene {

View file

@ -65,8 +65,7 @@ export default function EditStream() {
team_id: streamData.team_id,
});
// Handle both old and new API response formats
const teams = teamsData.success ? teamsData.data : teamsData;
const teams = teamsData.data;
// Map teams for dropdown
setTeams(

View file

@ -7,17 +7,12 @@ import { useToast } from '@/lib/useToast';
import { ToastContainer } from '@/components/Toast';
import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance';
type Stream = {
id: number;
name: string;
obs_source_name: string;
url: string;
};
import { StreamWithTeam } from '@/types';
type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
export default function Home() {
const [streams, setStreams] = useState<Stream[]>([]);
const [streams, setStreams] = useState<StreamWithTeam[]>([]);
const [activeSources, setActiveSources] = useState<Record<ScreenType, string | null>>({
large: null,
left: null,
@ -82,8 +77,9 @@ export default function Home() {
// Handle both old and new API response formats
const streams = streamsData.success ? streamsData.data : streamsData;
const activeSources = activeData.success ? activeData.data : activeData;
setStreams(streams);
setActiveSources(activeData);
setActiveSources(activeSources);
} catch (error) {
console.error('Error fetching data:', error);
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
@ -100,9 +96,9 @@ export default function Home() {
const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => {
const selectedStream = streams.find((stream) => stream.id === id);
// Generate stream group name for optimistic updates
// Generate stream group name for optimistic updates - must match obsClient.js format
const streamGroupName = selectedStream
? `${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream`
? `${selectedStream.team_name?.toLowerCase().replace(/\s+/g, '_') || 'unknown'}_${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream`
: null;
// Update local state immediately for optimistic updates

View file

@ -40,9 +40,8 @@ export default function AddStream() {
const teamsData = await teamsResponse.json();
const streamsData = await streamsResponse.json();
// Handle both old and new API response formats
const teams = teamsData.success ? teamsData.data : teamsData;
const streams = streamsData.success ? streamsData.data : streamsData;
const teams = teamsData.data;
const streams = streamsData.data;
// Map the API data to the format required by the Dropdown
setTeams(
@ -67,9 +66,37 @@ export default function AddStream() {
}, [fetchData]);
const extractTwitchUsername = (input: string): string => {
const trimmed = input.trim();
// If it's a URL, extract username
const urlPatterns = [
/^https?:\/\/(www\.)?twitch\.tv\/([a-zA-Z0-9_]+)\/?$/,
/^(www\.)?twitch\.tv\/([a-zA-Z0-9_]+)\/?$/,
/^twitch\.tv\/([a-zA-Z0-9_]+)\/?$/
];
for (const pattern of urlPatterns) {
const match = trimmed.match(pattern);
if (match) {
return match[match.length - 1]; // Last capture group is always the username
}
}
// Otherwise assume it's just a username
return trimmed;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Special handling for twitch_username to extract from URL if needed
if (name === 'twitch_username') {
const username = extractTwitchUsername(value);
setFormData((prev) => ({ ...prev, [name]: username }));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
// Clear validation error when user starts typing
if (validationErrors[name]) {
@ -214,7 +241,7 @@ export default function AddStream() {
{/* Twitch Username */}
<div>
<label className="block text-white font-semibold mb-3">
Twitch Username
Twitch Username or URL
</label>
<input
type="text"
@ -225,7 +252,7 @@ export default function AddStream() {
className={`input ${
validationErrors.twitch_username ? 'border-red-500/60 bg-red-500/10' : ''
}`}
placeholder="Enter Twitch username"
placeholder="Enter username or paste full Twitch URL (e.g., 'streamer' or 'https://twitch.tv/streamer')"
/>
{validationErrors.twitch_username && (
<div className="text-red-400 text-sm mt-2">

View file

@ -41,9 +41,7 @@ export default function Teams() {
try {
const res = await fetch('/api/teams');
const data = await res.json();
// Handle both old and new API response formats
const teams = data.success ? data.data : data;
setTeams(teams);
setTeams(data.data);
} catch (error) {
console.error('Error fetching teams:', error);
} finally {

View file

@ -163,7 +163,6 @@ export default function Footer() {
</div>
</>
)}
</div>
)}
</div>

View file

@ -323,7 +323,7 @@ async function createTextSource(sceneName, textSourceName, text) {
text,
font: {
face: 'Arial',
size: 72,
size: 96,
style: 'Bold'
},
color: 0xFFFFFFFF, // White text
@ -352,7 +352,7 @@ async function createTextSource(sceneName, textSourceName, text) {
text,
font: {
face: 'Arial',
size: 72,
size: 96,
style: 'Bold'
},
color: 0xFFFFFFFF, // White text

View file

@ -38,13 +38,17 @@ export function useThrottle<T extends (...args: unknown[]) => unknown>(
}
// Memoized stream lookup utilities
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) {
const sourceToIdMap = new Map<string, number>();
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>();
streams.forEach(stream => {
// Generate stream group name to match what's written to files
const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`;
// Format: {team_name}_{stream_name}_stream (matching obsClient.js logic)
const cleanTeamName = stream.team_name ? stream.team_name.toLowerCase().replace(/\s+/g, '_') : 'unknown';
const cleanStreamName = stream.name.toLowerCase().replace(/\s+/g, '_');
const streamGroupName = `${cleanTeamName}_${cleanStreamName}_stream`;
sourceToIdMap.set(streamGroupName, stream.id);
idToStreamMap.set(stream.id, stream);
});
@ -53,7 +57,7 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n
}
// Hook version for React components
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>) {
return useMemo(() => {
return createStreamLookupMaps(streams);
}, [streams]);
@ -61,7 +65,7 @@ export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name
// Efficient active source lookup
export function useActiveSourceLookup(
streams: Array<{ id: number; obs_source_name: string; name: string }>,
streams: Array<{ id: number; obs_source_name: string; name: string; team_name?: string; group_name?: string | null }>,
activeSources: Record<string, string | null>
) {
const { sourceToIdMap } = useStreamLookupMaps(streams);