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:
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user