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

fix(screensharing): picker performance and UI improvements #1003

Merged
merged 10 commits into from
Jan 31, 2025
Prev Previous commit
Next Next commit
refactor(screensharing): migrate to TS
Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
  • Loading branch information
ShGKme committed Jan 31, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
commit 5b8ff22fcf8d943e5a292b613d70f5ee8ba6cd60
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -135,7 +135,7 @@ module.exports = {

overrides: [
{
files: '*.ts',
files: ['*.ts', '*.vue'],
rules: {
'jsdoc/require-returns-type': 'off', // TODO upstream
},
5 changes: 5 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,11 @@ declare module '*.svg' {
export default url
}

declare module '*.svg?raw' {
const url: string
export default url
}

declare module 'vue-material-design-icons/*.vue' {
import type { Component } from 'vue'
const component: Component<Record<string, never>, Record<string, never>, Record<string, never>, { size: number }, Record<string, never>>
15 changes: 7 additions & 8 deletions src/talk/renderer/screensharing/AppGetDesktopMediaSource.vue
Original file line number Diff line number Diff line change
@@ -3,25 +3,24 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup>
<script setup lang="ts">
import type { ScreensharingSourceId } from './screensharing.types.ts'
import { ref } from 'vue'

import DesktopMediaSourceDialog from './DesktopMediaSourceDialog.vue'

const showDialog = ref(false)
const showDialog = ref<boolean>(false)

let promiseWithResolvers = null
let promiseWithResolvers: PromiseWithResolvers<{ sourceId: ScreensharingSourceId | '' }> | null = null

const handlePrompt = (sourceId) => {
promiseWithResolvers.resolve({ sourceId })
const handlePrompt = (sourceId: ScreensharingSourceId | '') => {
promiseWithResolvers!.resolve({ sourceId })
promiseWithResolvers = null
showDialog.value = false
}

/**
* Prompt user to select a desktop media source to share and return the selected sourceId or an empty string if canceled
*
* @return {Promise<{ sourceId: string }>} sourceId of the selected mediaSource or an empty string if canceled
* @return sourceId of the selected mediaSource or an empty string if canceled
*/
function promptDesktopMediaSource() {
if (promiseWithResolvers) {
43 changes: 27 additions & 16 deletions src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue
Original file line number Diff line number Diff line change
@@ -3,20 +3,21 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconMonitorShare from '@mdi/svg/svg/monitor-share.svg?raw'

import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'

import { translate as t } from '@nextcloud/l10n'
import DesktopMediaSourcePreview from './DesktopMediaSourcePreview.vue'
import type { ScreensharingSource, ScreensharingSourceId } from './screensharing.types.ts'

const emit = defineEmits(['submit', 'cancel'])
const emit = defineEmits<{
(event: 'submit', sourceId: ScreensharingSourceId): void
(event: 'cancel'): void
}>()

const RE_REQUEST_SOURCES_TIMEOUT = 1000

@@ -25,10 +26,10 @@ const RE_REQUEST_SOURCES_TIMEOUT = 1000
// See: https://github.com/electron/electron/issues/27732
const previewType = window.systemInfo.isWayland ? 'thumbnail' : 'live'

const selectedSourceId = ref(null)
const sources = ref(null)
const selectedSourceId = ref<ScreensharingSourceId | null>(null)
const sources = ref<ScreensharingSource[] | null>(null)

const handleSubmit = () => emit('submit', selectedSourceId.value)
const handleSubmit = () => emit('submit', selectedSourceId.value!)
const handleCancel = () => emit('cancel')

const dialogButtons = computed(() => [
@@ -47,11 +48,12 @@ const dialogButtons = computed(() => [
])

const requestDesktopCapturerSources = async () => {
sources.value = await window.TALK_DESKTOP.getDesktopCapturerSources()
sources.value = await window.TALK_DESKTOP.getDesktopCapturerSources() as ScreensharingSource[] | null

// There is no source. Probably the user hasn't granted the permission.
if (!sources.value) {
emit('cancel')
return
}

// On Wayland there might be no name from the desktopCapturer
@@ -67,9 +69,11 @@ const requestDesktopCapturerSources = async () => {

// There is no sourceId for the entire desktop with all the screens and audio in Electron.
// But it is possible to capture it. "entire-desktop:0:0" is a custom sourceId for this specific case.
const entireDesktop = {
const entireDesktop: ScreensharingSource = {
id: 'entire-desktop:0:0',
name: screens.length > 1 ? t('talk_desktop', 'Audio + All screens') : t('talk_desktop', 'Audio + Screen'),
icon: null,
thumbnail: null,
}

// Wayland uses the system picker via PipeWire to select the source.
@@ -79,17 +83,24 @@ const requestDesktopCapturerSources = async () => {
sources.value = window.systemInfo.isWayland || window.systemInfo.isMac ? [...screens, ...windows] : [...screens, entireDesktop, ...windows]
}

const handleVideoSuspend = (source) => {
sources.value.splice(sources.value.indexOf(source), 1)
/**
* Handle the suspend event of the video element
* @param source - The source that was suspended
*/
function handleVideoSuspend(source: ScreensharingSource) {
sources.value!.splice(sources.value!.indexOf(source), 1)
if (selectedSourceId.value === source.id) {
selectedSourceId.value = null
}
}

let reRequestTimeout
let reRequestTimeout: number | undefined

const scheduleRequestDesktopCaprutererSources = () => {
reRequestTimeout = setTimeout(async () => {
/**
* Schedule a request for desktop capturer sources
*/
function scheduleRequestDesktopCaprutererSources() {
reRequestTimeout = window.setTimeout(async () => {
await requestDesktopCapturerSources()
scheduleRequestDesktopCaprutererSources()
}, RE_REQUEST_SOURCES_TIMEOUT)
@@ -100,7 +111,7 @@ onMounted(async () => {

// Preselect the first media source if any
if (!selectedSourceId.value) {
selectedSourceId.value = sources.value[0]?.id
selectedSourceId.value = sources.value?.[0]?.id ?? null
}

if (previewType === 'live') {
54 changes: 33 additions & 21 deletions src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue
Original file line number Diff line number Diff line change
@@ -3,9 +3,9 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup>
<script setup lang="ts">
import type { ScreensharingSource, ScreensharingSourceId } from './screensharing.types.ts'
import { onBeforeUnmount, onMounted, ref } from 'vue'

import IconMonitor from 'vue-material-design-icons/Monitor.vue'
import IconApplicationOutline from 'vue-material-design-icons/ApplicationOutline.vue'
import IconVolumeHigh from 'vue-material-design-icons/VolumeHigh.vue'
@@ -15,22 +15,19 @@ import IconVolumeHigh from 'vue-material-design-icons/VolumeHigh.vue'
// See: https://github.com/electron/electron/issues/27732
const previewType = window.systemInfo.isWayland ? 'thumbnail' : 'live'

const videoElement = ref(null)

const props = defineProps({
source: {
type: Object,
required: true,
},
selected: {
type: Boolean,
required: true,
},
})
const videoElement = ref<HTMLVideoElement | null>(null)

const props = defineProps<{
source: ScreensharingSource
selected: boolean
}>()

const emit = defineEmits(['select', 'suspended'])
const emit = defineEmits<{
(event: 'select'): void
(event: 'suspend'): void
}>()

const getStreamForMediaSource = (mediaSourceId) => {
const getStreamForMediaSource = (mediaSourceId: ScreensharingSourceId) => {
const MAX_PREVIEW_SIZE = 320
// Special case for sharing all the screens with desktop audio in Electron
// In this case, it must have exactly these constraints
@@ -62,15 +59,22 @@ const getStreamForMediaSource = (mediaSourceId) => {
},
}

// @ts-expect-error Each browser has a different API, the current object is compatible with Chromium
return navigator.mediaDevices.getUserMedia(constraints)
}

const setVideoSource = async () => {
videoElement.value.srcObject = await getStreamForMediaSource(props.source.id)
/**
* Set the video source to the selected source
*/
async function setVideoSource() {
videoElement.value!.srcObject = await getStreamForMediaSource(props.source.id)
}

const releaseVideoSource = () => {
const stream = videoElement.value.srcObject
/**
* Release the video source
*/
function releaseVideoSource() {
const stream = videoElement.value!.srcObject! as MediaStream
for (const track of stream.getTracks()) {
track.stop()
}
@@ -86,6 +90,14 @@ onBeforeUnmount(() => {
// Release the stream, otherwise it is still captured even if no video element is using it
releaseVideoSource()
})

/**
* Handle the loadedmetadata event of the video element
* @param event - The event
*/
function onLoadedMetadata(event: Event) {
(event.target as HTMLVideoElement).play()
}
</script>

<template>
@@ -101,7 +113,7 @@ onBeforeUnmount(() => {
ref="videoElement"
class="capture-source__preview"
muted
@loadedmetadata="$event.target.play()"
@loadedmetadata="onLoadedMetadata"
@suspend="emit('suspend')" />
<img v-else-if="previewType === 'thumbnail' && source.thumbnail"
alt=""
8 changes: 3 additions & 5 deletions src/talk/renderer/screensharing/getDesktopMediaSource.ts
Original file line number Diff line number Diff line change
@@ -7,18 +7,16 @@ import Vue from 'vue'

import AppGetDesktopMediaSource from './AppGetDesktopMediaSource.vue'

/** @type {import('vue').ComponentPublicInstance<AppGetDesktopMediaSource>} */
let appGetDesktopMediaSourceInstance
let appGetDesktopMediaSourceInstance: InstanceType<typeof AppGetDesktopMediaSource> | null = null

/**
* Prompt user to select a desktop media source to share and return the selected sourceId or an empty string if canceled
*
* @return {Promise<{ sourceId: string }>} sourceId of the selected mediaSource or an empty string if canceled
* @return sourceId of the selected mediaSource or an empty string if canceled
*/
export async function getDesktopMediaSource() {
if (!appGetDesktopMediaSourceInstance) {
const container = document.body.appendChild(document.createElement('div'))
appGetDesktopMediaSourceInstance = new Vue(AppGetDesktopMediaSource).$mount(container)
appGetDesktopMediaSourceInstance = new Vue(AppGetDesktopMediaSource).$mount(container) as InstanceType<typeof AppGetDesktopMediaSource>
}

return appGetDesktopMediaSourceInstance.promptDesktopMediaSource()
19 changes: 19 additions & 0 deletions src/talk/renderer/screensharing/screensharing.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export type ScreensharingSourceId = 'entire-desktop:0:0' | `${'screen' | 'window'}:${number}:${number}`

export type ScreensharingSource = {
id: ScreensharingSourceId
name: string
/**
* data:image/png;base64 encoded icon of the source
*/
icon: string | null
/**
* data:image/png;base64 encoded thumbnail of the source
*/
thumbnail: string | null
}