diff --git a/.eslintrc.js b/.eslintrc.js index 1f4e61e0..2a2aa5ac 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,8 +52,10 @@ module.exports = { // It works fine on server because by default in @nextcloud/eslint-config .vue files are not inspected via eslint-plugin-import thus import/extensions doesn't include .vue // See: https://github.com/import-js/eslint-plugin-import/blob/main/README.md#importextensions 'import/default': 'off', - // import/namespace is not compatible with TS/JS mix + // Not compatible with TS/JS mix 'import/namespace': 'off', + // Not compatible with TS/JS mix or reexports + 'import/named': 'off', /** * ESLint */ @@ -135,7 +137,7 @@ module.exports = { overrides: [ { - files: '*.ts', + files: ['*.ts', '*.vue'], rules: { 'jsdoc/require-returns-type': 'off', // TODO upstream }, diff --git a/src/global.d.ts b/src/global.d.ts index 8517492c..8251ebd8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -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>> diff --git a/src/help/renderer/ButtonCopy.vue b/src/help/renderer/ButtonCopy.vue index fe0e8933..239f8a43 100644 --- a/src/help/renderer/ButtonCopy.vue +++ b/src/help/renderer/ButtonCopy.vue @@ -19,9 +19,9 @@ import { t } from '@nextcloud/l10n' const props = withDefaults( defineProps<{ - text?: string, - content?: string, - getContent?: () => string, + text?: string, + content?: string, + getContent?: () => string, }>(), { text: t('talk_desktop', 'Copy'), content: undefined, diff --git a/src/main.js b/src/main.js index ccf9e667..52725ca7 100644 --- a/src/main.js +++ b/src/main.js @@ -13,7 +13,7 @@ const { createAuthenticationWindow } = require('./authentication/authentication. const { openLoginWebView } = require('./authentication/login.window.js') const { createHelpWindow } = require('./help/help.window.js') const { createUpgradeWindow } = require('./upgrade/upgrade.window.js') -const { systemInfo, isLinux, isMac, isWayland, isWindows } = require('./app/system.utils.ts') +const { systemInfo, isLinux, isMac, isWindows } = require('./app/system.utils.ts') const { createTalkWindow } = require('./talk/talk.window.js') const { createWelcomeWindow } = require('./welcome/welcome.window.js') const { installVueDevtools } = require('./install-vue-devtools.js') @@ -94,8 +94,7 @@ ipcMain.handle('app:getDesktopCapturerSources', async () => { return null } - // We cannot show live previews on Wayland, so we show thumbnails - const thumbnailWidth = isWayland ? 320 : 0 + const thumbnailWidth = 800 const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], diff --git a/src/shared/globals/globals.js b/src/shared/globals/globals.js index cd62d9b1..48d4f12a 100644 --- a/src/shared/globals/globals.js +++ b/src/shared/globals/globals.js @@ -9,7 +9,7 @@ import { translate, translatePlural } from '@nextcloud/l10n' import { appData } from '../../app/AppData.js' import { dialogs } from './OC/dialogs.js' import { MimeTypeList } from './OC/mimetype.js' -import { getDesktopMediaSource } from '../../talk/renderer/getDesktopMediaSource.js' +import { getDesktopMediaSource } from '../../talk/renderer/screensharing/screensharing.module.ts' let enabledAbsoluteWebroot = false diff --git a/src/talk/renderer/components/DesktopMediaSourceDialog.vue b/src/talk/renderer/components/DesktopMediaSourceDialog.vue deleted file mode 100644 index 64a46653..00000000 --- a/src/talk/renderer/components/DesktopMediaSourceDialog.vue +++ /dev/null @@ -1,146 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> - -<script setup> -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' - -const emit = defineEmits(['submit', 'cancel']) - -const RE_REQUEST_SOURCES_TIMEOUT = 1000 - -// On Wayland getting each stream for the live preview requests user to select the source via system dialog again -// Instead - show static images. -// See: https://github.com/electron/electron/issues/27732 -const previewType = window.systemInfo.isWayland ? 'thumbnail' : 'live' - -const selectedSourceId = ref(null) -const sources = ref(null) - -const handleSubmit = () => emit('submit', selectedSourceId.value) -const handleCancel = () => emit('cancel') - -const dialogButtons = computed(() => [ - { - label: t('talk_desktop', 'Cancel'), - icon: IconCancel, - callback: handleCancel, - }, - { - label: t('talk_desktop', 'Share screen'), - type: 'primary', - icon: IconMonitorShare, - disabled: !selectedSourceId.value, - callback: handleSubmit, - }, -]) - -const requestDesktopCapturerSources = async () => { - sources.value = await window.TALK_DESKTOP.getDesktopCapturerSources() - - // There is no source. Probably the user hasn't granted the permission. - if (!sources.value) { - emit('cancel') - } - - // On Wayland there might be no name from the desktopCapturer - if (window.systemInfo.isWayland) { - for (const source of sources.value) { - source.name ||= t('talk_desktop', 'Selected screen or window') - } - } - - // Separate sources to combine them then to [screens, desktop, windows] - const screens = sources.value.filter((source) => source.id.startsWith('screen:')) - const windows = sources.value.filter((source) => source.id.startsWith('window:')) - - // 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 = { - id: 'entire-desktop:0:0', - name: screens.length > 1 ? t('talk_desktop', 'Audio + All screens') : t('talk_desktop', 'Audio + Screen'), - } - - // Wayland uses the system picker via PipeWire to select the source. - // Thus, only the selected source is available, and the custom entire-desktop option is neither supported nor needed. - // On macOS the entire-desktop captures only the primary screen and capturing system audio crashes audio (microphone). - // TODO: use the system picker on macOS Sonoma and later - sources.value = window.systemInfo.isWayland || window.systemInfo.isMac ? [...screens, ...windows] : [...screens, entireDesktop, ...windows] -} - -const handleVideoSuspend = (source) => { - sources.value.splice(sources.value.indexOf(source), 1) - if (selectedSourceId.value === source.id) { - selectedSourceId.value = null - } -} - -let reRequestTimeout - -const scheduleRequestDesktopCaprutererSources = () => { - reRequestTimeout = setTimeout(async () => { - await requestDesktopCapturerSources() - scheduleRequestDesktopCaprutererSources() - }, RE_REQUEST_SOURCES_TIMEOUT) -} - -onMounted(async () => { - await requestDesktopCapturerSources() - - // Preselect the first media source if any - if (!selectedSourceId.value) { - selectedSourceId.value = sources.value[0]?.id - } - - if (previewType === 'live') { - scheduleRequestDesktopCaprutererSources() - } -}) - -onBeforeUnmount(() => { - if (reRequestTimeout) { - clearTimeout(reRequestTimeout) - } -}) -</script> - -<template> - <NcDialog :name="t('talk_desktop', 'Choose what to share')" - size="normal" - :buttons="dialogButtons" - @update:open="handleCancel"> - <div v-if="sources" class="capture-source-grid"> - <DesktopMediaSourcePreview v-for="source in sources" - :key="source.id" - :source="source" - :selected="selectedSourceId === source.id" - @select="selectedSourceId = source.id" - @suspend="handleVideoSuspend(source)" /> - </div> - <NcEmptyContent v-else :name="t('talk_desktop', 'Loading …')"> - <template #icon> - <NcLoadingIcon /> - </template> - </NcEmptyContent> - </NcDialog> -</template> - -<style scoped lang="scss"> -.capture-source-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - grid-gap: calc(var(--default-grid-baseline) * 2); - width: 100%; -} -</style> diff --git a/src/talk/renderer/components/DesktopMediaSourcePreview.vue b/src/talk/renderer/components/DesktopMediaSourcePreview.vue deleted file mode 100644 index 24583760..00000000 --- a/src/talk/renderer/components/DesktopMediaSourcePreview.vue +++ /dev/null @@ -1,178 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> - -<script setup> -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' - -// On Wayland getting each stream for the live preview requests user to select the source via system dialog again -// Instead - show static images. -// 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 emit = defineEmits(['select', 'suspended']) - -const getStreamForMediaSource = (mediaSourceId) => { - 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 - // "entire-desktop:0:0" is a custom sourceId for this specific case - const constraints = mediaSourceId === 'entire-desktop:0:0' - ? { - audio: { - mandatory: { - chromeMediaSource: 'desktop', - }, - }, - video: { - mandatory: { - chromeMediaSource: 'desktop', - maxWidth: MAX_PREVIEW_SIZE, - maxHeight: MAX_PREVIEW_SIZE, - }, - }, - } - : { - audio: false, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: mediaSourceId, - maxWidth: MAX_PREVIEW_SIZE, - maxHeight: MAX_PREVIEW_SIZE, - }, - }, - } - - return navigator.mediaDevices.getUserMedia(constraints) -} - -const setVideoSource = async () => { - videoElement.value.srcObject = await getStreamForMediaSource(props.source.id) -} - -const releaseVideoSource = () => { - const stream = videoElement.value.srcObject - for (const track of stream.getTracks()) { - track.stop() - } -} - -onMounted(async () => { - if (previewType === 'live') { - await setVideoSource() - } -}) - -onBeforeUnmount(() => { - // Release the stream, otherwise it is still captured even if no video element is using it - releaseVideoSource() -}) -</script> - -<template> - <label :key="source.id" class="capture-source"> - <input :id="source.id" - class="capture-source__input" - type="radio" - :value="source.id" - :checked="selected" - @change="emit('select')"> - - <video v-if="previewType === 'live'" - ref="videoElement" - class="capture-source__preview" - muted - @loadedmetadata="$event.target.play()" - @suspend="emit('suspend')" /> - <img v-else-if="previewType === 'thumbnail' && source.thumbnail" - alt="" - :src="source.thumbnail" - class="capture-source__preview"> - <span v-else class="capture-source__preview" /> - - <span class="capture-source__caption"> - <img v-if="source.icon" - alt="" - :src="source.icon" - class="capture-source__caption-icon"> - <IconVolumeHigh v-else-if="source.id.startsWith('entire-desktop:')" :size="16" /> - <IconMonitor v-else-if="source.id.startsWith('screen:')" :size="16" /> - <IconApplicationOutline v-else-if="source.id.startsWith('window:')" :size="16" /> - <span class="capture-source__caption-text">{{ source.name }}</span> - </span> - </label> -</template> - -<style scoped lang="scss"> -.capture-source { - display: flex; - flex-direction: column; - gap: var(--default-grid-baseline); - overflow: hidden; - - &__input { - position: absolute; - z-index: -1; - opacity: 0 !important; - width: var(--default-clickable-area); - height: var(--default-clickable-area); - margin: 4px 14px; - } - - &__preview { - aspect-ratio: 16 / 9; - object-fit: contain; - width: calc(100% - 4px - 4px); - flex: 1 0; - margin: 4px auto; - border-radius: var(--border-radius-large); - } - - &:focus &__preview, - &:hover &__preview { - box-shadow: 0 0 0 2px var(--color-primary-element); - background-color: var(--color-background-hover); - } - - &:has(&__input:checked) &__preview { - box-shadow: 0 0 0 4px var(--color-primary-element); - background-color: var(--color-background-hover); - } - - &__caption { - display: flex; - gap: var(--default-grid-baseline); - align-items: center; - padding: var(--default-grid-baseline); - } - - &__caption-icon { - height: 16px; - } - - &__caption-text { - text-wrap: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } -} -</style> diff --git a/src/talk/renderer/AppGetDesktopMediaSource.vue b/src/talk/renderer/screensharing/AppGetDesktopMediaSource.vue similarity index 60% rename from src/talk/renderer/AppGetDesktopMediaSource.vue rename to src/talk/renderer/screensharing/AppGetDesktopMediaSource.vue index ce408048..51f8057f 100644 --- a/src/talk/renderer/AppGetDesktopMediaSource.vue +++ b/src/talk/renderer/screensharing/AppGetDesktopMediaSource.vue @@ -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' -import DesktopMediaSourceDialog from './components/DesktopMediaSourceDialog.vue' +const showDialog = ref<boolean>(false) -const showDialog = ref(false) +let promiseWithResolvers: PromiseWithResolvers<{ sourceId: ScreensharingSourceId | '' }> | null = null -let promiseWithResolvers = 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) { diff --git a/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue b/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue new file mode 100644 index 00000000..6e1916d1 --- /dev/null +++ b/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue @@ -0,0 +1,191 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { ScreensharingSource, ScreensharingSourceId } from './screensharing.types.ts' +import { computed, ref, watch } 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 NcDialogButton from '@nextcloud/vue/dist/Components/NcDialogButton.js' +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import { t } from '@nextcloud/l10n' +import { useWindowFocus } from '@vueuse/core' +import DesktopMediaSourcePreview from './DesktopMediaSourcePreview.vue' + +const emit = defineEmits<{ + (event: 'submit', sourceId: ScreensharingSourceId): void + (event: 'cancel'): void +}>() + +const livePreview = ref(false) +const selectedSourceId = ref<ScreensharingSourceId | null>(null) +const sources = ref<ScreensharingSource[] | null>(null) + +const screenSources = computed(() => sources.value?.filter((source) => source.id.startsWith('screen:') || source.id.startsWith('entire-desktop:'))) +const windowSources = computed(() => sources.value?.filter((source) => source.id.startsWith('window:'))) + +const singleSource = computed(() => sources.value && sources.value.length === 1) + +// On Wayland instead of the list of all available sources, +// the system picker is used to have a list of a single selected source. +// Getting the stream for the selected source triggers the system picker again. +// As a result: +// - Live preview is not possible +// - Sources list update is not possible +// - There is no the entire-desktop option +// See also: https://github.com/electron/electron/issues/27732 +const livePreviewAvailable = !window.systemInfo.isWayland +if (!window.systemInfo.isWayland) { + const isWindowFocused = useWindowFocus() + watch(isWindowFocused, requestDesktopCapturerSources) +} + +requestDesktopCapturerSources() + +/** + * Request the desktop capturer sources + */ +async function requestDesktopCapturerSources() { + 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 + if (window.systemInfo.isWayland) { + for (const source of sources.value) { + source.name ||= t('talk_desktop', 'Selected screen or window') + } + } + + // Separate sources to combine them then to [screens, desktop, windows] + const screens = sources.value.filter((source) => source.id.startsWith('screen:')) + const windows = sources.value.filter((source) => source.id.startsWith('window:')) + + // 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: 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. + // Thus, only the selected source is available, and the custom entire-desktop option is neither supported nor needed. + // On macOS the entire-desktop captures only the primary screen and capturing system audio crashes audio (microphone). + // TODO: use the system picker on macOS Sonoma and later + sources.value = window.systemInfo.isWayland || window.systemInfo.isMac ? [...screens, ...windows] : [...screens, entireDesktop, ...windows] + + // Preselect the first media source if any + if (!selectedSourceId.value) { + selectedSourceId.value = sources.value?.[0]?.id ?? null + } +} + +/** + * 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 + } +} + +/** + * Handle the submit event of the dialog + */ +function handleSubmit() { + emit('submit', selectedSourceId.value!) +} + +/** + * Handle the cancel event of the dialog + */ +function handleCancel() { + emit('cancel') +} +</script> + +<template> + <NcDialog :name="t('talk_desktop', 'Choose what to share')" + size="large" + @update:open="handleCancel"> + <div v-if="sources" class="capture-source-grid"> + <h3 v-if="screenSources?.length && !singleSource" class="capture-source-section-heading"> + {{ t('talk_desktop', 'Entire screens') }} + </h3> + <DesktopMediaSourcePreview v-for="source in screenSources" + :key="source.id" + :source="source" + :live="livePreview" + :selected="selectedSourceId === source.id" + @select="selectedSourceId = source.id" + @suspend="handleVideoSuspend(source)" /> + + <h3 v-if="!singleSource && windowSources?.length" class="capture-source-section-heading"> + {{ t('talk_desktop', 'Application windows') }} + </h3> + <DesktopMediaSourcePreview v-for="source in windowSources" + :key="source.id" + :source="source" + :live="livePreview" + :selected="selectedSourceId === source.id" + @select="selectedSourceId = source.id" + @suspend="handleVideoSuspend(source)" /> + </div> + + <NcEmptyContent v-else :name="t('talk_desktop', 'Loading …')"> + <template #icon> + <NcLoadingIcon /> + </template> + </NcEmptyContent> + + <template #actions> + <NcCheckboxRadioSwitch v-if="sources && livePreviewAvailable" + v-model="livePreview" + type="switch" + class="capture-mode-switch"> + {{ t('talk_desktop', 'Live preview') }} + </NcCheckboxRadioSwitch> + <NcDialogButton :icon="IconCancel" :label="t('talk_desktop', 'Cancel')" @click="handleCancel" /> + <NcDialogButton :icon="IconMonitorShare" + :label="t('talk_desktop', 'Share screen')" + type="primary" + :disabled="!selectedSourceId" + @click="handleSubmit" /> + </template> + </NcDialog> +</template> + +<style scoped lang="scss"> +.capture-source-grid { + display: grid; + /* 280 is approximately 1/3 of the default large dialog size */ + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + grid-gap: calc(var(--default-grid-baseline) * 2); + width: 100%; + padding: calc(2 * var(--default-grid-baseline)); +} + +.capture-source-section-heading { + grid-column: 1 / -1; + font-size: 18px; + text-align: center; + margin-block: calc(2 * var(--default-grid-baseline)) 0; +} + +.capture-mode-switch { + margin-inline-end: auto; +} +</style> diff --git a/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue b/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue new file mode 100644 index 00000000..54a0b0df --- /dev/null +++ b/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue @@ -0,0 +1,126 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { ScreensharingSource } from './screensharing.types.ts' +import { t } from '@nextcloud/l10n' +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' +import DesktopMediaSourcePreviewLive from './DesktopMediaSourcePreviewLive.vue' + +defineProps<{ + source: ScreensharingSource + live: boolean + selected: boolean +}>() + +const emit = defineEmits<{ + (event: 'select'): void + (event: 'suspend'): void +}>() +</script> + +<template> + <label :key="source.id" class="capture-source"> + <input :id="source.id" + class="capture-source__input" + type="radio" + :value="source.id" + :checked="selected" + @change="emit('select')"> + + <DesktopMediaSourcePreviewLive v-if="live" + class="capture-source__preview" + :media-source-id="source.id" + @suspend="emit('suspend')" /> + <img v-else-if="source.thumbnail" + alt="" + :src="source.thumbnail" + class="capture-source__preview"> + <span v-else class="capture-source__preview capture-source__preview-unavailable"> + {{ t('talk_desktop', 'Preview is not available') }} + </span> + + <span class="capture-source__caption"> + <img v-if="source.icon" + alt="" + :src="source.icon" + class="capture-source__caption-icon"> + <IconVolumeHigh v-else-if="source.id.startsWith('entire-desktop:')" :size="16" /> + <IconMonitor v-else-if="source.id.startsWith('screen:')" :size="16" /> + <IconApplicationOutline v-else-if="source.id.startsWith('window:')" :size="16" /> + <span class="capture-source__caption-text">{{ source.name }}</span> + </span> + </label> +</template> + +<style scoped lang="scss"> +.capture-source { + border-radius: var(--border-radius-element); + padding: calc(2 * var(--default-grid-baseline)); + display: flex; + flex-direction: column; + gap: var(--default-grid-baseline); + overflow: hidden; + + &__input { + position: absolute; + z-index: -1; + opacity: 0 !important; + width: var(--default-clickable-area); + height: var(--default-clickable-area); + margin: 4px 14px; + } + + &__preview { + aspect-ratio: 16 / 9; + object-fit: contain; + width: 100%; + border-radius: var(--border-radius-small); + flex: 1 0; + } + + &__preview-unavailable { + background-color: var(--color-background-hover); + display: grid; + place-content: center; + color: var(--color-text-maxcontrast); + font-size: 120%; + } + + &:focus, + &:hover { + background-color: var(--color-background-hover); + outline: 2px solid var(--color-primary-element); + outline-offset: 2px; + } + + &:has(&__input:checked) { + background-color: var(--color-primary-element); + color: var(--color-primary-text); + + .capture-source__caption-text { + font-weight: bolder; + } + } + + &__caption { + display: flex; + gap: 1ch; + align-items: center; + } + + &__caption-icon { + height: 16px; + } + + &__caption-text { + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } +} +</style> diff --git a/src/talk/renderer/screensharing/DesktopMediaSourcePreviewLive.vue b/src/talk/renderer/screensharing/DesktopMediaSourcePreviewLive.vue new file mode 100644 index 00000000..77375a06 --- /dev/null +++ b/src/talk/renderer/screensharing/DesktopMediaSourcePreviewLive.vue @@ -0,0 +1,150 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { ScreensharingSourceId } from './screensharing.types.ts' +import { onBeforeUnmount, onMounted, ref } from 'vue' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' + +const props = defineProps<{ + mediaSourceId: ScreensharingSourceId +}>() + +const emit = defineEmits<{ + (event: 'suspend'): void +}>() + +const videoElement = ref<HTMLVideoElement | null>(null) +let stream: MediaStream | null = null +const isReady = ref(false) + +/** + * Get the stream for the media source + * @param mediaSourceId - The media source ID + */ +function getStreamForMediaSource(mediaSourceId: ScreensharingSourceId) { + const MAX_PREVIEW_SIZE = 1024 + // Special case for sharing all the screens with desktop audio in Electron + // In this case, it must have exactly these constraints + // "entire-desktop:0:0" is a custom sourceId for this specific case + const constraints = mediaSourceId === 'entire-desktop:0:0' + ? { + audio: { + mandatory: { + chromeMediaSource: 'desktop', + }, + }, + video: { + mandatory: { + chromeMediaSource: 'desktop', + maxWidth: MAX_PREVIEW_SIZE, + maxHeight: MAX_PREVIEW_SIZE, + }, + }, + } + : { + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: mediaSourceId, + maxWidth: MAX_PREVIEW_SIZE, + maxHeight: MAX_PREVIEW_SIZE, + }, + }, + } + + // @ts-expect-error Each browser has a different API, the current object is compatible with Chromium + return navigator.mediaDevices.getUserMedia(constraints) +} + +/** + * Set the video source to the selected source + */ +async function setVideoSource() { + stream = await getStreamForMediaSource(props.mediaSourceId) + if (videoElement.value) { + videoElement.value.srcObject = stream + } else { + // If there is no video element - something went wrong or the component is destroyed already + // We still must release the stream + releaseVideoSource() + } +} + +/** + * Release the video source + */ +function releaseVideoSource() { + if (!stream) { + return + } + for (const track of stream.getTracks()) { + track.stop() + } +} + +onMounted(async () => { + await setVideoSource() +}) + +onBeforeUnmount(() => { + releaseVideoSource() +}) + +/** + * Handle the loadedmetadata event of the video element + * @param event - The event + */ +function onLoadedMetadata(event: Event) { + isReady.value = true + ;(event.target as HTMLVideoElement).play() +} + +/** + * Handle the suspend event of the video element + * This is supposed to happen only if the source disappears, e.g., the source window is closed + */ +function onSuspend() { + if (videoElement.value!.srcObject) { + emit('suspend') + } +} +</script> + +<template> + <div class="live-preview"> + <video v-show="isReady" + ref="videoElement" + class="live-preview__video" + muted + @loadedmetadata="onLoadedMetadata" + @suspend="onSuspend" /> + <span v-if="!isReady" class="live-preview__placeholder"> + <NcLoadingIcon :size="40" /> + </span> + </div> +</template> + +<style scoped> +.live-preview { + max-width: 100%; +} + +.live-preview__video { + width: 100%; + height: 100%; +} + +.live-preview__placeholder { + border-radius: var(--border-radius-small); + background-color: var(--color-background-hover); + color: var(--color-text-maxcontrast); + display: grid; + place-content: center; + width: 100%; + height: 100%; +} +</style> diff --git a/src/talk/renderer/getDesktopMediaSource.js b/src/talk/renderer/screensharing/getDesktopMediaSource.ts similarity index 72% rename from src/talk/renderer/getDesktopMediaSource.js rename to src/talk/renderer/screensharing/getDesktopMediaSource.ts index 04e8d6e9..8a31d9c9 100644 --- a/src/talk/renderer/getDesktopMediaSource.js +++ b/src/talk/renderer/screensharing/getDesktopMediaSource.ts @@ -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() diff --git a/src/talk/renderer/screensharing/screensharing.module.ts b/src/talk/renderer/screensharing/screensharing.module.ts new file mode 100644 index 00000000..0f59fbdd --- /dev/null +++ b/src/talk/renderer/screensharing/screensharing.module.ts @@ -0,0 +1,6 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export { getDesktopMediaSource } from './getDesktopMediaSource.ts' diff --git a/src/talk/renderer/screensharing/screensharing.types.ts b/src/talk/renderer/screensharing/screensharing.types.ts new file mode 100644 index 00000000..82ad06b2 --- /dev/null +++ b/src/talk/renderer/screensharing/screensharing.types.ts @@ -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 +}