started working on voice chat
This commit is contained in:
@@ -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('')
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
src/components/VoiceControl.vue
Normal file
154
src/components/VoiceControl.vue
Normal 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>
|
||||
28
src/main.ts
28
src/main.ts
@@ -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'
|
||||
|
||||
238
src/store.ts
238
src/store.ts
@@ -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
196
src/voice.ts
Normal 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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user