fix webrtc and login

This commit is contained in:
2026-09-24 13:55:59 +02:00
parent 5bac3174df
commit 844eaadee0
28 changed files with 628 additions and 43 deletions
+1
View File
@@ -36,6 +36,7 @@
"eslint-config-vuetify": "^4.3.4",
"npm-run-all2": "^8.0.4",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.9.0",
"typescript": "~5.9.3",
"unplugin-fonts": "^1.4.0",
"vite": "^8.0.0",
+21
View File
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
import UserListDrawer from '@/components/UserListDrawer.vue'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {useContextMenu} from '@/composables/useContextMenu'
import {useVoiceStore} from '@/stores/voice'
const serverStore = useServerStore()
const voiceStore = useVoiceStore()
const route = useRoute()
const router = useRouter()
const {openContextMenu} = useContextMenu()
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
/>
<router-view/>
<div v-if="voiceStore.channelId" class="voice-controls pa-3 elevation-4 bg-surface">
<span class="text-success font-weight-bold"><v-icon icon="mdi-volume-high" /> Vocal connecté</span>
<v-btn size="small" prepend-icon="mdi-phone-hangup" @click="voiceStore.leave()">Quitter</v-btn>
</div>
<v-snackbar :model-value="!!voiceStore.error" color="error" @update:model-value="voiceStore.error = null">
{{ voiceStore.error }}
<template #actions><v-btn @click="voiceStore.error = null">Fermer</v-btn></template>
</v-snackbar>
<!-- Menu contextuel global -->
<ContextMenu/>
<v-dialog v-model="showDialog" width="400">
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
</template>
<style scoped>
.voice-controls {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 16px;
border-radius: 8px;
}
.space-y-4 {
display: flex;
flex-direction: column;
+1 -1
View File
@@ -60,7 +60,7 @@ async function handleRegister() {
throw new Error(errData.message || 'Échec de l\'inscription')
}
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : '/login')
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : {name: 'login'})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
} finally {
+60 -6
View File
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {onReloadAll} from '@/plugins/events.ts'
import {onGatewayEvent, onReloadAll} from '@/plugins/events.ts'
import {useVoiceStore} from '@/stores/voice'
const props = defineProps<{
serverId: string
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu()
const authStore = useAuthStore()
const voiceStore = useVoiceStore()
const voiceChannels = computed<Channel[]>(() => currentTree.value.flatMap(item =>
'Category' in item ? item.Category[1].filter((channel: Channel) => channel.channel_type === 'voice') :
'Channel' in item && item.Channel.channel_type === 'voice' ? [item.Channel] : [],
))
const showPermissionsDialog = ref(false)
const selectedChannel = ref<any | null>(null)
const channelToEdit = ref<Channel | null>(null)
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
const showServerSettings = ref(false)
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
function selectChannel(channel: Channel) {
if (channel.channel_type === 'voice') {
void voiceStore.join(props.serverId, channel.id)
if (route.params.channelId) void router.push(`/server/${props.serverId}`)
}
else void router.push(`/server/${props.serverId}/channel/${channel.id}`)
}
watch([() => props.channelId, voiceChannels], () => {
const channel = voiceChannels.value.find(item => item.id === props.channelId)
if (channel) selectChannel(channel)
})
const loadServerData = async (targetServerId: string) => {
if (!targetServerId) return
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
}
let stopReloadAll: (() => void) | null = null
let stopVoicePresence: (() => void) | null = null
onMounted(() => {
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
stopVoicePresence = onGatewayEvent('VoicePresence', ({action, content}) => {
const {server_id, channel_id, user} = content ?? {}
if (server_id !== props.serverId || !user?.id) return
const channel = voiceChannels.value.find(item => item.id === channel_id)
if (!channel) return
const participants = channel.voice_participants ?? []
if (action === 'joined' && !participants.some(participant => participant.id === user.id)) {
channel.voice_participants = [...participants, user].sort((a, b) => a.username.localeCompare(b.username))
} else if (action === 'left') {
channel.voice_participants = participants.filter(participant => participant.id !== user.id)
}
})
})
onUnmounted(() => {
stopReloadAll?.()
stopVoicePresence?.()
})
watch(
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
/>
</template>
<template v-for="channel in item.Category[1]" :key="channel.id">
<v-list-item
v-for="channel in item.Category[1]"
:key="channel.id"
:title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`"
:active="voiceStore.channelId === channel.id"
:class="{
'font-weight-bold': (channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(channel.id, 'before'),
'server-item-drop-after': isDropTarget(channel.id, 'after'),
}"
link
@click="selectChannel(channel)"
draggable="true"
@dragstart="startDragging($event, channel.id, 'channel')"
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, channel)"
>
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
<template #append>
<v-chip
v-if="(channel.unread_count ?? 0) > 0"
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</v-chip>
</template>
</v-list-item>
<div v-if="channel.channel_type === 'voice'" class="voice-participants">
<div v-for="participant in channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
</div>
</div>
</template>
</v-list-group>
<!-- Canal orphelin (racine) -->
<template v-else-if="'Channel' in item">
<v-list-item
v-else-if="'Channel' in item"
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
:active="voiceStore.channelId === item.Channel.id"
:class="{
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
}"
link
@click="selectChannel(item.Channel)"
draggable="true"
@dragstart="startDragging($event, item.Channel.id, 'channel')"
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, item.Channel)"
>
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
<template #append>
<v-chip
v-if="(item.Channel.unread_count ?? 0) > 0"
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</v-chip>
</template>
</v-list-item>
<div v-if="item.Channel.channel_type === 'voice'" class="voice-participants">
<div v-for="participant in item.Channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
</div>
</div>
</template>
</template>
</v-list>
</v-navigation-drawer>
<CreateChannelDialog
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</template>
<style scoped>
.voice-participants {
padding-left: 48px;
color: rgb(var(--v-theme-on-surface));
opacity: 0.75;
}
.server-item-drop-before {
border-top: 2px solid rgb(var(--v-theme-primary));
}
+1 -1
View File
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
baseurl: 'http://goesseau.eu:8080',
baseurl: '',
}),
});
+2
View File
@@ -1,5 +1,6 @@
import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi";
import {useVoiceStore} from '@/stores/voice'
export interface User {
id: string
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
},
async logout() {
useVoiceStore().leave()
const api = useApi()
try {
await api.post('/auth/logout')
+1
View File
@@ -5,6 +5,7 @@ export interface Channel {
id: string
name?: string
channel_type: string
voice_participants?: {id: string; username: string}[]
server_id?: string | null
category_id?: string | null
created_at: string
+125
View File
@@ -0,0 +1,125 @@
import {defineStore} from 'pinia'
export const useVoiceStore = defineStore('voice', {
state: () => ({
channelId: null as string | null,
serverId: null as string | null,
connecting: false,
error: null as string | null,
}),
actions: {
async join(serverId: string, channelId: string) {
if (this.channelId === channelId) return
this.leave()
this.connecting = true
this.error = null
const generation = ++voiceGeneration
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true})
if (generation !== voiceGeneration) {
stream.getTracks().forEach(track => track.stop())
return
}
microphone = stream
const connection = new RTCPeerConnection()
peer = connection
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(url)
socket = ws
this.channelId = channelId
this.serverId = serverId
const pendingCandidates: string[] = []
const tracks = new Map<string, MediaStreamTrack>()
connection.ontrack = ({track, streams}) => {
if (peer !== connection) return
const audio = new Audio()
audio.autoplay = true
audio.srcObject = new MediaStream([track])
audioElements.set(track, audio)
if (streams[0]) tracks.set(streams[0].id, track)
track.onended = () => removeAudio(track)
void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' })
}
connection.onicecandidate = ({candidate}) => {
if (candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
}
}
ws.onopen = async () => {
try {
const offer = await connection.createOffer()
await connection.setLocalDescription(offer)
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
}
} catch (error) {
if (socket === ws) { this.error = String(error); this.leave() }
}
}
let queue = Promise.resolve()
ws.onmessage = ({data}) => {
queue = queue.then(async () => {
if (socket !== ws) return
const message = JSON.parse(data)
if (message.action === 'answer') {
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
pendingCandidates.length = 0
} else if (message.action === 'sdp-offer') {
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
const answer = await connection.createAnswer()
await connection.setLocalDescription(answer)
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
} else if (message.action === 'ice-candidate') {
if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
else pendingCandidates.push(message.candidate)
} else if (message.action === 'source-left') {
const track = tracks.get(message.id)
if (track) removeAudio(track)
tracks.delete(message.id)
} else if (message.action === 'error') {
this.error = message.message
this.leave()
}
}).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } })
}
ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' }
ws.onclose = () => { if (socket === ws) this.leave() }
} catch (error) {
if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}`
} finally {
if (generation === voiceGeneration) this.connecting = false
}
},
leave() {
voiceGeneration++
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'}))
socket?.close()
socket = null
peer?.close()
peer = null
microphone?.getTracks().forEach(track => track.stop())
microphone = null
for (const track of audioElements.keys()) removeAudio(track)
this.channelId = null
this.serverId = null
this.connecting = false
},
},
})
let voiceGeneration = 0
let socket: WebSocket | null = null
let peer: RTCPeerConnection | null = null
let microphone: MediaStream | null = null
const audioElements = new Map<MediaStreamTrack, HTMLAudioElement>()
function removeAudio(track: MediaStreamTrack) {
const audio = audioElements.get(track)
if (!audio) return
audio.pause()
audio.srcObject = null
audioElements.delete(track)
}
+18 -2
View File
@@ -1,8 +1,21 @@
import {readFileSync} from 'node:fs'
import {resolve} from 'node:path'
import {fileURLToPath, URL} from 'node:url'
import Vue from '@vitejs/plugin-vue'
import Fonts from 'unplugin-fonts/vite'
import {defineConfig} from 'vite'
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
import {parse} from 'smol-toml'
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
const config = parse(readFileSync(resolve(projectRoot, 'config.toml'), 'utf8'))
const network = config.network as {tcp_port: number, tls?: {cert_path: string, key_path: string}}
const tls = network.tls
const https = tls ? {
cert: readFileSync(resolve(projectRoot, tls.cert_path)),
key: readFileSync(resolve(projectRoot, tls.key_path)),
} : undefined
const target = `${tls ? 'https' : 'http'}://localhost:${network.tcp_port}`
// https://vitejs.dev/config/
export default defineConfig({
@@ -47,15 +60,18 @@ export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
https,
allowedHosts: ["goesseau.eu"],
proxy: {
'/api': {
target: 'http://goesseau.eu:8080',
target,
changeOrigin: true,
secure: !tls,
},
'/ws': {
target: 'ws://goesseau.eu:8080',
target,
ws: true,
secure: !tls,
},
},
},
+5
View File
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
smol-toml@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817"
integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"