screen sharing

This commit is contained in:
2025-12-27 01:48:49 +06:00
parent 47a464f08f
commit 169d43f0db
12 changed files with 291 additions and 119 deletions

View File

@@ -1,107 +1,129 @@
<template>
<div class="py-3">
<div class="flex items-center gap-3">
<div
class="overflow-hidden rounded-xl transition-[background-color]"
:class="{
'hover:bg-surface-800 cursor-pointer': !isMe,
'bg-surface-800': expanded,
}"
>
<div class="p-3 flex items-center gap-3" @click="toggleExpand">
<PrimeAvatar size="small">
<template #icon>
<User :size="20" />
</template>
</PrimeAvatar>
<div class="flex-1 overflow-hidden">
<div class="overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
{{ client.displayName }}
</div>
<div v-if="client.username !== client.displayName" class="overflow-hidden mt-1 text-xs leading-5 text-muted-color">
{{ client.username }}
</div>
<div class="flex-1 overflow-hidden text-sm leading-5 font-medium text-color whitespace-nowrap overflow-ellipsis">
{{ client.displayName || client.username }}
</div>
<PrimeBadge v-if="client.outputMuted" severity="info" value="No sound" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" />
<PrimeBadge v-if="isMe" severity="secondary" value="You" />
<div class="flex align-center gap-1">
<PrimeBadge v-if="!!shareConsumer" v-tooltip.top="'Watch'" severity="success" value="Streaming" size="small" @click.stop="watchStream" />
<template v-if="!isMe">
<PrimeButton icon="pi pi-ellipsis-h" text size="small" severity="contrast" @click="menuRef?.toggle" />
<PrimeBadge v-if="premuted" severity="danger" value="Muted" size="small" />
<PrimeBadge v-else-if="client.outputMuted" severity="info" value="No sound" size="small" />
<PrimeBadge v-else-if="inputMuted" severity="info" value="Muted" size="small" />
<PrimeMenu ref="menu" popup :model="menuItems" style="translate: calc(-100% + 2rem) 0.5rem">
<template #start>
<div class="px-4 py-3">
<div class="flex justify-between">
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" class="mt-4" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
</div>
</template>
</PrimeMenu>
</template>
<PrimeBadge v-if="isMe" severity="secondary" value="You" size="small" class="shrink-0" />
</div>
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" />
</div>
<CollapseTransition v-if="!isMe">
<div v-if="expanded">
<div class="px-3 pb-3">
<div class="flex justify-between text-sm mb-3">
<span>Volume</span>
<span>{{ volume }}</span>
</div>
<PrimeSlider v-model="volume" :min="0" :max="1000" :step="volume < 200 ? 5 : 25" />
<div class="mt-3 flex gap-1 justify-end">
<PrimeButton size="small" variant="text" @click="premuted = !premuted">
{{ premuted ? 'Unmute' : 'Mute' }}
</PrimeButton>
</div>
</div>
</div>
</CollapseTransition>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '#shared/types'
import type { MenuItem } from 'primevue/menuitem'
import { useLocalStorage } from '@vueuse/core'
import { User } from 'lucide-vue-next'
import { ChevronDown, ChevronUp, User } from 'lucide-vue-next'
import CollapseTransition from '~/components/CollapseTransition.vue'
const props = defineProps<{
client: ChadClient
}>()
const { outputMuted } = useApp()
const { getClientConsumers, micProducer } = useMediasoup()
const { consumers: allConsumers, micProducer } = useMediasoup()
const { me } = useClients()
const { show } = useFullscreenVideo()
const expanded = ref(false)
const volume = useLocalStorage<number>(computed(() => `CLIENT_VOLUME_${props.client.userId}`), 100, { writeDefaults: false })
const menuRef = useTemplateRef<HTMLAudioElement>('menu')
const menuItems: MenuItem[] = [
// {
// label: 'Mute',
// icon: 'pi pi-headphones',
// },
// {
// label: 'DM',
// icon: 'pi pi-comment',
// disabled: true,
// },
]
const premuted = useLocalStorage<boolean>(computed(() => `CLIENT_PREMUTED_${props.client.userId}`), false, { writeDefaults: false })
const isMe = computed(() => {
return me.value && props.client.userId === me.value.userId
})
const audioConsumer = computed(() => {
if (isMe.value)
return undefined
const consumers = getClientConsumers(props.client.socketId)
return consumers.find(consumer => consumer.track.kind === 'audio')
const consumers = computed(() => {
return allConsumers.value.values().filter(consumer => consumer.appData.socketId === props.client.socketId).toArray()
})
const inputMuted = computed(() => {
if (isMe.value)
return micProducer.value?.paused ?? false
const audioConsumer = computed(() => {
return consumers.value.find(consumer => consumer.track.kind === 'audio')
})
const consumers = getClientConsumers(props.client.socketId)
const videoConsumers = computed(() => {
return consumers.value.filter(consumer => consumer.track.kind === 'video')
})
return consumers.find(consumer => consumer.track.kind === 'audio')?.paused
const shareConsumer = computed(() => {
return videoConsumers.value.find(consumer => consumer.appData.source === 'share')
})
const audioTrack = computed(() => {
return audioConsumer.value?.track
})
const audioConsumerPaused = ref(false)
const inputMuted = computed(() => {
if (isMe.value)
return micProducer.value?.paused ?? false
return premuted.value || audioConsumerPaused.value
})
watch(allConsumers, () => {
audioConsumerPaused.value = audioConsumer.value?.paused ?? false
})
const { setGain } = useAudioContext(audioTrack)
watch(volume, (volume) => {
setGain(outputMuted.value ? 0 : (volume * 0.01))
}, { immediate: true })
watch(outputMuted, (outputMuted) => {
setGain(outputMuted ? 0 : (volume.value * 0.01))
watchEffect(() => {
setGain((outputMuted.value || premuted.value) ? 0 : (volume.value * 0.01))
})
function toggleExpand() {
if (isMe.value)
return
expanded.value = !expanded.value
}
function watchStream() {
if (!shareConsumer.value)
return
show(new MediaStream([shareConsumer.value.track]))
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<Transition name="collapse-transition" v-on="bindings">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
defineOptions({
name: 'CollapseTransition',
})
const emit = defineEmits<{
expanded: []
collapsed: []
}>()
const bindings = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.elExistsHeight = el.style.height ?? undefined
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow
if (el.dataset.elExistsHeight) {
el.style.maxHeight = el.dataset.elExistsHeight
}
else if (el.scrollHeight !== 0) {
el.style.maxHeight = `${el.scrollHeight}px`
}
else {
el.style.maxHeight = 0
}
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
el.style.overflow = 'hidden'
})
},
afterEnter(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
emit('expanded')
},
enterCancelled(el: RendererElement) {
reset(el)
},
beforeLeave(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.oldOverflow = el.style.overflow
el.style.maxHeight = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
}
},
afterLeave(el: RendererElement) {
reset(el)
emit('collapsed')
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
function reset(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
}
</script>
<style lang="scss">
.collapse-transition {
transition-property: height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition-property: opacity, max-height, padding-top, padding-bottom;
transition-timing-function: var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--default-transition-duration, 150ms);
}
.collapse-transition-leave-to,
.collapse-transition-enter-from {
opacity: 0;
}
</style>