feat: support concurrent screen recordings across multiple tabs

- Change offscreen.ts from single recording variable to Map<tabId, RecordingState>
- Update all recording message handlers to route by tabId
- Track sessionId in relay server for proper recording lookup when stopping
- All recording operations now consistently require tabId
This commit is contained in:
Tommy D. Rossi
2026-01-25 17:18:20 +01:00
parent c9e8361a54
commit 54d3f02a5b
3 changed files with 85 additions and 43 deletions
+8 -5
View File
@@ -1184,9 +1184,10 @@ async function handleStopRecording(params: StopRecordingParams): Promise<Extensi
logger.debug('Stopping recording for tab:', tabId) logger.debug('Stopping recording for tab:', tabId)
try { try {
// Send message to offscreen document to stop recording // Send message to offscreen document to stop recording - include tabId for concurrent support
const result = await chrome.runtime.sendMessage({ const result = await chrome.runtime.sendMessage({
action: 'stopRecording', action: 'stopRecording',
tabId,
}) as { success: boolean; tabId?: number; duration?: number; error?: string } }) as { success: boolean; tabId?: number; duration?: number; error?: string }
if (!result.success) { if (!result.success) {
@@ -1225,10 +1226,11 @@ async function handleIsRecording(params: IsRecordingParams): Promise<IsRecording
return { isRecording: false, tabId } return { isRecording: false, tabId }
} }
// Check with offscreen document for actual recording state // Check with offscreen document for actual recording state - include tabId for concurrent support
try { try {
const result = await chrome.runtime.sendMessage({ const result = await chrome.runtime.sendMessage({
action: 'isRecording', action: 'isRecording',
tabId,
}) as { isRecording: boolean; tabId?: number; startedAt?: number } }) as { isRecording: boolean; tabId?: number; startedAt?: number }
return { return {
@@ -1256,9 +1258,10 @@ async function handleCancelRecording(params: CancelRecordingParams): Promise<Can
logger.debug('Cancelling recording for tab:', tabId) logger.debug('Cancelling recording for tab:', tabId)
try { try {
// Send message to offscreen document to cancel recording // Send message to offscreen document to cancel recording - include tabId for concurrent support
await chrome.runtime.sendMessage({ await chrome.runtime.sendMessage({
action: 'cancelRecording', action: 'cancelRecording',
tabId,
}) })
activeRecordings.delete(tabId) activeRecordings.delete(tabId)
@@ -1292,8 +1295,8 @@ async function cleanupRecordingForTab(tabId: number): Promise<void> {
if (recording) { if (recording) {
logger.debug('Cleaning up recording for disconnected tab:', tabId) logger.debug('Cleaning up recording for disconnected tab:', tabId)
try { try {
// Tell offscreen document to cancel recording // Tell offscreen document to cancel recording - include tabId for concurrent support
await chrome.runtime.sendMessage({ action: 'cancelRecording' }) await chrome.runtime.sendMessage({ action: 'cancelRecording', tabId })
} catch (e) { } catch (e) {
logger.debug('Error cleaning up recording:', e) logger.debug('Error cleaning up recording:', e)
} }
+51 -30
View File
@@ -44,7 +44,8 @@ interface OffscreenRecordingState {
tabId: number tabId: number
} }
let recording: OffscreenRecordingState | null = null // Map of tabId -> recording state for concurrent recording support
const recordings = new Map<number, OffscreenRecordingState>()
// Message types // Message types
type StartRecordingMessage = { type StartRecordingMessage = {
@@ -59,14 +60,17 @@ type StartRecordingMessage = {
type StopRecordingMessage = { type StopRecordingMessage = {
action: 'stopRecording' action: 'stopRecording'
tabId: number
} }
type IsRecordingMessage = { type IsRecordingMessage = {
action: 'isRecording' action: 'isRecording'
tabId: number
} }
type CancelRecordingMessage = { type CancelRecordingMessage = {
action: 'cancelRecording' action: 'cancelRecording'
tabId: number
} }
type OffscreenMessage = StartRecordingMessage | StopRecordingMessage | IsRecordingMessage | CancelRecordingMessage type OffscreenMessage = StartRecordingMessage | StopRecordingMessage | IsRecordingMessage | CancelRecordingMessage
@@ -81,19 +85,21 @@ async function handleMessage(message: OffscreenMessage): Promise<any> {
case 'startRecording': case 'startRecording':
return handleStartRecording(message) return handleStartRecording(message)
case 'stopRecording': case 'stopRecording':
return handleStopRecording() return handleStopRecording(message)
case 'isRecording': case 'isRecording':
return handleIsRecording() return handleIsRecording(message)
case 'cancelRecording': case 'cancelRecording':
return handleCancelRecording() return handleCancelRecording(message)
default: default:
return { success: false, error: 'Unknown action' } return { success: false, error: 'Unknown action' }
} }
} }
async function handleStartRecording(params: StartRecordingMessage): Promise<any> { async function handleStartRecording(params: StartRecordingMessage): Promise<any> {
if (recording) { const { tabId } = params
return { success: false, error: 'Recording already in progress' }
if (recordings.has(tabId)) {
return { success: false, error: `Recording already in progress for tab ${tabId}` }
} }
try { try {
@@ -123,14 +129,14 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
const startedAt = Date.now() const startedAt = Date.now()
recording = { recordings.set(tabId, {
recorder, recorder,
stream, stream,
startedAt, startedAt,
tabId: params.tabId, tabId,
} })
// Send chunks to service worker // Send chunks to service worker - each chunk includes tabId for routing
recorder.ondataavailable = async (event) => { recorder.ondataavailable = async (event) => {
if (event.data.size > 0) { if (event.data.size > 0) {
// Convert blob to array buffer and send to service worker // Convert blob to array buffer and send to service worker
@@ -138,38 +144,41 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
const uint8Array = new Uint8Array(arrayBuffer) const uint8Array = new Uint8Array(arrayBuffer)
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: 'recordingChunk', action: 'recordingChunk',
tabId: params.tabId, tabId,
data: Array.from(uint8Array), // Convert to regular array for message passing data: Array.from(uint8Array), // Convert to regular array for message passing
}) })
} }
} }
recorder.onerror = (event: Event) => { recorder.onerror = (event: Event) => {
console.error('MediaRecorder error:', (event as ErrorEvent).error) console.error(`MediaRecorder error for tab ${tabId}:`, (event as ErrorEvent).error)
handleCancelRecording() handleCancelRecordingForTab(tabId)
} }
recorder.onstop = () => { recorder.onstop = () => {
console.log('MediaRecorder stopped') console.log(`MediaRecorder stopped for tab ${tabId}`)
} }
// Start with 1 second chunks // Start with 1 second chunks
recorder.start(1000) recorder.start(1000)
return { success: true, tabId: params.tabId, startedAt, mimeType: 'video/mp4' } return { success: true, tabId, startedAt, mimeType: 'video/mp4' }
} catch (error: any) { } catch (error: any) {
console.error('Failed to start recording:', error) console.error(`Failed to start recording for tab ${tabId}:`, error)
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
async function handleStopRecording(): Promise<any> { async function handleStopRecording(params: StopRecordingMessage): Promise<any> {
const { tabId } = params
const recording = recordings.get(tabId)
if (!recording) { if (!recording) {
return { success: false, error: 'No active recording' } return { success: false, error: `No active recording for tab ${tabId}` }
} }
try { try {
const { recorder, stream, startedAt, tabId } = recording const { recorder, stream, startedAt } = recording
// Stop recorder and wait for final data // Stop recorder and wait for final data
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -199,33 +208,45 @@ async function handleStopRecording(): Promise<any> {
final: true, final: true,
}) })
recording = null recordings.delete(tabId)
return { success: true, tabId, duration } return { success: true, tabId, duration }
} catch (error: any) { } catch (error: any) {
console.error('Failed to stop recording:', error) console.error(`Failed to stop recording for tab ${tabId}:`, error)
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
function handleIsRecording(): any { function handleIsRecording(params: IsRecordingMessage): any {
const { tabId } = params
const recording = recordings.get(tabId)
if (!recording) { if (!recording) {
return { isRecording: false } return { isRecording: false, tabId }
} }
return { return {
isRecording: recording.recorder?.state === 'recording', isRecording: recording.recorder?.state === 'recording',
tabId: recording.tabId, tabId,
startedAt: recording.startedAt, startedAt: recording.startedAt,
} }
} }
function handleCancelRecording(): any { function handleCancelRecording(params: CancelRecordingMessage): any {
const { tabId } = params
return handleCancelRecordingForTab(tabId)
}
// Helper function to cancel recording for a specific tab - used by error handlers too
function handleCancelRecordingForTab(tabId: number): any {
const recording = recordings.get(tabId)
if (!recording) { if (!recording) {
return { success: true } return { success: true, tabId }
} }
try { try {
const { recorder, stream, tabId } = recording const { recorder, stream } = recording
if (recorder.state !== 'inactive') { if (recorder.state !== 'inactive') {
recorder.stop() recorder.stop()
@@ -237,11 +258,11 @@ function handleCancelRecording(): any {
tabId, tabId,
}) })
recording = null recordings.delete(tabId)
return { success: true } return { success: true, tabId }
} catch (error: any) { } catch (error: any) {
console.error('Failed to cancel recording:', error) console.error(`Failed to cancel recording for tab ${tabId}:`, error)
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
+26 -8
View File
@@ -88,8 +88,10 @@ export async function startPlayWriterCDPRelayServer({
} }
// Recording state - tracks active recordings and their accumulated chunks // Recording state - tracks active recordings and their accumulated chunks
// Keyed by tabId for routing chunks, but also stores sessionId for lookup when stopping
type ActiveRecording = { type ActiveRecording = {
tabId: number tabId: number
sessionId?: string // The sessionId used to start this recording, for lookup when stopping
outputPath: string outputPath: string
chunks: Buffer[] chunks: Buffer[]
startedAt: number startedAt: number
@@ -1250,14 +1252,15 @@ export async function startPlayWriterCDPRelayServer({
} }
if (result.success) { if (result.success) {
// Track this recording // Track this recording - store sessionId for lookup when stopping
activeRecordings.set(result.tabId, { activeRecordings.set(result.tabId, {
tabId: result.tabId, tabId: result.tabId,
sessionId: params.sessionId,
outputPath, outputPath,
chunks: [], chunks: [],
startedAt: result.startedAt, startedAt: result.startedAt,
}) })
logger?.log(pc.green(`Recording started for tab ${result.tabId}, output: ${outputPath}`)) logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${params.sessionId || 'none'}), output: ${outputPath}`))
} }
return c.json(result) return c.json(result)
@@ -1276,14 +1279,29 @@ export async function startPlayWriterCDPRelayServer({
return c.json({ success: false, error: 'Extension not connected' } as StopRecordingResult, 503) return c.json({ success: false, error: 'Extension not connected' } as StopRecordingResult, 503)
} }
// Find the active recording. Currently we only support one recording at a time. // Find the active recording by sessionId for concurrent recording support
// The extension handles sessionId → tabId resolution on its side when stopping. // If no sessionId provided, fall back to the first recording (backward compatibility)
// TODO: If we need to support multiple concurrent recordings, we'd need to track const findRecording = (): ActiveRecording | undefined => {
// which sessionId corresponds to which tabId when starting a recording. if (params.sessionId) {
const recording = activeRecordings.values().next().value as typeof activeRecordings extends Map<any, infer V> ? V : never | undefined for (const recording of activeRecordings.values()) {
if (recording.sessionId === params.sessionId) {
return recording
}
}
// SessionId provided but no matching recording found
return undefined
}
// No sessionId - return first recording (backward compat for single-recording case)
return activeRecordings.values().next().value
}
const recording = findRecording()
if (!recording) { if (!recording) {
return c.json({ success: false, error: 'No active recording found' } as StopRecordingResult, 404) const errorMsg = params.sessionId
? `No active recording found for sessionId: ${params.sessionId}`
: 'No active recording found'
return c.json({ success: false, error: errorMsg } as StopRecordingResult, 404)
} }
// Set up promise to wait for final chunk // Set up promise to wait for final chunk