fix webrtc and login
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
baseurl: 'http://goesseau.eu:8080',
|
||||
baseurl: '',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user