Skip to content

UBERF-10517 Fix screen recording in desktop app #8861

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

Merged
merged 5 commits into from
May 7, 2025
Merged
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
4 changes: 2 additions & 2 deletions desktop/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import { workbenchId, logOut } from '@hcengineering/workbench'

import { isOwnerOrMaintainer } from '@hcengineering/core'
import { configurePlatform } from './platform'
import { defineScreenShare, defineScreenRecorder } from './screenShare'
import { defineScreenShare, defineGetDisplayMedia } from './screenShare'
import { IPCMainExposed } from './types'

defineScreenShare()
defineScreenRecorder()
defineGetDisplayMedia()

void configurePlatform().then(() => {
createApp(document.body)
Expand Down
83 changes: 43 additions & 40 deletions desktop/src/ui/screenShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,56 @@ import { showPopup } from '@hcengineering/ui'
import { Track, LocalTrack, LocalAudioTrack, LocalVideoTrack, ParticipantEvent, TrackInvalidError, ScreenShareCaptureOptions, DeviceUnsupportedError, ScreenSharePresets } from 'livekit-client'

import { IPCMainExposed } from './types'
import { setMetadata } from '@hcengineering/platform'
import recordPlugin from '@hcengineering/recorder'

export async function getMediaStream (opts?: DisplayMediaStreamOptions): Promise<MediaStream> {
if (opts === undefined) {
throw new Error('opts must be provided')
}
const ipcMain = (window as any).electron as IPCMainExposed
const sources = await ipcMain.getScreenSources()

const hasAccess = await ipcMain.getScreenAccess()
if (!hasAccess) {
log.error('No screen access granted')
throw new Error('No screen access granted')
export function defineGetDisplayMedia (): void {
if (navigator?.mediaDevices === undefined) {
console.warn('mediaDevices API not available')
return
}

if (navigator.mediaDevices.getDisplayMedia === undefined) {
throw new DeviceUnsupportedError('getDisplayMedia not supported')
}
return await new Promise<MediaStream>((resolve, reject) => {
showPopup(
love.component.SelectScreenSourcePopup,
{
sources
},
'top',
() => {
reject(new Error('No source selected'))
},
(val) => {
if (val != null) {
opts.video = {
mandatory: {
...(typeof opts.video === 'boolean' ? {} : opts.video),
chromeMediaSource: 'desktop',
chromeMediaSourceId: val
}
} as any
resolve(window.navigator.mediaDevices.getUserMedia(opts))
}
}
)
})
}

export function defineScreenRecorder (): void {
setMetadata(recordPlugin.metadata.GetCustomMediaStream, getMediaStream)
navigator.mediaDevices.getDisplayMedia = async (opts?: DisplayMediaStreamOptions): Promise<MediaStream> => {
if (opts === undefined) {
throw new Error('opts must be provided')
}

const ipcMain = (window as any).electron as IPCMainExposed
const sources = await ipcMain.getScreenSources()

const hasAccess = await ipcMain.getScreenAccess()
if (!hasAccess) {
log.error('No screen access granted')
throw new Error('No screen access granted')
}

return await new Promise<MediaStream>((resolve, reject) => {
showPopup(
love.component.SelectScreenSourcePopup,
{
sources
},
'top',
() => {
reject(new Error('No source selected'))
},
(val) => {
if (val != null) {
opts.video = {
mandatory: {
...(typeof opts.video === 'boolean' ? {} : opts.video),
chromeMediaSource: 'desktop',
chromeMediaSourceId: val
}
} as any
resolve(window.navigator.mediaDevices.getUserMedia(opts))
}
}
)
})
}
}

export function defineScreenShare (): void {
Expand Down
2 changes: 1 addition & 1 deletion dev/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ services:
- 'huly.local:host-gateway'
container_name: stream
environment:
- STREAM_ENDPOINT_URL=s3://huly.local:9000
- STREAM_ENDPOINT_URL=datalake://huly.local:4030
- STREAM_INSECURE=true
- STREAM_SERVER_SECRET=secret
- AWS_ACCESS_KEY_ID=minioadmin
Expand Down
4 changes: 2 additions & 2 deletions plugins/love-resources/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,8 @@ lk.on(RoomEvent.Connected, () => {

const session = useMedia({
state: {
camera: { enabled: false },
microphone: current?.type === RoomType.Video ? { enabled: false } : undefined
camera: current?.type === RoomType.Video ? { enabled: false } : undefined,
microphone: { enabled: false }
},
autoDestroy: false
})
Expand Down
20 changes: 16 additions & 4 deletions plugins/media-resources/src/components/MediaPopupCamPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,24 @@
}
</script>

{#if stream !== null}
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} width="100%" height="100%" autoplay muted disablepictureinpicture />
{/if}
<div class="container">
{#if stream !== null}
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} width="100%" height="100%" autoplay muted disablepictureinpicture />
{/if}
</div>

<style lang="scss">
.container {
padding: 0.375rem;
border-radius: 0.375rem;
width: 100%;

display: flex;
align-items: center;
justify-content: center;
}

video {
border-radius: inherit;
transform: rotateY(180deg);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,7 @@
{/each}

{#if selected}
<div class="preview">
<MediaPopupCamPreview {selected} />
</div>
<MediaPopupCamPreview {selected} />
{/if}
{:else}
<MediaPopupItem
Expand All @@ -126,14 +124,4 @@
color: var(--theme-state-positive-color);
}
}

.preview {
padding: 0.375rem;
border-radius: 0.375rem;
width: 100%;

display: flex;
align-items: center;
justify-content: center;
}
</style>
11 changes: 11 additions & 0 deletions plugins/media/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,14 @@ export async function getMicrophoneStream (
return null
}
}

export async function getDisplayMedia (constraints: MediaStreamConstraints): Promise<MediaStream> {
if (
navigator?.mediaDevices?.getDisplayMedia !== undefined &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
) {
return await navigator.mediaDevices.getDisplayMedia(constraints)
}

throw new Error('getDisplayMedia not supported')
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
style="padding-left: 0.25rem"
on:click={handleStartRecording}
>
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--theme-state-negative-color)' }} size="small" />
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--theme-dark-color)' }} size="small" />
<Icon icon={IconRec} iconProps={{ fill: 'var(--theme-dark-color)' }} size="small" />
</button>
{/if}
Expand Down
3 changes: 2 additions & 1 deletion plugins/recorder-resources/src/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.
//

import { getDisplayMedia } from '@hcengineering/media'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
Expand Down Expand Up @@ -69,7 +70,7 @@ export async function startRecording (options: RecordingOptions): Promise<void>

let displayStream: MediaStream
try {
displayStream = await navigator.mediaDevices.getDisplayMedia({
displayStream = await getDisplayMedia({
video: {
frameRate: { ideal: fps ?? 30 }
}
Expand Down
8 changes: 1 addition & 7 deletions plugins/recorder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ import { type UploadHandler } from '@hcengineering/uploader'
*/
export const recorderId = 'recorder' as Plugin

/**
* @public
*/
export type GetMediaStream = (options?: DisplayMediaStreamOptions) => Promise<MediaStream>

/**
* @public
*/
Expand All @@ -34,8 +29,7 @@ const recordPlugin = plugin(recorderId, {
Record: '' as Asset
},
metadata: {
StreamUrl: '' as Metadata<string>,
GetCustomMediaStream: '' as Metadata<GetMediaStream>
StreamUrl: '' as Metadata<string>
},
space: {
Drive: '' as Ref<Drive>
Expand Down
Loading