started working on voice chat

This commit is contained in:
2026-01-25 17:47:40 +01:00
parent b0676d3834
commit f029a322c4
16 changed files with 913 additions and 156 deletions

View File

@@ -26,6 +26,8 @@
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
<VoiceControl />
<footer v-if="!$route.meta.hideNavbar">
<Navbar />
</footer>
@@ -40,6 +42,7 @@ import { apiFetch } from './api/client'
import { VersionResponse } from './types'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { platform } from '@tauri-apps/plugin-os';
import VoiceControl from './components/VoiceControl.vue'
const currentPlatform = ref('')

View File

@@ -2,24 +2,24 @@ import { apiFetch } from './client'
import type { Message } from '../types'
export function fetchMessages(roomUuid: string, before?: string, limit: number = 30) {
let url = `/messages/${roomUuid}?limit=${limit}`;
if (before) {
url += `&before=${before}`;
}
return apiFetch<Message[]>(url);
let url = `/messages/${roomUuid}?limit=${limit}`;
if (before) {
url += `&before=${before}`;
}
return apiFetch<Message[]>(url);
}
export function sendMessage(roomUuid: string, content: string) {
return apiFetch<Message>(`/messages/${roomUuid}`, {
method: 'POST',
body: JSON.stringify({
message_type: 'text',
content,
}),
})
return apiFetch<Message>(`/messages/${roomUuid}`, {
method: 'POST',
body: JSON.stringify({
message_type: 'text',
content,
}),
})
}
export async function getWsToken(): Promise<string> {
const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`);
return res.token;
const res = await apiFetch<{ token: string }>(`/ws/issue-token`);
return res.token;
}

View File

@@ -44,6 +44,11 @@
<i class="fa-solid fa-users"></i>
</button>
<button class="invite-btn" @click="toggleVoice" :class="{ 'active-voice': isCurrentRoomVoice }"
title="Join Voice Chat">
<i class="fa-solid" :class="isCurrentRoomVoice ? 'fa-phone-slash' : 'fa-phone'"></i>
</button>
<div v-if="connectionError" class="connection-error">
<p>{{ connectionError }}</p>
</div>
@@ -68,6 +73,7 @@ import { getAuthData } from "../store.ts";
import { fetchRoomInfo } from "../api/rooms.ts";
import { useFluent } from 'fluent-vue';
import { sendNotification } from '@tauri-apps/plugin-notification';
import { voiceActions, voiceState } from "../voice";
const { $t } = useFluent();
@@ -109,6 +115,10 @@ const handleRoomChanged = () => {
emit('room-action');
};
const isCurrentRoomVoice = computed(() => {
return voiceState.currentRoomUuid === props.uuid && voiceState.status === 'connected';
});
onMounted(async () => {
await connectGlobalWebSocket();
@@ -172,7 +182,7 @@ async function connectGlobalWebSocket() {
try {
// Get a one-time token for the connection
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
const res = await apiFetch<{ token: string }>('/ws/issue-token');
const wsToken = res.token;
const url = `${API_WS}/messages?token=${wsToken}`;
@@ -309,6 +319,14 @@ async function onSend(content: string) {
if (props.uuid === 'none') return;
await sendMessage(props.uuid, content);
}
async function toggleVoice() {
if (isCurrentRoomVoice.value) {
await voiceActions.leaveRoom();
} else {
await voiceActions.joinRoom(props.uuid);
}
}
</script>
<style scoped>
@@ -457,4 +475,8 @@ async function onSend(content: string) {
margin: 0;
font-size: 1rem;
}
.active-voice {
color: #f87171;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div v-if="voiceState.status !== 'disconnected'" class="voice-bar">
<div class="voice-info">
<div class="status-indicator" :class="voiceState.status"></div>
<div class="details">
<span class="room-label">Voice Connected</span>
<span class="speaking-label" v-if="speakersList.length > 0">
Speaking: {{ speakersList.length }}
</span>
<span class="speaking-label" v-else>Room Quiet</span>
</div>
</div>
<div class="voice-controls">
<button class="control-btn" :class="{ 'active': voiceState.isMuted }" @click="voiceActions.toggleMute()">
<i class="fa-solid" :class="voiceState.isMuted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
</button>
<button class="control-btn hangup" @click="voiceActions.leaveRoom()">
<i class="fa-solid fa-phone-slash"></i>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { voiceState, voiceActions } from '../voice'; // Ensure this path points to your voice.ts
const speakersList = computed(() => Array.from(voiceState.speakingUsers));
</script>
<style scoped>
.device-select {
background: transparent;
color: var(--text);
border: none;
font-size: 0.8rem;
max-width: 150px;
opacity: 0.8;
cursor: pointer;
}
.device-select:hover {
opacity: 1;
}
.voice-bar {
background-color: var(--panel-accent);
border-top: 1px solid var(--border);
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
height: 50px;
z-index: 90;
}
.voice-info {
display: flex;
align-items: center;
gap: 12px;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--muted);
}
.status-indicator.connected {
background-color: #4ade80;
/* Green */
box-shadow: 0 0 5px #4ade80;
}
.status-indicator.connecting {
background-color: #facc15;
/* Yellow */
animation: pulse 1s infinite;
}
.status-indicator.error {
background-color: #f87171;
}
.details {
display: flex;
flex-direction: column;
}
.room-label {
font-size: 0.85rem;
font-weight: bold;
}
.speaking-label {
font-size: 0.75rem;
color: var(--muted);
}
.voice-controls {
display: flex;
gap: 10px;
}
.control-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text);
transition: all 0.2s;
}
.control-btn:hover {
background: var(--panel-hover);
}
.control-btn.active {
background: var(--panel-hover);
color: #f87171;
}
.control-btn.hangup {
color: #f87171;
border-color: #f87171;
}
.control-btn.hangup:hover {
background: #f87171;
color: white;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -3,15 +3,13 @@ import router from './router.ts'
import App from './App.vue'
import { validateToken, initTheme } from './store.ts'
import { fluent, setLanguage } from './i18n'
// import {
// createChannel,
// Importance,
// Visibility,
// } from '@tauri-apps/plugin-notification';
import './base.css'
import { getLocalePreference } from './store.ts'
import { invoke } from '@tauri-apps/api/core'
export function logJS(msg: any) {
invoke('log', { message: typeof msg === 'string' ? msg : JSON.stringify(msg) }).catch(() => { });
}
async function init() {
await validateToken()
@@ -43,14 +41,22 @@ async function init() {
// sound: 'notification_sound',
// });
// window.addEventListener("error", (event) => {
// logJS(`Uncaught JS Error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`);
// });
//
// window.addEventListener("unhandledrejection", (event) => {
// logJS(`Unhandled Promise Rejection: ${JSON.stringify(event.reason)}`);
// });
app.mount('#app')
}
init()
export const API = 'http://127.0.0.1:8080'
// export const API = 'http://127.0.0.1:8080'
// export const API = 'http://192.168.1.183:8080'
// export const API = 'https://alatreon.org/frangipane'
export const API_WS = 'ws://127.0.0.1:8080/ws'
export const API = 'https://alatreon.org/frangipane'
// export const API_WS = 'ws://127.0.0.1:8080/ws'
// export const API_WS = 'ws://192.168.1.183:8080/ws'
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
export const API_WS = 'wss://alatreon.org/frangipane/ws'

View File

@@ -14,153 +14,153 @@ let store: Store | null = null
export const initAuth = getAuthData
async function getStore() {
if (!store) store = await load('store.json')
return store
if (!store) store = await load('store.json')
return store
}
export async function getAuthData() {
const s = await getStore()
const token = await s.get<string>('token')
const user = await s.get<User>('user')
return { token: token || null, user: user || null, isAuthenticated: !!token }
const s = await getStore()
const token = await s.get<string>('token')
const user = await s.get<User>('user')
return { token: token || null, user: user || null, isAuthenticated: !!token }
}
export async function saveAuthData(token: string, user: User) {
const s = await getStore()
await s.set('token', token)
await s.set('user', user)
await s.save()
const s = await getStore()
await s.set('token', token)
await s.set('user', user)
await s.save()
}
export async function clearAuthData() {
const s = await getStore()
s.clear()
await s.save()
const s = await getStore()
s.clear()
await s.save()
}
export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) {
const updatedUser = {
username: newData.username,
email: newData.email,
uuid,
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
}
const updatedUser = {
username: newData.username,
email: newData.email,
uuid,
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
}
const s = await getStore()
await s.set('user', updatedUser)
await s.save()
const s = await getStore()
await s.set('user', updatedUser)
await s.save()
}
export async function refreshLocalUser() {
const s = await getStore()
const user = await s.get<User>('user')
const s = await getStore()
const user = await s.get<User>('user')
if (user) {
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
if (user) {
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
await s.set('user', user)
await s.save()
}
await s.set('user', user)
await s.save()
}
}
export async function setLastRoom(uuid: string) {
if (!uuid || uuid === 'none') return
const s = await getStore()
await s.set('last_room_uuid', uuid)
await s.save()
if (!uuid || uuid === 'none') return
const s = await getStore()
await s.set('last_room_uuid', uuid)
await s.save()
}
export async function getLastRoom(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('last_room_uuid')) ?? null
const s = await getStore()
return (await s.get<string>('last_room_uuid')) ?? null
}
export async function saveLocalePreference(locale: string) {
const s = await getStore()
await s.set('language', locale)
await s.save()
const s = await getStore()
await s.set('language', locale)
await s.save()
}
export async function getLocalePreference(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('language')) ?? null
const s = await getStore()
return (await s.get<string>('language')) ?? null
}
export async function login(email: string, username: string, password: string) {
const res: LoginResponse = await apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, username, password }),
})
const res: LoginResponse = await apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, username, password }),
})
let user: User = {
uuid: res.uuid,
username: res.username,
email: res.email,
avatar_url: getAvatar(res.uuid)
};
await saveAuthData(res.token, user)
return { token: res.token, uuid: res.uuid, isAuthenticated: true }
let user: User = {
uuid: res.uuid,
username: res.username,
email: res.email,
avatar_url: getAvatar(res.uuid)
};
await saveAuthData(res.token, user)
return { token: res.token, uuid: res.uuid, isAuthenticated: true }
}
export async function logout() {
await clearAuthData()
return { token: null, uuid: null, isAuthenticated: false }
await clearAuthData()
return { token: null, uuid: null, isAuthenticated: false }
}
export async function validateToken(): Promise<boolean> {
const auth = await getAuthData()
if (!auth.token) return false
const auth = await getAuthData()
if (!auth.token) return false
try {
await apiFetch('/validate-token')
return true
} catch (e) {
return false
}
try {
await apiFetch('/validate-token')
return true
} catch (e) {
return false
}
}
export async function register(email: string, username: string, password: string) {
const response: LoginResponse = await apiFetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, username, password })
});
const response: LoginResponse = await apiFetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, username, password })
});
await login(email, username, password)
return response;
await login(email, username, password)
return response;
}
const requests = ref<FriendRequest[]>([])
const invites = ref<RoomInvite[]>([])
export function useNotifications() {
const totalCount = computed(() => requests.value.length + invites.value.length)
const totalCount = computed(() => requests.value.length + invites.value.length)
async function refreshNotifications() {
const auth = await getAuthData()
if (!auth.token) {
return
async function refreshNotifications() {
const auth = await getAuthData()
if (!auth.token) {
return
}
try {
const [fReqs, rInvs] = await Promise.all([
fetchFriendRequests(),
fetchRoomInvites()
])
requests.value = fReqs
invites.value = rInvs
} catch (err) {
console.error("Failed to fetch notifications", err)
}
}
try {
const [fReqs, rInvs] = await Promise.all([
fetchFriendRequests(),
fetchRoomInvites()
])
requests.value = fReqs
invites.value = rInvs
} catch (err) {
console.error("Failed to fetch notifications", err)
return {
requests,
invites,
totalCount,
refreshNotifications
}
}
return {
requests,
invites,
totalCount,
refreshNotifications
}
}
// A reactive object to store the last updated timestamp for each user
@@ -168,71 +168,71 @@ const avatarTimestamps = reactive<Record<string, number>>({})
export function getAvatar(uuid: string): string {
return `${API}/account/get-avatar/${uuid}`;
return `${API}/account/get-avatar/${uuid}`;
}
// Generates the avatar URL with a timestamp
export function getAvatarUrl(uuid: string | undefined | null) {
if (!uuid) return ''
if (!uuid) return ''
if (!avatarTimestamps[uuid]) {
avatarTimestamps[uuid] = Date.now()
}
if (!avatarTimestamps[uuid]) {
avatarTimestamps[uuid] = Date.now()
}
return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}`
return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}`
}
export function refreshAvatar(uuid: string) {
avatarTimestamps[uuid] = Date.now()
avatarTimestamps[uuid] = Date.now()
}
// ==== Color themes ====
export async function saveThemePreference(themeId: string) {
const s = await getStore();
await s.set('theme', themeId);
await s.save();
applyTheme(themeId);
const s = await getStore();
await s.set('theme', themeId);
await s.save();
applyTheme(themeId);
}
export async function initTheme() {
const s = await getStore();
const themeId = (await s.get<string>('theme')) || 'default';
applyTheme(themeId);
const s = await getStore();
const themeId = (await s.get<string>('theme')) || 'default';
applyTheme(themeId);
}
export async function getThemePreference(): Promise<string> {
const s = await getStore()
return (await s.get<string>('theme')) ?? 'default'
const s = await getStore()
return (await s.get<string>('theme')) ?? 'default'
}
export async function applyStoredTheme() {
const theme = await getThemePreference();
document.documentElement.setAttribute('data-theme', theme);
const theme = await getThemePreference();
document.documentElement.setAttribute('data-theme', theme);
}
// ==== Layout ====
export async function saveCompactLayoutPreference(enabled: boolean) {
const s = await getStore();
await s.set('compact_layout', enabled);
await s.save();
const s = await getStore();
await s.set('compact_layout', enabled);
await s.save();
}
export async function getCompactLayoutPreference(): Promise<boolean> {
const s = await getStore();
return (await s.get<boolean>('compact_layout')) ?? false;
const s = await getStore();
return (await s.get<boolean>('compact_layout')) ?? false;
}
// ==== Message draft ====
export async function saveMessageDraft(text: string) {
const s = await getStore()
await s.set('message_draft', text)
await s.save()
const s = await getStore()
await s.set('message_draft', text)
await s.save()
}
export async function getMessageDraft(): Promise<string> {
const s = await getStore()
return (await s.get<string>('message_draft')) ?? ''
const s = await getStore()
return (await s.get<string>('message_draft')) ?? ''
}

196
src/voice.ts Normal file
View File

@@ -0,0 +1,196 @@
import { reactive } from 'vue';
import { API_WS, logJS } from './main';
import { apiFetch } from './api/client';
import WebSocket from '@tauri-apps/plugin-websocket';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
interface VoiceState {
status: 'disconnected' | 'connecting' | 'connected';
currentRoomUuid: string | null;
isMuted: boolean;
speakingUsers: Set<string>;
captureSampleRate: number;
captureChannels: number;
}
export const voiceState = reactive<VoiceState>({
status: 'disconnected',
currentRoomUuid: null,
isMuted: false,
speakingUsers: new Set(),
captureSampleRate: 48000,
captureChannels: 1,
});
let socket: WebSocket | null = null;
let audioContext: AudioContext | null = null;
let unlistenMic: UnlistenFn | null = null;
let unlistenConfig: UnlistenFn | null = null;
let unlistenError: UnlistenFn | null = null;
let nextStartTime = 0;
async function ensureMicrophonePermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop it immediately so it releases the mic
stream.getTracks().forEach(track => track.stop());
return true;
} catch (err) {
console.error("Microphone permission denied:", err);
return true;
}
}
// Setup global error listener
export async function initVoiceListeners() {
if (unlistenError) return;
unlistenError = await listen('microphone-error', (event) => {
console.error("RUST AUDIO ERROR:", event.payload);
voiceActions.leaveRoom();
});
}
export const voiceActions = {
async joinRoom(roomUuid: string) {
if (voiceState.status === 'connected') return;
nextStartTime = 0;
voiceState.status = 'connecting';
voiceState.currentRoomUuid = roomUuid;
// Ensure listeners are active
await initVoiceListeners();
try {
const res = await apiFetch<{ token: string }>('/ws/issue-token');
const url = `${API_WS}/voice/${roomUuid}?token=${res.token}`;
socket = await WebSocket.connect(url);
socket.addListener((msg) => {
if (msg.type === 'Binary') {
handleIncomingAudio(msg.data);
} else if (msg.type === 'Close') {
voiceActions.leaveRoom();
}
});
await this.startAudioCapture();
voiceState.status = 'connected';
} catch (e) {
logJS(e);
console.error("Voice join failed", e);
voiceActions.leaveRoom();
}
},
async leaveRoom() {
nextStartTime = 0;
voiceState.status = 'disconnected';
voiceState.currentRoomUuid = null;
voiceState.speakingUsers.clear();
await this.stopAudioCapture();
if (socket) {
// WebSocket plugin disconnect is async
await socket.disconnect();
socket = null;
}
},
toggleMute() {
voiceState.isMuted = !voiceState.isMuted;
},
async startAudioCapture() {
const hasPermission = await ensureMicrophonePermission();
if (!hasPermission) {
throw new Error("Microphone permission denied");
}
// Give the OS a moment to release the mic resource from the WebView
await new Promise(r => setTimeout(r, 200));
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
unlistenConfig = await listen<{ sample_rate: number, channels: number }>('microphone-config', (event) => {
console.log("Mic Config Received:", event.payload);
voiceState.captureSampleRate = event.payload.sample_rate;
voiceState.captureChannels = event.payload.channels;
});
unlistenMic = await listen<number[]>('microphone-data', (event) => {
if (voiceState.isMuted || !socket) return;
socket.send(event.payload).catch(console.error);
});
try {
await invoke('start_microphone');
} catch (e) {
console.error("Failed to start mic:", e);
throw e;
}
},
async stopAudioCapture() {
try {
await invoke('stop_microphone');
} catch (e) { console.warn(e); }
if (unlistenMic) { unlistenMic(); unlistenMic = null; }
if (unlistenConfig) { unlistenConfig(); unlistenConfig = null; }
if (audioContext) {
await audioContext.close();
audioContext = null;
}
}
};
function handleIncomingAudio(data: number[]) {
if (!audioContext) return;
const arrayBuffer = new Uint8Array(data).buffer;
const audioDataOffset = 16; // Skip UUID
if (arrayBuffer.byteLength <= audioDataOffset) return;
const audioByteLength = arrayBuffer.byteLength - audioDataOffset;
const pcmData = new Int16Array(arrayBuffer, audioDataOffset, audioByteLength / 2);
const float32Data = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
float32Data[i] = pcmData[i] / 32768.0;
}
const buffer = audioContext.createBuffer(
1,
float32Data.length,
voiceState.captureSampleRate
);
buffer.getChannelData(0).set(float32Data);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
const now = audioContext.currentTime;
if (nextStartTime === 0) nextStartTime = now + 0.05;
// If drifted too far behind (lag > 0.5s), jump ahead
if (nextStartTime < now) {
nextStartTime = now;
}
if (nextStartTime > now + 5.0) {
nextStartTime = now;
}
source.start(nextStartTime);
nextStartTime += buffer.duration;
}