Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Poc whiteboard recording #284

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,60 @@
background-color: var(--color-background-hover) !important;
border-radius: var(--border-radius-large) !important;
}

.whiteboard-recording-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: var(--zIndex-popup);
display: flex;
gap: 8px;
padding: 8px;
background: var(--color-main-background);
border-radius: var(--border-radius-large);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);

.recording-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: var(--border-radius);
background: var(--color-background-dark);
color: var(--color-main-text);
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;

&:hover {
background: var(--color-background-darker);
}

.recording-icon {
font-size: 16px;
}

&.start .recording-icon {
color: var(--color-error);
}

&.stop {
background: var(--color-error);
color: var(--color-primary-text);

&:hover {
background: var(--color-error-hover);
}
}

&.download {
background: var(--color-primary);
color: var(--color-primary-text);

&:hover {
background: var(--color-primary-hover);
}
}
}
}
43 changes: 39 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Icon } from '@mdi/react'
import { mdiSlashForwardBox } from '@mdi/js'
import { createRoot } from 'react-dom'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import {
Excalidraw,
MainMenu,
Expand All @@ -29,6 +31,8 @@ import type { ResolvablePromise } from '@excalidraw/excalidraw/types/utils'
import type { NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
import { useExcalidrawLang } from './hooks/useExcalidrawLang'
import { useWhiteboardRecording } from './hooks/useWhiteboardRecording'
import { RecordingControls } from './components/RecordingControls'

interface WhiteboardAppProps {
fileId: number
Expand Down Expand Up @@ -236,17 +240,42 @@ export default function App({
)
}

const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const {
recordingState,
startRecording,
stopRecording,
downloadRecording,
} = useWhiteboardRecording()

const handleStartRecording = useCallback(() => {
if (!excalidrawAPIRef.current) {
showError(t('whiteboard', 'Could not access whiteboard'))
return
}

const staticCanvas = document.querySelector('.excalidraw__canvas.static') as HTMLCanvasElement
const interactiveCanvas = document.querySelector('.excalidraw__canvas.interactive') as HTMLCanvasElement

if (!staticCanvas || !interactiveCanvas) {
showError(t('whiteboard', 'Could not find canvases to record'))
return
}

startRecording({ staticCanvas, interactiveCanvas })
}, [startRecording])

return (
<div className="App">
<div className="excalidraw-wrapper">
<Excalidraw
validateEmbeddable={() => true}
renderEmbeddable={Embeddable}
excalidrawAPI={(api: ExcalidrawImperativeAPI) => {
console.log(api)
console.log('Setting API')
console.log('Excalidraw API initialized')
excalidrawAPIRef.current = api
setExcalidrawAPI(api)
}}
validateEmbeddable={() => true}
renderEmbeddable={Embeddable}
initialData={initialStatePromiseRef.current.promise}
onPointerUpdate={collab?.onPointerUpdate}
viewModeEnabled={viewModeEnabled}
Expand All @@ -263,6 +292,12 @@ export default function App({
langCode={lang}>
{renderMenu()}
</Excalidraw>
<RecordingControls
recordingState={recordingState}
onStartRecording={handleStartRecording}
onStopRecording={stopRecording}
onDownloadRecording={downloadRecording}
/>
</div>
</div>
)
Expand Down
63 changes: 63 additions & 0 deletions src/components/RecordingControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { useCallback } from 'react'
import type { RecordingState } from '../hooks/useWhiteboardRecording'
import { translate as t } from '@nextcloud/l10n'

interface RecordingControlsProps {
recordingState: RecordingState
onStartRecording: () => void
onStopRecording: () => void
onDownloadRecording: () => void
}

export function RecordingControls({
recordingState,
onStartRecording,
onStopRecording,
onDownloadRecording,
}: RecordingControlsProps) {
const formatDuration = useCallback((seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}, [])

return (
<div className="whiteboard-recording-controls">
{!recordingState.isRecording
? (
<button
className="recording-button start"
onClick={onStartRecording}
title={t('whiteboard', 'Start recording')}>
<span className="recording-icon">⏺</span>
{t('whiteboard', 'Record')}
</button>
)
: (
<>
<button
className="recording-button stop"
onClick={onStopRecording}
title={t('whiteboard', 'Stop recording')}>
<span className="recording-icon">⏹</span>
{formatDuration(recordingState.duration)}
</button>
</>
)}
{!recordingState.isRecording && recordingState.frames.length > 0 && (
<button
className="recording-button download"
onClick={onDownloadRecording}
title={t('whiteboard', 'Download recording')}>
<span className="recording-icon">⬇</span>
{t('whiteboard', 'Download')}
</button>
)}
</div>
)
}
160 changes: 160 additions & 0 deletions src/hooks/useWhiteboardRecording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { useState, useRef, useCallback } from 'react'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'

interface ExtendedMediaRecorder extends MediaRecorder {
animationFrameId?: number
}

export interface RecordingState {
isRecording: boolean
duration: number
frames: Blob[]
}

interface CanvasParams {
staticCanvas: HTMLCanvasElement
interactiveCanvas: HTMLCanvasElement
}

export function useWhiteboardRecording() {
const [recordingState, setRecordingState] = useState<RecordingState>({
isRecording: false,
duration: 0,
frames: [],
})

const mediaRecorderRef = useRef<ExtendedMediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const durationIntervalRef = useRef<number | null>(null)
const animationFrameIdRef = useRef<number | null>(null)

const startRecording = useCallback(({ staticCanvas, interactiveCanvas }: CanvasParams) => {
try {
const combinedCanvas = document.createElement('canvas')
const ctx = combinedCanvas.getContext('2d')
if (!ctx) {
throw new Error('Failed to get canvas context')
}

combinedCanvas.width = staticCanvas.width
combinedCanvas.height = staticCanvas.height

const drawFrame = () => {

ctx.clearRect(0, 0, combinedCanvas.width, combinedCanvas.height)

ctx.drawImage(staticCanvas, 0, 0)

ctx.drawImage(interactiveCanvas, 0, 0)
}

const stream = combinedCanvas.captureStream(60)
streamRef.current = stream

const updateCanvas = () => {
drawFrame()
animationFrameIdRef.current = requestAnimationFrame(updateCanvas)
}
updateCanvas()

const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp8',
videoBitsPerSecond: 8000000,
}) as ExtendedMediaRecorder

mediaRecorder.ondataavailable = (event) => {
setRecordingState((prev) => ({
...prev,
frames: [...prev.frames, event.data],
}))
}

mediaRecorder.start(500)
mediaRecorderRef.current = mediaRecorder

durationIntervalRef.current = window.setInterval(() => {
setRecordingState((prev) => ({
...prev,
duration: prev.duration + 1,
}))
}, 1000)

setRecordingState((prev) => ({
...prev,
isRecording: true,
duration: 0,
frames: [],
}))

showSuccess(t('whiteboard', 'Recording started'))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to start recording:', error)
showError(t('whiteboard', 'Failed to start recording'))
}
}, [])

const stopRecording = useCallback(async () => {
if (!mediaRecorderRef.current || !streamRef.current) {
return
}

if (animationFrameIdRef.current !== null) {
cancelAnimationFrame(animationFrameIdRef.current)
animationFrameIdRef.current = null
}

mediaRecorderRef.current.stop()

streamRef.current.getTracks().forEach((track) => track.stop())

if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current)
}

setRecordingState((prev) => ({
...prev,
isRecording: false,
}))

showSuccess(t('whiteboard', 'Recording stopped'))
}, [])

const downloadRecording = useCallback(() => {
if (recordingState.frames.length === 0) {
return
}

try {
const blob = new Blob(recordingState.frames, { type: 'video/webm' })
const url = URL.createObjectURL(blob)

const a = document.createElement('a')
a.href = url
a.download = `whiteboard-recording-${Date.now()}.webm`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)

showSuccess(t('whiteboard', 'Recording saved'))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save recording:', error)
showError(t('whiteboard', 'Failed to save recording'))
}
}, [recordingState.frames])

return {
recordingState,
startRecording,
stopRecording,
downloadRecording,
}
}
Loading