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
+}