From 36fefaf348219922e2f0870e2b6413a9d8e60bfa Mon Sep 17 00:00:00 2001 From: Bernard Moerdler Date: Fri, 24 Oct 2025 15:53:43 +0300 Subject: [PATCH 1/9] feat: StreamGrid 2.0 Phase 1 - Sound Management, Auto-Start, and Linux Updater Fix --- electron.vite.config.ts | 28 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 280 ++++++++++++------ src/renderer/src/App.tsx | 140 +++++++-- .../src/components/AddStreamDialog.tsx | 35 ++- .../src/components/GridManagementDialog.tsx | 9 + src/renderer/src/components/GridSelector.tsx | 6 + .../src/components/SettingsDialog.tsx | 190 ++++++++++++ src/renderer/src/components/StreamCard.tsx | 99 +++++-- src/renderer/src/components/UpdateAlert.tsx | 5 +- src/renderer/src/components/Versions.tsx | 2 +- src/renderer/src/store/useStreamStore.ts | 72 ++++- src/renderer/src/types/stream.ts | 12 + 14 files changed, 709 insertions(+), 175 deletions(-) create mode 100644 src/renderer/src/components/SettingsDialog.tsx 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..00379f7 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": { diff --git a/package.json b/package.json index e9d308c..952d4e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "streamgrid", - "version": "1.2.4", + "version": "2.0.0", "description": "A high-performance multi-stream viewer application for watching multiple live streams simultaneously in customizable grid layouts", "main": "./out/main/index.js", "author": { diff --git a/src/main/index.ts b/src/main/index.ts index bfc2ea5..2c4e55b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,17 +1,41 @@ -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' -// Register custom protocol for Twitch embeds -protocol.registerSchemesAsPrivileged([ - { scheme: 'streamgrid', privileges: { secure: true, standard: true, corsEnabled: true } } -]) +// 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') +} + +// 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 +69,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) + }) + }) +} -function createWindow(): void { +async function checkForUpdates(): Promise { + if (!autoUpdater) return + try { + await autoUpdater.checkForUpdates() + } catch (e: any) { + console.log('Update check failed (non-critical):', e?.message || e) + } +} + +async function createWindow(): Promise { // Create the browser window. const mainWindow = new BrowserWindow({ width: 900, @@ -116,7 +153,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 +170,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 +205,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 +228,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 +304,30 @@ 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) { + // Lazy load electron-updater only in packaged builds + if (app.isPackaged) { + const mod = await import('electron-updater') + autoUpdater = mod.autoUpdater + wireAutoUpdaterEvents(autoUpdater) checkForUpdates() - // Check for updates every hour setInterval(checkForUpdates, 60 * 60 * 1000) } // 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,7 +351,10 @@ 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: 'All Files', extensions: ['*'] } ] @@ -284,7 +369,6 @@ app.whenReady().then(async () => { return null }) - // Grid management setup await setupGridManagement() @@ -330,7 +414,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 +459,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 +487,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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ec91d41..f7c5758 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,23 @@ import { DialogTitle, DialogContent, TextField, - DialogActions + DialogActions, + IconButton, + Tooltip } from '@mui/material' -import { Add, GitHub } from '@mui/icons-material' +import { Add, GitHub, VolumeOff, VolumeUp, Settings } 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 { 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,6 +37,9 @@ 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 autoStartTriggeredRef = useRef(false) + const { streams, layout, @@ -52,8 +59,32 @@ export const App: React.FC = () => { saveDebounceMs: 5000, // 5 seconds instead of 1 second streamUpdateDebounceMs: 500 }) + + const { settings, toggleGlobalMute } = useStreamStore() const [editingStream, setEditingStream] = useState(undefined) + // Define all callbacks before any conditional returns + const handleGlobalMuteToggle = useCallback(() => { + toggleGlobalMute() + }, [toggleGlobalMute]) + + // 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 +107,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 +138,43 @@ 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]) + // Auto-save is now handled by the debounced store + // No need for manual auto-save implementation here + + if (isLoading) { + return + } return ( @@ -168,7 +218,40 @@ export const App: React.FC = () => { onManageGrids={() => setGridManagementOpen(true)} /> - + + + {/* Global Mute Button */} + + + {settings.globalMuted ? : } + + + + {/* Settings Button */} + + setSettingsOpen(true)} + sx={{ + color: 'text.primary', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + + ) +} diff --git a/src/renderer/src/components/StreamCard.tsx b/src/renderer/src/components/StreamCard.tsx index 2a80321..e8a6129 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' @@ -153,7 +155,13 @@ 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) @@ -161,6 +169,7 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, const [logoUrl, setLogoUrl] = useState('') 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(() => { @@ -230,6 +239,26 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, }, 0) }, [cleanUrl]) + // 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) setError(null) @@ -284,6 +313,11 @@ const StreamCard: React.FC = memo(({ stream, onRemove, onEdit, 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, }} >