From 47a464f08f4fa8601db0c70ed7c728acbb02de8b Mon Sep 17 00:00:00 2001 From: opti1337 Date: Fri, 26 Dec 2025 18:22:22 +0600 Subject: [PATCH] screen sharing --- client/app/components/ClientRow.vue | 23 ++-- client/app/composables/use-app.ts | 126 ++++++++++++---------- client/app/composables/use-devices.ts | 28 +++++ client/app/composables/use-mediasoup.ts | 126 ++++++++++++++-------- client/app/composables/use-preferences.ts | 15 +-- client/app/layouts/default.vue | 20 +++- client/app/pages/index.vue | 46 ++++++-- client/app/pages/preferences.vue | 3 +- client/src-tauri/tauri.conf.json | 2 +- 9 files changed, 254 insertions(+), 135 deletions(-) create mode 100644 client/app/composables/use-devices.ts diff --git a/client/app/components/ClientRow.vue b/client/app/components/ClientRow.vue index e6ac5e3..b1a0aaa 100644 --- a/client/app/components/ClientRow.vue +++ b/client/app/components/ClientRow.vue @@ -58,15 +58,15 @@ const volume = useLocalStorage(computed(() => `CLIENT_VOLUME_${props.cli const menuRef = useTemplateRef('menu') const menuItems: MenuItem[] = [ - { - label: 'Mute', - icon: 'pi pi-headphones', - }, - { - label: 'DM', - icon: 'pi pi-comment', - disabled: true, - }, + // { + // label: 'Mute', + // icon: 'pi pi-headphones', + // }, + // { + // label: 'DM', + // icon: 'pi pi-comment', + // disabled: true, + // }, ] const isMe = computed(() => { @@ -98,10 +98,7 @@ const audioTrack = computed(() => { const { setGain } = useAudioContext(audioTrack) watch(volume, (volume) => { - if (outputMuted.value) - return - - setGain(volume * 0.01) + setGain(outputMuted.value ? 0 : (volume * 0.01)) }, { immediate: true }) watch(outputMuted, (outputMuted) => { diff --git a/client/app/composables/use-app.ts b/client/app/composables/use-app.ts index 76fa093..cf27930 100644 --- a/client/app/composables/use-app.ts +++ b/client/app/composables/use-app.ts @@ -10,78 +10,94 @@ export const useApp = createGlobalState(() => { const toast = useToast() const ready = ref(false) - - const inputMuted = ref(false) - const outputMuted = ref(false) - - const previousInputMuted = ref(inputMuted.value) - const isTauri = computed(() => '__TAURI_INTERNALS__' in window) - const commitSha = __COMMIT_SHA__ - const version = computedAsync(() => isTauri.value ? getVersion() : 'web', '-') - watch(inputMuted, async (inputMuted) => { - if (inputMuted) { - await mediasoup.pauseProducer('microphone') - } - else { - if (outputMuted.value) { - outputMuted.value = false - } - await mediasoup.resumeProducer('microphone') - } + const inputMuted = computed(() => { + return !!mediasoup.micProducer.value?.paused + }) + const previousInputMuted = ref(inputMuted.value) - const toastText = inputMuted ? 'Microphone muted' : 'Microphone activated' - toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 }) + const outputMuted = ref(false) + + const sharingEnabled = computed(() => { + return !!mediasoup.shareProducer.value }) - watch(outputMuted, async (outputMuted) => { - if (outputMuted) { - previousInputMuted.value = inputMuted.value - muteInput() - } - else { - inputMuted.value = previousInputMuted.value + async function muteInput() { + if (inputMuted.value) + return + + await mediasoup.pauseProducer('microphone') + + toast.add({ severity: 'info', summary: 'Microphone muted', closable: false, life: 1000 }) + } + + async function unmuteInput() { + if (!inputMuted.value) + return + + if (outputMuted.value) { + await unmuteOutput() } - const updatedMe = await signaling.socket.value?.emitWithAck('updateClient', { - outputMuted, + await mediasoup.resumeProducer('microphone') + + toast.add({ severity: 'info', summary: 'Microphone activated', closable: false, life: 1000 }) + } + + async function toggleInput() { + if (inputMuted.value) + await unmuteInput() + else + await muteInput() + } + + async function muteOutput() { + if (outputMuted.value) + return + + outputMuted.value = true + + previousInputMuted.value = inputMuted.value + + await muteInput() + + await signaling.socket.value?.emitWithAck('updateClient', { + outputMuted: false, }) - const toastText = outputMuted ? 'Sound muted' : 'Sound resumed' - toast.add({ severity: 'info', summary: toastText, closable: false, life: 1000 }) - }) - - function muteInput() { - inputMuted.value = true + toast.add({ severity: 'info', summary: 'Sound muted', closable: false, life: 1000 }) } - function unmuteInput() { - inputMuted.value = false - } - - function toggleInput() { - if (inputMuted.value) - unmuteInput() - else - muteInput() - } - - function muteOutput() { - outputMuted.value = true - } - - function unmuteOutput() { + async function unmuteOutput() { outputMuted.value = false + + if (!previousInputMuted.value) + await unmuteInput() + + await signaling.socket.value?.emitWithAck('updateClient', { + outputMuted: false, + }) + + toast.add({ severity: 'info', summary: 'Sound resumed', closable: false, life: 1000 }) } - function toggleOutput() { + async function toggleOutput() { if (outputMuted.value) - unmuteOutput() + await unmuteOutput() else - muteOutput() + await muteOutput() + } + + async function toggleShare() { + if (!mediasoup.shareProducer.value) { + await mediasoup.enableShare() + } + else { + await mediasoup.disableProducer('share') + } } return { @@ -98,5 +114,7 @@ export const useApp = createGlobalState(() => { version, isTauri, commitSha, + toggleShare, + sharingEnabled, } }) diff --git a/client/app/composables/use-devices.ts b/client/app/composables/use-devices.ts new file mode 100644 index 0000000..85f265b --- /dev/null +++ b/client/app/composables/use-devices.ts @@ -0,0 +1,28 @@ +import { createGlobalState, useDevicesList } from '@vueuse/core' + +export const useDevices = createGlobalState(() => { + const { + ensurePermissions, + permissionGranted, + videoInputs, + audioInputs, + audioOutputs, + } = useDevicesList() + + async function getShareStream(fps = 30) { + return navigator.mediaDevices.getDisplayMedia({ + audio: false, + video: { + displaySurface: 'monitor', + frameRate: { max: fps }, + }, + }) + } + + return { + videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))), + audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))), + audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))), + getShareStream, + } +}) diff --git a/client/app/composables/use-mediasoup.ts b/client/app/composables/use-mediasoup.ts index a7761f5..eaf67b6 100644 --- a/client/app/composables/use-mediasoup.ts +++ b/client/app/composables/use-mediasoup.ts @@ -1,4 +1,5 @@ import type { ChadClient } from '#shared/types' +import type { MediaKind, ProducerOptions } from 'mediasoup-client/types' import { createSharedComposable } from '@vueuse/core' import * as mediasoupClient from 'mediasoup-client' import { usePreferences } from '~/composables/use-preferences' @@ -25,7 +26,6 @@ export const useMediasoup = createSharedComposable(() => { const signaling = useSignaling() const { addClient, removeClient } = useClients() const preferences = usePreferences() - const { me } = useAuth() const device = shallowRef() const rtpCapabilities = shallowRef() @@ -158,7 +158,7 @@ export const useMediasoup = createSharedComposable(() => { producerId, kind, rtpParameters, - streamId: `${socketId}-${appData.share ? 'share' : 'mic-webcam'}`, + streamId: `${socketId}-${appData.source === 'share' ? 'share' : 'mic-webcam'}`, appData: { ...appData, socketId }, }) @@ -234,16 +234,62 @@ export const useMediasoup = createSharedComposable(() => { return consumers.value.values().filter(consumer => consumer.appData.socketId === socketId) } - async function enableMic() { - if (micProducer.value) + async function enableProducer(type: ProducerType, options: ProducerOptions) { + const producer = getProducerByType(type) + + if (producer.value) return if (!device.value || !sendTransport.value) return - if (!device.value.canProduce('audio')) + if (!options.track) return + if (!device.value.canProduce(options.track.kind as MediaKind)) + return + + producer.value = await sendTransport.value.produce(options) + + producers.value.set(producer.value.id, producer.value) + triggerRef(producers) + triggerRef(producer) + + producer.value.on('transportclose', () => { + micProducer.value = undefined + }) + + producer.value.on('trackended', () => { + disableProducer(type) + }) + } + + async function disableProducer(type: ProducerType) { + const producer = getProducerByType(type) + + if (!signaling.socket.value || !producer.value) + return + + producers.value.delete(producer.value.id) + + try { + producer.value.close() + + await signaling.socket.value.emitWithAck('closeProducer', { + producerId: producer.value.id, + }) + } + catch { + } + finally { + triggerRef(producers) + triggerRef(producer) + } + + producer.value = undefined + } + + async function enableMic() { const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: preferences.inputDeviceId.value }, @@ -258,7 +304,7 @@ export const useMediasoup = createSharedComposable(() => { if (!track) return - micProducer.value = await sendTransport.value.produce({ + await enableProducer('microphone', { track, codecOptions: { opusStereo: true, @@ -266,41 +312,41 @@ export const useMediasoup = createSharedComposable(() => { opusFec: false, // Фиксит пакет лос }, }) - - producers.value.set(micProducer.value.id, micProducer.value) - triggerRef(producers) - triggerRef(micProducer) - - micProducer.value.on('transportclose', () => { - micProducer.value = undefined - }) - - micProducer.value.on('trackended', () => { - disableMic() - }) } async function disableMic() { - if (!signaling.socket.value || !micProducer.value) + await disableProducer('microphone') + } + + async function enableShare() { + if (!device.value) return - producers.value.delete(micProducer.value.id) + const stream = await navigator.mediaDevices.getDisplayMedia({ + audio: false, + video: { + displaySurface: 'monitor', + frameRate: { max: 30 }, + }, + }) - try { - micProducer.value.close() + const track = stream.getVideoTracks()[0] - await signaling.socket.value.emitWithAck('closeProducer', { - producerId: micProducer.value.id, - }) - } - catch { - } - finally { - triggerRef(producers) - triggerRef(micProducer) - } + if (!track) + return - micProducer.value = undefined + await enableProducer('share', { + track, + codec: device.value.rtpCapabilities.codecs?.find( + c => c.mimeType.toLowerCase() === 'video/h264', + ), + codecOptions: { + videoGoogleStartBitrate: 1000, + }, + appData: { + source: 'share', + }, + }) } async function pauseProducer(type: ProducerType) { @@ -371,18 +417,6 @@ export const useMediasoup = createSharedComposable(() => { } } - watch( - preferences.inputDeviceId, - async (inputDeviceId) => { - await disableMic() - - if (!inputDeviceId) - return - - await enableMic() - }, - ) - watch([ preferences.inputDeviceId, preferences.echoCancellation, @@ -411,5 +445,7 @@ export const useMediasoup = createSharedComposable(() => { getClientConsumers, pauseProducer, resumeProducer, + enableShare, + disableProducer, } }) diff --git a/client/app/composables/use-preferences.ts b/client/app/composables/use-preferences.ts index c26c5b6..80ff7b6 100644 --- a/client/app/composables/use-preferences.ts +++ b/client/app/composables/use-preferences.ts @@ -1,5 +1,5 @@ import chadApi from '#shared/chad-api' -import { createGlobalState, useDevicesList, useLocalStorage, watchDebounced } from '@vueuse/core' +import { createGlobalState, useLocalStorage, watchDebounced } from '@vueuse/core' export interface SyncedPreferences { toggleInputHotkey: string @@ -8,6 +8,8 @@ export interface SyncedPreferences { } export const usePreferences = createGlobalState(() => { + const { videoInputs, audioInputs, audioOutputs } = useDevices() + const synced = ref(false) const inputDeviceId = useLocalStorage('INPUT_DEVICE_ID', 'default') @@ -20,14 +22,6 @@ export const usePreferences = createGlobalState(() => { const toggleInputHotkey = ref('') const toggleOutputHotkey = ref('') - const { - ensurePermissions, - permissionGranted, - videoInputs, - audioInputs, - audioOutputs, - } = useDevicesList() - const inputDeviceExist = computed(() => { return audioInputs.value.some(device => device.deviceId === inputDeviceId.value) }) @@ -67,8 +61,5 @@ export const usePreferences = createGlobalState(() => { toggleOutputHotkey, inputDeviceExist, outputDeviceExist, - videoInputs: computed(() => JSON.parse(JSON.stringify(videoInputs.value))), - audioInputs: computed(() => JSON.parse(JSON.stringify(audioInputs.value))), - audioOutputs: computed(() => JSON.parse(JSON.stringify(audioOutputs.value))), } }) diff --git a/client/app/layouts/default.vue b/client/app/layouts/default.vue index 72e5a68..fe4f529 100644 --- a/client/app/layouts/default.vue +++ b/client/app/layouts/default.vue @@ -20,6 +20,12 @@ + + + +
diff --git a/client/app/pages/preferences.vue b/client/app/pages/preferences.vue index 33617c8..6713c13 100644 --- a/client/app/pages/preferences.vue +++ b/client/app/pages/preferences.vue @@ -106,6 +106,7 @@ definePageMeta({ }) const { isTauri, version, commitSha } = useApp() const { checking, checkForUpdates, lastUpdate } = useUpdater() +const { audioInputs, audioOutputs } = useDevices() const { inputDeviceId, outputDeviceId, @@ -116,8 +117,6 @@ const { toggleOutputHotkey, inputDeviceExist, outputDeviceExist, - audioInputs, - audioOutputs, } = usePreferences() const setupToggleInputHotkey = (event: KeyboardEvent) => setupHotkey(event, toggleInputHotkey) diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index c987691..daf7760 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "chad", - "version": "0.2.15", + "version": "0.2.16", "identifier": "xyz.koptilnya.chad", "build": { "frontendDist": "../.output/public",