diff --git a/frontend/src/components/apps/CameraApp.jsx b/frontend/src/components/apps/CameraApp.jsx
index b8ae6cc..36b9640 100644
--- a/frontend/src/components/apps/CameraApp.jsx
+++ b/frontend/src/components/apps/CameraApp.jsx
@@ -1,8 +1,51 @@
-import { useState, useRef, useEffect } from 'react'
+import { useState, useRef, useEffect, useCallback } from 'react'
import { Camera, RotateCcw, Image as ImageIcon } from 'lucide-react'
import { usePipeClient, StringIterator, BinaryIterator } from '../../api/socket'
import { useSystem } from '../useSystem'
+// Global webcam manager to coordinate between multiple camera app instances
+class WebcamManager {
+ constructor() {
+ this.activeInstances = new Set()
+ this.sharedStream = null
+ }
+
+ async acquireStream(instanceId) {
+ this.activeInstances.add(instanceId)
+
+ if (!this.sharedStream) {
+ try {
+ this.sharedStream = await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: false
+ })
+ } catch (err) {
+ console.error('Error accessing camera:', err)
+ throw err
+ }
+ }
+
+ return this.sharedStream
+ }
+
+ releaseStream(instanceId) {
+ this.activeInstances.delete(instanceId)
+
+ // If no more instances are using the webcam, stop the stream
+ if (this.activeInstances.size === 0 && this.sharedStream) {
+ this.sharedStream.getTracks().forEach(track => track.stop())
+ this.sharedStream = null
+ }
+ }
+
+ getActiveInstanceCount() {
+ return this.activeInstances.size
+ }
+}
+
+// Singleton webcam manager
+const webcamManager = new WebcamManager()
+
function CameraApp({ windowId }) {
const { addNotification } = useSystem()
const client = usePipeClient()
@@ -17,24 +60,93 @@ function CameraApp({ windowId }) {
const [showGallery, setShowGallery] = useState(false)
const [selectedPhoto, setSelectedPhoto] = useState(null)
+ // Start camera automatically
+ const startCamera = useCallback(async () => {
+ try {
+ const mediaStream = await webcamManager.acquireStream(windowId)
+ setStream(mediaStream)
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = mediaStream
+ }
+ } catch (err) {
+ console.error('Error accessing camera:', err)
+ addNotification('Failed to access camera. Please grant camera permissions.', 'error')
+ }
+ }, [windowId, addNotification])
+
+ // Load saved photos from filesystem
+ const loadPhotos = useCallback(async () => {
+ if (!client) return
+
+ try {
+ // Create Pictures directory if it doesn't exist
+ await client.run_command(
+ `mkdir -p ~/Pictures`,
+ StringIterator('')
+ )
+
+ // List all files in Pictures directory using client.ls
+ const fileList = await client.ls('~/Pictures')
+
+ // Filter for image files based on common image extensions
+ const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']
+ const imageFiles = fileList.filter(file => {
+ if (file.type !== 'file') return false
+ const extension = file.name.split('.').pop().toLowerCase()
+ return imageExtensions.includes(extension)
+ })
+
+ // Load each photo
+ const photos = []
+ for (const file of imageFiles) {
+ const readResult = await client.run_command(
+ `cat ~/Pictures/${file.name} | base64`,
+ StringIterator('')
+ )
+
+ if (readResult.command_result === 0 && readResult.output) {
+ // Remove the \r\n that cat adds at the end
+ const base64Data = readResult.output.trim().replace(/[\r\n]+$/, '')
+ const extension = file.name.split('.').pop().toLowerCase()
+
+ // Detect MIME type based on extension
+ let mimeType = 'image/jpeg'
+ if (extension === 'png') mimeType = 'image/png'
+ else if (extension === 'gif') mimeType = 'image/gif'
+ else if (extension === 'webp') mimeType = 'image/webp'
+ else if (extension === 'bmp') mimeType = 'image/bmp'
+
+ photos.push({
+ filename: file.name,
+ data: `data:${mimeType};base64,${base64Data}`,
+ timestamp: new Date().toISOString()
+ })
+ }
+ }
+
+ setSavedPhotos(photos)
+ } catch (err) {
+ console.error('Error loading photos:', err)
+ }
+ }, [client])
+
// Auto-start camera on mount
useEffect(() => {
startCamera()
- // Cleanup on unmount
+ // Cleanup on unmount - use webcam manager for proper coordination
return () => {
- if (stream) {
- stream.getTracks().forEach(track => track.stop())
- }
+ webcamManager.releaseStream(windowId)
}
- }, [])
+ }, [windowId, startCamera])
// Load existing photos on mount
useEffect(() => {
if (client) {
loadPhotos()
}
- }, [client])
+ }, [client, loadPhotos])
// Re-attach stream to video element when it becomes visible again
useEffect(() => {
@@ -43,24 +155,6 @@ function CameraApp({ windowId }) {
}
}, [capturedImage, stream])
- // Start camera automatically
- const startCamera = async () => {
- try {
- const mediaStream = await navigator.mediaDevices.getUserMedia({
- video: true,
- audio: false
- })
- setStream(mediaStream)
-
- if (videoRef.current) {
- videoRef.current.srcObject = mediaStream
- }
- } catch (err) {
- console.error('Error accessing camera:', err)
- addNotification('Failed to access camera. Please grant camera permissions.', 'error')
- }
- }
-
// Capture photo
const capturePhoto = () => {
if (!videoRef.current || !canvasRef.current) return
@@ -144,62 +238,6 @@ function CameraApp({ windowId }) {
}
}
- // Load saved photos from filesystem
- const loadPhotos = async () => {
- if (!client) return
-
- try {
- // Create Pictures directory if it doesn't exist
- await client.run_command(
- `mkdir -p ~/Pictures`,
- StringIterator('')
- )
-
- // List all files in Pictures directory using client.ls
- const fileList = await client.ls('~/Pictures')
-
- // Filter for image files based on common image extensions
- const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']
- const imageFiles = fileList.filter(file => {
- if (file.type !== 'file') return false
- const extension = file.name.split('.').pop().toLowerCase()
- return imageExtensions.includes(extension)
- })
-
- // Load each photo
- const photos = []
- for (const file of imageFiles) {
- const readResult = await client.run_command(
- `cat ~/Pictures/${file.name} | base64`,
- StringIterator('')
- )
-
- if (readResult.command_result === 0 && readResult.output) {
- // Remove the \r\n that cat adds at the end
- const base64Data = readResult.output.trim().replace(/[\r\n]+$/, '')
- const extension = file.name.split('.').pop().toLowerCase()
-
- // Detect MIME type based on extension
- let mimeType = 'image/jpeg'
- if (extension === 'png') mimeType = 'image/png'
- else if (extension === 'gif') mimeType = 'image/gif'
- else if (extension === 'webp') mimeType = 'image/webp'
- else if (extension === 'bmp') mimeType = 'image/bmp'
-
- photos.push({
- filename: file.name,
- data: `data:${mimeType};base64,${base64Data}`,
- timestamp: new Date().toISOString()
- })
- }
- }
-
- setSavedPhotos(photos)
- } catch (err) {
- console.error('Error loading photos:', err)
- }
- }
-
// Retake photo
const retakePhoto = () => {
setCapturedImage(null)
@@ -539,7 +577,7 @@ function CameraApp({ windowId }) {
justifyContent: 'space-between'
}}>
Window ID: {windowId}
- Status: {stream ? '🟢 Camera Active' : '⚫ Camera Off'}
+ Status: {stream ? `🟢 Camera Active (${webcamManager.getActiveInstanceCount()} instance${webcamManager.getActiveInstanceCount() !== 1 ? 's' : ''})` : '⚫ Camera Off'}
)