UBERF-10517 Fix screen recording in desktop app (#8861)

This commit is contained in:
Alexander Onnikov 2025-05-07 10:48:29 +07:00 committed by GitHub
parent 58fc4453b4
commit ff4b03f596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 80 additions and 71 deletions

View File

@ -19,11 +19,11 @@ import { workbenchId, logOut } from '@hcengineering/workbench'
import { isOwnerOrMaintainer } from '@hcengineering/core' import { isOwnerOrMaintainer } from '@hcengineering/core'
import { configurePlatform } from './platform' import { configurePlatform } from './platform'
import { defineScreenShare, defineScreenRecorder } from './screenShare' import { defineScreenShare, defineGetDisplayMedia } from './screenShare'
import { IPCMainExposed } from './types' import { IPCMainExposed } from './types'
defineScreenShare() defineScreenShare()
defineScreenRecorder() defineGetDisplayMedia()
void configurePlatform().then(() => { void configurePlatform().then(() => {
createApp(document.body) createApp(document.body)

View File

@ -5,53 +5,56 @@ import { showPopup } from '@hcengineering/ui'
import { Track, LocalTrack, LocalAudioTrack, LocalVideoTrack, ParticipantEvent, TrackInvalidError, ScreenShareCaptureOptions, DeviceUnsupportedError, ScreenSharePresets } from 'livekit-client' import { Track, LocalTrack, LocalAudioTrack, LocalVideoTrack, ParticipantEvent, TrackInvalidError, ScreenShareCaptureOptions, DeviceUnsupportedError, ScreenSharePresets } from 'livekit-client'
import { IPCMainExposed } from './types' import { IPCMainExposed } from './types'
import { setMetadata } from '@hcengineering/platform'
import recordPlugin from '@hcengineering/recorder'
export async function getMediaStream (opts?: DisplayMediaStreamOptions): Promise<MediaStream> { export function defineGetDisplayMedia (): void {
if (opts === undefined) { if (navigator?.mediaDevices === undefined) {
throw new Error('opts must be provided') console.warn('mediaDevices API not available')
} return
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')
} }
if (navigator.mediaDevices.getDisplayMedia === undefined) { if (navigator.mediaDevices.getDisplayMedia === undefined) {
throw new DeviceUnsupportedError('getDisplayMedia not supported') 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 { navigator.mediaDevices.getDisplayMedia = async (opts?: DisplayMediaStreamOptions): Promise<MediaStream> => {
setMetadata(recordPlugin.metadata.GetCustomMediaStream, getMediaStream) 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 { export function defineScreenShare (): void {

View File

@ -5,7 +5,7 @@ services:
- 'huly.local:host-gateway' - 'huly.local:host-gateway'
container_name: stream container_name: stream
environment: environment:
- STREAM_ENDPOINT_URL=s3://huly.local:9000 - STREAM_ENDPOINT_URL=datalake://huly.local:4030
- STREAM_INSECURE=true - STREAM_INSECURE=true
- STREAM_SERVER_SECRET=secret - STREAM_SERVER_SECRET=secret
- AWS_ACCESS_KEY_ID=minioadmin - AWS_ACCESS_KEY_ID=minioadmin

View File

@ -436,8 +436,8 @@ lk.on(RoomEvent.Connected, () => {
const session = useMedia({ const session = useMedia({
state: { state: {
camera: { enabled: false }, camera: current?.type === RoomType.Video ? { enabled: false } : undefined,
microphone: current?.type === RoomType.Video ? { enabled: false } : undefined microphone: { enabled: false }
}, },
autoDestroy: false autoDestroy: false
}) })

View File

@ -64,12 +64,24 @@
} }
</script> </script>
{#if stream !== null} <div class="container">
<!-- svelte-ignore a11y-media-has-caption --> {#if stream !== null}
<video bind:this={video} width="100%" height="100%" autoplay muted disablepictureinpicture /> <!-- svelte-ignore a11y-media-has-caption -->
{/if} <video bind:this={video} width="100%" height="100%" autoplay muted disablepictureinpicture />
{/if}
</div>
<style lang="scss"> <style lang="scss">
.container {
padding: 0.375rem;
border-radius: 0.375rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
video { video {
border-radius: inherit; border-radius: inherit;
transform: rotateY(180deg); transform: rotateY(180deg);

View File

@ -101,9 +101,7 @@
{/each} {/each}
{#if selected} {#if selected}
<div class="preview"> <MediaPopupCamPreview {selected} />
<MediaPopupCamPreview {selected} />
</div>
{/if} {/if}
{:else} {:else}
<MediaPopupItem <MediaPopupItem
@ -126,14 +124,4 @@
color: var(--theme-state-positive-color); 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> </style>

View File

@ -196,3 +196,14 @@ export async function getMicrophoneStream (
return null 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')
}

View File

@ -54,7 +54,7 @@
style="padding-left: 0.25rem" style="padding-left: 0.25rem"
on:click={handleStartRecording} 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" /> <Icon icon={IconRec} iconProps={{ fill: 'var(--theme-dark-color)' }} size="small" />
</button> </button>
{/if} {/if}

View File

@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { getDisplayMedia } from '@hcengineering/media'
import { getMetadata } from '@hcengineering/platform' import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation' import presentation from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui' import { showPopup } from '@hcengineering/ui'
@ -69,7 +70,7 @@ export async function startRecording (options: RecordingOptions): Promise<void>
let displayStream: MediaStream let displayStream: MediaStream
try { try {
displayStream = await navigator.mediaDevices.getDisplayMedia({ displayStream = await getDisplayMedia({
video: { video: {
frameRate: { ideal: fps ?? 30 } frameRate: { ideal: fps ?? 30 }
} }

View File

@ -21,11 +21,6 @@ import { type UploadHandler } from '@hcengineering/uploader'
*/ */
export const recorderId = 'recorder' as Plugin export const recorderId = 'recorder' as Plugin
/**
* @public
*/
export type GetMediaStream = (options?: DisplayMediaStreamOptions) => Promise<MediaStream>
/** /**
* @public * @public
*/ */
@ -34,8 +29,7 @@ const recordPlugin = plugin(recorderId, {
Record: '' as Asset Record: '' as Asset
}, },
metadata: { metadata: {
StreamUrl: '' as Metadata<string>, StreamUrl: '' as Metadata<string>
GetCustomMediaStream: '' as Metadata<GetMediaStream>
}, },
space: { space: {
Drive: '' as Ref<Drive> Drive: '' as Ref<Drive>