diff --git a/README.md b/README.md index 19e8648..9d59ff7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ StreamGrid revolutionizes multi-stream viewing by giving you complete freedom over your layout. Want a massive main stream surrounded by smaller feeds? Or five equally-sized streams? Or any other arrangement you can imagine? StreamGrid makes it possible. Built with Electron, React, and TypeScript, it's the perfect solution for watching multiple streams exactly the way you want. +**📚 Documentation:** [API Reference](docs/API.md) | [Bruno API Collection](bruno/README.md) + https://github.com/user-attachments/assets/1e098512-ed39-4094-ab13-84c144e60f7c ## ✨ Features @@ -26,28 +28,45 @@ https://github.com/user-attachments/assets/1e098512-ed39-4094-ab13-84c144e60f7c - Remove streams with a single click - Persistent layout saving with aggressive auto-save - Export and Import your stream setups to share with friends -- **Grid Management System** (New in v1.2.0): +- **Grid Management System**: - Save multiple grid configurations - Switch between different saved layouts instantly - Rename and organize your grid presets - Perfect for different viewing scenarios (gaming, monitoring, events) - **Responsive Design**: Automatically adjusts to window size while maintaining video aspect ratios - **Stream Platform Support**: - - **Local Files** (New in v1.2.0): Play video files directly from your computer + - **Local Files**: Play video files directly from your computer - **YouTube**: Support for standard videos, live streams, and shorts - **Twitch**: Support for channel live streams - - **RTSP Streams** (New in v1.2.1): Support for RTSP/RTSPS camera and streaming sources + - **RTSP Streams** (New in v2.0.0): Support for RTSP/RTSPS camera and streaming sources with automatic transcoding + - Requires FFmpeg installation + - Supports authentication (username/password in URL) + - Low-latency HLS transcoding + - Multiple concurrent RTSP streams + - Automatic retry on connection loss - **HLS Support**: Compatible with HTTP Live Streaming (HLS) video sources - **MPEG-DASH Support**: Compatible with Dynamic Adaptive Streaming over HTTP (DASH) video sources - **Chat Integration**: - YouTube chat for live streams and videos - Twitch chat for live streams - Draggable and resizable chat windows -- **Performance Optimized** (Enhanced in v1.2.0): - - Virtual grid rendering for smooth performance with many streams - - Intelligent player pooling to reduce memory usage - - Optimized startup times and resource management - **Cross-Platform**: Available for Windows, macOS, and Linux +- **REST API for Automation** (New in v2.0.0): + - Full programmatic control via REST API + - Add, update, and remove streams remotely + - Manage grid configurations programmatically + - API key authentication with rate limiting + - [API documentation](docs/API.md) with [Bruno test collection](bruno/README.md) +- **M3U Playlist Import** (New in v2.0.0): + - Import M3U/M3U8 playlists with one click + - Automatic grid arrangement with intelligent layout +- **Advanced Sound Management** (New in v2.0.0): + - Global mute/unmute all streams + - Per-stream audio controls + - Auto-start streams muted option +- **Auto-Start & Auto-Restart** (New in v2.0.0): + - Automatically play all streams on app launch + - Automatic retry for failed streams with exponential backoff ## 🚀 Getting Started @@ -55,9 +74,10 @@ https://github.com/user-attachments/assets/1e098512-ed39-4094-ab13-84c144e60f7c 1. Visit the [Releases](https://github.com/LordKnish/StreamGrid/releases) section 2. Download the latest version for your platform: - - **Windows**: `streamgrid-1.2.0-setup.exe` - - **macOS**: `streamgrid-1.2.0.dmg` - - **Linux**: `streamgrid-1.2.0.AppImage` + - **Windows**: `streamgrid-2.0.0-win-x64.exe` + - **macOS (Intel)**: `streamgrid-2.0.0-mac-x64.dmg` + - **macOS (Apple Silicon)**: `streamgrid-2.0.0-mac-arm64.dmg` + - **Linux**: `streamgrid-2.0.0-linux-x64.AppImage` 3. Install and run StreamGrid ### Option 2: Build from Source @@ -65,7 +85,31 @@ https://github.com/user-attachments/assets/1e098512-ed39-4094-ab13-84c144e60f7c #### Prerequisites - Node.js 18.x or higher - npm 9.x or higher -- FFmpeg (required for RTSP streaming support) +- **FFmpeg** (required for RTSP streaming support) + +##### Installing FFmpeg + +**Windows:** +1. Download FFmpeg from [ffmpeg.org](https://ffmpeg.org/download.html) +2. Extract to `C:\ffmpeg` +3. Add `C:\ffmpeg\bin` to your system PATH +4. Or use Chocolatey: `choco install ffmpeg` + +**macOS:** +```bash +brew install ffmpeg +``` + +**Linux (Ubuntu/Debian):** +```bash +sudo apt update +sudo apt install ffmpeg +``` + +**Linux (Fedora/RHEL):** +```bash +sudo yum install ffmpeg +``` #### Steps @@ -97,9 +141,71 @@ npm run build:linux # Linux ``` 5. **Find your built application** - - Windows: `dist/streamgrid-1.2.0-setup.exe` - - macOS: `dist/streamgrid-1.2.0.dmg` - - Linux: `dist/streamgrid-1.2.0.AppImage` + - Windows: `dist/streamgrid-2.0.0-win-x64.exe` + - macOS: `dist/streamgrid-2.0.0-mac-*.dmg` + - Linux: `dist/streamgrid-2.0.0-linux-x64.AppImage` + +## 📹 RTSP Stream Support + +StreamGrid supports RTSP (Real Time Streaming Protocol) streams from IP cameras, security systems, and other RTSP sources. + +### Requirements +- FFmpeg must be installed on your system (see installation instructions above) +- RTSP stream URL from your camera or source + +### RTSP URL Format +``` +rtsp://[username:password@]host[:port]/path +``` + +**Examples:** +``` +rtsp://192.168.1.100:554/stream1 +rtsp://admin:password@192.168.1.100/live +rtsps://secure-camera.example.com/stream +``` + +### How It Works +1. StreamGrid detects RTSP URLs automatically +2. FFmpeg transcodes the RTSP stream to HLS format in real-time +3. The HLS stream is served locally and played in the browser +4. Low latency (~2-3 seconds) with automatic retry on connection loss + +### Adding an RTSP Stream +1. Click "Add Stream" button +2. Enter your RTSP URL in the Stream URL field +3. The app will show "RTSP stream (requires FFmpeg)" if detected +4. Add a name and optional logo +5. Click "Add Stream" + +### Troubleshooting RTSP Streams + +**"FFmpeg not installed" error:** +- Install FFmpeg using the instructions above +- Restart StreamGrid after installation +- Verify FFmpeg is in your system PATH: `ffmpeg -version` + +**Stream fails to load:** +- Verify the RTSP URL is correct +- Check if authentication is required (username/password) +- Ensure your firewall allows RTSP connections +- Try using TCP transport: `rtsp://camera?tcp` + +**High latency or buffering:** +- RTSP streams have inherent 2-3 second latency due to HLS transcoding +- Check your network connection to the camera +- Reduce the number of concurrent RTSP streams + +**Stream stops after a while:** +- StreamGrid automatically retries failed streams (up to 3 times) +- Check camera timeout settings +- Verify network stability + +### Performance Tips +- Limit concurrent RTSP streams to 3-4 for best performance +- Use wired network connection for cameras when possible +- Close unused RTSP streams to free resources +- RTSP transcoding uses ~100MB RAM per stream ## 🛠 Tech Stack @@ -155,7 +261,29 @@ StreamGrid/ ## 📋 Changelog -### Version 1.2.0 (Latest) +### Version 2.0.0 (Latest) +**Major Feature Release - Automation & Advanced Controls** + +#### 🚀 New Features +- **REST API** - Full programmatic control with authentication and rate limiting +- **M3U Playlist Import** - Import playlists with intelligent auto-arrangement +- **RTSP Stream Support** - Production-ready IP camera streaming with FFmpeg transcoding +- **Sound Management System** - Global and per-stream audio controls +- **Auto-Start Streams** - Automatically play all streams on launch +- **Auto-Restart Failed Streams** - Automatic retry with exponential backoff +- **Auto-Arrange Grid** - Intelligent grid layout with row-budget algorithm + +#### 📚 Documentation +- Complete REST API documentation ([docs/API.md](docs/API.md)) +- Bruno API test collection ([bruno/README.md](bruno/README.md)) +- Comprehensive RTSP setup guide in README + +#### 🐛 Bug Fixes +- Fixed Linux auto-updater errors +- Improved CSP handling for Twitch embeds +- Enhanced error handling across all components + +### Version 1.2.0 **Major Performance Update & Enhanced Features** #### 🚀 Performance Optimizations diff --git a/bruno/Delete Stream.bru b/bruno/Delete Stream.bru new file mode 100644 index 0000000..178d807 --- /dev/null +++ b/bruno/Delete Stream.bru @@ -0,0 +1,24 @@ +meta { + name: Delete Stream + type: http + seq: 6 +} + +delete { + url: http://0.0.0.0:3737/api/streams/:streamId + body: none + auth: inherit +} + +params:path { + streamId: stream-1234567890 +} + +headers { + Authorization: Bearer {{apiKey}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Get Grids.bru b/bruno/Get Grids.bru new file mode 100644 index 0000000..1f63867 --- /dev/null +++ b/bruno/Get Grids.bru @@ -0,0 +1,20 @@ +meta { + name: Get Grids + type: http + seq: 7 +} + +get { + url: http://0.0.0.0:3737/api/grids + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{apiKey}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Get Streams.bru b/bruno/Get Streams.bru new file mode 100644 index 0000000..9532bcc --- /dev/null +++ b/bruno/Get Streams.bru @@ -0,0 +1,20 @@ +meta { + name: Get Streams + type: http + seq: 3 +} + +get { + url: 0.0.0.0:3737/api/streams + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{apiKey}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Health.bru b/bruno/Health.bru new file mode 100644 index 0000000..563cd52 --- /dev/null +++ b/bruno/Health.bru @@ -0,0 +1,16 @@ +meta { + name: Health + type: http + seq: 1 +} + +get { + url: 0.0.0.0:3737/health + body: none + auth: none +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Load Grid.bru b/bruno/Load Grid.bru new file mode 100644 index 0000000..06da23a --- /dev/null +++ b/bruno/Load Grid.bru @@ -0,0 +1,24 @@ +meta { + name: Load Grid + type: http + seq: 9 +} + +put { + url: http://0.0.0.0:3737/api/grids/:gridId/load + body: none + auth: inherit +} + +params:path { + gridId: grid-1234567890 +} + +headers { + Authorization: Bearer {{apiKey}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Post Grid.bru b/bruno/Post Grid.bru new file mode 100644 index 0000000..6bad265 --- /dev/null +++ b/bruno/Post Grid.bru @@ -0,0 +1,29 @@ +meta { + name: Post Grid + type: http + seq: 8 +} + +post { + url: http://0.0.0.0:3737/api/grids + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{apiKey}} +} + +body:json { + { + "name": "My New Grid", + "streams": [], + "layout": [], + "chats": [] + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Post Streams.bru b/bruno/Post Streams.bru new file mode 100644 index 0000000..4738fa6 --- /dev/null +++ b/bruno/Post Streams.bru @@ -0,0 +1,30 @@ +meta { + name: Post Streams + type: http + seq: 4 +} + +post { + url: 0.0.0.0:3737/api/streams + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{apiKey}} +} + +body:json { + { + "name": "My Stream", + "streamUrl": "https://example.com/stream.m3u8", + "logoUrl": "https://example.com/logo.png", + "isMuted": false, + "fitMode": "contain" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/Put Streams.bru b/bruno/Put Streams.bru new file mode 100644 index 0000000..9e16ee0 --- /dev/null +++ b/bruno/Put Streams.bru @@ -0,0 +1,32 @@ +meta { + name: Put Streams + type: http + seq: 5 +} + +put { + url: 0.0.0.0:3737/api/streams/:streamId + body: json + auth: inherit +} + +params:path { + streamId: stream-1234567890 +} + +headers { + Authorization: Bearer {{apiKey}} +} + +body:json { + { + "name": "Updated Name", + "isMuted": true, + "fitMode": "cover" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/README.md b/bruno/README.md new file mode 100644 index 0000000..2810812 --- /dev/null +++ b/bruno/README.md @@ -0,0 +1,50 @@ +# StreamGrid API - Bruno Collection + +This folder contains a [Bruno](https://www.usebruno.com/) API collection for testing the StreamGrid REST API. + +## Setup + +1. Install Bruno: https://www.usebruno.com/downloads +2. Open Bruno and import this collection +3. Configure your API key in `environments/Local.bru` +4. Enable the API in StreamGrid Settings +5. Run the requests! + +## Environment Variables + +Edit `environments/Local.bru` to set: +- `apiKey`: Your API key from StreamGrid Settings +- `baseUrl`: API server URL (default: http://localhost:3737) + +## Available Requests + +### Health Check +- **Health** - Check API server status (no auth required) + +### Stream Management +- **Get Streams** - List all streams +- **Post Streams** - Add a new stream +- **Put Streams** - Update a stream +- **Delete Stream** - Remove a stream + +### Grid Management +- **Get Grids** - List all saved grids +- **Post Grid** - Create a new grid +- **Load Grid** - Switch to a different grid + +## Usage Tips + +1. Start with **Health** to verify the API is running +2. Use **Get Streams** to see current streams +3. Use **Post Streams** to add test streams +4. Update the `streamId` parameter in **Put Streams** and **Delete Stream** with actual IDs from your responses + +## Getting Your API Key + +1. Open StreamGrid +2. Go to Settings (gear icon) +3. Scroll to "REST API Settings" +4. Enable REST API +5. Click "Generate API Key" if needed +6. Copy the API key +7. Paste it into `environments/Local.bru` diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..8bf241e --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,15 @@ +{ + "version": "1", + "name": "Streamgrid", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ], + "size": 0.0022039413452148438, + "filesCount": 9, + "presets": { + "requestType": "http", + "requestUrl": "0.0.0.0:3737" + } +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru new file mode 100644 index 0000000..8776bfb --- /dev/null +++ b/bruno/environments/Local.bru @@ -0,0 +1,4 @@ +vars { + apiKey: your-api-key-here + baseUrl: http://localhost:3737 +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..5bd5aa5 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,455 @@ +# StreamGrid REST API Documentation + +## Overview + +StreamGrid provides a REST API for external control and automation. The API allows you to programmatically manage streams and grids. + +**Base URL:** `http://localhost:3737` (default port, configurable in settings) + +## Authentication + +All API endpoints (except `/health`) require authentication using an API key. + +### API Key Header + +Include your API key in one of these headers: + +``` +X-API-Key: your-api-key-here +``` + +or + +``` +Authorization: Bearer your-api-key-here +``` + +### Generating an API Key + +1. Open StreamGrid Settings +2. Navigate to API section +3. Click "Generate API Key" +4. Enable the API server +5. Copy your API key (keep it secure!) + +## Rate Limiting + +- **Limit:** 100 requests per 15 minutes per IP address +- **Headers:** Rate limit info included in response headers + +## Endpoints + +### Health Check + +Check if the API server is running. + +**Endpoint:** `GET /health` or `GET /api/health` + +**Authentication:** Not required + +**Response:** +```json +{ + "status": "ok", + "apiEnabled": true, + "timestamp": "2025-01-24T16:00:00.000Z" +} +``` + +--- + +### Stream Management + +#### List All Streams + +Get all streams in the current grid. + +**Endpoint:** `GET /api/streams` + +**Response:** +```json +{ + "streams": [ + { + "id": "stream-1234567890", + "name": "Stream 1", + "streamUrl": "https://example.com/stream.m3u8", + "logoUrl": "https://example.com/logo.png", + "isMuted": false, + "fitMode": "contain" + } + ] +} +``` + +#### Add a Stream + +Add a new stream to the current grid. + +**Endpoint:** `POST /api/streams` + +**Request Body:** +```json +{ + "name": "My Stream", + "streamUrl": "https://example.com/stream.m3u8", + "logoUrl": "https://example.com/logo.png", + "isMuted": false, + "fitMode": "contain" +} +``` + +**Required Fields:** +- `name` (string): Stream name +- `streamUrl` (string): Stream URL (HLS, DASH, YouTube, Twitch, RTSP, etc.) + +**Optional Fields:** +- `logoUrl` (string): Logo/thumbnail URL +- `isMuted` (boolean): Start muted (default: false) +- `fitMode` (string): "contain" or "cover" (default: "contain") + +**Response:** +```json +{ + "success": true, + "stream": { + "id": "stream-1234567890", + "name": "My Stream", + "streamUrl": "https://example.com/stream.m3u8", + "logoUrl": "https://example.com/logo.png", + "isMuted": false, + "fitMode": "contain" + } +} +``` + +#### Update a Stream + +Update an existing stream's properties. + +**Endpoint:** `PUT /api/streams/:id` + +**URL Parameters:** +- `id` (string): Stream ID + +**Request Body:** +```json +{ + "name": "Updated Name", + "isMuted": true, + "fitMode": "cover" +} +``` + +**Response:** +```json +{ + "success": true, + "id": "stream-1234567890", + "updates": { + "name": "Updated Name", + "isMuted": true, + "fitMode": "cover" + } +} +``` + +#### Delete a Stream + +Remove a stream from the current grid. + +**Endpoint:** `DELETE /api/streams/:id` + +**URL Parameters:** +- `id` (string): Stream ID + +**Response:** +```json +{ + "success": true, + "id": "stream-1234567890" +} +``` + +--- + +### Grid Management + +#### List All Grids + +Get all saved grids. + +**Endpoint:** `GET /api/grids` + +**Response:** +```json +{ + "grids": [ + { + "id": "grid-1234567890", + "name": "My Grid", + "createdAt": "2025-01-24T16:00:00.000Z", + "lastModified": "2025-01-24T16:30:00.000Z", + "streamCount": 4, + "fileName": "grid-1234567890.json" + } + ] +} +``` + +#### Create a Grid + +Create a new saved grid. + +**Endpoint:** `POST /api/grids` + +**Request Body:** +```json +{ + "name": "My New Grid", + "streams": [], + "layout": [], + "chats": [] +} +``` + +**Required Fields:** +- `name` (string): Grid name + +**Optional Fields:** +- `streams` (array): Array of stream objects +- `layout` (array): Grid layout configuration +- `chats` (array): Chat windows configuration + +**Response:** +```json +{ + "success": true, + "grid": { + "id": "grid-1234567890", + "name": "My New Grid", + "createdAt": "2025-01-24T16:00:00.000Z", + "lastModified": "2025-01-24T16:00:00.000Z", + "streams": [], + "layout": [], + "chats": [] + } +} +``` + +#### Load a Grid + +Switch to a different saved grid. + +**Endpoint:** `PUT /api/grids/:id/load` + +**URL Parameters:** +- `id` (string): Grid ID + +**Response:** +```json +{ + "success": true, + "id": "grid-1234567890" +} +``` + +--- + +## Error Responses + +### 400 Bad Request +```json +{ + "error": "Missing required fields: name, streamUrl" +} +``` + +### 401 Unauthorized +```json +{ + "error": "Missing API key", + "message": "Provide API key in X-API-Key header or Authorization: Bearer " +} +``` + +### 403 Forbidden +```json +{ + "error": "Invalid API key", + "message": "The provided API key is invalid" +} +``` + +### 404 Not Found +```json +{ + "error": "Stream not found" +} +``` + +### 429 Too Many Requests +```json +{ + "error": "Too many requests", + "message": "Rate limit exceeded. Try again later." +} +``` + +### 500 Internal Server Error +```json +{ + "error": "Failed to add stream" +} +``` + +### 503 Service Unavailable +```json +{ + "error": "API is disabled", + "message": "The REST API is currently disabled. Enable it in settings." +} +``` + +--- + +## Example Usage + +### cURL Examples + +**Add a stream:** +```bash +curl -X POST http://localhost:3737/api/streams \ + -H "X-API-Key: your-api-key-here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Stream", + "streamUrl": "https://example.com/stream.m3u8", + "logoUrl": "https://example.com/logo.png" + }' +``` + +**List all streams:** +```bash +curl -X GET http://localhost:3737/api/streams \ + -H "X-API-Key: your-api-key-here" +``` + +**Delete a stream:** +```bash +curl -X DELETE http://localhost:3737/api/streams/stream-1234567890 \ + -H "X-API-Key: your-api-key-here" +``` + +**Load a grid:** +```bash +curl -X PUT http://localhost:3737/api/grids/grid-1234567890/load \ + -H "X-API-Key: your-api-key-here" +``` + +### Python Example + +```python +import requests + +API_URL = "http://localhost:3737/api" +API_KEY = "your-api-key-here" + +headers = { + "X-API-Key": API_KEY, + "Content-Type": "application/json" +} + +# Add a stream +response = requests.post( + f"{API_URL}/streams", + headers=headers, + json={ + "name": "My Stream", + "streamUrl": "https://example.com/stream.m3u8" + } +) +print(response.json()) + +# List all streams +response = requests.get(f"{API_URL}/streams", headers=headers) +streams = response.json()["streams"] +print(f"Total streams: {len(streams)}") +``` + +### JavaScript Example + +```javascript +const API_URL = 'http://localhost:3737/api'; +const API_KEY = 'your-api-key-here'; + +const headers = { + 'X-API-Key': API_KEY, + 'Content-Type': 'application/json' +}; + +// Add a stream +async function addStream() { + const response = await fetch(`${API_URL}/streams`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + name: 'My Stream', + streamUrl: 'https://example.com/stream.m3u8' + }) + }); + const data = await response.json(); + console.log(data); +} + +// List all streams +async function listStreams() { + const response = await fetch(`${API_URL}/streams`, { headers }); + const data = await response.json(); + console.log(`Total streams: ${data.streams.length}`); +} +``` + +--- + +## Security Best Practices + +1. **Keep your API key secret** - Never commit it to version control +2. **Use HTTPS in production** - Consider using a reverse proxy with SSL +3. **Restrict network access** - Use firewall rules to limit API access +4. **Rotate API keys regularly** - Generate new keys periodically +5. **Monitor API usage** - Check logs for suspicious activity +6. **Disable when not needed** - Turn off the API server when not in use + +--- + +## Troubleshooting + +### API Server Won't Start + +**Problem:** Port already in use + +**Solution:** Change the API port in settings or stop the conflicting service + +### Authentication Fails + +**Problem:** 403 Forbidden response + +**Solution:** +- Verify API key is correct +- Check that API is enabled in settings +- Ensure API key header is properly formatted + +### Rate Limit Exceeded + +**Problem:** 429 Too Many Requests + +**Solution:** Wait 15 minutes or reduce request frequency + +--- + +## Support + +For issues or questions: +- GitHub Issues: https://github.com/LordKnish/StreamGrid/issues +- Documentation: https://github.com/LordKnish/StreamGrid diff --git a/electron.vite.config.1761335502280.mjs b/electron.vite.config.1761335502280.mjs new file mode 100644 index 0000000..14a37d4 --- /dev/null +++ b/electron.vite.config.1761335502280.mjs @@ -0,0 +1,84 @@ +// electron.vite.config.ts +import { resolve } from "path"; +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +var electron_vite_config_default = defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + external: ["electron-updater"] + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()] + }, + renderer: { + resolve: { + alias: { + "@renderer": resolve("src/renderer/src") + } + }, + plugins: [react()], + server: { + // Remove CSP headers to allow local file access + headers: {} + }, + build: { + // Enable code splitting + rollupOptions: { + output: { + // Simplified manual chunks - only vendor packages + manualChunks: { + "react-vendor": ["react", "react-dom"], + "mui-vendor": ["@mui/material", "@mui/icons-material"], + "player-vendor": ["react-player"], + "utils": ["lodash", "uuid", "jdenticon", "web-vitals", "react-window"] + }, + // Use dynamic imports for better splitting + chunkFileNames: (chunkInfo) => { + const facadeModuleId = chunkInfo.facadeModuleId ? chunkInfo.facadeModuleId.split("/").pop() : "chunk"; + return `js/${facadeModuleId}-[hash].js`; + } + } + }, + // Optimize chunk size + chunkSizeWarningLimit: 1e3, + // Enable minification but keep console/debugger for debugging + minify: "terser", + terserOptions: { + compress: { + drop_console: false, + drop_debugger: false + } + }, + // Enable source maps for production debugging + sourcemap: true, + // Optimize CSS + cssCodeSplit: true, + // Asset optimization + assetsInlineLimit: 4096, + // Enable module preload polyfill + modulePreload: { + polyfill: true + } + }, + optimizeDeps: { + // Pre-bundle heavy dependencies + include: [ + "react", + "react-dom", + "@mui/material", + "react-player", + "lodash", + "react-window" + ], + // Exclude dependencies that should be external + exclude: ["electron"] + } + } +}); +export { + electron_vite_config_default as default +}; diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2940fce..e750563 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,7 +4,12 @@ import react from '@vitejs/plugin-react' export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()] + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + external: ['electron-updater'] + } + } }, preload: { plugins: [externalizeDepsPlugin()] @@ -24,23 +29,12 @@ export default defineConfig({ // Enable code splitting rollupOptions: { output: { - // Manual chunks for better code splitting + // Simplified manual chunks - only vendor packages manualChunks: { - // Vendor chunks 'react-vendor': ['react', 'react-dom'], 'mui-vendor': ['@mui/material', '@mui/icons-material'], 'player-vendor': ['react-player'], - 'utils': ['lodash', 'uuid', 'jdenticon'], - // Feature chunks - 'performance': [ - './src/renderer/src/hooks/usePerformanceMonitor', - './src/renderer/src/hooks/usePlayerPool', - 'web-vitals' - ], - 'virtual-grid': [ - './src/renderer/src/components/VirtualStreamGrid', - 'react-window' - ] + 'utils': ['lodash', 'uuid', 'jdenticon', 'web-vitals', 'react-window'] }, // Use dynamic imports for better splitting chunkFileNames: (chunkInfo) => { @@ -51,12 +45,12 @@ export default defineConfig({ }, // Optimize chunk size chunkSizeWarningLimit: 1000, - // Enable minification + // Enable minification but keep console/debugger for debugging minify: 'terser', terserOptions: { compress: { - drop_console: true, - drop_debugger: true + drop_console: false, + drop_debugger: false } }, // Enable source maps for production debugging diff --git a/package-lock.json b/package-lock.json index bca067d..bacbcd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "streamgrid", - "version": "1.2.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "streamgrid", - "version": "1.2.2", + "version": "2.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -22,10 +22,12 @@ "@types/uuid": "^10.0.0", "comlink": "^4.4.2", "compare-versions": "^6.1.1", + "cors": "^2.8.5", "dashjs": "^4.7.4", "date-fns": "^4.1.0", "electron-updater": "^6.1.7", "express": "^5.1.0", + "express-rate-limit": "^8.1.0", "fluent-ffmpeg": "^2.1.3", "hls.js": "^1.5.20", "jdenticon": "^3.3.0", @@ -4432,6 +4434,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5968,6 +5983,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7059,6 +7092,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index e9d308c..aeb1c31 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "streamgrid", - "version": "1.2.4", - "description": "A high-performance multi-stream viewer application for watching multiple live streams simultaneously in customizable grid layouts", + "version": "2.0.0", + "description": "A high-performance multi-stream viewer application for watching multiple live streams simultaneously in customizable grid layouts. Supports YouTube, Twitch, HLS, DASH, RTSP, and local files.", "main": "./out/main/index.js", "author": { "name": "LordKnish", @@ -52,10 +52,12 @@ "@types/uuid": "^10.0.0", "comlink": "^4.4.2", "compare-versions": "^6.1.1", + "cors": "^2.8.5", "dashjs": "^4.7.4", "date-fns": "^4.1.0", "electron-updater": "^6.1.7", "express": "^5.1.0", + "express-rate-limit": "^8.1.0", "fluent-ffmpeg": "^2.1.3", "hls.js": "^1.5.20", "jdenticon": "^3.3.0", diff --git a/src/main/apiAuth.ts b/src/main/apiAuth.ts new file mode 100644 index 0000000..1e75678 --- /dev/null +++ b/src/main/apiAuth.ts @@ -0,0 +1,81 @@ +import { Request, Response, NextFunction } from 'express' +import crypto from 'crypto' + +export interface ApiAuthConfig { + apiKey: string + enabled: boolean +} + +let authConfig: ApiAuthConfig = { + apiKey: '', + enabled: false +} + +/** + * Generate a secure random API key + */ +export function generateApiKey(): string { + return crypto.randomBytes(32).toString('hex') +} + +/** + * Update the authentication configuration + */ +export function updateAuthConfig(config: Partial): void { + authConfig = { ...authConfig, ...config } +} + +/** + * Get current auth configuration + */ +export function getAuthConfig(): ApiAuthConfig { + return { ...authConfig } +} + +/** + * Express middleware for API key authentication + */ +export function authenticateApiKey(req: Request, res: Response, next: NextFunction): void { + // If API is disabled, reject all requests + if (!authConfig.enabled) { + res.status(503).json({ + error: 'API is disabled', + message: 'The REST API is currently disabled. Enable it in settings.' + }) + return + } + + // Check for API key in header + const providedKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '') + + if (!providedKey) { + res.status(401).json({ + error: 'Missing API key', + message: 'Provide API key in X-API-Key header or Authorization: Bearer ' + }) + return + } + + // Validate API key + if (providedKey !== authConfig.apiKey) { + res.status(403).json({ + error: 'Invalid API key', + message: 'The provided API key is invalid' + }) + return + } + + // Authentication successful + next() +} + +/** + * Health check endpoint (no auth required) + */ +export function healthCheck(_req: Request, res: Response): void { + res.json({ + status: 'ok', + apiEnabled: authConfig.enabled, + timestamp: new Date().toISOString() + }) +} diff --git a/src/main/apiServer.ts b/src/main/apiServer.ts new file mode 100644 index 0000000..3a3cf73 --- /dev/null +++ b/src/main/apiServer.ts @@ -0,0 +1,391 @@ +import express, { Express, Request, Response } from 'express' +import cors from 'cors' +import rateLimit from 'express-rate-limit' +import { Server } from 'http' +import { BrowserWindow } from 'electron' +import { authenticateApiKey, healthCheck, updateAuthConfig, generateApiKey } from './apiAuth' +import type { Stream } from '../shared/types/api' +import type { SavedGrid } from '../shared/types/grid' + +export interface ApiServerConfig { + port: number + apiKey: string + enabled: boolean +} + +let server: Server | null = null +let app: Express | null = null +let currentConfig: ApiServerConfig | null = null + +/** + * Create and configure the Express app + */ +function createApp(): Express { + const expressApp = express() + + // Middleware + expressApp.use(express.json({ limit: '10mb' })) + expressApp.use(express.urlencoded({ extended: true })) + + // CORS configuration - allow all origins for local API + expressApp.use( + cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization'] + }) + ) + + // Rate limiting - 100 requests per 15 minutes per IP + const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + message: { error: 'Too many requests', message: 'Rate limit exceeded. Try again later.' }, + standardHeaders: true, + legacyHeaders: false + }) + expressApp.use('/api', limiter) + + // Health check endpoint (no auth required) + expressApp.get('/health', healthCheck) + expressApp.get('/api/health', healthCheck) + + // ===== STREAM ENDPOINTS ===== + // Note: Authentication is applied to each route individually + + // GET /api/streams - List all streams + expressApp.get('/api/streams', authenticateApiKey, async (_req: Request, res: Response) => { + try { + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + const result = await mainWindow.webContents.executeJavaScript( + 'window.streamStore?.getState().streams || []' + ) + res.json({ streams: result }) + } catch (error) { + console.error('Error getting streams:', error) + res.status(500).json({ error: 'Failed to get streams' }) + } + }) + + // POST /api/streams - Add a new stream + expressApp.post('/api/streams', authenticateApiKey, async (req: Request, res: Response) => { + try { + const { name, streamUrl, logoUrl, isMuted, fitMode } = req.body + + if (!name || !streamUrl) { + res.status(400).json({ error: 'Missing required fields: name, streamUrl' }) + return + } + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + // Generate unique ID + const streamId = `stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + + const newStream: Stream = { + id: streamId, + name, + streamUrl, + logoUrl: logoUrl || '', + isMuted: isMuted !== undefined ? isMuted : false, + fitMode: fitMode || 'contain' + } + + await mainWindow.webContents.executeJavaScript( + `window.streamStore?.getState().addStream(${JSON.stringify(newStream)})` + ) + + res.status(201).json({ success: true, stream: newStream }) + } catch (error) { + console.error('Error adding stream:', error) + res.status(500).json({ error: 'Failed to add stream' }) + } + }) + + // PUT /api/streams/:id - Update a stream + expressApp.put('/api/streams/:id', authenticateApiKey, async (req: Request, res: Response) => { + try { + const { id } = req.params + const updates = req.body + + if (!id) { + res.status(400).json({ error: 'Missing stream ID' }) + return + } + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + // Check if stream exists + const streams = await mainWindow.webContents.executeJavaScript( + 'window.streamStore?.getState().streams || []' + ) + const streamExists = streams.some((s: Stream) => s.id === id) + + if (!streamExists) { + res.status(404).json({ error: 'Stream not found' }) + return + } + + await mainWindow.webContents.executeJavaScript( + `window.streamStore?.getState().updateStream('${id}', ${JSON.stringify(updates)})` + ) + + res.json({ success: true, id, updates }) + } catch (error) { + console.error('Error updating stream:', error) + res.status(500).json({ error: 'Failed to update stream' }) + } + }) + + // DELETE /api/streams/:id - Remove a stream + expressApp.delete('/api/streams/:id', authenticateApiKey, async (req: Request, res: Response) => { + try { + const { id } = req.params + + if (!id) { + res.status(400).json({ error: 'Missing stream ID' }) + return + } + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + // Check if stream exists + const streams = await mainWindow.webContents.executeJavaScript( + 'window.streamStore?.getState().streams || []' + ) + const streamExists = streams.some((s: Stream) => s.id === id) + + if (!streamExists) { + res.status(404).json({ error: 'Stream not found' }) + return + } + + await mainWindow.webContents.executeJavaScript( + `window.streamStore?.getState().removeStream('${id}')` + ) + + res.json({ success: true, id }) + } catch (error) { + console.error('Error removing stream:', error) + res.status(500).json({ error: 'Failed to remove stream' }) + } + }) + + // ===== GRID ENDPOINTS ===== + + // GET /api/grids - List all grids + expressApp.get('/api/grids', authenticateApiKey, async (_req: Request, res: Response) => { + try { + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + // Get all grids via IPC + const grids = await mainWindow.webContents.executeJavaScript( + 'window.api.getAllGrids()' + ) + + res.json({ grids: grids || [] }) + } catch (error) { + console.error('Error getting grids:', error) + res.status(500).json({ error: 'Failed to get grids' }) + } + }) + + // POST /api/grids - Create a new grid + expressApp.post('/api/grids', authenticateApiKey, async (req: Request, res: Response) => { + try { + const { name, streams, layout, chats } = req.body + + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }) + return + } + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + const gridId = `grid-${Date.now()}` + const now = new Date().toISOString() + + const newGrid: SavedGrid = { + id: gridId, + name, + createdAt: now, + lastModified: now, + streams: streams || [], + layout: layout || [], + chats: chats || [] + } + + // Save grid via IPC + await mainWindow.webContents.executeJavaScript( + `window.api.saveGrid(${JSON.stringify(newGrid)})` + ) + + res.status(201).json({ success: true, grid: newGrid }) + } catch (error) { + console.error('Error creating grid:', error) + res.status(500).json({ error: 'Failed to create grid' }) + } + }) + + // PUT /api/grids/:id/load - Load/switch to a grid + expressApp.put('/api/grids/:id/load', authenticateApiKey, async (req: Request, res: Response) => { + try { + const { id } = req.params + + if (!id) { + res.status(400).json({ error: 'Missing grid ID' }) + return + } + + const mainWindow = BrowserWindow.getAllWindows()[0] + if (!mainWindow) { + res.status(503).json({ error: 'Application not ready' }) + return + } + + // Load grid via store method + await mainWindow.webContents.executeJavaScript( + `window.streamStore?.getState().loadGrid('${id}')` + ) + + res.json({ success: true, id }) + } catch (error) { + console.error('Error loading grid:', error) + res.status(500).json({ error: 'Failed to load grid' }) + } + }) + + // No catch-all needed - Express will handle 404s automatically + // or we can add a final middleware without wildcards + expressApp.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Endpoint not found' }) + }) + + return expressApp +} + +/** + * Start the API server + */ +export async function startApiServer(config: ApiServerConfig): Promise { + if (server) { + console.log('API server already running') + return + } + + if (!config.enabled) { + console.log('API server is disabled') + return + } + + try { + // Update auth configuration + updateAuthConfig({ + apiKey: config.apiKey, + enabled: config.enabled + }) + + // Create Express app + app = createApp() + currentConfig = config + + // Start server + await new Promise((resolve, reject) => { + server = app!.listen(config.port, () => { + console.log(`StreamGrid API server running on http://localhost:${config.port}`) + console.log(`Health check: http://localhost:${config.port}/health`) + resolve() + }).on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${config.port} is already in use`)) + } else { + reject(err) + } + }) + }) + } catch (error) { + console.error('Failed to start API server:', error) + server = null + app = null + currentConfig = null + throw error + } +} + +/** + * Stop the API server + */ +export async function stopApiServer(): Promise { + if (!server) { + console.log('API server is not running') + return + } + + return new Promise((resolve, reject) => { + server!.close((err) => { + if (err) { + console.error('Error stopping API server:', err) + reject(err) + } else { + console.log('API server stopped') + server = null + app = null + currentConfig = null + resolve() + } + }) + }) +} + +/** + * Restart the API server with new configuration + */ +export async function restartApiServer(config: ApiServerConfig): Promise { + await stopApiServer() + if (config.enabled) { + await startApiServer(config) + } +} + +/** + * Get current server status + */ +export function getServerStatus(): { + running: boolean + config: ApiServerConfig | null +} { + return { + running: server !== null, + config: currentConfig + } +} + +/** + * Export generateApiKey for use in main process + */ +export { generateApiKey } diff --git a/src/main/index.ts b/src/main/index.ts index bfc2ea5..dc2d863 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,17 +1,43 @@ -import { app, shell, BrowserWindow, ipcMain, dialog, protocol } from 'electron' +import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron' import { join } from 'path' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import { autoUpdater } from 'electron-updater' import icon from '../../resources/icon.svg?asset' import https from 'https' import fs from 'fs/promises' import path from 'path' import type { SavedGrid, GridManifest } from '../shared/types/grid' +import { rtspService } from './rtspService' +import { startApiServer, stopApiServer, restartApiServer, getServerStatus, generateApiKey } from './apiServer' + +// Diagnostic logging +console.log('MAIN ENTRY', { + type: (process as any).type, + versions: process.versions, + runAsNode: process.env.ELECTRON_RUN_AS_NODE || null +}) + +// Fail fast if running outside Electron main process +if ((process as any).type && (process as any).type !== 'browser') { + throw new Error('Main loaded outside Electron main process') +} -// Register custom protocol for Twitch embeds -protocol.registerSchemesAsPrivileged([ - { scheme: 'streamgrid', privileges: { secure: true, standard: true, corsEnabled: true } } -]) +// Hold reference to autoUpdater (lazy loaded) +let autoUpdater: typeof import('electron-updater').autoUpdater | null = null + +// Wait for dev server to be ready +async function waitFor(url: string, opts: { timeoutMs?: number; intervalMs?: number } = {}): Promise { + const timeoutMs = opts.timeoutMs ?? 15000 + const intervalMs = opts.intervalMs ?? 250 + const start = Date.now() + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const res = await fetch(url, { method: 'HEAD', cache: 'no-store' as any }) + if (res.ok) return + } catch { /* ignore until timeout */ } + if (Date.now() - start > timeoutMs) throw new Error(`Dev server not reachable: ${url}`) + await new Promise(r => setTimeout(r, intervalMs)) + } +} // Function to fetch latest GitHub release version async function getLatestGitHubVersion(): Promise { @@ -45,61 +71,74 @@ async function getLatestGitHubVersion(): Promise { }) } -// Configure autoUpdater -autoUpdater.autoDownload = false -autoUpdater.autoInstallOnAppQuit = true +// Wire up auto-updater events (called after lazy load) +function wireAutoUpdaterEvents(au: typeof import('electron-updater').autoUpdater): void { + au.autoDownload = false + au.autoInstallOnAppQuit = true -function checkForUpdates(): void { - autoUpdater.checkForUpdates() -} + au.on('checking-for-update', () => { + console.log('Checking for updates...') + }) -// Auto updater events -autoUpdater.on('checking-for-update', () => { - console.log('Checking for updates...') -}) + au.on('update-available', (info) => { + dialog + .showMessageBox({ + type: 'info', + title: 'Update Available', + message: `Version ${info.version} is available. Would you like to download it?`, + buttons: ['Yes', 'No'], + defaultId: 0 + }) + .then((result) => { + if (result.response === 0) { + au.downloadUpdate() + } + }) + }) -autoUpdater.on('update-available', (info) => { - dialog - .showMessageBox({ - type: 'info', - title: 'Update Available', - message: `Version ${info.version} is available. Would you like to download it?`, - buttons: ['Yes', 'No'], - defaultId: 0 - }) - .then((result) => { - if (result.response === 0) { - autoUpdater.downloadUpdate() - } - }) -}) + au.on('update-not-available', () => { + console.log('No updates available') + }) -autoUpdater.on('update-not-available', () => { - console.log('No updates available') -}) + au.on('error', (err) => { + // Log but don't show error dialog for update failures + // This prevents the Linux latest-linux.yml 404 error from being intrusive + console.log('Auto-updater error (non-critical):', err.message || err) -autoUpdater.on('error', (err) => { - console.error('Error in auto-updater:', err) -}) + // Only show error dialog for critical update errors, not missing update files + if (err.message && !err.message.includes('latest-linux.yml') && !err.message.includes('404')) { + console.error('Critical auto-updater error:', err) + } + }) -autoUpdater.on('download-progress', (progressObj) => { - console.log(`Download progress: ${progressObj.percent}%`) -}) + au.on('download-progress', (progressObj) => { + console.log(`Download progress: ${progressObj.percent}%`) + }) -autoUpdater.on('update-downloaded', (info) => { - dialog - .showMessageBox({ - type: 'info', - title: 'Update Ready', - message: `Version ${info.version} has been downloaded. The application will now restart to install the update.`, - buttons: ['Restart'] - }) - .then(() => { - autoUpdater.quitAndInstall(false, true) - }) -}) + au.on('update-downloaded', (info) => { + dialog + .showMessageBox({ + type: 'info', + title: 'Update Ready', + message: `Version ${info.version} has been downloaded. The application will now restart to install the update.`, + buttons: ['Restart'] + }) + .then(() => { + au.quitAndInstall(false, true) + }) + }) +} + +async function checkForUpdates(): Promise { + if (!autoUpdater) return + try { + await autoUpdater.checkForUpdates() + } catch (e: any) { + console.log('Update check failed (non-critical):', e?.message || e) + } +} -function createWindow(): void { +async function createWindow(): Promise { // Create the browser window. const mainWindow = new BrowserWindow({ width: 900, @@ -116,7 +155,9 @@ function createWindow(): void { } }) - // Intercept Twitch embed requests to add parent parameter + // TEMPORARILY DISABLED: Intercept Twitch embed requests to add parent parameter + // Commenting out to rule out webRequest interference during dev server loading + /* mainWindow.webContents.session.webRequest.onBeforeRequest( { urls: ['https://player.twitch.tv/*', 'https://embed.twitch.tv/*'] @@ -131,7 +172,7 @@ function createWindow(): void { // Only modify if parent is not already set if (!params.has('parent')) { // Set parent to localhost with port for development, or just localhost for production - const parentDomain = is.dev ? 'localhost:4000' : 'localhost' + const parentDomain = !app.isPackaged ? 'localhost:5173' : 'localhost' params.set('parent', parentDomain) params.set('referrer', `https://${parentDomain}/`) @@ -166,27 +207,7 @@ function createWindow(): void { }) } ) - - // Remove Content Security Policy since we've disabled web security for local file access - // This allows local files to be loaded without CSP restrictions - mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { - const responseHeaders = details.responseHeaders || {} - - // Modify CSP to allow blob URLs - if (responseHeaders['content-security-policy'] || responseHeaders['Content-Security-Policy']) { - const cspHeader = responseHeaders['content-security-policy'] || responseHeaders['Content-Security-Policy'] - if (cspHeader && Array.isArray(cspHeader)) { - // Add blob: to the CSP if it's not already there - cspHeader[0] = cspHeader[0].replace(/default-src ([^;]+)/, 'default-src $1 blob:') - cspHeader[0] = cspHeader[0].replace(/media-src ([^;]+)/, 'media-src $1 blob: data: file:') - } - } - - callback({ - cancel: false, - responseHeaders - }) - }) + */ // Add right-click menu for inspect element mainWindow.webContents.on('context-menu', (_, props): void => { @@ -209,21 +230,75 @@ function createWindow(): void { } }) - mainWindow.on('ready-to-show', () => { - mainWindow.show() - }) - mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) + const isDev = !app.isPackaged + const rendererUrl = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173' + + console.log('DEV?', isDev, 'ELECTRON_RENDERER_URL=', process.env.ELECTRON_RENDERER_URL) + console.log( + 'Loading URL:', + isDev ? rendererUrl : 'file://' + join(__dirname, '../renderer/index.html') + ) + + // Better diagnostics + mainWindow.webContents.on('did-finish-load', () => console.log('did-finish-load')) + mainWindow.webContents.on('did-navigate', (_e, url) => console.log('did-navigate', url)) + mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => + console.error('did-fail-load', { code, desc, url }) + ) + mainWindow.webContents.on('render-process-gone', (_e, d) => + console.error('render-process-gone', d) + ) - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + mainWindow.once('ready-to-show', () => { + console.log('ready-to-show') + mainWindow.show() + }) + + // Force foreground in case Windows puts it behind + mainWindow.once('show', () => { + mainWindow.focus() + mainWindow.setAlwaysOnTop(true, 'screen-saver') + setTimeout(() => mainWindow.setAlwaysOnTop(false), 500) + }) + + // Fallback show in case ready-to-show never arrives + setTimeout(() => { + if (!mainWindow.isVisible()) { + console.warn('Force show fallback') + mainWindow.show() + mainWindow.focus() + } + }, 3000) + + // Wait for dev server and handle loading errors + if (isDev) { + try { + await waitFor(rendererUrl) // ensure Vite is up + await mainWindow.loadURL(rendererUrl) // first attempt + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } catch (err: any) { + const msg = String(err?.message || err) + console.warn('loadURL error:', msg) + // benign second navigation during HMR + if (msg.includes('ERR_ABORTED')) { + console.warn('loadURL aborted; continuing') + } else { + // brief retry once + await new Promise(r => setTimeout(r, 500)) + try { + await mainWindow.loadURL(rendererUrl) + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } catch (e2) { + console.error('Second loadURL failed:', e2) + } + } + } } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + await mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } @@ -231,21 +306,37 @@ function createWindow(): void { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - // Check for updates on app start - if (!is.dev) { - checkForUpdates() - // Check for updates every hour - setInterval(checkForUpdates, 60 * 60 * 1000) + // Lazy load electron-updater only in packaged builds + if (app.isPackaged) { + try { + const mod = await import('electron-updater') + autoUpdater = mod.autoUpdater + if (autoUpdater) { + wireAutoUpdaterEvents(autoUpdater) + checkForUpdates() + setInterval(checkForUpdates, 60 * 60 * 1000) + } + } catch (error) { + console.error('Failed to load electron-updater:', error) + // Continue without auto-updater + } } // Set app user model id for windows - electronApp.setAppUserModelId('com.streamgrid.app') + app.setAppUserModelId('com.streamgrid.app') // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) + // Simple keyboard shortcut handling without toolkit + if (!app.isPackaged) { + window.webContents.on('before-input-event', (event, input) => { + // Reload on Ctrl/Cmd + R + if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { + event.preventDefault() + window.webContents.reload() + } + }) + } }) // IPC handlers @@ -269,8 +360,12 @@ app.whenReady().then(async () => { const result = await dialog.showOpenDialog({ properties: ['openFile'], filters: [ - { name: 'Video Files', extensions: ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv', 'm4v', 'flv', 'wmv'] }, + { + name: 'Video Files', + extensions: ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv', 'm4v', 'flv', 'wmv'] + }, { name: 'Audio Files', extensions: ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac'] }, + { name: 'M3U Playlists', extensions: ['m3u', 'm3u8'] }, { name: 'All Files', extensions: ['*'] } ] }) @@ -284,6 +379,143 @@ app.whenReady().then(async () => { return null }) + // Add handler for M3U file dialog + ipcMain.handle('show-m3u-dialog', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { name: 'M3U Playlists', extensions: ['m3u', 'm3u8'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0] + } + return null + }) + + // Add handler for reading M3U file content + ipcMain.handle('read-m3u-file', async (_, filePath: string) => { + try { + // Read file as buffer first to detect encoding + const buffer = await fs.readFile(filePath) + + // Check for UTF-16 BOM + let content: string + if (buffer.length >= 2) { + // UTF-16 LE BOM: FF FE + if (buffer[0] === 0xFF && buffer[1] === 0xFE) { + content = buffer.toString('utf16le') + } + // UTF-16 BE BOM: FE FF + else if (buffer[0] === 0xFE && buffer[1] === 0xFF) { + // Node.js doesn't have utf16be, so we need to swap bytes + const swapped = Buffer.alloc(buffer.length) + for (let i = 0; i < buffer.length; i += 2) { + swapped[i] = buffer[i + 1] + swapped[i + 1] = buffer[i] + } + content = swapped.toString('utf16le') + } + // UTF-8 BOM: EF BB BF + else if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + content = buffer.toString('utf-8') + } + // No BOM, try UTF-8 + else { + content = buffer.toString('utf-8') + } + } else { + content = buffer.toString('utf-8') + } + + return { success: true, content } + } catch (error) { + console.error('Error reading M3U file:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read file' + } + } + }) + + // Add handler for fetching M3U from URL + ipcMain.handle('fetch-m3u-url', async (_, url: string) => { + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'StreamGrid/2.0' + } + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const content = await response.text() + return { success: true, content } + } catch (error) { + console.error('Error fetching M3U URL:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch URL' + } + } + }) + + // RTSP streaming handlers + ipcMain.handle('rtspCheckFfmpeg', async () => { + return await rtspService.checkFfmpeg() + }) + + ipcMain.handle('rtspStartStream', async (_, streamId: string, rtspUrl: string) => { + return await rtspService.startStream(streamId, rtspUrl) + }) + + ipcMain.handle('rtspStopStream', async (_, streamId: string) => { + return await rtspService.stopStream(streamId) + }) + + // API server handlers + ipcMain.handle('start-api-server', async (_, config: { port: number; apiKey: string; enabled: boolean }) => { + try { + await startApiServer(config) + return { success: true } + } catch (error) { + console.error('Failed to start API server:', error) + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle('stop-api-server', async () => { + try { + await stopApiServer() + return { success: true } + } catch (error) { + console.error('Failed to stop API server:', error) + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle('restart-api-server', async (_, config: { port: number; apiKey: string; enabled: boolean }) => { + try { + await restartApiServer(config) + return { success: true } + } catch (error) { + console.error('Failed to restart API server:', error) + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle('get-api-server-status', async () => { + return getServerStatus() + }) + + ipcMain.handle('generate-api-key', async () => { + return generateApiKey() + }) // Grid management setup await setupGridManagement() @@ -330,7 +562,7 @@ async function setupGridManagement(): Promise { const manifestData = await fs.readFile(manifestPath, 'utf-8') const manifest: GridManifest = JSON.parse(manifestData) - const existingIndex = manifest.grids.findIndex(g => g.id === grid.id) + const existingIndex = manifest.grids.findIndex((g) => g.id === grid.id) const gridInfo = { id: grid.id, name: grid.name, @@ -375,7 +607,7 @@ async function setupGridManagement(): Promise { // Update manifest const manifestData = await fs.readFile(manifestPath, 'utf-8') const manifest: GridManifest = JSON.parse(manifestData) - manifest.grids = manifest.grids.filter(g => g.id !== gridId) + manifest.grids = manifest.grids.filter((g) => g.id !== gridId) if (manifest.currentGridId === gridId) { manifest.currentGridId = null @@ -403,7 +635,7 @@ async function setupGridManagement(): Promise { // Update manifest const manifestData = await fs.readFile(manifestPath, 'utf-8') const manifest: GridManifest = JSON.parse(manifestData) - const gridIndex = manifest.grids.findIndex(g => g.id === gridId) + const gridIndex = manifest.grids.findIndex((g) => g.id === gridId) if (gridIndex >= 0) { manifest.grids[gridIndex].name = newName @@ -454,6 +686,12 @@ app.on('before-quit', async (event) => { if (windows.length > 0) { event.preventDefault() + // Stop all RTSP streams + await rtspService.stopAllStreams() + + // Stop API server + await stopApiServer().catch(err => console.error('Error stopping API server:', err)) + // Send save request to all windows for (const window of windows) { window.webContents.send('app-before-quit') diff --git a/src/main/rtspService.ts b/src/main/rtspService.ts new file mode 100644 index 0000000..b1212f1 --- /dev/null +++ b/src/main/rtspService.ts @@ -0,0 +1,456 @@ +import { app } from 'electron' +import express, { Express } from 'express' +import { Server } from 'http' +import { spawn } from 'child_process' +import { promisify } from 'util' +import { exec } from 'child_process' +import fs from 'fs/promises' +import path from 'path' +import type { + RTSPStream, + RTSPStartResult, + RTSPStopResult, + FFmpegCheckResult, + RTSPSettings +} from '../shared/types/rtsp' + +const execPromise = promisify(exec) + +class RTSPService { + private streams: Map = new Map() + private expressApp: Express + private server: Server | null = null + private settings: RTSPSettings = { + defaultTransport: 'tcp', + segmentDuration: 2, + maxRetries: 3, + connectionTimeout: 10000, + basePort: 8100 + } + private usedPorts: Set = new Set() + private tempDir: string + + constructor() { + this.expressApp = express() + this.tempDir = path.join(app.getPath('temp'), 'streamgrid-rtsp') + this.initialize() + } + + private async initialize(): Promise { + // Create temp directory + try { + await fs.mkdir(this.tempDir, { recursive: true }) + console.log('[RTSP] Temp directory created:', this.tempDir) + } catch (error) { + console.error('[RTSP] Failed to create temp directory:', error) + } + + // Clean up old directories on startup + await this.cleanupOldDirectories() + + // Setup Express server + this.setupExpressServer() + } + + private async cleanupOldDirectories(): Promise { + try { + const entries = await fs.readdir(this.tempDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory()) { + const dirPath = path.join(this.tempDir, entry.name) + await fs.rm(dirPath, { recursive: true, force: true }) + console.log('[RTSP] Cleaned up old directory:', dirPath) + } + } + } catch (error) { + console.error('[RTSP] Error cleaning up old directories:', error) + } + } + + private setupExpressServer(): void { + // Log all requests + this.expressApp.use((req, _res, next) => { + console.log(`[RTSP] Express request: ${req.method} ${req.url}`) + next() + }) + + // Enable CORS for local access + this.expressApp.use((_req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type') + next() + }) + + // Serve HLS playlists and segments + this.expressApp.get('/rtsp/:streamId/:file', async (req, res): Promise => { + const { streamId, file } = req.params + console.log(`[RTSP] Request for stream ${streamId}, file: ${file}`) + + const stream = this.streams.get(streamId) + + if (!stream) { + console.error(`[RTSP] Stream not found: ${streamId}`) + res.status(404).send('Stream not found') + return + } + + const filePath = path.join(stream.outputDir, file) + console.log(`[RTSP] Serving file: ${filePath}`) + + try { + // Check if file exists + await fs.access(filePath) + + // Set appropriate content type + if (file.endsWith('.m3u8')) { + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl') + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + } else if (file.endsWith('.ts')) { + res.setHeader('Content-Type', 'video/mp2t') + res.setHeader('Cache-Control', 'public, max-age=3600') + } + + // Send file + res.sendFile(filePath, (err) => { + if (err) { + console.error('[RTSP] Error sending file:', err) + } + }) + } catch (error) { + console.error('[RTSP] File not found or error:', filePath, error) + res.status(404).send('File not found') + } + }) + + // Health check endpoint + this.expressApp.get('/rtsp/:streamId/health', (req, res): void => { + const { streamId } = req.params + const stream = this.streams.get(streamId) + + if (!stream) { + res.status(404).json({ status: 'not_found' }) + return + } + + res.json({ + status: stream.status, + uptime: Date.now() - stream.startTime.getTime(), + errorCount: stream.errorCount + }) + }) + + // Debug endpoint to list all active streams + this.expressApp.get('/rtsp/debug/streams', (_req, res): void => { + const streamList = Array.from(this.streams.entries()).map(([id, stream]) => ({ + id, + status: stream.status, + outputDir: stream.outputDir, + port: stream.port + })) + res.json({ + activeStreams: streamList.length, + streams: streamList + }) + }) + + // Start server + const port = this.settings.basePort + this.server = this.expressApp.listen(port, 'localhost', () => { + console.log(`[RTSP] Express server listening on http://localhost:${port}`) + console.log(`[RTSP] Routes registered:`) + console.log(` - GET /rtsp/:streamId/:file`) + console.log(` - GET /rtsp/:streamId/health`) + console.log(` - GET /rtsp/debug/streams`) + }) + + this.server.on('error', (error) => { + console.error('[RTSP] Express server error:', error) + }) + } + + async checkFfmpeg(): Promise { + try { + const { stdout } = await execPromise('ffmpeg -version') + const versionMatch = stdout.match(/ffmpeg version (\S+)/) + const version = versionMatch ? versionMatch[1] : undefined + + console.log('[RTSP] FFmpeg found:', version) + return { + available: true, + version, + path: 'ffmpeg' + } + } catch (error) { + console.error('[RTSP] FFmpeg not found:', error) + return { + available: false + } + } + } + + private async findAvailablePort(): Promise { + const maxAttempts = 100 + for (let i = 0; i < maxAttempts; i++) { + const port = this.settings.basePort + i + if (!this.usedPorts.has(port)) { + this.usedPorts.add(port) + return port + } + } + throw new Error('No available ports') + } + + async startStream(streamId: string, rtspUrl: string): Promise { + try { + console.log(`[RTSP] startStream called for ${streamId}`) + console.log(`[RTSP] Current streams in map:`, Array.from(this.streams.keys())) + + // Check if stream already exists + if (this.streams.has(streamId)) { + console.log('[RTSP] Stream already running:', streamId) + const existingStream = this.streams.get(streamId)! + return { + success: true, + url: `http://localhost:${this.settings.basePort}/rtsp/${streamId}/playlist.m3u8`, + port: existingStream.port + } + } + + // Check FFmpeg availability + const ffmpegCheck = await this.checkFfmpeg() + if (!ffmpegCheck.available) { + return { + success: false, + error: 'FFmpeg is not installed or not found in PATH' + } + } + + // Create output directory + const outputDir = path.join(this.tempDir, streamId) + await fs.mkdir(outputDir, { recursive: true }) + + // Find available port (not used for now, but reserved for future multi-server support) + const port = await this.findAvailablePort() + + // Create stream object + const stream: RTSPStream = { + id: streamId, + rtspUrl, + ffmpegProcess: null, + outputDir, + port, + status: 'starting', + startTime: new Date(), + errorCount: 0, + retryCount: 0 + } + + // Add to streams map BEFORE spawning FFmpeg + this.streams.set(streamId, stream) + console.log(`[RTSP] Stream added to map: ${streamId}`) + console.log(`[RTSP] Streams in map after add:`, Array.from(this.streams.keys())) + + // Spawn FFmpeg process + await this.spawnFFmpeg(stream) + + console.log('[RTSP] Stream started:', streamId) + console.log(`[RTSP] Final streams in map:`, Array.from(this.streams.keys())) + return { + success: true, + url: `http://localhost:${this.settings.basePort}/rtsp/${streamId}/playlist.m3u8`, + port: this.settings.basePort + } + } catch (error) { + console.error('[RTSP] Error starting stream:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + private async spawnFFmpeg(stream: RTSPStream): Promise { + const playlistPath = path.join(stream.outputDir, 'playlist.m3u8') + const segmentPath = path.join(stream.outputDir, 'segment_%03d.ts') + + // FFmpeg arguments for RTSP to HLS transcoding + const args = [ + '-rtsp_transport', this.settings.defaultTransport, + '-i', stream.rtspUrl, + '-c:v', 'copy', // Copy video codec (no re-encoding) + '-c:a', 'aac', // Encode audio to AAC + '-f', 'hls', + '-hls_time', this.settings.segmentDuration.toString(), + '-hls_list_size', '5', // Keep only 5 segments + '-hls_flags', 'delete_segments+append_list', + '-hls_segment_filename', segmentPath, + playlistPath + ] + + console.log('[RTSP] Spawning FFmpeg with args:', args.join(' ')) + + const ffmpegProcess = spawn('ffmpeg', args, { + stdio: ['ignore', 'pipe', 'pipe'] + }) + + stream.ffmpegProcess = ffmpegProcess + + // Handle stdout + ffmpegProcess.stdout?.on('data', (data) => { + console.log('[RTSP] FFmpeg stdout:', data.toString()) + }) + + // Handle stderr (FFmpeg outputs to stderr) + let lastOutput = '' + ffmpegProcess.stderr?.on('data', (data) => { + const output = data.toString() + lastOutput = output + + // Log all output for debugging + console.log('[RTSP] FFmpeg output:', output.substring(0, 200)) + + // Log errors prominently + if (output.toLowerCase().includes('error')) { + console.error('[RTSP] FFmpeg ERROR:', output) + } + }) + + // Handle process exit + ffmpegProcess.on('exit', (code, signal) => { + console.log(`[RTSP] FFmpeg process exited with code ${code}, signal ${signal}`) + if (lastOutput) { + console.log('[RTSP] Last FFmpeg output:', lastOutput) + } + + if (code !== 0 && code !== null) { + stream.errorCount++ + stream.status = 'error' + + // Retry logic + if (stream.retryCount < this.settings.maxRetries) { + stream.retryCount++ + console.log(`[RTSP] Retrying stream ${stream.id} (attempt ${stream.retryCount}/${this.settings.maxRetries})`) + setTimeout(() => { + this.spawnFFmpeg(stream) + }, 2000 * stream.retryCount) // Exponential backoff + } else { + console.error(`[RTSP] Stream ${stream.id} failed after ${this.settings.maxRetries} retries`) + } + } + }) + + // Handle process errors + ffmpegProcess.on('error', (error) => { + console.error('[RTSP] FFmpeg process error:', error) + stream.status = 'error' + stream.errorCount++ + }) + + // Wait for playlist to be created (with retries) + let attempts = 0 + const maxAttempts = 15 // 15 seconds total + while (attempts < maxAttempts) { + try { + await fs.access(playlistPath) + stream.status = 'running' + console.log('[RTSP] Stream running:', stream.id) + return + } catch { + // Playlist not ready yet, wait and retry + await new Promise(resolve => setTimeout(resolve, 1000)) + attempts++ + } + } + + // If we get here, playlist was never created + stream.status = 'error' + console.error('[RTSP] Playlist not created after 15 seconds for stream:', stream.id) + } + + async stopStream(streamId: string): Promise { + try { + const stream = this.streams.get(streamId) + if (!stream) { + return { + success: false, + error: 'Stream not found' + } + } + + // Kill FFmpeg process + if (stream.ffmpegProcess) { + stream.ffmpegProcess.kill('SIGTERM') + stream.ffmpegProcess = null + } + + // Clean up output directory + await this.cleanupStream(streamId) + + // Remove from streams map + this.streams.delete(streamId) + + // Release port + this.usedPorts.delete(stream.port) + + console.log('[RTSP] Stream stopped:', streamId) + return { + success: true + } + } catch (error) { + console.error('[RTSP] Error stopping stream:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + private async cleanupStream(streamId: string): Promise { + const stream = this.streams.get(streamId) + if (!stream) return + + try { + await fs.rm(stream.outputDir, { recursive: true, force: true }) + console.log('[RTSP] Cleaned up stream directory:', stream.outputDir) + } catch (error) { + console.error('[RTSP] Error cleaning up stream directory:', error) + } + } + + async stopAllStreams(): Promise { + console.log('[RTSP] Stopping all streams...') + const streamIds = Array.from(this.streams.keys()) + for (const streamId of streamIds) { + await this.stopStream(streamId) + } + } + + async shutdown(): Promise { + console.log('[RTSP] Shutting down RTSP service...') + + // Stop all streams + await this.stopAllStreams() + + // Close Express server + if (this.server) { + await new Promise((resolve) => { + this.server!.close(() => { + console.log('[RTSP] Express server closed') + resolve() + }) + }) + } + + // Clean up temp directory + try { + await fs.rm(this.tempDir, { recursive: true, force: true }) + console.log('[RTSP] Temp directory cleaned up') + } catch (error) { + console.error('[RTSP] Error cleaning up temp directory:', error) + } + } +} + +// Export singleton instance +export const rtspService = new RTSPService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5755666..2cb188f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -9,6 +9,10 @@ declare global { getGitHubVersion: () => Promise openExternal: (url: string) => Promise showOpenDialog: () => Promise<{ filePath: string; fileUrl: string } | null> + // M3U import APIs + showM3UDialog: () => Promise + readM3UFile: (filePath: string) => Promise<{ success: boolean; content?: string; error?: string }> + fetchM3UUrl: (url: string) => Promise<{ success: boolean; content?: string; error?: string }> // Grid management APIs saveGrid: (grid: SavedGrid) => Promise loadGrid: (gridId: string) => Promise @@ -20,6 +24,12 @@ declare global { rtspStartStream: (streamId: string, rtspUrl: string) => Promise<{ success: boolean; url?: string; port?: number; error?: string }> rtspStopStream: (streamId: string) => Promise<{ success: boolean; error?: string }> rtspCheckFfmpeg: () => Promise<{ available: boolean }> + // API server management + startApiServer: (config: { port: number; apiKey: string; enabled: boolean }) => Promise<{ success: boolean; error?: string }> + stopApiServer: () => Promise<{ success: boolean; error?: string }> + restartApiServer: (config: { port: number; apiKey: string; enabled: boolean }) => Promise<{ success: boolean; error?: string }> + getApiServerStatus: () => Promise<{ running: boolean; config: { port: number; apiKey: string; enabled: boolean } | null }> + generateApiKey: () => Promise // App lifecycle events onAppBeforeQuit: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8c2326e..7d44642 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,10 @@ const api = { getGitHubVersion: (): Promise => ipcRenderer.invoke('get-github-version'), openExternal: (url: string): Promise => shell.openExternal(url), showOpenDialog: (): Promise<{ filePath: string; fileUrl: string } | null> => ipcRenderer.invoke('show-open-dialog'), + // M3U import APIs + showM3UDialog: (): Promise => ipcRenderer.invoke('show-m3u-dialog'), + readM3UFile: (filePath: string): Promise<{ success: boolean; content?: string; error?: string }> => ipcRenderer.invoke('read-m3u-file', filePath), + fetchM3UUrl: (url: string): Promise<{ success: boolean; content?: string; error?: string }> => ipcRenderer.invoke('fetch-m3u-url', url), // Grid management APIs saveGrid: (grid: SavedGrid): Promise => ipcRenderer.invoke('save-grid', grid), loadGrid: (gridId: string): Promise => ipcRenderer.invoke('load-grid', gridId), @@ -15,6 +19,24 @@ const api = { renameGrid: (gridId: string, newName: string): Promise => ipcRenderer.invoke('rename-grid', gridId, newName), getGridManifest: (): Promise => ipcRenderer.invoke('get-grid-manifest'), getAllGrids: (): Promise> => ipcRenderer.invoke('get-all-grids'), + // RTSP streaming APIs + rtspStartStream: (streamId: string, rtspUrl: string): Promise<{ success: boolean; url?: string; port?: number; error?: string }> => + ipcRenderer.invoke('rtspStartStream', streamId, rtspUrl), + rtspStopStream: (streamId: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('rtspStopStream', streamId), + rtspCheckFfmpeg: (): Promise<{ available: boolean; version?: string; path?: string }> => + ipcRenderer.invoke('rtspCheckFfmpeg'), + // API server management + startApiServer: (config: { port: number; apiKey: string; enabled: boolean }): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('start-api-server', config), + stopApiServer: (): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('stop-api-server'), + restartApiServer: (config: { port: number; apiKey: string; enabled: boolean }): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('restart-api-server', config), + getApiServerStatus: (): Promise<{ running: boolean; config: { port: number; apiKey: string; enabled: boolean } | null }> => + ipcRenderer.invoke('get-api-server-status'), + generateApiKey: (): Promise => + ipcRenderer.invoke('generate-api-key'), // App lifecycle events onAppBeforeQuit: (callback: () => void): (() => void) => { const listener = (): void => callback() diff --git a/src/renderer/index.html b/src/renderer/index.html index b2000f4..c0ca94e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - + StreamGrid diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ec91d41..3b00dbb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, AppBar, @@ -12,19 +12,24 @@ import { DialogTitle, DialogContent, TextField, - DialogActions + DialogActions, + IconButton, + Tooltip } from '@mui/material' -import { Add, GitHub } from '@mui/icons-material' +import { Add, GitHub, VolumeOff, VolumeUp, Settings, GridOn } from '@mui/icons-material' import StreamGridLogo from './assets/StreamGrid.svg' import { v4 as uuidv4 } from 'uuid' import { StreamGrid } from './components/StreamGrid' import { AddStreamDialog } from './components/AddStreamDialog' import { GridSelector } from './components/GridSelector' import { GridManagementDialog } from './components/GridManagementDialog' +import { SettingsDialog } from './components/SettingsDialog' +import { AutoArrangeDialog } from './components/AutoArrangeDialog' import { useDebouncedStore } from './hooks/useDebouncedStore' import { Stream, StreamFormData } from './types/stream' import { LoadingScreen } from './components/LoadingScreen' import { UpdateAlert } from './components/UpdateAlert' +import { useStreamStore } from './store/useStreamStore' export const App: React.FC = () => { const [isLoading, setIsLoading] = useState(true) @@ -33,11 +38,16 @@ export const App: React.FC = () => { const [newGridDialogOpen, setNewGridDialogOpen] = useState(false) const [newGridName, setNewGridName] = useState('') const [gridManagementOpen, setGridManagementOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [autoArrangeDialogOpen, setAutoArrangeDialogOpen] = useState(false) + const autoStartTriggeredRef = useRef(false) + const { streams, layout, chats, addStream, + addMultipleStreams, removeStream, updateLayout, updateStream, @@ -52,8 +62,38 @@ export const App: React.FC = () => { saveDebounceMs: 5000, // 5 seconds instead of 1 second streamUpdateDebounceMs: 500 }) + + const { settings, toggleGlobalMute, autoArrangeStreams } = useStreamStore() const [editingStream, setEditingStream] = useState(undefined) + // Define all callbacks before any conditional returns + const handleGlobalMuteToggle = useCallback(() => { + toggleGlobalMute() + }, [toggleGlobalMute]) + + const handleAutoArrange = useCallback(async () => { + autoArrangeStreams() + // Save immediately after auto-arrange + await saveNow() + }, [autoArrangeStreams, saveNow]) + + // Auto-start streams on launch + useEffect(() => { + if (!autoStartTriggeredRef.current && settings.autoStartOnLaunch && streams.length > 0) { + autoStartTriggeredRef.current = true + + const delay = settings.autoStartDelay * 1000 + console.log(`Auto-starting ${streams.length} streams in ${settings.autoStartDelay} seconds...`) + + setTimeout(() => { + // Trigger play on all streams by dispatching custom event + const event = new CustomEvent('auto-start-streams') + window.dispatchEvent(event) + console.log('Auto-start triggered for all streams') + }, delay) + } + }, [settings.autoStartOnLaunch, settings.autoStartDelay, streams.length]) + useEffect(() => { // Set loading to false immediately as resources are already loaded setIsLoading(false) @@ -76,26 +116,28 @@ export const App: React.FC = () => { window.addEventListener('beforeunload', handleBeforeUnload) - // Add IPC listener for app quit - const removeQuitListener = window.api.onAppBeforeQuit(handleAppQuit) + // Add IPC listener for app quit (with safety check) + let removeQuitListener: (() => void) | undefined + if (window.api?.onAppBeforeQuit) { + removeQuitListener = window.api.onAppBeforeQuit(handleAppQuit) + } - return () => { + return (): void => { window.removeEventListener('beforeunload', handleBeforeUnload) - removeQuitListener() + if (removeQuitListener) { + removeQuitListener() + } } }, [hasUnsavedChanges, saveNow]) - // Auto-save is now handled by the debounced store - // No need for manual auto-save implementation here - - if (isLoading) { - return - } - - const handleAddStream = async (data: StreamFormData): Promise => { + // Define all event handlers before conditional return + const handleAddStream = useCallback(async (data: StreamFormData): Promise => { const newStream: Stream = { id: uuidv4(), - ...data, + name: data.name, + logoUrl: data.logoUrl, + streamUrl: data.streamUrl, + isMuted: data.startMuted, isLivestream: data.streamUrl.includes('twitch.tv') || data.streamUrl.includes('youtube.com/live') || @@ -105,26 +147,51 @@ export const App: React.FC = () => { addStream(newStream) // Save immediately after adding a stream await saveNow() - } + }, [addStream, saveNow]) - const handleRemoveStream = async (id: string): Promise => { + const handleRemoveStream = useCallback(async (id: string): Promise => { removeChatsForStream(id) removeStream(id) // Save immediately after removing a stream await saveNow() - } + }, [removeChatsForStream, removeStream, saveNow]) - const handleEditStream = (stream: Stream): void => { + const handleEditStream = useCallback((stream: Stream): void => { setEditingStream(stream) setIsAddDialogOpen(true) - } + }, []) - const handleUpdateStream = async (id: string, data: StreamFormData): Promise => { - updateStream(id, data) + const handleUpdateStream = useCallback(async (id: string, data: StreamFormData): Promise => { + const updates: Partial = { + name: data.name, + logoUrl: data.logoUrl, + streamUrl: data.streamUrl + } + + // Only update isMuted if startMuted is defined + if (data.startMuted !== undefined) { + updates.isMuted = data.startMuted + } + + updateStream(id, updates) // Save immediately after updating a stream await saveNow() - } + }, [updateStream, saveNow]) + + const handleAddMultipleStreams = useCallback(async (importedStreams: Stream[]): Promise => { + // Add all imported streams + addMultipleStreams(importedStreams) + + // Save immediately after import + await saveNow() + }, [addMultipleStreams, saveNow]) + + // Auto-save is now handled by the debounced store + // No need for manual auto-save implementation here + if (isLoading) { + return + } return ( @@ -168,7 +235,59 @@ export const App: React.FC = () => { onManageGrids={() => setGridManagementOpen(true)} /> - + + + {/* Global Mute Button */} + + + {settings.globalMuted ? : } + + + + {/* Auto-Arrange Button */} + + setAutoArrangeDialogOpen(true)} + disabled={streams.length === 0 && chats.length === 0} + sx={{ + color: 'text.primary', + '&:hover': { + backgroundColor: 'action.hover' + }, + '&.Mui-disabled': { + color: 'action.disabled' + } + }} + > + + + + + {/* Settings Button */} + + setSettingsOpen(true)} + sx={{ + color: 'text.primary', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + diff --git a/src/renderer/src/components/AutoArrangeDialog.tsx b/src/renderer/src/components/AutoArrangeDialog.tsx new file mode 100644 index 0000000..0a08f14 --- /dev/null +++ b/src/renderer/src/components/AutoArrangeDialog.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Box, + Typography +} from '@mui/material' +import { GridOn, Warning } from '@mui/icons-material' + +interface AutoArrangeDialogProps { + open: boolean + onClose: () => void + onConfirm: () => void + streamCount: number +} + +export const AutoArrangeDialog: React.FC = ({ + open, + onClose, + onConfirm, + streamCount +}) => { + return ( + + + + Auto-Arrange Streams + + + + + This will automatically rearrange all {streamCount} stream{streamCount !== 1 ? 's' : ''} and chat{streamCount !== 1 ? 's' : ''} in your grid using an intelligent layout algorithm. + + + + + ✨ Smart Features: + + +
  • Optimizes tile sizes to maximize screen space
  • +
  • Maintains 16:9 aspect ratio for video streams
  • +
  • Intelligently handles odd numbers of streams
  • +
  • Centers the last row for aesthetic balance
  • +
  • Minimizes wasted space in the grid
  • +
    +
    + + + + + Your current layout will be replaced. + + +
    +
    + + + + +
    + ) +} diff --git a/src/renderer/src/components/GridManagementDialog.tsx b/src/renderer/src/components/GridManagementDialog.tsx index 7fce2a8..11500d5 100644 --- a/src/renderer/src/components/GridManagementDialog.tsx +++ b/src/renderer/src/components/GridManagementDialog.tsx @@ -61,6 +61,9 @@ export const GridManagementDialog: React.FC = ({ open const loadGrids = async (): Promise => { try { + if (!window.api?.getAllGrids) { + return + } const allGrids = await window.api.getAllGrids() // Map the API response to include createdAt field const gridsWithCreatedAt = allGrids.map(grid => ({ @@ -114,6 +117,9 @@ export const GridManagementDialog: React.FC = ({ open const handleExportGrid = async (grid: GridInfo): Promise => { try { + if (!window.api?.loadGrid) { + return + } const gridData = await window.api.loadGrid(grid.id) if (!gridData) return @@ -133,6 +139,9 @@ export const GridManagementDialog: React.FC = ({ open const handleDuplicateGrid = async (grid: GridInfo): Promise => { try { + if (!window.api?.loadGrid) { + return + } const gridData = await window.api.loadGrid(grid.id) if (!gridData) return diff --git a/src/renderer/src/components/GridSelector.tsx b/src/renderer/src/components/GridSelector.tsx index eaf9813..9b6e006 100644 --- a/src/renderer/src/components/GridSelector.tsx +++ b/src/renderer/src/components/GridSelector.tsx @@ -81,6 +81,9 @@ export const GridSelector: React.FC = ({ onNewGrid, onManageG const loadRecentGridsInfo = async (): Promise => { try { + if (!window.api?.getAllGrids) { + return + } const allGrids = await window.api.getAllGrids() const recent = recentGridIds .map(id => allGrids.find(g => g.id === id)) @@ -160,6 +163,9 @@ export const GridSelector: React.FC = ({ onNewGrid, onManageG handleCloseContextMenu() try { + if (!window.api?.loadGrid) { + return + } const grid = await window.api.loadGrid(selectedGrid.id) if (grid) { const exportData = { diff --git a/src/renderer/src/components/M3U8ImportDialog.tsx b/src/renderer/src/components/M3U8ImportDialog.tsx new file mode 100644 index 0000000..586ebe2 --- /dev/null +++ b/src/renderer/src/components/M3U8ImportDialog.tsx @@ -0,0 +1,421 @@ +import React, { useState, useCallback } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Stack, + Box, + Typography, + Tab, + Tabs, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Checkbox, + Paper, + Alert, + CircularProgress, + IconButton, + Tooltip +} from '@mui/material' +import { FolderOpen, Link as LinkIcon, CheckBox, CheckBoxOutlineBlank } from '@mui/icons-material' +import { parseM3U, ParsedM3UEntry } from '../utils/m3u8Parser' +import { v4 as uuidv4 } from 'uuid' +import { Stream } from '../types/stream' +import jdenticon from 'jdenticon/standalone' + +interface M3U8ImportDialogProps { + open: boolean + onClose: () => void + onImport: (streams: Stream[], replaceExisting: boolean) => void +} + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +function TabPanel(props: TabPanelProps): JSX.Element { + const { children, value, index, ...other } = props + return ( + + ) +} + +export const M3U8ImportDialog: React.FC = ({ + open, + onClose, + onImport +}): JSX.Element => { + const [tabValue, setTabValue] = useState(0) + const [url, setUrl] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [parsedEntries, setParsedEntries] = useState([]) + const [selectedEntries, setSelectedEntries] = useState>(new Set()) + const [parseErrors, setParseErrors] = useState([]) + + const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue) + setError(null) + }, []) + + const handleFileSelect = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const filePath = await window.api.showM3UDialog() + if (!filePath) { + setLoading(false) + return + } + + const result = await window.api.readM3UFile(filePath) + if (!result.success || !result.content) { + setError(result.error || 'Failed to read file') + setLoading(false) + return + } + + const parseResult = parseM3U(result.content) + setParsedEntries(parseResult.entries) + setParseErrors(parseResult.errors) + + // Select all entries by default + setSelectedEntries(new Set(parseResult.entries.map((_, index) => index))) + + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load file') + setLoading(false) + } + }, []) + + const handleUrlLoad = useCallback(async () => { + if (!url.trim()) { + setError('Please enter a URL') + return + } + + try { + setLoading(true) + setError(null) + + const result = await window.api.fetchM3UUrl(url.trim()) + if (!result.success || !result.content) { + setError(result.error || 'Failed to fetch URL') + setLoading(false) + return + } + + const parseResult = parseM3U(result.content) + setParsedEntries(parseResult.entries) + setParseErrors(parseResult.errors) + + // Select all entries by default + setSelectedEntries(new Set(parseResult.entries.map((_, index) => index))) + + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch URL') + setLoading(false) + } + }, [url]) + + const handleToggleEntry = useCallback((index: number) => { + setSelectedEntries(prev => { + const newSet = new Set(prev) + if (newSet.has(index)) { + newSet.delete(index) + } else { + newSet.add(index) + } + return newSet + }) + }, []) + + const handleToggleAll = useCallback(() => { + if (selectedEntries.size === parsedEntries.length) { + setSelectedEntries(new Set()) + } else { + setSelectedEntries(new Set(parsedEntries.map((_, index) => index))) + } + }, [selectedEntries.size, parsedEntries.length]) + + const handleImport = useCallback((replaceExisting: boolean) => { + const selectedStreams = parsedEntries + .filter((_, index) => selectedEntries.has(index)) + .map(entry => ({ + id: uuidv4(), + name: entry.name, + logoUrl: entry.logoUrl || '', + streamUrl: entry.streamUrl, + isMuted: false + })) + + onImport(selectedStreams, replaceExisting) + handleClose() + }, [parsedEntries, selectedEntries, onImport]) + + const handleClose = useCallback(() => { + setParsedEntries([]) + setSelectedEntries(new Set()) + setParseErrors([]) + setError(null) + setUrl('') + setTabValue(0) + onClose() + }, [onClose]) + + const allSelected = selectedEntries.size === parsedEntries.length && parsedEntries.length > 0 + const someSelected = selectedEntries.size > 0 && selectedEntries.size < parsedEntries.length + + return ( + + + Import M3U Playlist + + Import streams from M3U/M3U8 playlist files or URLs + + + + + + {parsedEntries.length === 0 ? ( + <> + + + + + + + + + {loading && ( + + + + )} + + + + + + setUrl(e.target.value)} + placeholder="https://example.com/playlist.m3u8" + disabled={loading} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleUrlLoad() + } + }} + /> + + {loading && ( + + + + )} + + + + {error && ( + setError(null)}> + {error} + + )} + + ) : ( + <> + + + Found {parsedEntries.length} stream{parsedEntries.length !== 1 ? 's' : ''} + {selectedEntries.size > 0 && ` (${selectedEntries.size} selected)`} + + + + {allSelected ? : someSelected ? : } + + + + + {parseErrors.length > 0 && ( + + + {parseErrors.length} parsing error{parseErrors.length !== 1 ? 's' : ''}: + + + {parseErrors.slice(0, 3).map((err, i) => ( +
  • + {err} +
  • + ))} + {parseErrors.length > 3 && ( +
  • + + ...and {parseErrors.length - 3} more + +
  • + )} +
    +
    + )} + + + + + + + Logo + Name + URL + + + + {parsedEntries.map((entry, index) => ( + handleToggleEntry(index)} + sx={{ cursor: 'pointer' }} + > + + + + + + {entry.name}) => { + e.currentTarget.src = `data:image/svg+xml,${encodeURIComponent(jdenticon.toSvg(entry.name, 40))}` + }} + /> + + + + + {entry.name} + + + + + {entry.streamUrl} + + + + ))} + +
    +
    + + )} +
    +
    + + + {parsedEntries.length > 0 && ( + + )} + + {parsedEntries.length > 0 && ( + <> + + + + )} + +
    + ) +} diff --git a/src/renderer/src/components/SettingsDialog.tsx b/src/renderer/src/components/SettingsDialog.tsx new file mode 100644 index 0000000..e30c2db --- /dev/null +++ b/src/renderer/src/components/SettingsDialog.tsx @@ -0,0 +1,414 @@ +import React, { useState, useEffect } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + Switch, + FormControlLabel, + Slider, + Divider, + IconButton, + TextField, + Alert, + Snackbar, + InputAdornment +} from '@mui/material' +import { Close, VolumeUp, PlayArrow, Api, ContentCopy, Refresh, CheckCircle } from '@mui/icons-material' +import { useStreamStore } from '../store/useStreamStore' + +interface SettingsDialogProps { + open: boolean + onClose: () => void +} + +export const SettingsDialog: React.FC = ({ open, onClose }) => { + const { settings, updateSettings } = useStreamStore() + const [apiServerRunning, setApiServerRunning] = useState(false) + const [copySuccess, setCopySuccess] = useState(false) + const [apiError, setApiError] = useState(null) + + // Check API server status on mount and when dialog opens + useEffect(() => { + if (open) { + checkApiStatus() + } + }, [open]) + + const checkApiStatus = async (): Promise => { + try { + const status = await window.api.getApiServerStatus() + setApiServerRunning(status.running) + } catch (error) { + console.error('Failed to check API status:', error) + } + } + + const handleDefaultMuteChange = (event: React.ChangeEvent): void => { + updateSettings({ defaultMuteNewStreams: event.target.checked }) + } + + const handleAutoStartChange = (event: React.ChangeEvent): void => { + updateSettings({ autoStartOnLaunch: event.target.checked }) + } + + const handleDelayChange = (_event: Event, value: number | number[]): void => { + updateSettings({ autoStartDelay: value as number }) + } + + const handleApiEnabledChange = async (event: React.ChangeEvent): Promise => { + const enabled = event.target.checked + updateSettings({ apiEnabled: enabled }) + + try { + if (enabled) { + // Generate API key if not exists + let apiKey = settings.apiKey + if (!apiKey) { + apiKey = await window.api.generateApiKey() + updateSettings({ apiKey }) + } + + const result = await window.api.startApiServer({ + port: settings.apiPort, + apiKey: apiKey, + enabled: true + }) + + if (result.success) { + setApiServerRunning(true) + setApiError(null) + } else { + setApiError(result.error || 'Failed to start API server') + updateSettings({ apiEnabled: false }) + } + } else { + await window.api.stopApiServer() + setApiServerRunning(false) + setApiError(null) + } + } catch (error) { + setApiError(error instanceof Error ? error.message : 'Unknown error') + updateSettings({ apiEnabled: false }) + } + } + + const handlePortChange = (event: React.ChangeEvent): void => { + const port = parseInt(event.target.value, 10) + if (!isNaN(port) && port > 0 && port < 65536) { + updateSettings({ apiPort: port }) + } + } + + const handleGenerateApiKey = async (): Promise => { + try { + const newKey = await window.api.generateApiKey() + updateSettings({ apiKey: newKey }) + + // Restart server if running + if (settings.apiEnabled && apiServerRunning) { + await window.api.restartApiServer({ + port: settings.apiPort, + apiKey: newKey, + enabled: true + }) + } + } catch (error) { + setApiError(error instanceof Error ? error.message : 'Failed to generate API key') + } + } + + const handleCopyApiKey = (): void => { + if (settings.apiKey) { + navigator.clipboard.writeText(settings.apiKey) + setCopySuccess(true) + } + } + + return ( + + + + Settings + + + + + + + + {/* Audio Settings Section */} + + + + + Audio Settings + + + + + } + label={ + + Start new streams muted + + All newly added streams will start with audio muted + + + } + sx={{ mb: 1, alignItems: 'flex-start' }} + /> + + + + + {/* Auto-Start Settings Section */} + + + + + Auto-Start Settings + + + + + } + label={ + + Auto-start streams on launch + + Automatically play all streams when the application opens + + + } + sx={{ mb: 2, alignItems: 'flex-start' }} + /> + + {settings.autoStartOnLaunch && ( + + + Start Delay: {settings.autoStartDelay} second{settings.autoStartDelay !== 1 ? 's' : ''} + + + Wait before starting streams after app launch + + + + )} + + + + + {/* API Settings Section */} + + + + + REST API Settings + + + + + } + label={ + + Enable REST API + + Allow external control via HTTP API + + + } + sx={{ mb: 2, alignItems: 'flex-start' }} + /> + + {settings.apiEnabled && ( + + {/* API Server Status */} + + + + {apiServerRunning ? 'API Server Running' : 'API Server Stopped'} + + + + {/* Port Configuration */} + + + {/* API Key */} + + + + + + + + + ) + }} + sx={{ mb: 1 }} + /> + + + ⚠️ Keep your API key secure. Regenerating will invalidate the old key. + + + + + )} + + + + + 💡 Tip: All settings are automatically saved and will persist across app restarts + + + + + + + + + {/* Success Snackbar */} + setCopySuccess(false)} + message="API key copied to clipboard" + /> + + {/* Error Snackbar */} + setApiError(null)} + > + setApiError(null)}> + {apiError} + + + + ) +} diff --git a/src/renderer/src/components/StreamCard.tsx b/src/renderer/src/components/StreamCard.tsx index 2a80321..521274e 100644 --- a/src/renderer/src/components/StreamCard.tsx +++ b/src/renderer/src/components/StreamCard.tsx @@ -7,10 +7,12 @@ import React, { lazy, Suspense, useEffect, - useMemo + useMemo, + useImperativeHandle, + forwardRef } from 'react' -import { Card, IconButton, Typography, Box, CircularProgress } from '@mui/material' -import { PlayArrow, Stop, Close, Edit, Chat, AspectRatio, CropFree } from '@mui/icons-material' +import { Card, IconButton, Typography, Box, CircularProgress, Tooltip } from '@mui/material' +import { PlayArrow, Stop, Close, Edit, Chat, AspectRatio, CropFree, VolumeOff, VolumeUp } from '@mui/icons-material' import { Stream } from '../types/stream' import { StreamErrorBoundary } from './StreamErrorBoundary' import { useStreamStore } from '../store/useStreamStore' @@ -51,8 +53,13 @@ const extractYoutubeVideoId = (url: string): string | null => { return null } -const detectStreamType = (url: string): 'hls' | 'dash' | 'youtube' | 'twitch' | 'local' | 'other' => { - // Check for local file first +const detectStreamType = (url: string): 'hls' | 'dash' | 'youtube' | 'twitch' | 'local' | 'rtsp' | 'other' => { + // Check for RTSP first + if (url.startsWith('rtsp://') || url.startsWith('rtsps://')) { + return 'rtsp' + } + + // Check for local file if (url.startsWith('file://')) { return 'local' } @@ -153,14 +160,23 @@ interface StreamCardProps { onAddChat?: (videoId: string, streamId: string, streamName: string) => void } -const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, onAddChat }) => { +export interface StreamCardRef { + play: () => void + stop: () => void +} + +const StreamCard = memo( + forwardRef(({ stream, onRemove, onEdit, onAddChat }, ref) => { const { removeChatsForStream, updateStream } = useStreamStore() const [isPlaying, setIsPlaying] = useState(false) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [loadingMessage, setLoadingMessage] = useState('') const [logoUrl, setLogoUrl] = useState('') + const [transcodedUrl, setTranscodedUrl] = useState(null) const errorTimerRef = useRef(null) const currentFitMode = stream.fitMode || 'contain' + const currentMuteState = stream.isMuted || false // Generate avatar data URL if no logo URL is provided const generatedAvatarUrl = useMemo(() => { @@ -194,6 +210,9 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, } else if (type === 'local') { // For local files, use minimal config config = { file: BASE_CONFIG } + } else if (type === 'rtsp') { + // RTSP streams will be transcoded to HLS, so use HLS config + config = { file: HLS_CONFIG } } else { config = { file: HLS_CONFIG } } @@ -222,13 +241,70 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, errorTimerRef.current = null } - // Start playing in next tick + // Handle RTSP streams + if (streamType === 'rtsp') { + console.log('Starting RTSP stream:', cleanUrl) + + // Show loading immediately with custom message + setLoadingMessage('Starting RTSP transcoding...') + setIsLoading(true) + setIsPlaying(true) // Set playing to show the player container with loading overlay + + // Check FFmpeg availability + const ffmpegCheck = await window.api.rtspCheckFfmpeg() + if (!ffmpegCheck.available) { + setError('FFmpeg not installed. Please install FFmpeg to play RTSP streams.') + setIsLoading(false) + setIsPlaying(false) + setLoadingMessage('') + return + } + + // Start RTSP transcoding + setLoadingMessage('Connecting to RTSP stream...') + const result = await window.api.rtspStartStream(stream.id, stream.streamUrl) + if (result.success && result.url) { + console.log('RTSP transcoding started, HLS URL:', result.url) + console.log('Will play HLS stream with config:', HLS_CONFIG) + setLoadingMessage('Buffering stream...') + setTranscodedUrl(result.url) + // Keep loading state - will be cleared by onReady callback + } else { + setError(result.error || 'Failed to start RTSP stream') + setIsLoading(false) + setIsPlaying(false) + setLoadingMessage('') + } + return + } + + // Start playing in next tick for non-RTSP streams setTimeout(() => { console.log('Attempting to play stream:', cleanUrl) setIsPlaying(true) setIsLoading(true) }, 0) - }, [cleanUrl]) + }, [cleanUrl, streamType, stream.id, stream.streamUrl]) + + // Expose play/stop methods via ref + useImperativeHandle(ref, () => ({ + play: handlePlay, + stop: handleStop + })) + + // Listen for auto-start event + useEffect(() => { + const handleAutoStart = (): void => { + if (!isPlaying) { + handlePlay() + } + } + + window.addEventListener('auto-start-streams', handleAutoStart) + return (): void => { + window.removeEventListener('auto-start-streams', handleAutoStart) + } + }, [isPlaying, handlePlay]) const handleStop = useCallback(async (): Promise => { setIsPlaying(false) @@ -238,22 +314,32 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, errorTimerRef.current = null } + // Stop RTSP transcoding if this is an RTSP stream + if (streamType === 'rtsp') { + console.log('Stopping RTSP stream:', stream.id) + await window.api.rtspStopStream(stream.id) + setTranscodedUrl(null) + } + removeChatsForStream(stream.id) - }, [stream.id, removeChatsForStream]) + }, [stream.id, streamType, removeChatsForStream]) const handleReady = useCallback(() => { - console.log('Stream ready:', cleanUrl) + const displayUrl = transcodedUrl || cleanUrl + console.log('Stream ready:', displayUrl) setIsLoading(false) + setLoadingMessage('') setError(null) // Clear any pending error timer when stream recovers if (errorTimerRef.current) { clearTimeout(errorTimerRef.current) errorTimerRef.current = null } - }, [cleanUrl]) + }, [cleanUrl, transcodedUrl]) - const handleError = useCallback(() => { - console.error('Stream connection issue:', cleanUrl) + const handleError = useCallback((error: any) => { + const displayUrl = transcodedUrl || cleanUrl + console.error('Stream connection issue:', displayUrl, error) setIsLoading(true) // Clear any existing timer @@ -263,27 +349,36 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, // Set new timer for 15 seconds errorTimerRef.current = setTimeout(() => { - console.error('Stream failed to recover:', cleanUrl) + console.error('Stream failed to recover:', displayUrl) setError('Failed to load stream') setIsLoading(false) errorTimerRef.current = null }, 15000) - }, [cleanUrl]) + }, [cleanUrl, transcodedUrl]) - // Cleanup timer on unmount + // Cleanup timer and RTSP stream on unmount React.useEffect(() => { return (): void => { if (errorTimerRef.current) { clearTimeout(errorTimerRef.current) } + // Stop RTSP stream on unmount + if (streamType === 'rtsp' && isPlaying) { + window.api.rtspStopStream(stream.id) + } } - }, []) + }, [streamType, isPlaying, stream.id]) const handleToggleFitMode = useCallback(() => { const newFitMode = currentFitMode === 'contain' ? 'cover' : 'contain' updateStream(stream.id, { fitMode: newFitMode }) }, [currentFitMode, stream.id, updateStream]) + const handleToggleMute = useCallback(() => { + const newMuteState = !currentMuteState + updateStream(stream.id, { isMuted: newMuteState }) + }, [currentMuteState, stream.id, updateStream]) + return ( = memo(({ stream, onRemove, onEdit, {isPlaying && ( <> - - {currentFitMode === 'contain' ? : } - + + + {currentMuteState ? : } + + + + + {currentFitMode === 'contain' ? : } + + = memo(({ stream, onRemove, onEdit, }} >