Initial commit: mumh5, a modern Mumble client

Electron desktop app with a Svelte 5 interface for any Mumble server.

- Mumble protocol core: TLS, handshake, channels, users, text, plugin data,
  client-side pacing of Murmur's rate limits
- Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk,
  always-on, devices, per-user volume and local mute
- Several servers at once, voice on one; server rail with icons and ordering
- Chat: channels, direct messages, side chat, file sharing through f0ckm,
  inline images without it, click-to-play YouTube
- Profiles with rich descriptions, registration, rename, nicknames,
  connection information and moderation menus
- Identity wizard, multiple identities, PKCS#12 import/export, desktop
  Mumble certificate import, certificate pinning and viewer
- Tray icon with voice state
- Unit, server and end-to-end tests against a real Murmur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:28:43 +02:00
co-authored by Claude Opus 5.5
commit 6d29ee1485
85 changed files with 22997 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import ServerRail from './ui/ServerRail.svelte';
import Sidebar from './ui/Sidebar.svelte';
import Chat from './ui/Chat.svelte';
import RightPanel from './ui/RightPanel.svelte';
import ConnectDialog from './ui/ConnectDialog.svelte';
import SettingsDialog from './ui/SettingsDialog.svelte';
import CertDialog from './ui/CertDialog.svelte';
import CertViewer from './ui/CertViewer.svelte';
import PromptDialog from './ui/PromptDialog.svelte';
import ContextMenu from './ui/ContextMenu.svelte';
import IdentityWizard from './ui/IdentityWizard.svelte';
import QuickAudio from './ui/QuickAudio.svelte';
import UserInfoDialog from './ui/UserInfoDialog.svelte';
import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts';
import { ui } from './lib/ui.svelte.ts';
import type { SavedServer } from './lib/settings.svelte.ts';
let width = $state(window.innerWidth);
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
let navOpen = $state(false);
let editing = $state<{ server: SavedServer | null } | null>(null);
const showPanel = $derived(ui.panelOpen && session.status === 'connected');
// The panel starts open on wide layouts only
let lastLayout = '';
$effect(() => {
if (layout === lastLayout) return;
ui.panelOpen = layout === 'wide';
lastLayout = layout;
});
// On phones one drawer at a time
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
function togglePanel() {
if (!ui.panelOpen) ui.panelOpen = true;
else if (session.panel.kind !== 'members') session.closePanel();
else ui.panelOpen = false;
}
const closeOverlays = () => {
navOpen = false;
if (layout !== 'wide') ui.panelOpen = false;
};
</script>
<svelte:window bind:innerWidth={width} />
<div class="app {layout}">
{#if layout !== 'narrow' || navOpen}
<div class="nav" class:drawer={layout === 'narrow'}>
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })}
onsettings={() => (ui.settingsOpen = true)} />
<Sidebar onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
</div>
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
{/if}
<Chat showMenu={layout === 'narrow'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{#if showPanel}
{#if layout === 'wide'}
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
{:else}
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
<div class="panel-drawer"><RightPanel onnavigate={closeOverlays} onclose={() => (ui.panelOpen = false)} /></div>
{/if}
{/if}
</div>
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
{#if ui.quickAudio}<QuickAudio />{/if}
{#if ui.userInfo != null && session.status === 'connected'}
{#key ui.userInfo}<UserInfoDialog target={ui.userInfo} onclose={() => (ui.userInfo = null)} />{/key}
{/if}
{#if session.certPrompt}<CertDialog />{/if}
{#if ui.cert}<CertViewer {...ui.cert} onclose={() => (ui.cert = null)} />{/if}
{#if identities.loaded && !identities.setupDone}<IdentityWizard mode="setup" onclose={() => {}} />
{:else if ui.wizard}<IdentityWizard mode={ui.wizard} onclose={() => (ui.wizard = null)} />{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
<ContextMenu />
<style>
.app { position: relative; height: 100%; display: flex; overflow: hidden; }
.nav { display: flex; flex: none; min-height: 0; }
.nav.drawer { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; max-width: calc(100% - 48px); box-shadow: 8px 0 32px rgb(0 0 0 / 0.4); }
.nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); }
.panel-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; max-width: calc(100% - 48px); display: flex; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
</style>
+81
View File
@@ -0,0 +1,81 @@
:root {
--bg-0: #0d0e10;
--bg-1: #151619;
--bg-2: #1b1c20;
--bg-3: #24262b;
--bg-hover: #2a2c32;
--bg-active: #33363d;
--line: #2c2e34;
--text: #e3e4e8;
--text-dim: #9a9ca5;
--text-faint: #686a73;
--accent: #7c6cff;
--accent-hover: #8f81ff;
--accent-text: #ffffff;
--speaking: #3bd16f;
--danger: #f0504a;
--warn: #e8b23c;
--link: #6fb3ff;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif;
--mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
--fs: 15px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; border-radius: 0 !important; }
html, body, #app { height: 100%; margin: 0; }
body {
background: var(--bg-2);
color: var(--text);
font: var(--fs)/1.45 var(--font);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
button:disabled { cursor: default; opacity: 0.5; }
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg-0);
border: 1px solid var(--line);
padding: 8px 10px;
outline: none;
width: 100%;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 8px 16px; font-weight: 600; background: var(--bg-3); border: 1px solid var(--line);
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; color: var(--text-dim);
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.on { color: var(--danger); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); }
::-webkit-scrollbar-track { background: transparent; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+482
View File
@@ -0,0 +1,482 @@
import { Emitter } from './emitter.ts';
import { FrameReader, frame, type Codec, type MessageName } from './proto.ts';
import type { Transport } from './transport.ts';
export const CLIENT_RELEASE = 'mumh5 0.1.0';
const VERSION = { major: 1, minor: 5, patch: 0 };
const PING_INTERVAL_MS = 15000;
// Murmur drops these messages without telling the client when they come too fast
// (leaky bucket, defaults messageburst=5 and messagelimit=1/s). Mirroring the bucket and
// queueing keeps quick actions from getting lost. Slightly slower refill for safety.
const RATE_BURST = 5;
const RATE_REFILL_MS = 1100;
const RATE_LIMITED = new Set<MessageName>(['TextMessage', 'ChannelState', 'ACL', 'Version']);
export interface Channel {
id: number;
parent: number | null;
name: string;
description: string;
descriptionHash: Uint8Array | null;
position: number;
temporary: boolean;
links: Set<number>;
maxUsers: number;
canEnter: boolean;
}
export interface User {
session: number;
name: string;
userId: number | null;
channelId: number;
mute: boolean;
deaf: boolean;
suppress: boolean;
selfMute: boolean;
selfDeaf: boolean;
prioritySpeaker: boolean;
recording: boolean;
comment: string;
commentHash: Uint8Array | null;
texture: Uint8Array | null;
textureHash: Uint8Array | null;
hash: string;
}
export interface TextMessage {
actor: number | null;
sessions: number[];
channels: number[];
trees: number[];
html: string;
time: number;
}
export interface ServerConfig {
allowHtml: boolean;
messageLength: number;
imageMessageLength: number;
maxUsers: number;
recordingAllowed: boolean;
}
export interface Denial {
type: number;
permission: number;
reason: string;
channelId: number | null;
session: number | null;
name: string;
}
// ACL permission bits, as used in PermissionDenied.permission
export const PERMISSIONS: Record<number, string> = {
0x1: 'Write ACL', 0x2: 'Traverse', 0x4: 'Enter', 0x8: 'Speak', 0x10: 'Mute/Deafen', 0x20: 'Move',
0x40: 'Make channel', 0x80: 'Link channel', 0x100: 'Whisper', 0x200: 'Text message',
0x400: 'Make temporary channel', 0x800: 'Listen', 0x10000: 'Kick', 0x20000: 'Ban',
0x40000: 'Register', 0x80000: 'Register self', 0x100000: 'Reset user content'
};
// Human-readable text for a PermissionDenied message
export function describeDenial(d: Denial, channelName?: string): string {
switch (d.type) {
case 0: return d.reason || 'Denied by the server';
case 1: {
const what = PERMISSIONS[d.permission] ?? 'this action';
return `You do not have permission for ${what}${channelName ? ` in ${channelName}` : ''}`;
}
case 2: return 'The SuperUser cannot be modified';
case 3: return 'Invalid channel name';
case 4: return 'Message too long for this server';
case 6: return 'Not allowed in a temporary channel';
case 7: return 'This needs a registered certificate';
case 8: return `Invalid user name${d.name ? `: ${d.name}` : ''}`;
case 9: return 'The channel is full';
case 10: return 'Channels are nested too deeply';
case 11: return 'The server has reached its channel limit';
default: return d.reason || 'Denied by the server';
}
}
export interface ConnectOptions {
username: string;
password?: string;
tokens?: string[];
os?: string;
osVersion?: string;
}
type ClientEvents = {
synced: () => void;
channel: (ch: Channel) => void;
channelRemove: (id: number) => void;
user: (user: User, changed: string[], actor: number | null, isNew: boolean) => void;
userRemove: (user: User, actor: number | null, reason: string, ban: boolean) => void;
text: (msg: TextMessage) => void;
pluginData: (sender: number, dataId: string, data: Uint8Array) => void;
permissionDenied: (msg: Denial) => void;
reject: (type: number, reason: string) => void;
voice: (packet: Uint8Array) => void;
userStats: (stats: any) => void;
ping: (rttMs: number) => void;
close: (reason: string) => void;
message: (name: MessageName, msg: any) => void;
};
export class MumbleClient extends Emitter<ClientEvents> {
readonly channels = new Map<number, Channel>();
readonly users = new Map<number, User>();
session: number | null = null;
synced = false;
welcomeText = '';
maxBandwidth = 0;
serverVersion = '';
// Numeric server version (major << 16 | minor << 8 | patch), 0 until known
serverVersionNum = 0;
rtt = 0;
config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true };
private transport: Transport | null = null;
private reader = new FrameReader();
private pingTimer: ReturnType<typeof setInterval> | null = null;
private closed = false;
private tokens = RATE_BURST;
private lastRefill = Date.now();
private queue: Uint8Array[] = [];
private queueTimer: ReturnType<typeof setTimeout> | null = null;
private codec: Codec;
constructor(codec: Codec) {
super();
this.codec = codec;
}
get self(): User | null {
return this.session == null ? null : this.users.get(this.session) ?? null;
}
connect(transport: Transport, opts: ConnectOptions): void {
this.transport = transport;
transport.onData = chunk => {
try {
this.reader.push(chunk, (id, body) => this.handleFrame(id, body));
} catch (e) {
this.disconnect(`Protocol error: ${(e as Error).message}`);
}
};
transport.onClose = reason => this.handleClose(reason);
const { major, minor, patch } = VERSION;
this.send('Version', {
version_v1: (major << 16) | (minor << 8) | patch,
version_v2: major * 2 ** 48 + minor * 2 ** 32 + patch * 2 ** 16,
release: CLIENT_RELEASE,
os: opts.os ?? 'unknown',
os_version: opts.osVersion ?? ''
});
this.send('Authenticate', {
username: opts.username,
password: opts.password ?? '',
tokens: opts.tokens ?? [],
opus: true,
client_type: 0
});
this.pingTimer = setInterval(() => this.ping(), PING_INTERVAL_MS);
}
disconnect(reason = 'Disconnected'): void {
this.transport?.close();
this.handleClose(reason);
}
send(name: MessageName, payload: Record<string, unknown>): void {
if (!this.transport || this.closed) return;
const bytes = this.codec.encode(name, payload);
// Only changes to our own user state count against the bucket
const ownState = name === 'UserState' && (payload.session == null || payload.session === this.session);
if (RATE_LIMITED.has(name) || ownState) this.sendPaced(bytes);
else this.transport.send(bytes);
}
// Messages still waiting for the rate limit
get queued(): number {
return this.queue.length;
}
private refill(): void {
const now = Date.now();
const gained = Math.floor((now - this.lastRefill) / RATE_REFILL_MS);
if (gained > 0) {
this.tokens = Math.min(RATE_BURST, this.tokens + gained);
this.lastRefill += gained * RATE_REFILL_MS;
}
if (this.tokens === RATE_BURST) this.lastRefill = now;
}
private sendPaced(bytes: Uint8Array): void {
this.queue.push(bytes);
this.drain();
}
private drain(): void {
if (this.queueTimer || this.closed) return;
this.refill();
while (this.queue.length && this.tokens > 0) {
this.tokens--;
this.transport?.send(this.queue.shift()!);
}
if (this.queue.length) {
this.queueTimer = setTimeout(() => { this.queueTimer = null; this.drain(); }, RATE_REFILL_MS - (Date.now() - this.lastRefill) + 5);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────
sendText(target: { channels?: number[]; users?: number[]; trees?: number[] }, html: string): void {
this.send('TextMessage', {
session: target.users ?? [],
channel_id: target.channels ?? [],
tree_id: target.trees ?? [],
message: html
});
}
joinChannel(channelId: number): void {
if (this.session == null) return;
this.send('UserState', { session: this.session, channel_id: channelId });
}
setSelfMute(mute: boolean): void {
// Unmuting also undeafens, matching the desktop client
this.send('UserState', mute ? { self_mute: true } : { self_mute: false, self_deaf: false });
}
setSelfDeaf(deaf: boolean): void {
// Deafening implies muting
this.send('UserState', deaf ? { self_mute: true, self_deaf: true } : { self_deaf: false });
}
setComment(comment: string): void {
if (this.session == null) return;
this.send('UserState', { session: this.session, comment });
}
kick(session: number, reason: string, ban = false): void {
this.send('UserRemove', { session, reason, ban });
}
// Server-side (admin) state of another user
setUserState(session: number, state: { mute?: boolean; deaf?: boolean; priority_speaker?: boolean; channel_id?: number }): void {
this.send('UserState', { session, ...state });
}
// Connection details of a user; the server decides how much we may see
requestUserStats(session: number, statsOnly = false): void {
this.send('UserStats', { session, stats_only: statsOnly });
}
// Fetch comments/descriptions/textures that the server only announced by hash.
requestBlob(req: { textures?: number[]; comments?: number[]; descriptions?: number[] }): void {
this.send('RequestBlob', {
session_texture: req.textures ?? [],
session_comment: req.comments ?? [],
channel_description: req.descriptions ?? []
});
}
sendPluginData(receivers: number[], dataId: string, data: Uint8Array): void {
if (this.session == null || !receivers.length) return;
this.send('PluginDataTransmission', {
senderSession: this.session,
receiverSessions: receivers,
data,
dataID: dataId
});
}
// Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message
sendVoiceTunnel(packet: Uint8Array): void {
if (!this.transport || this.closed) return;
this.transport.send(frame(1, packet));
}
// 1.5+ servers talk to 1.5+ clients in the protobuf voice format
get protobufVoice(): boolean {
return this.serverVersionNum >= 0x010500;
}
// Registers a user (ourselves or, with the Register permission, someone else) on the server
register(session: number): void {
this.send('UserState', { session, user_id: 0 });
}
// ─── Incoming ──────────────────────────────────────────────────────────────
private ping(): void {
this.send('Ping', { timestamp: Date.now() });
}
private handleClose(reason: string): void {
if (this.closed) return;
this.closed = true;
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.queueTimer) clearTimeout(this.queueTimer);
this.pingTimer = null;
this.queueTimer = null;
this.queue = [];
this.emit('close', reason);
}
private handleFrame(typeId: number, body: Uint8Array): void {
// UDPTunnel carries a raw voice packet, not a protobuf message
if (typeId === 1) {
this.emit('voice', body.slice());
return;
}
const decoded = this.codec.decode(typeId, body);
if (!decoded) return;
const { name, msg } = decoded;
switch (name) {
case 'Version':
this.serverVersion = msg.release ?? '';
if (msg.version_v2) {
const v = Number(msg.version_v2);
this.serverVersionNum = (Math.floor(v / 2 ** 48) << 16) | ((Math.floor(v / 2 ** 32) & 0xffff) << 8) | (Math.floor(v / 2 ** 16) & 0xffff);
} else if (msg.version_v1) {
this.serverVersionNum = msg.version_v1;
}
break;
case 'Ping':
if (msg.timestamp) {
this.rtt = Date.now() - Number(msg.timestamp);
this.emit('ping', this.rtt);
}
break;
case 'Reject':
this.emit('reject', msg.type ?? 0, msg.reason ?? 'Rejected');
this.disconnect(msg.reason || 'Connection rejected');
break;
case 'ServerSync':
this.session = msg.session;
this.maxBandwidth = msg.max_bandwidth ?? 0;
this.welcomeText = msg.welcome_text ?? '';
this.synced = true;
this.ping();
this.emit('synced');
break;
case 'ServerConfig':
if (msg.allow_html != null) this.config.allowHtml = msg.allow_html;
if (msg.message_length != null) this.config.messageLength = msg.message_length;
if (msg.image_message_length != null) this.config.imageMessageLength = msg.image_message_length;
if (msg.max_users != null) this.config.maxUsers = msg.max_users;
if (msg.recording_allowed != null) this.config.recordingAllowed = msg.recording_allowed;
break;
case 'ChannelState':
this.applyChannelState(msg);
break;
case 'ChannelRemove':
this.channels.delete(msg.channel_id);
this.emit('channelRemove', msg.channel_id);
break;
case 'UserState':
this.applyUserState(msg);
break;
case 'UserRemove': {
const user = this.users.get(msg.session);
if (user) {
this.users.delete(msg.session);
this.emit('userRemove', user, msg.actor ?? null, msg.reason ?? '', !!msg.ban);
}
break;
}
case 'TextMessage':
this.emit('text', {
actor: msg.actor ?? null,
sessions: msg.session ?? [],
channels: msg.channel_id ?? [],
trees: msg.tree_id ?? [],
html: msg.message ?? '',
time: Date.now()
});
break;
case 'PermissionDenied':
this.emit('permissionDenied', {
type: msg.type ?? 0,
permission: msg.permission ?? 0,
reason: msg.reason ?? '',
channelId: msg.channel_id ?? null,
session: msg.session ?? null,
name: msg.name ?? ''
});
break;
case 'UserStats':
this.emit('userStats', msg);
break;
case 'PluginDataTransmission':
if (msg.senderSession != null && msg.dataID) {
this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0));
}
break;
}
this.emit('message', name, msg);
}
private applyChannelState(msg: any): void {
const id: number = msg.channel_id;
let ch = this.channels.get(id);
if (!ch) {
ch = {
id, parent: null, name: '', description: '', descriptionHash: null, position: 0,
temporary: false, links: new Set(), maxUsers: 0, canEnter: true
};
this.channels.set(id, ch);
}
if (msg.parent != null) ch.parent = msg.parent;
if (msg.name != null) ch.name = msg.name;
if (msg.description_hash != null && msg.description == null) ch.description = '';
if (msg.description != null) ch.description = msg.description;
if (msg.description_hash != null) ch.descriptionHash = msg.description_hash;
if (msg.position != null) ch.position = msg.position;
if (msg.temporary != null) ch.temporary = msg.temporary;
if (msg.max_users != null) ch.maxUsers = msg.max_users;
if (msg.can_enter != null) ch.canEnter = msg.can_enter;
if (msg.links?.length) ch.links = new Set(msg.links);
for (const l of msg.links_add ?? []) ch.links.add(l);
for (const l of msg.links_remove ?? []) ch.links.delete(l);
this.emit('channel', ch);
}
private applyUserState(msg: any): void {
const session: number = msg.session ?? this.session;
let user = this.users.get(session);
const isNew = !user;
if (!user) {
user = {
session, name: '', userId: null, channelId: 0, mute: false, deaf: false, suppress: false,
selfMute: false, selfDeaf: false, prioritySpeaker: false, recording: false,
comment: '', commentHash: null, texture: null, textureHash: null, hash: ''
};
this.users.set(session, user);
}
const fields: [string, keyof User][] = [
['name', 'name'], ['user_id', 'userId'], ['channel_id', 'channelId'], ['mute', 'mute'],
['deaf', 'deaf'], ['suppress', 'suppress'], ['self_mute', 'selfMute'], ['self_deaf', 'selfDeaf'],
['priority_speaker', 'prioritySpeaker'], ['recording', 'recording'], ['comment', 'comment'],
['comment_hash', 'commentHash'], ['texture', 'texture'], ['texture_hash', 'textureHash'], ['hash', 'hash']
];
// A new hash without text means the text changed and must be fetched (RequestBlob)
if (msg.comment_hash != null && msg.comment == null) user.comment = '';
const changed: string[] = [];
for (const [src, dst] of fields) {
if (msg[src] != null) {
(user as any)[dst] = msg[src];
changed.push(dst);
}
}
this.emit('user', user, changed, msg.actor ?? null, isNew);
}
}
+15
View File
@@ -0,0 +1,15 @@
// Minimal typed event emitter that works in both browser and Node.
export class Emitter<Events extends Record<string, (...args: any[]) => void>> {
private handlers = new Map<keyof Events, Set<Function>>();
on<K extends keyof Events>(event: K, fn: Events[K]): () => void {
let set = this.handlers.get(event);
if (!set) this.handlers.set(event, (set = new Set()));
set.add(fn);
return () => set!.delete(fn);
}
emit<K extends keyof Events>(event: K, ...args: Parameters<Events[K]>): void {
for (const fn of this.handlers.get(event) ?? []) fn(...args);
}
}
+2
View File
@@ -0,0 +1,2 @@
// Types for the generated static module; the codec in proto.ts adds the real shape.
export const MumbleProto: Record<string, unknown>;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export const MumbleUDP: Record<string, any>;
+639
View File
@@ -0,0 +1,639 @@
/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-mixed-operators, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars, default-case, jsdoc/require-param*/
import $protobuf from "protobufjs/minimal.js";
const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
const $Object = $util.global.Object, $undefined = $util.global.undefined, $Error = $util.global.Error, $RangeError = $util.global.RangeError, $Array = $util.global.Array, $TypeError = $util.global.TypeError, $Number = $util.global.Number, $parseInt = $util.global.parseInt, $Boolean = $util.global.Boolean, $String = $util.global.String, $BigInt = $util.global.BigInt, $isFinite = $util.global.isFinite;
const $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
export const MumbleUDP = $root.MumbleUDP = (() => {
const MumbleUDP = {};
MumbleUDP.Audio = (function() {
const Audio = function (properties) {
this.positional_data = [];
if (properties)
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
if (properties[keys[i]] != null && keys[i] !== "__proto__")
this[keys[i]] = properties[keys[i]];
};
Audio.prototype.target = null;
Audio.prototype.context = null;
Audio.prototype.sender_session = 0;
Audio.prototype.frame_number = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Audio.prototype.opus_data = $util.newBuffer([]);
Audio.prototype.positional_data = $util.emptyArray;
Audio.prototype.volume_adjustment = 0;
Audio.prototype.is_terminator = false;
let $oneOfFields;
$Object.defineProperty(Audio.prototype, "Header", {
get: $util.oneOfGetter($oneOfFields = ["target", "context"]),
set: $util.oneOfSetter($oneOfFields)
});
Audio.create = function(properties) {
return new Audio(properties);
};
Audio.encode = function (message, writer, _depth) {
if (!writer)
writer = $Writer.create();
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
if (message.target != null && $Object.hasOwnProperty.call(message, "target"))
writer.uint32(8).uint32(message.target);
if (message.context != null && $Object.hasOwnProperty.call(message, "context"))
writer.uint32(16).uint32(message.context);
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session") && message.sender_session !== 0)
writer.uint32(24).uint32(message.sender_session);
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number") && (typeof message.frame_number === "object" ? message.frame_number.low || message.frame_number.high : message.frame_number !== 0))
writer.uint32(32).uint64(message.frame_number);
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data") && message.opus_data.length)
writer.uint32(42).bytes(message.opus_data);
if (message.positional_data != null && message.positional_data.length)
writer.uint32(50).floats(message.positional_data);
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment") && !$Object.is(message.volume_adjustment, 0))
writer.uint32(61).float(message.volume_adjustment);
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator") && message.is_terminator !== false)
writer.uint32(128).bool(message.is_terminator);
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
for (let i = 0; i < message.$unknowns.length; ++i)
writer.raw(message.$unknowns[i]);
return writer;
};
Audio.decode = function (reader, length, _end, _depth, _target) {
if (!(reader instanceof $Reader))
reader = $Reader.create(reader);
if (_depth === $undefined)
_depth = 0;
if (_depth > $Reader.recursionLimit)
throw $Error("max depth exceeded");
let end, message, value;
if (length === $undefined)
end = reader.len;
else {
end = reader.pos + length;
if (end > reader.len)
throw $RangeError("index out of range");
length = reader.len;
reader.len = end;
}
message = _target || new $root.MumbleUDP.Audio();
while (reader.pos < end) {
let start = reader.pos;
let tag = reader.tag();
if (tag === _end) {
_end = $undefined;
break;
}
let wireType = tag & 7;
switch (tag >>>= 3) {
case 1: {
if (wireType !== 0)
break;
message.target = reader.uint32();
message.Header = "target";
continue;
}
case 2: {
if (wireType !== 0)
break;
message.context = reader.uint32();
message.Header = "context";
continue;
}
case 3: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.sender_session = value;
else
delete message.sender_session;
continue;
}
case 4: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.frame_number = value;
else
delete message.frame_number;
continue;
}
case 5: {
if (wireType !== 2)
break;
if ((value = reader.bytes()).length)
message.opus_data = value;
else
delete message.opus_data;
continue;
}
case 6: {
if (wireType === 2) {
if (!(message.positional_data && message.positional_data.length))
message.positional_data = [];
reader.floats(message.positional_data);
continue;
}
if (wireType !== 5)
break;
if (!(message.positional_data && message.positional_data.length))
message.positional_data = [];
message.positional_data.push(reader.float());
continue;
}
case 7: {
if (wireType !== 5)
break;
if (!$Object.is(value = reader.float(), 0))
message.volume_adjustment = value;
else
delete message.volume_adjustment;
continue;
}
case 16: {
if (wireType !== 0)
break;
if (value = reader.bool())
message.is_terminator = value;
else
delete message.is_terminator;
continue;
}
}
reader.skipType(wireType, _depth, tag);
if (!reader.discardUnknown) {
$util.makeProp(message, "$unknowns", false);
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
}
}
if (length !== $undefined) {
if (reader.pos !== end)
throw $RangeError("index out of range");
reader.len = length;
}
if (_end !== $undefined)
throw $Error("missing end group");
return message;
};
Audio.verify = function (message, _depth) {
if (typeof message !== "object" || message === null)
return "object expected";
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
return "max depth exceeded";
let properties = {};
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
properties.Header = 1;
if (!$util.isInteger(message.target))
return "target: integer expected";
}
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
if (properties.Header === 1)
return "Header: multiple values";
properties.Header = 1;
if (!$util.isInteger(message.context))
return "context: integer expected";
}
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
if (!$util.isInteger(message.sender_session))
return "sender_session: integer expected";
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
if (!$util.isInteger(message.frame_number) && !(message.frame_number && $util.isInteger(message.frame_number.low) && $util.isInteger(message.frame_number.high)))
return "frame_number: integer|Long expected";
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
if (!(message.opus_data && typeof message.opus_data.length === "number" || $util.isString(message.opus_data)))
return "opus_data: buffer expected";
if (message.positional_data != null && $Object.hasOwnProperty.call(message, "positional_data")) {
if (!$Array.isArray(message.positional_data))
return "positional_data: array expected";
for (let i = 0; i < message.positional_data.length; ++i)
if (typeof message.positional_data[i] !== "number")
return "positional_data: number[] expected";
}
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
if (typeof message.volume_adjustment !== "number")
return "volume_adjustment: number expected";
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
if (typeof message.is_terminator !== "boolean")
return "is_terminator: boolean expected";
return null;
};
Audio.fromObject = function (object, _depth) {
if (object instanceof $root.MumbleUDP.Audio)
return object;
if (!$util.isObject(object))
throw $TypeError(".MumbleUDP.Audio: object expected");
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let message = new $root.MumbleUDP.Audio();
if (object.target != null)
message.target = object.target >>> 0;
if (object.context != null)
message.context = object.context >>> 0;
if (object.sender_session != null)
if ($Number(object.sender_session) !== 0)
message.sender_session = object.sender_session >>> 0;
if (object.frame_number != null)
if (typeof object.frame_number === "object" ? object.frame_number.low || object.frame_number.high : $Number(object.frame_number) !== 0)
if ($util.Long)
message.frame_number = $util.Long.fromValue(object.frame_number, true);
else if (typeof object.frame_number === "string")
message.frame_number = $parseInt(object.frame_number, 10);
else if (typeof object.frame_number === "number")
message.frame_number = object.frame_number;
else if (typeof object.frame_number === "object")
message.frame_number = new $util.LongBits(object.frame_number.low >>> 0, object.frame_number.high >>> 0).toNumber(true);
if (object.opus_data != null)
if (object.opus_data.length)
if (typeof object.opus_data === "string")
$util.base64.decode(object.opus_data, message.opus_data = $util.newBuffer($util.base64.length(object.opus_data)), 0);
else if (object.opus_data.length >= 0)
message.opus_data = object.opus_data;
if (object.positional_data) {
if (!$Array.isArray(object.positional_data))
throw $TypeError(".MumbleUDP.Audio.positional_data: array expected");
message.positional_data = $Array(object.positional_data.length);
for (let i = 0; i < object.positional_data.length; ++i)
message.positional_data[i] = $Number(object.positional_data[i]);
}
if (object.volume_adjustment != null)
if (!$Object.is($Number(object.volume_adjustment), 0))
message.volume_adjustment = $Number(object.volume_adjustment);
if (object.is_terminator != null)
if (object.is_terminator)
message.is_terminator = $Boolean(object.is_terminator);
return message;
};
Audio.toObject = function (message, options, _depth) {
if (!options)
options = {};
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let object = {};
if (options.arrays || options.defaults)
object.positional_data = [];
if (options.defaults) {
object.sender_session = 0;
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.frame_number = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.frame_number = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
if (options.bytes === $String)
object.opus_data = "";
else {
object.opus_data = [];
if (options.bytes !== $Array)
object.opus_data = $util.newBuffer(object.opus_data);
}
object.volume_adjustment = 0;
object.is_terminator = false;
}
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
object.target = message.target;
if (options.oneofs)
object.Header = "target";
}
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
object.context = message.context;
if (options.oneofs)
object.Header = "context";
}
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
object.sender_session = message.sender_session;
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.frame_number = typeof message.frame_number === "number" ? $BigInt(message.frame_number) : $util.Long.fromBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0, true).toBigInt();
else if (typeof message.frame_number === "number")
object.frame_number = options.longs === $String ? $String(message.frame_number) : message.frame_number;
else
object.frame_number = options.longs === $String ? $util.Long.prototype.toString.call(message.frame_number) : options.longs === $Number ? new $util.LongBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0).toNumber(true) : message.frame_number;
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
object.opus_data = options.bytes === $String ? $util.base64.encode(message.opus_data, 0, message.opus_data.length) : options.bytes === $Array ? $Array.prototype.slice.call(message.opus_data) : message.opus_data;
if (message.positional_data && message.positional_data.length) {
object.positional_data = $Array(message.positional_data.length);
for (let j = 0; j < message.positional_data.length; ++j)
object.positional_data[j] = options.json && !$isFinite(message.positional_data[j]) ? $String(message.positional_data[j]) : message.positional_data[j];
}
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
object.volume_adjustment = options.json && !$isFinite(message.volume_adjustment) ? $String(message.volume_adjustment) : message.volume_adjustment;
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
object.is_terminator = message.is_terminator;
return object;
};
Audio.prototype.toJSON = function() {
return Audio.toObject(this, $protobuf.util.toJSONOptions);
};
Audio.getTypeUrl = function(prefix) {
if (prefix === $undefined)
prefix = "type.googleapis.com";
return prefix + "/MumbleUDP.Audio";
};
return Audio;
})();
MumbleUDP.Ping = (function() {
const Ping = function (properties) {
if (properties)
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
if (properties[keys[i]] != null && keys[i] !== "__proto__")
this[keys[i]] = properties[keys[i]];
};
Ping.prototype.timestamp = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Ping.prototype.request_extended_information = false;
Ping.prototype.server_version_v2 = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Ping.prototype.user_count = 0;
Ping.prototype.max_user_count = 0;
Ping.prototype.max_bandwidth_per_user = 0;
Ping.create = function(properties) {
return new Ping(properties);
};
Ping.encode = function (message, writer, _depth) {
if (!writer)
writer = $Writer.create();
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp") && (typeof message.timestamp === "object" ? message.timestamp.low || message.timestamp.high : message.timestamp !== 0))
writer.uint32(8).uint64(message.timestamp);
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information") && message.request_extended_information !== false)
writer.uint32(16).bool(message.request_extended_information);
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2") && (typeof message.server_version_v2 === "object" ? message.server_version_v2.low || message.server_version_v2.high : message.server_version_v2 !== 0))
writer.uint32(24).uint64(message.server_version_v2);
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count") && message.user_count !== 0)
writer.uint32(32).uint32(message.user_count);
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count") && message.max_user_count !== 0)
writer.uint32(40).uint32(message.max_user_count);
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user") && message.max_bandwidth_per_user !== 0)
writer.uint32(48).uint32(message.max_bandwidth_per_user);
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
for (let i = 0; i < message.$unknowns.length; ++i)
writer.raw(message.$unknowns[i]);
return writer;
};
Ping.decode = function (reader, length, _end, _depth, _target) {
if (!(reader instanceof $Reader))
reader = $Reader.create(reader);
if (_depth === $undefined)
_depth = 0;
if (_depth > $Reader.recursionLimit)
throw $Error("max depth exceeded");
let end, message, value;
if (length === $undefined)
end = reader.len;
else {
end = reader.pos + length;
if (end > reader.len)
throw $RangeError("index out of range");
length = reader.len;
reader.len = end;
}
message = _target || new $root.MumbleUDP.Ping();
while (reader.pos < end) {
let start = reader.pos;
let tag = reader.tag();
if (tag === _end) {
_end = $undefined;
break;
}
let wireType = tag & 7;
switch (tag >>>= 3) {
case 1: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.timestamp = value;
else
delete message.timestamp;
continue;
}
case 2: {
if (wireType !== 0)
break;
if (value = reader.bool())
message.request_extended_information = value;
else
delete message.request_extended_information;
continue;
}
case 3: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.server_version_v2 = value;
else
delete message.server_version_v2;
continue;
}
case 4: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.user_count = value;
else
delete message.user_count;
continue;
}
case 5: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.max_user_count = value;
else
delete message.max_user_count;
continue;
}
case 6: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.max_bandwidth_per_user = value;
else
delete message.max_bandwidth_per_user;
continue;
}
}
reader.skipType(wireType, _depth, tag);
if (!reader.discardUnknown) {
$util.makeProp(message, "$unknowns", false);
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
}
}
if (length !== $undefined) {
if (reader.pos !== end)
throw $RangeError("index out of range");
reader.len = length;
}
if (_end !== $undefined)
throw $Error("missing end group");
return message;
};
Ping.verify = function (message, _depth) {
if (typeof message !== "object" || message === null)
return "object expected";
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
return "max depth exceeded";
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
if (!$util.isInteger(message.timestamp) && !(message.timestamp && $util.isInteger(message.timestamp.low) && $util.isInteger(message.timestamp.high)))
return "timestamp: integer|Long expected";
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
if (typeof message.request_extended_information !== "boolean")
return "request_extended_information: boolean expected";
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
if (!$util.isInteger(message.server_version_v2) && !(message.server_version_v2 && $util.isInteger(message.server_version_v2.low) && $util.isInteger(message.server_version_v2.high)))
return "server_version_v2: integer|Long expected";
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
if (!$util.isInteger(message.user_count))
return "user_count: integer expected";
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
if (!$util.isInteger(message.max_user_count))
return "max_user_count: integer expected";
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
if (!$util.isInteger(message.max_bandwidth_per_user))
return "max_bandwidth_per_user: integer expected";
return null;
};
Ping.fromObject = function (object, _depth) {
if (object instanceof $root.MumbleUDP.Ping)
return object;
if (!$util.isObject(object))
throw $TypeError(".MumbleUDP.Ping: object expected");
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let message = new $root.MumbleUDP.Ping();
if (object.timestamp != null)
if (typeof object.timestamp === "object" ? object.timestamp.low || object.timestamp.high : $Number(object.timestamp) !== 0)
if ($util.Long)
message.timestamp = $util.Long.fromValue(object.timestamp, true);
else if (typeof object.timestamp === "string")
message.timestamp = $parseInt(object.timestamp, 10);
else if (typeof object.timestamp === "number")
message.timestamp = object.timestamp;
else if (typeof object.timestamp === "object")
message.timestamp = new $util.LongBits(object.timestamp.low >>> 0, object.timestamp.high >>> 0).toNumber(true);
if (object.request_extended_information != null)
if (object.request_extended_information)
message.request_extended_information = $Boolean(object.request_extended_information);
if (object.server_version_v2 != null)
if (typeof object.server_version_v2 === "object" ? object.server_version_v2.low || object.server_version_v2.high : $Number(object.server_version_v2) !== 0)
if ($util.Long)
message.server_version_v2 = $util.Long.fromValue(object.server_version_v2, true);
else if (typeof object.server_version_v2 === "string")
message.server_version_v2 = $parseInt(object.server_version_v2, 10);
else if (typeof object.server_version_v2 === "number")
message.server_version_v2 = object.server_version_v2;
else if (typeof object.server_version_v2 === "object")
message.server_version_v2 = new $util.LongBits(object.server_version_v2.low >>> 0, object.server_version_v2.high >>> 0).toNumber(true);
if (object.user_count != null)
if ($Number(object.user_count) !== 0)
message.user_count = object.user_count >>> 0;
if (object.max_user_count != null)
if ($Number(object.max_user_count) !== 0)
message.max_user_count = object.max_user_count >>> 0;
if (object.max_bandwidth_per_user != null)
if ($Number(object.max_bandwidth_per_user) !== 0)
message.max_bandwidth_per_user = object.max_bandwidth_per_user >>> 0;
return message;
};
Ping.toObject = function (message, options, _depth) {
if (!options)
options = {};
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let object = {};
if (options.defaults) {
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.timestamp = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.timestamp = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
object.request_extended_information = false;
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.server_version_v2 = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.server_version_v2 = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
object.user_count = 0;
object.max_user_count = 0;
object.max_bandwidth_per_user = 0;
}
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.timestamp = typeof message.timestamp === "number" ? $BigInt(message.timestamp) : $util.Long.fromBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0, true).toBigInt();
else if (typeof message.timestamp === "number")
object.timestamp = options.longs === $String ? $String(message.timestamp) : message.timestamp;
else
object.timestamp = options.longs === $String ? $util.Long.prototype.toString.call(message.timestamp) : options.longs === $Number ? new $util.LongBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0).toNumber(true) : message.timestamp;
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
object.request_extended_information = message.request_extended_information;
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.server_version_v2 = typeof message.server_version_v2 === "number" ? $BigInt(message.server_version_v2) : $util.Long.fromBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0, true).toBigInt();
else if (typeof message.server_version_v2 === "number")
object.server_version_v2 = options.longs === $String ? $String(message.server_version_v2) : message.server_version_v2;
else
object.server_version_v2 = options.longs === $String ? $util.Long.prototype.toString.call(message.server_version_v2) : options.longs === $Number ? new $util.LongBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0).toNumber(true) : message.server_version_v2;
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
object.user_count = message.user_count;
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
object.max_user_count = message.max_user_count;
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
object.max_bandwidth_per_user = message.max_bandwidth_per_user;
return object;
};
Ping.prototype.toJSON = function() {
return Ping.toObject(this, $protobuf.util.toJSONOptions);
};
Ping.getTypeUrl = function(prefix) {
if (prefix === $undefined)
prefix = "type.googleapis.com";
return prefix + "/MumbleUDP.Ping";
};
return Ping;
})();
return MumbleUDP;
})();
export {
$root as default
};
+81
View File
@@ -0,0 +1,81 @@
// Generated from proto/Mumble.proto by `npm run proto` (static code, no eval, so it runs under a strict CSP)
import { MumbleProto } from './mumble-pb.js';
// TCP control message type ids, in wire order (index = type id).
export const MESSAGE_TYPES = [
'Version', 'UDPTunnel', 'Authenticate', 'Ping', 'Reject', 'ServerSync',
'ChannelRemove', 'ChannelState', 'UserRemove', 'UserState', 'BanList',
'TextMessage', 'PermissionDenied', 'ACL', 'QueryUsers', 'CryptSetup',
'ContextActionModify', 'ContextAction', 'UserList', 'VoiceTarget',
'PermissionQuery', 'CodecVersion', 'UserStats', 'RequestBlob',
'ServerConfig', 'SuggestConfig', 'PluginDataTransmission'
] as const;
export type MessageName = (typeof MESSAGE_TYPES)[number];
export interface Codec {
encode(name: MessageName, payload: Record<string, unknown>): Uint8Array;
decode(typeId: number, body: Uint8Array): { name: MessageName; msg: any } | null;
}
interface GeneratedType {
verify(obj: Record<string, unknown>): string | null;
fromObject(obj: Record<string, unknown>): unknown;
encode(msg: unknown): { finish(): Uint8Array };
decode(body: Uint8Array): unknown;
toObject(msg: unknown, opts: { longs: NumberConstructor }): any;
}
// Field names keep their snake_case spelling from the .proto file; 64-bit integers become numbers.
export function createCodec(): Codec {
const types = MESSAGE_TYPES.map(n => (MumbleProto as unknown as Record<string, GeneratedType>)[n]);
return {
encode(name, payload) {
const id = MESSAGE_TYPES.indexOf(name);
const type = types[id];
const err = type.verify(payload);
if (err) throw new Error(`${name}: ${err}`);
const body = type.encode(type.fromObject(payload)).finish();
return frame(id, body);
},
decode(typeId, body) {
const type = types[typeId];
if (!type) return null;
const msg = type.toObject(type.decode(body), { longs: Number });
return { name: MESSAGE_TYPES[typeId], msg };
}
};
}
// 6-byte header: uint16 type, uint32 length (big endian), then the body.
export function frame(typeId: number, body: Uint8Array): Uint8Array {
const out = new Uint8Array(6 + body.length);
const view = new DataView(out.buffer);
view.setUint16(0, typeId);
view.setUint32(2, body.length);
out.set(body, 6);
return out;
}
// Reassembles framed messages from an arbitrary chunked byte stream.
export class FrameReader {
private buf = new Uint8Array(0);
push(chunk: Uint8Array, onFrame: (typeId: number, body: Uint8Array) => void): void {
const merged = new Uint8Array(this.buf.length + chunk.length);
merged.set(this.buf);
merged.set(chunk, this.buf.length);
let off = 0;
while (merged.length - off >= 6) {
const view = new DataView(merged.buffer, merged.byteOffset + off);
const typeId = view.getUint16(0);
const len = view.getUint32(2);
if (len > 8 * 1024 * 1024) throw new Error(`Frame too large: ${len}`);
if (merged.length - off - 6 < len) break;
onFrame(typeId, merged.subarray(off + 6, off + 6 + len));
off += 6 + len;
}
this.buf = merged.slice(off);
}
}
+42
View File
@@ -0,0 +1,42 @@
// A reliable, ordered byte stream to a Mumble server (TLS in Electron, WebSocket proxy on the web).
export interface Transport {
send(bytes: Uint8Array): void;
close(): void;
onData: ((chunk: Uint8Array) => void) | null;
onClose: ((reason: string) => void) | null;
}
export interface ConnectTarget {
host: string;
port: number;
}
// Identity certificate the client presents; Mumble uses it to recognize registered users.
export interface Identity {
certPem: string;
keyPem: string;
fingerprint: string;
}
export interface CertDetails {
subject: string;
issuer: string;
validFrom: string;
validTo: string;
serialNumber: string;
fingerprint: string; // SHA-1, the hash Mumble uses for users
fingerprint256: string;
subjectAltName: string;
keyType: string;
keyBits: number | null;
ca: boolean;
selfSigned: boolean;
pem: string;
}
export interface ServerCertInfo {
fingerprint: string; // sha256 of the server certificate, colon-separated hex
authorized: boolean; // true if it chains to a trusted CA
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first
}
+121
View File
@@ -0,0 +1,121 @@
// Mumble voice packets, as sent over UDP or tunneled through TCP (UDPTunnel).
// Two wire formats exist: the legacy one (header byte + Mumble varints) and, since 1.5,
// a protobuf one (type byte 0 + MumbleUDP.Audio). Servers pick per client version.
import { MumbleUDP } from './mumble-udp-pb.js';
export interface VoicePacket {
session: number; // sender (only in packets from the server)
target: number; // 0 normal talking, 1-30 whisper targets, 31 server loopback; context when received
frame: number; // sequence number, in 10 ms audio frames
opus: Uint8Array;
last: boolean; // end of this transmission
}
const LEGACY_OPUS = 4;
const PROTOBUF_AUDIO = 0;
// ─── Mumble varint (PacketDataStream) ─────────────────────────────────────────
export function writeVarint(out: number[], value: number): void {
if (!Number.isInteger(value) || value < 0) throw new Error(`Unsupported varint ${value}`);
if (value < 0x80) out.push(value);
else if (value < 0x4000) out.push(0x80 | (value >> 8), value & 0xff);
else if (value < 0x200000) out.push(0xc0 | (value >> 16), (value >> 8) & 0xff, value & 0xff);
else if (value < 0x10000000) out.push(0xe0 | (value >> 24), (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
else if (value < 0x100000000) out.push(0xf0, (value >>> 24) & 0xff, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
else {
const hi = Math.floor(value / 0x100000000), lo = value >>> 0;
out.push(0xf4, (hi >>> 24) & 0xff, (hi >> 16) & 0xff, (hi >> 8) & 0xff, hi & 0xff,
(lo >>> 24) & 0xff, (lo >> 16) & 0xff, (lo >> 8) & 0xff, lo & 0xff);
}
}
export function readVarint(buf: Uint8Array, pos: number): [value: number, next: number] {
const need = (n: number) => { if (pos + n > buf.length) throw new Error('Truncated varint'); };
need(1);
const b = buf[pos];
if ((b & 0x80) === 0) return [b, pos + 1];
if ((b & 0xc0) === 0x80) { need(2); return [((b & 0x3f) << 8) | buf[pos + 1], pos + 2]; }
if ((b & 0xe0) === 0xc0) { need(3); return [((b & 0x1f) << 16) | (buf[pos + 1] << 8) | buf[pos + 2], pos + 3]; }
if ((b & 0xf0) === 0xe0) { need(4); return [((b & 0x0f) << 24 | (buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3]) >>> 0, pos + 4]; }
const be32 = (p: number) => ((buf[p] << 24) | (buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]) >>> 0;
if ((b & 0xfc) === 0xf0) { need(5); return [be32(pos + 1), pos + 5]; }
if ((b & 0xfc) === 0xf4) { need(9); return [be32(pos + 1) * 0x100000000 + be32(pos + 5), pos + 9]; }
// Negative numbers (0xf8, 0xfc prefixes) never occur in audio headers
throw new Error('Unsupported varint prefix');
}
// ─── Encoding (client to server) ──────────────────────────────────────────────
export function encodeVoice(p: Omit<VoicePacket, 'session'>, protobuf: boolean): Uint8Array {
if (protobuf) {
const msg = MumbleUDP.Audio.fromObject({
target: p.target,
frame_number: p.frame,
opus_data: p.opus,
is_terminator: p.last
});
const body: Uint8Array = MumbleUDP.Audio.encode(msg).finish();
const out = new Uint8Array(body.length + 1);
out[0] = PROTOBUF_AUDIO;
out.set(body, 1);
return out;
}
if (p.opus.length >= 0x2000) throw new Error('Opus frame too large');
const head: number[] = [(LEGACY_OPUS << 5) | (p.target & 0x1f)];
writeVarint(head, p.frame);
writeVarint(head, p.opus.length | (p.last ? 0x2000 : 0));
const out = new Uint8Array(head.length + p.opus.length);
out.set(head);
out.set(p.opus, head.length);
return out;
}
// ─── Decoding (server to client) ──────────────────────────────────────────────
// Returns null for pings, other codecs and malformed packets.
export function decodeVoice(buf: Uint8Array): VoicePacket | null {
if (!buf.length) return null;
try {
if (buf[0] === PROTOBUF_AUDIO) {
const m = MumbleUDP.Audio.toObject(MumbleUDP.Audio.decode(buf.subarray(1)), { longs: Number, defaults: true });
return {
session: m.sender_session,
target: m.context ?? m.target ?? 0,
frame: m.frame_number,
opus: m.opus_data instanceof Uint8Array ? m.opus_data : new Uint8Array(m.opus_data ?? []),
last: !!m.is_terminator
};
}
if (buf[0] >> 5 !== LEGACY_OPUS) return null;
const target = buf[0] & 0x1f;
let pos = 1, session, frame, size;
[session, pos] = readVarint(buf, pos);
[frame, pos] = readVarint(buf, pos);
[size, pos] = readVarint(buf, pos);
const len = size & 0x1fff;
if (pos + len > buf.length) return null;
return { session, target, frame, opus: buf.slice(pos, pos + len), last: (size & 0x2000) !== 0 };
} catch {
return null;
}
}
// For tests and the server-side view: a client packet as the server would relay it
export function relayAsServer(clientPacket: Uint8Array, session: number, protobuf: boolean): Uint8Array {
if (protobuf) {
const m = MumbleUDP.Audio.decode(clientPacket.subarray(1));
m.sender_session = session;
m.context = 0;
const body: Uint8Array = MumbleUDP.Audio.encode(m).finish();
const out = new Uint8Array(body.length + 1);
out.set(body, 1);
return out;
}
const head: number[] = [clientPacket[0] & 0xe0];
writeVarint(head, session);
const out = new Uint8Array(head.length + clientPacket.length - 1);
out.set(head);
out.set(clientPacket.subarray(1), head.length);
return out;
}
+86
View File
@@ -0,0 +1,86 @@
import { session } from './session.svelte.ts';
import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts';
import type { MenuItem } from './menu.svelte.ts';
import type { Channel, User } from '../core/client.ts';
// Right-click menu for a channel in the tree
export function channelMenu(c: Channel): MenuItem[] {
const here = session.self?.channelId === c.id;
return [
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
{ separator: true, label: '' },
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) },
{ separator: true, label: '' },
{ label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') }
];
}
// Right-click menu for a user. Moderation entries are always offered; the server decides
// whether we may use them and answers with a permission denial otherwise.
export function userMenu(u: User): MenuItem[] {
const self = session.self;
const isSelf = u.session === self?.session;
const items: MenuItem[] = [
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } },
{ label: 'Information', action: () => (ui.userInfo = u.session) }
];
if (isSelf) {
items.push(
{ label: 'Mute', checked: u.selfMute, action: () => session.setSelfMute(!u.selfMute) },
{ label: 'Deafen', checked: u.selfDeaf, action: () => session.setSelfDeaf(!u.selfDeaf) },
{ label: 'Change name...', action: () => changeNamePrompt(u) }
);
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else {
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
{ label: 'Set nickname...', action: () => nicknamePrompt(u) }
);
if (self && u.channelId !== self.channelId) {
items.push({ label: 'Move to my channel', action: () => session.moveUser(u.session, self.channelId) });
}
}
items.push(
{ separator: true, label: '' },
{ label: 'Server mute', checked: u.mute, action: () => session.setServerMute(u.session, !u.mute) },
{ label: 'Server deafen', checked: u.deaf, action: () => session.setServerDeaf(u.session, !u.deaf) },
{ label: 'Priority speaker', checked: u.prioritySpeaker, action: () => session.setPrioritySpeaker(u.session, !u.prioritySpeaker) }
);
if (!isSelf && u.userId == null) items.push({ label: 'Register user', action: () => session.register(u.session) });
if (!isSelf) {
items.push(
{ separator: true, label: '' },
{ label: 'Kick...', danger: true, action: () => (ui.prompt = { title: `Kick ${u.name}`, label: 'Reason (optional)', confirm: 'Kick', danger: true, onsubmit: r => session.kick(u.session, r) }) },
{ label: 'Ban...', danger: true, action: () => (ui.prompt = { title: `Ban ${u.name}`, label: 'Reason (optional)', confirm: 'Ban', danger: true, onsubmit: r => session.kick(u.session, r, true) }) }
);
}
items.push({ separator: true, label: '' }, { label: 'Copy name', action: () => navigator.clipboard.writeText(u.name) });
if (u.hash) items.push({ label: 'Copy certificate hash', action: () => navigator.clipboard.writeText(u.hash) });
return items;
}
export function changeNamePrompt(u: User): void {
ui.prompt = {
title: 'Change your name', label: 'New name', confirm: 'Reconnect with this name', initial: u.name,
text: u.userId != null
? 'You are registered here, so the server keeps your registered name. The new name applies on servers where you are a guest.'
: 'Mumble cannot rename you while connected, so mumh5 reconnects with the new name and remembers it for this server.',
onsubmit: name => session.changeName(name)
};
}
export function nicknamePrompt(u: User): void {
ui.prompt = {
title: `Nickname for ${u.name}`, label: 'Nickname (only you see it; empty to remove)', confirm: 'Save',
initial: store.nicknames[userKey(u)] ?? '',
onsubmit: nick => store.setNickname(userKey(u), nick)
};
}
+33
View File
@@ -0,0 +1,33 @@
/// <reference path="./worklet-env.d.ts" />
// Cuts microphone input into 10 ms mono frames (480 samples at 48 kHz) for the encoder,
// with the RMS level of each frame for the meter and voice activity detection.
const FRAME = 480;
class CaptureProcessor extends AudioWorkletProcessor {
private buf = new Float32Array(FRAME);
private fill = 0;
process(inputs: Float32Array[][]): boolean {
const input = inputs[0]?.[0];
if (!input) return true;
let i = 0;
while (i < input.length) {
const n = Math.min(FRAME - this.fill, input.length - i);
this.buf.set(input.subarray(i, i + n), this.fill);
this.fill += n;
i += n;
if (this.fill === FRAME) {
let sum = 0;
for (let k = 0; k < FRAME; k++) sum += this.buf[k] * this.buf[k];
const frame = this.buf;
this.port.postMessage({ frame, rms: Math.sqrt(sum / FRAME) }, [frame.buffer]);
this.buf = new Float32Array(FRAME);
this.fill = 0;
}
}
return true;
}
}
registerProcessor('mumh5-capture', CaptureProcessor);
+95
View File
@@ -0,0 +1,95 @@
/// <reference path="./worklet-env.d.ts" />
// Mixes decoded voice of all speakers. Each speaker has a queue that starts playing once
// `jitter` ms are buffered (absorbs network jitter), and re-buffers after running dry.
interface Speaker {
chunks: Float32Array[];
offset: number; // read position in chunks[0]
queued: number; // samples waiting
playing: boolean;
gain: number;
peak: number;
}
class PlaybackProcessor extends AudioWorkletProcessor {
private speakers = new Map<number, Speaker>();
private jitter = Math.round(sampleRate * 0.06);
private maxQueue = Math.round(sampleRate * 0.4);
private gains = new Map<number, number>();
private sinceReport = 0;
constructor() {
super();
this.port.onmessage = e => {
const m = e.data;
if (m.pcm) this.push(m.session, m.pcm);
else if (m.gain !== undefined) {
this.gains.set(m.session, m.gain);
const s = this.speakers.get(m.session);
if (s) s.gain = m.gain;
} else if (m.jitterMs !== undefined) this.jitter = Math.round(sampleRate * m.jitterMs / 1000);
else if (m.drop !== undefined) this.speakers.delete(m.drop);
else if (m.clear) this.speakers.clear();
};
}
private push(session: number, pcm: Float32Array) {
let s = this.speakers.get(session);
if (!s) {
s = { chunks: [], offset: 0, queued: 0, playing: false, gain: this.gains.get(session) ?? 1, peak: 0 };
this.speakers.set(session, s);
}
s.chunks.push(pcm);
s.queued += pcm.length;
// Too far behind (e.g. after a stall): drop the oldest audio to catch up
while (s.queued > this.maxQueue && s.chunks.length > 1) {
const c = s.chunks.shift()!;
s.queued -= c.length - s.offset;
s.offset = 0;
}
if (!s.playing && s.queued >= this.jitter) s.playing = true;
}
process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const out = outputs[0];
const n = out[0].length;
const mix = out[0];
mix.fill(0);
for (const s of this.speakers.values()) {
if (!s.playing) continue;
let i = 0;
while (i < n && s.chunks.length) {
const c = s.chunks[0];
const take = Math.min(n - i, c.length - s.offset);
for (let k = 0; k < take; k++) {
const v = c[s.offset + k] * s.gain;
mix[i + k] += v;
const a = v < 0 ? -v : v;
if (a > s.peak) s.peak = a;
}
i += take;
s.offset += take;
s.queued -= take;
if (s.offset === c.length) { s.chunks.shift(); s.offset = 0; }
}
if (!s.chunks.length) s.playing = false; // ran dry: buffer again before resuming
}
// Soft clip so several loud speakers do not distort harshly
for (let i = 0; i < n; i++) {
const v = mix[i];
if (v > 1 || v < -1) mix[i] = Math.tanh(v);
}
for (let ch = 1; ch < out.length; ch++) out[ch].set(mix);
this.sinceReport += n;
if (this.sinceReport >= sampleRate / 10) {
const levels: Record<number, number> = {};
for (const [id, s] of this.speakers) { levels[id] = s.peak; s.peak = 0; }
this.port.postMessage({ levels });
this.sinceReport = 0;
}
return true;
}
}
registerProcessor('mumh5-playback', PlaybackProcessor);
+447
View File
@@ -0,0 +1,447 @@
import captureUrl from './capture.worklet.ts?worker&url';
import playbackUrl from './playback.worklet.ts?worker&url';
import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts';
import type { MumbleClient } from '../../core/client.ts';
import { load, save } from '../storage.ts';
export type TransmitMode = 'vad' | 'ptt' | 'continuous';
export interface VoiceSettings {
inputDevice: string;
outputDevice: string;
mode: TransmitMode;
pttKey: string; // KeyboardEvent.code
vadThreshold: number; // dBFS; louder frames open the voice activity gate
vadHoldMs: number; // keep sending this long after the level drops
inputGain: number; // 0..3
outputVolume: number; // 0..2
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
bitrate: number; // Opus bits per second
frameMs: 10 | 20 | 40 | 60;
jitterMs: number;
// Per-user local volume and mute, keyed by certificate hash (or name without one)
userVolumes: Record<string, number>;
localMutes: Record<string, boolean>;
}
const defaults: VoiceSettings = {
inputDevice: 'default',
outputDevice: 'default',
mode: 'vad',
pttKey: 'Backquote',
vadThreshold: -45,
vadHoldMs: 350,
inputGain: 1,
outputVolume: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
bitrate: 40000,
frameMs: 20,
jitterMs: 60,
userVolumes: {},
localMutes: {}
};
const SAMPLE_RATE = 48000;
const FRAME = 480; // 10 ms
// Rough per-packet cost of IP, TCP/TLS or UDP, and Mumble headers, for the bandwidth limit
const PACKET_OVERHEAD_BYTES = 60;
export const userKey = (u: { hash: string; name: string }) => u.hash || `name:${u.name}`;
export interface DeviceInfo { id: string; label: string }
class VoiceEngine {
settings = $state<VoiceSettings>(load('mumh5.voice', defaults));
inputDevices = $state<DeviceInfo[]>([]);
outputDevices = $state<DeviceInfo[]>([]);
running = $state(false);
error = $state('');
level = $state(-100); // current input level, dBFS
transmitting = $state(false);
pttDown = $state(false);
testing = $state(false); // hear yourself
talking = $state<Record<number, boolean>>({});
effectiveBitrate = $state(0);
stats = $state({ sent: 0, received: 0, decoded: 0, lost: 0 });
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private source: MediaStreamAudioSourceNode | null = null;
private inputGainNode: GainNode | null = null;
private outputGainNode: GainNode | null = null;
private capture: AudioWorkletNode | null = null;
private playback: AudioWorkletNode | null = null;
private encoder: AudioEncoder | null = null;
private decoders = new Map<number, { decoder: AudioDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
private client: MumbleClient | null = null;
private sessionGains = new Map<number, number>();
private unsubscribe: (() => void)[] = [];
private frameCounter = 0; // Mumble sequence, in 10 ms frames
private sampleTime = 0; // encoder timestamps, microseconds
private lastVoiceAt = 0; // for VAD hold
private open = false; // gate state
private stopping = false; // flushing the last packet of a transmission
private held: Uint8Array[] = [];
private levelRaw = -100;
private levelTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
if (typeof window === 'undefined') return;
window.addEventListener('keydown', e => this.onKey(e, true));
window.addEventListener('keyup', e => this.onKey(e, false));
// Released keys are not reported while the window is in the background
window.addEventListener('blur', () => { this.pttDown = false; });
navigator.mediaDevices?.addEventListener('devicechange', () => this.refreshDevices());
}
save(): void {
save('mumh5.voice', $state.snapshot(this.settings));
}
update(patch: Partial<VoiceSettings>): void {
Object.assign(this.settings, patch);
this.save();
if ('inputGain' in patch && this.inputGainNode) this.inputGainNode.gain.value = this.settings.inputGain;
if ('outputVolume' in patch && this.outputGainNode) this.outputGainNode.gain.value = this.settings.outputVolume;
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
if ('outputDevice' in patch) this.applyOutputDevice();
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput();
}
// ─── Devices ────────────────────────────────────────────────────────────────
async refreshDevices(): Promise<void> {
if (!navigator.mediaDevices?.enumerateDevices) return;
const all = await navigator.mediaDevices.enumerateDevices();
const map = (kind: MediaDeviceKind) => all.filter(d => d.kind === kind && d.deviceId !== 'communications')
.map(d => ({ id: d.deviceId, label: d.label || (d.deviceId === 'default' ? 'System default' : 'Unnamed device') }));
this.inputDevices = map('audioinput');
this.outputDevices = map('audiooutput');
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
// Starts the audio graph; safe to call repeatedly
async start(): Promise<void> {
if (this.ctx) return;
this.error = '';
try {
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
throw new Error('This system cannot encode Opus audio');
}
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
this.ctx = ctx;
await ctx.audioWorklet.addModule(captureUrl);
await ctx.audioWorklet.addModule(playbackUrl);
this.outputGainNode = new GainNode(ctx, { gain: this.settings.outputVolume });
this.playback = new AudioWorkletNode(ctx, 'mumh5-playback', { numberOfInputs: 0, outputChannelCount: [2] });
this.playback.port.onmessage = e => this.onLevels(e.data.levels);
this.playback.port.postMessage({ jitterMs: this.settings.jitterMs });
for (const [s, g] of this.sessionGains) this.playback.port.postMessage({ session: s, gain: g });
this.playback.connect(this.outputGainNode).connect(ctx.destination);
this.inputGainNode = new GainNode(ctx, { gain: this.settings.inputGain });
this.capture = new AudioWorkletNode(ctx, 'mumh5-capture', { numberOfOutputs: 0 });
this.capture.port.onmessage = e => this.onFrame(e.data.frame, e.data.rms);
this.inputGainNode.connect(this.capture);
await this.applyOutputDevice();
await this.restartInput();
this.configureEncoder();
await ctx.resume();
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
this.running = true;
await this.refreshDevices();
} catch (e) {
this.error = (e as Error).message;
await this.stop();
}
}
async stop(): Promise<void> {
this.endTransmission();
if (this.levelTimer) clearInterval(this.levelTimer);
this.levelTimer = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
this.decoders.clear();
try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = null;
await this.ctx?.close().catch(() => {});
this.ctx = null;
this.capture = this.playback = null;
this.running = false;
this.transmitting = false;
this.talking = {};
this.level = -100;
}
private async restartInput(): Promise<void> {
if (!this.ctx || !this.inputGainNode) return;
this.source?.disconnect();
this.stream?.getTracks().forEach(t => t.stop());
const s = this.settings;
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
echoCancellation: s.echoCancellation,
noiseSuppression: s.noiseSuppression,
autoGainControl: s.autoGainControl,
channelCount: 1,
sampleRate: SAMPLE_RATE
}
});
} catch (e) {
this.error = `Microphone unavailable: ${(e as Error).message}`;
return;
}
this.error = '';
this.source = this.ctx.createMediaStreamSource(this.stream);
this.source.connect(this.inputGainNode);
this.refreshDevices();
}
private async applyOutputDevice(): Promise<void> {
const ctx = this.ctx as (AudioContext & { setSinkId?: (id: string) => Promise<void> }) | null;
if (!ctx?.setSinkId) return;
try {
await ctx.setSinkId(this.settings.outputDevice === 'default' ? '' : this.settings.outputDevice);
} catch (e) {
this.error = `Output device unavailable: ${(e as Error).message}`;
}
}
// ─── Connection ─────────────────────────────────────────────────────────────
attach(client: MumbleClient): void {
this.unsubscribe.forEach(off => off());
this.client = client;
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
this.unsubscribe = [
client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
client.on('userRemove', u => this.dropSession(u.session))
];
this.limitBitrate();
this.start();
}
detach(): void {
this.endTransmission();
this.unsubscribe.forEach(off => off());
this.unsubscribe = [];
this.client = null;
for (const s of [...this.decoders.keys()]) this.dropSession(s);
this.playback?.port.postMessage({ clear: true });
this.talking = {};
this.transmitting = false;
// Release the microphone while not connected (unless testing it)
if (!this.testing) this.stop();
}
// Opus bitrate that keeps bitrate plus packet overhead inside the server's per-user limit
private allowedBitrate(): number {
const max = this.client?.maxBandwidth ?? 0;
const overhead = PACKET_OVERHEAD_BYTES * 8 * (1000 / this.settings.frameMs);
return Math.min(this.settings.bitrate, max ? Math.max(8000, max - overhead) : Infinity);
}
private limitBitrate(): void {
if (this.allowedBitrate() !== this.effectiveBitrate) this.configureEncoder();
}
// ─── Sending ────────────────────────────────────────────────────────────────
private encoderConfig(): AudioEncoderConfig {
return {
codec: 'opus',
sampleRate: SAMPLE_RATE,
numberOfChannels: 1,
bitrate: this.effectiveBitrate || this.allowedBitrate(),
opus: { frameDuration: this.settings.frameMs * 1000, complexity: 10, useinbandfec: true, usedtx: false }
} as AudioEncoderConfig;
}
private configureEncoder(): void {
this.effectiveBitrate = this.allowedBitrate();
if (!this.ctx) return;
try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = new AudioEncoder({
output: chunk => this.onEncoded(chunk),
error: e => { this.error = `Encoder: ${e.message}`; }
});
this.encoder.configure(this.encoderConfig());
}
private canTalk(): boolean {
const self = this.client?.self;
return !!self && this.client!.synced && !self.selfMute && !self.mute && !self.suppress && !self.selfDeaf && !self.deaf;
}
private onFrame(frame: Float32Array<ArrayBuffer>, rms: number): void {
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
this.levelRaw = Math.max(-100, db);
const now = performance.now();
const s = this.settings;
if (this.testing) this.playback?.port.postMessage({ session: -1, pcm: frame.slice() });
let wanted: boolean;
if (s.mode === 'continuous') wanted = true;
else if (s.mode === 'ptt') wanted = this.pttDown;
else {
if (db > s.vadThreshold) this.lastVoiceAt = now;
wanted = now - this.lastVoiceAt < s.vadHoldMs;
}
wanted &&= this.canTalk() && !!this.encoder && this.encoder.state === 'configured';
if (wanted && !this.open) { this.open = true; this.transmitting = true; }
if (!wanted && this.open) { this.endTransmission(); return; }
if (!this.open) return;
const data = new AudioData({
format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1,
timestamp: this.sampleTime, data: frame
});
this.sampleTime += 10000;
this.encoder!.encode(data);
data.close();
}
private onEncoded(chunk: EncodedAudioChunk): void {
const opus = new Uint8Array(chunk.byteLength);
chunk.copyTo(opus);
if (this.stopping) { this.held.push(opus); return; }
this.sendPacket(opus, false);
}
private sendPacket(opus: Uint8Array, last: boolean): void {
const client = this.client;
if (!client) return;
client.sendVoiceTunnel(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
this.frameCounter += this.settings.frameMs / 10;
this.stats.sent++;
}
// Flush what the encoder still holds and mark the final packet, so receivers end the stream cleanly
private endTransmission(): void {
if (!this.open) return;
this.open = false;
this.transmitting = false;
const enc = this.encoder;
if (!enc || enc.state !== 'configured') return;
this.stopping = true;
enc.flush().then(() => {
const held = this.held;
this.held = [];
this.stopping = false;
held.forEach((opus, i) => this.sendPacket(opus, i === held.length - 1));
if (!held.length) this.sendPacket(new Uint8Array(0), true);
}).catch(() => { this.stopping = false; this.held = []; });
}
private onKey(e: KeyboardEvent, down: boolean): void {
if (e.code !== this.settings.pttKey || e.repeat) return;
// Typing in a text field does not trigger push to talk
const t = e.target as HTMLElement | null;
if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
this.pttDown = down;
}
// ─── Receiving ──────────────────────────────────────────────────────────────
private receive(raw: Uint8Array): void {
const p = decodeVoice(raw);
if (!p) return;
this.stats.received++;
const self = this.client?.self;
if (!this.ctx || !this.playback || self?.selfDeaf || self?.deaf) return;
const user = this.client?.users.get(p.session);
if (user && this.settings.localMutes[userKey(user)]) return;
let d = this.decoders.get(p.session);
if (!d) {
const session = p.session;
const decoder = new AudioDecoder({
output: data => this.onDecoded(session, data),
error: () => this.dropSession(session)
});
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
d = { decoder, lastFrame: -1, timer: null };
this.decoders.set(session, d);
if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1);
}
if (d.lastFrame >= 0 && p.frame > d.lastFrame + 6 && p.frame - d.lastFrame < 500) this.stats.lost++;
d.lastFrame = p.frame;
if (p.opus.length) {
d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus }));
}
if (!this.talking[p.session]) this.talking[p.session] = true;
if (d.timer) clearTimeout(d.timer);
// Speaking ends with the terminator, or when packets stop arriving
d.timer = setTimeout(() => { delete this.talking[p.session]; }, p.last ? this.settings.jitterMs + 60 : 400);
}
private onDecoded(session: number, data: AudioData): void {
const pcm = new Float32Array(data.numberOfFrames);
data.copyTo(pcm, { planeIndex: 0, format: 'f32-planar' });
data.close();
this.stats.decoded++;
this.playback?.port.postMessage({ session, pcm }, [pcm.buffer]);
}
private dropSession(session: number): void {
const d = this.decoders.get(session);
if (d) {
if (d.timer) clearTimeout(d.timer);
try { d.decoder.close(); } catch { /* closed */ }
this.decoders.delete(session);
}
this.playback?.port.postMessage({ drop: session });
delete this.talking[session];
}
private onLevels(_levels: Record<number, number>): void {
// Per-speaker peaks from the playback worklet, for future meters
}
// ─── Per-user controls ──────────────────────────────────────────────────────
private setSessionGain(session: number, gain: number): void {
this.sessionGains.set(session, gain);
this.playback?.port.postMessage({ session, gain });
}
setUserVolume(user: { session: number; hash: string; name: string }, volume: number): void {
this.settings.userVolumes[userKey(user)] = volume;
this.save();
this.setSessionGain(user.session, volume);
}
setLocalMute(user: { session: number; hash: string; name: string }, muted: boolean): void {
if (muted) this.settings.localMutes[userKey(user)] = true;
else delete this.settings.localMutes[userKey(user)];
this.save();
if (muted) this.dropSession(user.session);
}
setTesting(on: boolean): void {
this.testing = on;
if (on) this.start();
else {
this.playback?.port.postMessage({ drop: -1 });
if (!this.client) this.stop();
}
}
}
export const voice = new VoiceEngine();
+7
View File
@@ -0,0 +1,7 @@
// Minimal AudioWorklet global scope typings (not part of the DOM lib)
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
abstract process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
}
declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void;
declare const sampleRate: number;
+167
View File
@@ -0,0 +1,167 @@
import DOMPurify, { type Config } from 'dompurify';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube';
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
export function youtubeVideo(href: string): { id: string; start: number } | null {
let u: URL;
try { u = new URL(href); } catch { return null; }
const host = u.hostname.replace(/^(www|m|music)\./, '');
let id = '';
if (host === 'youtu.be') id = u.pathname.slice(1).split('/')[0];
else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
if (u.pathname === '/watch') id = u.searchParams.get('v') ?? '';
else id = /^\/(?:shorts|embed|live|v)\/([^/?#]+)/.exec(u.pathname)?.[1] ?? '';
}
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return null;
// t=90, t=1m30s, start=90
const t = u.searchParams.get('t') ?? u.searchParams.get('start') ?? '';
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/.exec(t);
const start = m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0;
return { id, start };
}
const EXT: Record<string, EmbedKind> = {
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', avif: 'image',
mp4: 'video', webm: 'video', mov: 'video', m4v: 'video',
mp3: 'audio', ogg: 'audio', opus: 'audio', wav: 'audio', flac: 'audio', m4a: 'audio'
};
export function mediaKind(name: string): EmbedKind | null {
const m = /\.([a-z0-9]+)$/i.exec(name);
return m ? EXT[m[1].toLowerCase()] ?? null : null;
}
const purifyConfig: Config & { RETURN_DOM_FRAGMENT: true } = {
ALLOWED_TAGS: ['a', 'b', 'i', 'u', 's', 'strong', 'em', 'br', 'p', 'span', 'div', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre', 'img', 'h1', 'h2', 'h3', 'h4', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
'hr', 'sub', 'sup', 'font'],
// style is reduced to a safe subset below; color is the <font color> attribute older clients send
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'color'],
ALLOW_DATA_ATTR: false,
RETURN_DOM_FRAGMENT: true
};
// Text styling that Mumble clients (Qt rich text) use; anything else is dropped
const STYLE_PROPS: Record<string, RegExp> = {
'color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
'background-color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
'font-weight': /^(normal|bold|bolder|lighter|[1-9]00)$/i,
'font-style': /^(normal|italic|oblique)$/i,
'text-decoration': /^(none|underline|line-through|overline)( (underline|line-through|overline))*$/i,
'font-size': /^(\d{1,2}(\.\d+)?(pt|px|em|%)|small|medium|large|x-large|xx-large|smaller|larger)$/i,
'text-align': /^(left|right|center|justify)$/i
};
// rgb(1, 2, 3) -> #010203, which every Mumble client understands
function normalizeColor(v: string): string {
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
return m ? '#' + m.slice(1, 4).map(n => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('') : v;
}
export function cleanStyle(style: string): string {
const out: string[] = [];
for (const decl of style.split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const prop = decl.slice(0, i).trim().toLowerCase();
let value = decl.slice(i + 1).trim().replace(/\s*!important$/i, '');
if (!STYLE_PROPS[prop]?.test(value)) continue;
if (prop.endsWith('color')) value = normalizeColor(value);
out.push(`${prop}:${value}`);
}
return out.join(';');
}
DOMPurify.addHook('afterSanitizeAttributes', node => {
const el = node as Element;
if (el.hasAttribute('style')) {
const clean = cleanStyle(el.getAttribute('style') ?? '');
if (clean) el.setAttribute('style', clean);
else el.removeAttribute('style');
}
if (el.hasAttribute('color') && !STYLE_PROPS.color.test(el.getAttribute('color') ?? '')) el.removeAttribute('color');
if (el.tagName === 'A') {
const href = el.getAttribute('href') ?? '';
if (!/^https?:\/\//i.test(href)) el.removeAttribute('href');
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
}
// Only inline data: images; remote images would leak our IP to arbitrary hosts
if (el.tagName === 'IMG' && !/^data:image\/(png|jpe?g|gif|webp);/i.test(el.getAttribute('src') ?? '')) {
el.replaceWith(document.createTextNode('[image]'));
}
});
// Sanitize an incoming Mumble HTML message and pull out media links from trusted hosts.
export function renderIncoming(html: string, trustedHosts: string[]): { html: string; embeds: Embed[] } {
const frag = DOMPurify.sanitize(html, purifyConfig);
const embeds: Embed[] = [];
for (const a of frag.querySelectorAll('a[href]')) {
const yt = youtubeVideo(a.getAttribute('href')!);
if (yt) {
if (!embeds.some(e => e.videoId === yt.id) && embeds.filter(e => e.kind === 'youtube').length < 3) {
embeds.push({ kind: 'youtube', url: a.getAttribute('href')!, name: a.textContent ?? '', videoId: yt.id, start: yt.start });
}
continue;
}
let url: URL;
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
if (!trustedHosts.includes(url.host.toLowerCase())) continue;
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
const kind = mediaKind(name);
if (kind) embeds.push({ kind, url: url.href, name });
}
// The inline thumbnail is only there for vanilla clients; we show the real media instead
if (embeds.some(e => e.kind !== 'youtube')) frag.querySelectorAll('img').forEach(img => img.remove());
const div = document.createElement('div');
div.append(frag);
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Plain composer text to the HTML Mumble clients expect: escaped, links clickable, newlines as <br/>.
// Output must stay well-formed XML: Murmur parses long messages as XML and denies them otherwise.
export function textToHtml(text: string): string {
return escapeHtml(text)
.replace(/https?:\/\/[^\s<]+[^\s<.,:;"')\]]/g, url => `<a href="${url}">${url}</a>`)
.replace(/\r?\n/g, '<br/>');
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`;
return `${(n / 1073741824).toFixed(2)} GB`;
}
// ─── Outgoing rich text (comments) ────────────────────────────────────────────
const VOID_TAGS = new Set(['br', 'hr', 'img']);
const OUT_ATTRS = new Set(['href', 'src', 'alt', 'title', 'style', 'color']);
const escText = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (t: string) => escText(t).replace(/"/g, '&quot;');
function serialize(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return escText(node.textContent ?? '');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as Element;
const tag = el.tagName.toLowerCase();
const attrs = [...el.attributes].filter(a => OUT_ATTRS.has(a.name)).map(a => ` ${a.name}="${escAttr(a.value)}"`).join('');
if (VOID_TAGS.has(tag)) return `<${tag}${attrs}/>`;
return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`;
}
// Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render
export function toMumbleHtml(html: string): string {
const frag = DOMPurify.sanitize(html, purifyConfig);
return [...frag.childNodes].map(serialize).join('')
.replace(/(<p><br\/><\/p>)+$/, '')
.replace(/^(<br\/>)+|(<br\/>)+$/g, '')
.trim();
}
+30
View File
@@ -0,0 +1,30 @@
// SVG path data (24x24 viewBox, square caps) shared by <Icon> and the tray icon
export const ICON_PATHS: Record<string, string> = {
mic: 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3',
'mic-off': 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18',
headphones: 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7z',
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
users: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
menu: 'M3 6h18M3 12h18M3 18h18',
x: 'M18 6L6 18M6 6l12 12',
power: 'M18.4 6.6a9 9 0 1 1-12.8 0M12 2v10',
lock: 'M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4',
file: 'M14 2H6v20h12V8zM14 2v6h6',
send: 'M22 2L11 13M22 2l-7 20-4-9-9-4z',
message: 'M21 15H7l-4 4V3h18z',
trash: 'M3 6h18M8 6V3h8v3M19 6l-1 15H6L5 6',
server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
download: 'M12 3v12M7 10l5 5 5-5M4 21h16',
list: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01',
'list-ordered': 'M10 6h11M10 12h11M10 18h11M4 4v4M3 18h3l-3 3h3M3 10h2l-2 3h2',
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
};
+27
View File
@@ -0,0 +1,27 @@
import { native, type IdentitySummary } from './native.ts';
// Client certificates kept by the desktop app's main process
class Identities {
list = $state<IdentitySummary[]>([]);
setupDone = $state(true);
loaded = $state(false);
async refresh(): Promise<void> {
if (!native) return;
const r = await native.identities.list();
this.list = r.identities;
this.setupDone = r.setupDone;
this.loaded = true;
}
get default(): IdentitySummary | undefined {
return this.list.find(i => i.isDefault) ?? this.list[0];
}
byId(id: string | undefined): IdentitySummary | undefined {
return this.list.find(i => i.id === id) ?? this.default;
}
}
export const identities = new Identities();
identities.refresh();
+25
View File
@@ -0,0 +1,25 @@
export interface MenuItem {
label: string;
action?: () => void;
danger?: boolean;
checked?: boolean;
disabled?: boolean;
separator?: boolean;
}
// One context menu for the whole app, opened at the pointer position
class MenuStore {
open = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
show(e: MouseEvent, items: MenuItem[]): void {
e.preventDefault();
e.stopPropagation();
this.open = { x: e.clientX, y: e.clientY, items };
}
close(): void {
this.open = null;
}
}
export const menu = new MenuStore();
+72
View File
@@ -0,0 +1,72 @@
import type { Transport, ServerCertInfo, CertDetails } from '../core/transport.ts';
interface NativeApi {
platformInfo(): Promise<{ os: string; osVersion: string }>;
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
findMumble(): Promise<{ name: string } | null>;
importMumble(name: string): Promise<IdentitySummary>;
exportP12(id: string, password: string): Promise<string | null>;
setDefault(id: string): Promise<void>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
finishSetup(): Promise<void>;
};
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void;
close(connId: string): void;
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
}
export interface IdentitySummary {
id: string;
name: string;
fingerprint: string;
isDefault: boolean;
cert: CertDetails;
}
declare global {
interface Window { mumh5Native?: NativeApi }
}
export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
// Transport backed by a TLS socket in the Electron main process.
// `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
// so the caller can check the server certificate before sending a password.
export class ElectronTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private id = crypto.randomUUID();
private offs: (() => void)[] = [];
private api: NativeApi;
constructor(api: NativeApi, host: string, port: number, identityId?: string) {
this.api = api;
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
const id = this.id;
this.offs.push(
api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }),
api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }),
api.on('close', (cid, reason) => {
if (cid !== id) return;
this.offs.forEach(off => off());
rejectSecure(new Error(reason));
this.onClose?.(reason);
})
);
api.open(id, host, port, identityId).catch(e => rejectSecure(e));
}
send(bytes: Uint8Array): void { this.api.send(this.id, bytes); }
close(): void { this.api.close(this.id); }
}
+678
View File
@@ -0,0 +1,678 @@
import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, ElectronTransport } from './native.ts';
import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts';
import { voice } from './audio/voice.svelte.ts';
const codec = createCodec();
// A chat view is a channel ("ch:<id>") or a direct conversation with a user ("dm:<session>").
export type ViewKey = `ch:${number}` | `dm:${number}`;
export interface ChatEntry {
id: number;
kind: 'text' | 'system';
author: string;
authorSession: number | null;
self: boolean;
html: string;
embeds: Embed[];
time: number;
}
export interface UploadJob {
id: number;
key: string;
name: string;
progress: number;
error: string;
}
export interface CertPrompt {
server: SavedServer;
hostPort: string;
fingerprint: string;
previous: string;
}
// Right-hand panel: member list, a channel (description preview or its side chat), or a user profile
export type Panel =
| { kind: 'members' }
| { kind: 'channel'; id: number; tab: 'info' | 'chat' }
| { kind: 'user'; session: number };
let nextId = 1;
export class Session {
status = $state<'idle' | 'connecting' | 'connected'>('idle');
error = $state('');
server = $state<SavedServer | null>(null);
// Bumped on every client state change; views read it to re-derive from the client's maps
tick = $state(0);
view = $state<ViewKey>('ch:0');
panel = $state<Panel>({ kind: 'members' });
logs = $state<Record<string, ChatEntry[]>>({});
unread = $state<Record<string, number>>({});
uploads = $state<UploadJob[]>([]);
certPrompt = $state<CertPrompt | null>(null);
rtt = $state(0);
// TLS details of the current server, for the certificate viewer and the trusted badge
serverCert = $state<ServerCertInfo | null>(null);
uploadInfo = $state<UploadInfo | null>(null);
// Channel tree expansion the user chose; channels not in here follow the default
expanded = $state<Record<number, boolean>>({});
client: MumbleClient | null = null;
// Incremented per connect so a slow, superseded attempt can tell it is stale
private attempt = 0;
private uploadInfoFor = '';
// Blob hashes already requested, so a preview does not ask the server repeatedly
private requested = new Set<string>();
// Last message with an inline image, resent as a plain link if the server refuses it
private lastImageSend: { key: string; fallback: string; at: number } | null = null;
// Channel we asked to move to; the chat view switches when the server confirms it
private pendingJoin: number | null = null;
// ─── Derived helpers ────────────────────────────────────────────────────────
// The client mutates its objects in place. These return shallow copies and read `tick`,
// so every state change hands Svelte new objects and dependent views update.
get self(): User | null {
void this.tick;
const u = this.client?.self;
return u ? { ...u } : null;
}
channel(id: number): Channel | undefined {
void this.tick;
const c = this.client?.channels.get(id);
return c && { ...c };
}
user(session: number): User | undefined {
void this.tick;
const u = this.client?.users.get(session);
return u && { ...u };
}
childrenOf(parent: number | null): Channel[] {
void this.tick;
if (!this.client) return [];
return [...this.client.channels.values()]
.filter(c => (parent === null ? c.id === 0 : c.parent === parent && c.id !== 0))
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))
.map(c => ({ ...c }));
}
usersIn(channelId: number): User[] {
void this.tick;
if (!this.client) return [];
return [...this.client.users.values()]
.filter(u => u.channelId === channelId)
.sort((a, b) => a.name.localeCompare(b.name))
.map(u => ({ ...u }));
}
allUsers(): User[] {
void this.tick;
return this.client ? [...this.client.users.values()].sort((a, b) => a.name.localeCompare(b.name)).map(u => ({ ...u })) : [];
}
// Users in a channel and all of its subchannels
subtreeUserCount(id: number): number {
void this.tick;
const client = this.client;
if (!client) return 0;
const ids = new Set([id]);
let grew = true;
while (grew) {
grew = false;
for (const c of client.channels.values()) {
if (c.parent != null && ids.has(c.parent) && !ids.has(c.id)) { ids.add(c.id); grew = true; }
}
}
let n = 0;
for (const u of client.users.values()) if (ids.has(u.channelId)) n++;
return n;
}
// Collapsed by default; channels with people in them open up, like the desktop client
isExpanded(id: number): boolean {
if (id === 0) return true;
return this.expanded[id] ?? this.subtreeUserCount(id) > 0;
}
toggleExpanded(id: number): void {
this.expanded[id] = !this.isExpanded(id);
}
setAllExpanded(open: boolean): void {
const all: Record<number, boolean> = {};
for (const id of this.client?.channels.keys() ?? []) all[id] = open;
this.expanded = all;
}
viewTitle(key: string = this.view): string {
const [kind, id] = key.split(':');
if (kind === 'ch') return this.channel(Number(id))?.name || 'Root';
return this.user(Number(id))?.name ?? 'Offline user';
}
// Keys whose chat is on screen right now (main view and an open side chat)
private visibleKeys(): string[] {
const keys: string[] = [this.view];
if (this.panel.kind === 'channel' && this.panel.tab === 'chat') keys.push(`ch:${this.panel.id}`);
return keys;
}
// ─── Connection ─────────────────────────────────────────────────────────────
async connect(server: SavedServer): Promise<void> {
this.disconnect();
const attempt = ++this.attempt;
this.server = server;
this.error = '';
this.logs = {};
this.unread = {};
this.expanded = {};
this.panel = { kind: 'members' };
this.requested.clear();
this.serverCert = null;
this.status = 'connecting';
if (!native) {
this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.');
return;
}
const transport = new ElectronTransport(native, server.host, server.port, server.identityId);
let info;
try {
info = await transport.secure;
} catch (e) {
if (attempt === this.attempt) this.fail((e as Error).message);
return;
}
if (attempt !== this.attempt) return transport.close();
this.serverCert = info;
// Trust on first use: pin the certificate, and stop before sending credentials if it changed
const hostPort = `${server.host.toLowerCase()}:${server.port}`;
const pinned = store.pins[hostPort];
if (!pinned) {
store.pin(hostPort, info.fingerprint);
} else if (pinned !== info.fingerprint && !info.authorized) {
transport.close();
this.certPrompt = { server, hostPort, fingerprint: info.fingerprint, previous: pinned };
this.fail('The server certificate changed.');
return;
}
const client = new MumbleClient(codec);
this.client = client;
this.wire(client);
// TLS is up but the server may still never finish the login
setTimeout(() => { if (this.client === client && !client.synced) client.disconnect('Login timed out'); }, 20000);
const platform = await native.platformInfo();
if (attempt !== this.attempt) return transport.close();
client.connect(transport, {
username: server.username,
password: server.password,
os: platform.os,
osVersion: platform.osVersion
});
}
acceptCertificate(): void {
const p = this.certPrompt;
if (!p) return;
store.pin(p.hostPort, p.fingerprint);
this.certPrompt = null;
this.connect(p.server);
}
disconnect(): void {
this.attempt++;
const client = this.client;
this.client = null;
client?.disconnect();
sessions.onClosed(this);
this.status = 'idle';
this.tick++;
}
private fail(msg: string): void {
this.status = 'idle';
this.error = msg;
}
private wire(client: MumbleClient): void {
const live = () => this.client === client;
const bump = () => { if (live()) this.tick++; };
client.on('synced', () => {
if (!live()) return;
this.status = 'connected';
this.view = `ch:${client.self?.channelId ?? 0}`;
sessions.onSynced(this);
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
bump();
});
client.on('channel', bump);
client.on('channelRemove', id => {
if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
bump();
});
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
client.on('user', (user, changed, _actor, isNew) => {
if (!live()) return;
const self = client.self;
if (client.synced && self) {
if (isNew) {
if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`);
} else if (changed.includes('userId') && user.userId != null) {
const who = user.session === self.session ? 'You are' : `${escapeHtml(user.name)} is`;
this.system(this.view, `${who} now registered on this server`);
} else if (changed.includes('channelId')) {
if (user.session === self.session) {
// Follow our own channel moves in the chat view (also from a DM when we asked to join)
if (this.view.startsWith('ch:') || this.pendingJoin === user.channelId) this.selectView(`ch:${user.channelId}`);
this.pendingJoin = null;
this.system(`ch:${user.channelId}`, `You joined ${escapeHtml(this.channel(user.channelId)?.name || 'Root')}`);
} else if (user.channelId === self.channelId) {
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} joined the channel`);
}
}
}
bump();
});
client.on('userRemove', (user, actor, reason, ban) => {
if (!live()) return;
const self = client.self;
if (self && user.channelId === self.channelId) {
const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null;
const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected';
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} ${what}${reason ? `: ${escapeHtml(reason)}` : ''}`);
}
if (this.panel.kind === 'user' && this.panel.session === user.session) this.panel = { kind: 'members' };
bump();
});
client.on('text', msg => { if (live()) this.receive(client, msg); });
client.on('permissionDenied', d => {
if (!live()) return;
// An image preview can exceed what the server accepts; the link alone still gets through
const img = this.lastImageSend;
if (d.type === 4 && img && Date.now() - img.at < 5000) {
this.lastImageSend = null;
this.system(img.key, 'The server refused the image preview, sent the link only');
client.sendText(this.target(img.key), img.fallback);
return;
}
if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null;
const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined;
this.system(this.view, escapeHtml(describeDenial(d, ch)));
});
client.on('close', reason => {
if (this.client !== client) return;
this.client = null;
sessions.onClosed(this);
this.status = 'idle';
this.error = reason === 'Disconnected' ? '' : reason;
this.tick++;
});
}
// ─── Panels, descriptions, comments ─────────────────────────────────────────
showChannel(id: number, tab: 'info' | 'chat' = 'info'): void {
this.panel = { kind: 'channel', id, tab };
if (tab === 'chat') delete this.unread[`ch:${id}`];
this.loadDescription(id);
}
showUser(session: number): void {
this.panel = { kind: 'user', session };
this.loadComment(session);
}
closePanel(): void {
this.panel = { kind: 'members' };
}
// Long descriptions and comments are only announced by hash; fetch the text on demand
loadDescription(id: number): void {
const c = this.client?.channels.get(id);
if (!c?.descriptionHash || c.description) return;
const tag = `d:${id}:${[...c.descriptionHash].join(',')}`;
if (this.requested.has(tag)) return;
this.requested.add(tag);
this.client!.requestBlob({ descriptions: [id] });
}
loadComment(session: number): void {
const u = this.client?.users.get(session);
if (!u?.commentHash || u.comment) return;
const tag = `c:${session}:${[...u.commentHash].join(',')}`;
if (this.requested.has(tag)) return;
this.requested.add(tag);
this.client!.requestBlob({ comments: [session] });
}
register(session: number): void {
this.client?.register(session);
}
// Mumble has no rename while connected: reconnect under the new name (saved for this server)
changeName(name: string): void {
const server = this.server;
if (!server || !name.trim()) return;
const updated = { ...$state.snapshot(server), username: name.trim() };
store.upsertServer(updated);
this.connect(updated);
}
// html comes from the rich editor, already sanitized XHTML
setComment(html: string): void {
this.client?.setComment(html);
}
// Largest description or image message the server accepts (0: no limit)
get htmlLimit(): number {
void this.tick;
return this.client?.config.imageMessageLength ?? 131072;
}
// ─── Chat ───────────────────────────────────────────────────────────────────
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void {
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
if (!this.logs[key]) this.logs[key] = [];
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() });
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1;
}
private system(key: string, html: string, trusted = false): void {
const rendered = trusted ? renderIncoming(html, store.trustedHosts) : { html, embeds: [] };
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
}
private receive(client: MumbleClient, msg: TextMessage): void {
const self = client.self;
const sender = msg.actor != null ? client.users.get(msg.actor) : undefined;
let key: string;
if (msg.actor == null) key = this.view;
else if (msg.channels.length || msg.trees.length) key = `ch:${msg.channels[0] ?? self?.channelId ?? 0}`;
else key = `dm:${msg.actor}`;
const rendered = renderIncoming(msg.html, store.trustedHosts);
if (msg.actor == null) {
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
} else {
this.push(key, { kind: 'text', author: sender ? store.displayName(sender) : `#${msg.actor}`, authorSession: msg.actor, self: false, ...rendered });
}
}
// Opening a server marks what is on screen as read
markVisibleRead(): void {
for (const k of this.visibleKeys()) delete this.unread[k];
}
selectView(key: ViewKey): void {
this.view = key;
delete this.unread[key];
}
// The view follows once the server confirms the move (see the 'user' handler). Murmur
// silently drops own state changes sent too quickly (leaky bucket, about one per second),
// so an unconfirmed join is sent once more before giving up.
joinChannel(id: number): void {
const client = this.client;
if (!client) return;
// Joining a channel is where you want to talk: voice moves to this server
sessions.setVoice(this);
if (this.self?.channelId === id) {
this.selectView(`ch:${id}`);
return;
}
this.pendingJoin = id;
client.joinChannel(id);
const moved = () => this.client !== client || this.pendingJoin !== id || client.self?.channelId === id;
setTimeout(() => {
if (moved()) return;
client.joinChannel(id);
setTimeout(() => {
if (moved()) return;
this.pendingJoin = null;
this.system(this.view, `The server did not move you to ${escapeHtml(client.channels.get(id)?.name || 'Root')}. Try again in a moment.`);
}, 1500);
}, 1200);
}
private target(key: string): { channels?: number[]; users?: number[] } {
const [kind, id] = key.split(':');
return kind === 'ch' ? { channels: [Number(id)] } : { users: [Number(id)] };
}
// Send HTML to a view and echo it locally (the server does not echo our messages).
sendHtml(html: string, key: string = this.view): boolean {
const client = this.client;
if (!client || this.status !== 'connected') return false;
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
if (limit && html.length > limit) {
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
return false;
}
client.sendText(this.target(key), html);
const rendered = renderIncoming(html, store.trustedHosts);
this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered });
return true;
}
sendText(text: string, key: string = this.view): boolean {
const trimmed = text.trim();
return trimmed ? this.sendHtml(textToHtml(trimmed), key) : false;
}
async uploadFiles(files: File[], key: string = this.view): Promise<void> {
const { uploadHost, uploadKey, uploadExpiry, sendThumbnails } = store.settings;
if (!uploadHost || !uploadKey) return this.sendInlineImages(files, key);
let info: UploadInfo;
try {
info = await this.loadUploadInfo();
} catch (e) {
this.system(key, `File sharing unavailable: ${escapeHtml((e as Error).message)}`);
return;
}
for (const f of files) {
const problem = checkFile(info, f);
if (problem) this.system(key, escapeHtml(problem));
}
for (const file of files.filter(f => !checkFile(info, f))) {
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
this.uploads.push(job);
const live = () => this.uploads.find(j => j.id === job.id)!;
try {
const res = await uploadFile(uploadHost, uploadKey, file, uploadExpiry, p => { live().progress = p; });
// Well-formed XHTML (<br/>, <img/>): Murmur rejects long messages that do not parse as XML
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
let html = link;
const cfg = this.client?.config;
if (sendThumbnails && cfg?.allowHtml !== false) {
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
const thumb = await makeThumbnail(file, budget);
if (thumb) html += `<br/><img src="${thumb}"/>`;
}
if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() };
this.sendHtml(html, key);
this.uploads = this.uploads.filter(j => j.id !== job.id);
} catch (e) {
live().error = (e as Error).message;
setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000);
}
}
}
// Without file hosting: images go inline like a pasted picture in the desktop client,
// scaled to the server's image limit. Other files need an upload host.
private async sendInlineImages(files: File[], key: string): Promise<void> {
const cfg = this.client?.config;
for (const file of files) {
if (!file.type.startsWith('image/')) {
this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`);
continue;
}
if (cfg?.allowHtml === false) {
this.system(key, 'This server does not allow images in messages.');
return;
}
// 0 means no limit; stay reasonable anyway
const limit = cfg?.imageMessageLength || 1024 * 1024;
const uri = await makeInlineImage(file, limit - 64);
if (!uri) {
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
continue;
}
this.sendHtml(`<img src="${uri}"/>`, key);
}
}
// Cached per host+key; refreshed when settings change
async loadUploadInfo(): Promise<UploadInfo> {
const { uploadHost, uploadKey } = store.settings;
const id = `${uploadHost}|${uploadKey}`;
if (this.uploadInfo && this.uploadInfoFor === id) return this.uploadInfo;
const info = await fetchUploadInfo(uploadHost, uploadKey);
this.uploadInfo = info;
this.uploadInfoFor = id;
return info;
}
// ─── Voice state and moderation ─────────────────────────────────────────────
setSelfMute(v: boolean) { this.client?.setSelfMute(v); }
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); }
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
setServerDeaf(session: number, v: boolean) { this.client?.setUserState(session, { deaf: v }); }
setPrioritySpeaker(session: number, v: boolean) { this.client?.setUserState(session, { priority_speaker: v }); }
moveUser(session: number, channelId: number) { this.client?.setUserState(session, { channel_id: channelId }); }
}
// ─── Several servers at once ───────────────────────────────────────────────────
// Every server has its own Session. Text works on all of them; voice runs on one (like
// Discord): the server where you last joined a channel. On the others you are shown as
// deafened, so nobody expects you to hear them, and your state is restored when voice returns.
class Sessions {
list = $state<Session[]>([]);
activeId = $state<string | null>(null);
voiceId = $state<string | null>(null);
private idle = new Session();
// Self mute/deaf before we deafened a background server, to restore later
private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
get active(): Session {
return this.list.find(s => s.server?.id === this.activeId) ?? this.idle;
}
byServer(id: string): Session | undefined {
return this.list.find(s => s.server?.id === id);
}
// Show a server, connecting if needed
open(server: SavedServer): void {
let s = this.byServer(server.id);
this.activeId = server.id;
s?.markVisibleRead();
if (!s) {
s = new Session();
this.list.push(s);
s = this.list[this.list.length - 1];
}
if (s.status === 'idle') s.connect(server);
}
select(id: string): void {
this.activeId = id;
this.active.markVisibleRead();
}
close(id: string): void {
const s = this.byServer(id);
if (!s) return;
s.disconnect();
this.list = this.list.filter(x => x !== s);
if (this.activeId === id) this.activeId = this.list.find(x => x.status !== 'idle')?.server?.id ?? null;
}
get voiceSession(): Session | undefined {
return this.list.find(s => s.server?.id === this.voiceId);
}
onSynced(s: Session): void {
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true);
else this.toBackground(s);
}
onClosed(s: Session): void {
this.saved.delete(s);
if (this.voiceId !== s.server?.id) return;
voice.detach();
// Hand voice to another connected server, if any
const next = this.list.find(x => x !== s && x.client && x.status === 'connected');
this.voiceId = null;
if (next) this.setVoice(next);
}
setVoice(s: Session, first = false): void {
const id = s.server?.id ?? null;
if (!id || (this.voiceId === id && !first)) return;
const prev = this.voiceSession;
if (prev && prev !== s) {
voice.detach();
this.toBackground(prev);
}
this.voiceId = id;
this.fromBackground(s);
if (s.client) voice.attach(s.client);
}
private toBackground(s: Session): void {
const self = s.client?.self;
if (!s.client || !self || this.saved.has(s)) return;
this.saved.set(s, { mute: self.selfMute, deaf: self.selfDeaf });
if (!self.selfDeaf) s.client.setSelfDeaf(true);
}
private fromBackground(s: Session): void {
const prev = this.saved.get(s);
this.saved.delete(s);
if (!prev || !s.client || prev.deaf) return;
s.client.send('UserState', { self_deaf: false, self_mute: prev.mute });
}
}
export const sessions = new Sessions();
// The session of the server on screen. Forwards to the active Session so components keep
// using `session.x`; methods are bound to the real instance so async work stays with its server.
export const session = new Proxy({} as Session, {
get(_t, prop) {
const a = sessions.active;
const v = Reflect.get(a, prop, a);
return typeof v === 'function' ? v.bind(a) : v;
},
set(_t, prop, value) {
return Reflect.set(sessions.active, prop, value, sessions.active);
}
});
+100
View File
@@ -0,0 +1,100 @@
import { load, loadList, save } from './storage.ts';
export interface SavedServer {
id: string;
label: string;
host: string;
port: number;
username: string;
password: string;
identityId?: string; // certificate to connect with; the default identity when unset
icon?: string; // custom rail icon, a small square data: image
}
export interface Settings {
uploadHost: string; // f0ckm base URL, e.g. https://f0ck.dev
uploadKey: string; // upload-only key from scripts/chat-upload-key.mjs
uploadExpiry: string; // 1h / 24h / 7d / 30d
embedHosts: string[]; // extra hosts whose media links render inline
sendThumbnails: boolean; // include a small inline image for vanilla Mumble clients
compact: boolean;
youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail
}
const defaults: Settings = {
uploadHost: '',
uploadKey: '',
uploadExpiry: '30d',
embedHosts: [],
sendThumbnails: true,
compact: false,
youtube: 'click'
};
class SettingsStore {
settings = $state<Settings>(load('mumh5.settings', defaults));
servers = $state<SavedServer[]>(loadList('mumh5.servers'));
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
pins = $state<Record<string, string>>(load('mumh5.pins', {}));
// Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {}));
saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
upsertServer(s: SavedServer) {
const i = this.servers.findIndex(x => x.id === s.id);
if (i === -1) this.servers.push(s);
else this.servers[i] = s;
save('mumh5.servers', $state.snapshot(this.servers));
}
// Reorder the server list (drag and drop in the rail)
moveServer(id: string, toIndex: number) {
const from = this.servers.findIndex(s => s.id === id);
if (from < 0) return;
const list = [...this.servers];
const [item] = list.splice(from, 1);
list.splice(Math.max(0, Math.min(list.length, toIndex)), 0, item);
this.servers = list;
save('mumh5.servers', $state.snapshot(this.servers));
}
setServerIcon(id: string, icon: string | undefined) {
const s = this.servers.find(x => x.id === id);
if (!s) return;
if (icon) s.icon = icon;
else delete s.icon;
save('mumh5.servers', $state.snapshot(this.servers));
}
removeServer(id: string) {
this.servers = this.servers.filter(s => s.id !== id);
save('mumh5.servers', $state.snapshot(this.servers));
}
setNickname(key: string, nick: string) {
if (nick.trim()) this.nicknames[key] = nick.trim();
else delete this.nicknames[key];
save('mumh5.nicknames', $state.snapshot(this.nicknames));
}
// Name to show for a user: our local nickname, else their name
displayName(u: { hash: string; name: string }): string {
return this.nicknames[u.hash || `name:${u.name}`] ?? u.name;
}
pin(hostPort: string, fingerprint: string) {
this.pins[hostPort] = fingerprint;
save('mumh5.pins', $state.snapshot(this.pins));
}
// Hosts whose media links are embedded: the upload host plus user-added ones.
// Other links stay plain links, so opening a chat never pings arbitrary servers.
get trustedHosts(): string[] {
const hosts = [...this.settings.embedHosts];
try { if (this.settings.uploadHost) hosts.push(new URL(this.settings.uploadHost).host); } catch { /* invalid URL */ }
return hosts.map(h => h.trim().toLowerCase()).filter(Boolean);
}
}
export const store = new SettingsStore();
+23
View File
@@ -0,0 +1,23 @@
// localStorage wrapper that tolerates missing or broken storage.
export function load<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
} catch {
return fallback;
}
}
export function loadList<T>(key: string): T[] {
try {
const raw = localStorage.getItem(key);
const v = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}
export function save(key: string, value: unknown): void {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage unavailable */ }
}
+80
View File
@@ -0,0 +1,80 @@
import { native } from './native.ts';
import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.svelte.ts';
import { ui } from './ui.svelte.ts';
type TrayLook = 'offline' | 'idle' | 'talking' | 'muted' | 'deafened';
const LOOK: Record<TrayLook, { bg: string; glyph: string }> = {
offline: { bg: '#4a4c54', glyph: 'mic' },
idle: { bg: '#7c6cff', glyph: 'mic' },
talking: { bg: '#2fb863', glyph: 'mic' },
muted: { bg: '#e0443e', glyph: 'mic-off' },
deafened: { bg: '#e0443e', glyph: 'headphones-off' }
};
const cache = new Map<TrayLook, string>();
// Square tile with a white glyph, like the rail tiles; 64 px so the OS can scale it down sharply
function drawIcon(look: TrayLook): string {
const hit = cache.get(look);
if (hit) return hit;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = LOOK[look].bg;
ctx.fillRect(0, 0, size, size);
ctx.translate(8, 8);
ctx.scale(2, 2); // 24 unit glyph into 48 px
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.6;
ctx.lineCap = 'square';
ctx.lineJoin = 'miter';
ctx.stroke(new Path2D(ICON_PATHS[LOOK[look].glyph]));
const url = canvas.toDataURL('image/png');
cache.set(look, url);
return url;
}
// Mirrors voice state into the system tray and handles its menu
export function startTray(): void {
if (!native) return;
const api = native;
$effect.root(() => {
$effect(() => {
const self = session.self;
const voiceSession = sessions.voiceSession;
const connected = session.status === 'connected';
const vSelf = voiceSession?.self;
const muted = !!(vSelf ? vSelf.selfMute || vSelf.mute : self?.selfMute);
const deafened = !!(vSelf ? vSelf.selfDeaf || vSelf.deaf : self?.selfDeaf);
const look: TrayLook = !connected && !voiceSession ? 'offline'
: deafened ? 'deafened' : muted ? 'muted' : voice.transmitting ? 'talking' : 'idle';
const server = voiceSession?.server?.label ?? session.server?.label ?? '';
const state = { offline: 'not connected', idle: 'connected', talking: 'talking', muted: 'muted', deafened: 'deafened' }[look];
api.updateTray({
icon: drawIcon(look),
tooltip: server ? `mumh5: ${state} on ${server}` : 'mumh5: not connected',
connected: !!(voiceSession?.status === 'connected' || connected),
server,
muted,
deafened,
mode: voice.settings.mode
});
});
});
api.onTrayAction(action => {
// Tray actions apply where voice runs
const target = sessions.voiceSession ?? sessions.active;
const self = target.self;
if (action === 'toggle-mute' && self) target.setSelfMute(!self.selfMute);
else if (action === 'toggle-deafen' && self) target.setSelfDeaf(!self.selfDeaf);
else if (action.startsWith('mode:')) voice.update({ mode: action.slice(5) as TransmitMode });
else if (action === 'settings') { ui.settingsTab = 'voice'; ui.settingsOpen = true; }
else if (action === 'disconnect' && target.server) sessions.close(target.server.id);
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { CertDetails } from '../core/transport.ts';
export interface PromptRequest {
title: string;
label: string;
confirm: string;
danger?: boolean;
input?: 'text' | 'password' | 'none';
initial?: string;
text?: string;
onsubmit: (value: string) => void;
}
export interface CertRequest {
title: string;
chain: CertDetails[];
status?: { ok: boolean; text: string };
}
// App-wide dialogs that can be opened from anywhere (menus, panels)
class Ui {
prompt = $state<PromptRequest | null>(null);
cert = $state<CertRequest | null>(null);
settingsOpen = $state(false);
wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output)
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
settingsTab = $state<'voice' | 'files' | 'identities'>('voice');
// Session whose connection information is shown
userInfo = $state<number | null>(null);
// Set when a panel was opened on a layout where it is an overlay
panelOpen = $state(false);
}
export const ui = new Ui();
+118
View File
@@ -0,0 +1,118 @@
export interface UploadResult {
url: string;
name: string;
mime: string;
inline: boolean;
size: number;
expires_at: number;
delete_token: string;
}
export interface UploadInfo {
allowed_mimes: string[];
allowed_extensions: string[];
max_bytes: number;
}
const endpoint = (host: string) => host.replace(/\/+$/, '') + '/api/chat/upload';
// Allowed types and limits of the upload host; also serves as a key check.
export async function fetchUploadInfo(host: string, key: string): Promise<UploadInfo> {
const r = await fetch(endpoint(host), { headers: { 'X-API-Key': key } });
const body = await r.json().catch(() => null);
if (r.status === 401) throw new Error('Key rejected');
if (!r.ok || !body?.success) throw new Error(body?.msg || `Unexpected response (HTTP ${r.status})`);
return body;
}
// Client-side pre-check; the host verifies the real type again after upload
export function checkFile(info: UploadInfo, file: File): string | null {
const ext = /\.([a-z0-9]+)$/i.exec(file.name)?.[1]?.toLowerCase() ?? '';
const typeOk = file.type ? info.allowed_mimes.includes(file.type) : info.allowed_extensions.includes(ext);
if (!typeOk) return `${file.name}: file type not allowed on this upload host`;
if (file.size > info.max_bytes) return `${file.name}: too large (max ${Math.round(info.max_bytes / 1048576)} MB)`;
return null;
}
// Value for <input type=file accept>
export function acceptAttr(info: UploadInfo | null): string {
if (!info) return '';
return [...info.allowed_mimes, ...info.allowed_extensions.map(e => '.' + e)].join(',');
}
// Upload one file to a f0ckm chat-upload endpoint. XHR instead of fetch for upload progress.
export function uploadFile(
host: string, key: string, file: File, expiry: string, onProgress: (fraction: number) => void
): Promise<UploadResult> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint(host));
xhr.setRequestHeader('X-API-Key', key);
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.setRequestHeader('X-Upload-Expiry', expiry);
if (file.type) xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
xhr.onerror = () => reject(new Error('Network error'));
xhr.onload = () => {
let body: any = null;
try { body = JSON.parse(xhr.responseText); } catch { /* not JSON */ }
if (xhr.status === 200 && body?.success) resolve(body);
else reject(new Error(body?.msg || `Upload failed (HTTP ${xhr.status})`));
};
xhr.send(file);
});
}
const readDataUrl = (file: Blob) => new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
// JPEG data URI of an image, scaled down until it fits in maxChars. Mumble clients render
// inline data: images, which is also how the desktop client sends pasted pictures.
export async function makeInlineImage(file: File, maxChars: number, sizes = [1280, 1024, 800, 640, 480, 320, 200, 120]): Promise<string | null> {
if (!/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(file.type) || maxChars < 2000) return null;
// Small enough as it is: send the original (keeps GIF animation and PNG transparency)
if (/^image\/(png|jpeg|gif|webp)$/.test(file.type) && Math.ceil(file.size / 3) * 4 + 40 < maxChars) {
return readDataUrl(file);
}
try {
const bmp = await createImageBitmap(file);
for (const size of sizes) {
const scale = Math.min(1, size / Math.max(bmp.width, bmp.height));
if (scale === 1 && size !== sizes[0] && Math.max(bmp.width, bmp.height) < size) continue;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bmp.width * scale));
canvas.height = Math.max(1, Math.round(bmp.height * scale));
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
for (const q of [0.85, 0.7, 0.5]) {
const uri = canvas.toDataURL('image/jpeg', q);
if (uri.length < maxChars) return uri;
}
}
} catch { /* undecodable image */ }
return null;
}
// Small preview next to an f0ckm link, for vanilla Mumble clients
export function makeThumbnail(file: File, maxChars: number): Promise<string | null> {
return makeInlineImage(file, maxChars, [320, 200, 120]);
}
// Square server icon: center-cropped, scaled to 128x128, as a small WebP data URI
export async function makeServerIcon(file: File, size = 128): Promise<string> {
const bmp = await createImageBitmap(file);
const side = Math.min(bmp.width, bmp.height);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, size, size);
bmp.close();
const webp = canvas.toDataURL('image/webp', 0.85);
// Browsers without WebP encoding fall back to PNG
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/png');
}
+7
View File
@@ -0,0 +1,7 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
import { startTray } from './lib/tray.svelte.ts';
mount(App, { target: document.getElementById('app')! });
startTray();
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
let { name, size = 32, speaking = false }: { name: string; size?: number; speaking?: boolean } = $props();
const hue = $derived([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 360);
</script>
<span class="avatar" class:speaking style:width="{size}px" style:height="{size}px"
style:font-size="{Math.round(size * 0.45)}px" style:background="hsl({hue} 45% 38%)">
{(name.trim()[0] ?? '?').toUpperCase()}
</span>
<style>
.avatar {
flex: none; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: #fff; user-select: none; box-shadow: 0 0 0 2px transparent;
transition: box-shadow 80ms;
}
.speaking { box-shadow: 0 0 0 2px var(--speaking); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
const p = $derived(session.certPrompt!);
</script>
<Modal title="Server certificate changed" onclose={() => (session.certPrompt = null)} width={520}>
<p>The certificate of <strong>{p.hostPort}</strong> is different from the one it had before. This happens when a server is reinstalled, but it can also mean someone is intercepting the connection. Your password has not been sent.</p>
<p class="k">Previous</p><code>{p.previous}</code>
<p class="k">Now</p><code>{p.fingerprint}</code>
{#snippet footer()}
<button class="btn" onclick={() => (session.certPrompt = null)}>Cancel</button>
<button class="btn danger" onclick={() => session.acceptCertificate()}>Trust new certificate</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
.k { margin: 12px 0 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
code { display: block; padding: 8px; background: var(--bg-0); font: 12px var(--mono); overflow-wrap: anywhere; }
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import Modal from './Modal.svelte';
import type { CertDetails } from '../core/transport.ts';
let { title, chain, status, onclose }:
{ title: string; chain: CertDetails[]; status?: { ok: boolean; text: string }; onclose: () => void } = $props();
let index = $state(0);
const cert = $derived(chain[index]);
// "CN=foo\nO=bar" -> [["CN","foo"],["O","bar"]]
const parts = (dn: string) => dn.split('\n').filter(Boolean).map(l => {
const i = l.indexOf('=');
return [l.slice(0, i), l.slice(i + 1)];
});
const names: Record<string, string> = { CN: 'Common name', O: 'Organization', OU: 'Unit', C: 'Country', ST: 'State', L: 'Locality', emailAddress: 'Email' };
const date = (iso: string) => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
const expired = $derived(cert ? new Date(cert.validTo) < new Date() : false);
function copyPem() { navigator.clipboard.writeText(cert.pem); }
</script>
<Modal {title} {onclose} width={620}>
{#if status}<p class="status" class:ok={status.ok}>{status.text}</p>{/if}
{#if chain.length > 1}
<div class="tabs" role="tablist">
{#each chain as c, i (c.fingerprint256)}
<button role="tab" aria-selected={i === index} class:active={i === index} onclick={() => (index = i)}>
{parts(c.subject).find(p => p[0] === 'CN')?.[1] ?? `Certificate ${i + 1}`}
</button>
{/each}
</div>
{/if}
{#if cert}
<h3>Subject</h3>
<dl>{#each parts(cert.subject) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Issuer {#if cert.selfSigned}<span class="tag">self-signed</span>{/if}</h3>
<dl>{#each parts(cert.issuer) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Validity {#if expired}<span class="tag bad">expired</span>{/if}</h3>
<dl>
<dt>Not before</dt><dd>{date(cert.validFrom)}</dd>
<dt>Not after</dt><dd>{date(cert.validTo)}</dd>
</dl>
<h3>Details</h3>
<dl>
<dt>Public key</dt><dd>{cert.keyType.toUpperCase()}{cert.keyBits ? ` ${cert.keyBits} bit` : ''}</dd>
{#if cert.subjectAltName}<dt>Alt names</dt><dd>{cert.subjectAltName}</dd>{/if}
<dt>Serial</dt><dd class="mono">{cert.serialNumber}</dd>
<dt>SHA-1</dt><dd class="mono">{cert.fingerprint}</dd>
<dt>SHA-256</dt><dd class="mono">{cert.fingerprint256}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn" onclick={copyPem}>Copy PEM</button>
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
.status { margin: 8px 0 0; padding: 8px 10px; border-left: 3px solid var(--warn); background: var(--bg-0); font-size: 14px; }
.status.ok { border-color: var(--speaking); }
.tabs { display: flex; gap: 2px; margin-top: 12px; overflow-x: auto; }
.tabs button { padding: 6px 12px; background: var(--bg-3); color: var(--text-dim); white-space: nowrap; font-size: 13px; }
.tabs button.active { background: var(--accent); color: var(--accent-text); }
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
.tag { text-transform: none; letter-spacing: 0; font-weight: 600; padding: 0 6px; border: 1px solid var(--warn); color: var(--warn); }
.tag.bad { border-color: var(--danger); color: var(--danger); }
dl { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
const users = $derived(session.usersIn(channel.id));
const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === channel.id);
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined;
function clickOrDouble(single: () => void, double: () => void) {
return {
onclick: () => { clearTimeout(timer); timer = setTimeout(single, 220); },
ondblclick: () => { clearTimeout(timer); double(); }
};
}
const channelClicks = clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); }
);
</script>
<li>
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
</button>
{:else}
<span class="caret"></span>
{/if}
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
<Icon name={channel.canEnter ? 'channel' : 'lock'} size={16} />
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if}
</button>
</div>
{#if users.length && expanded}
<ul class="users">
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble(
() => { session.showUser(u.session); ui.panelOpen = true; },
() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }
)}
<li>
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
{#if u.deaf || u.selfDeaf}
<span class="state" class:admin={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="state" class:admin={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if children.length && expanded}
<ul>
{#each children as c (c.id)}
<ChannelNode channel={c} depth={depth + 1} {onnavigate} />
{/each}
</ul>
{/if}
</li>
<style>
ul { list-style: none; margin: 0; padding: 0; }
li { margin: 0; }
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); }
.channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
.caret.collapsed svg { transform: rotate(-90deg); }
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
.user.self .label { color: var(--text); }
.label.talking { color: var(--speaking); }
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
.state { color: var(--text-faint); display: inline-flex; }
.state.admin { color: var(--danger); }
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import Icon from './Icon.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
const isDm = $derived(session.view.startsWith('dm:'));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return '';
return (session.channel(Number(session.view.slice(3)))?.description ?? '').replace(/<[^>]*>/g, ' ').trim();
});
</script>
<section class="chat" aria-label="Chat">
<header>
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
<Icon name={isDm ? 'message' : 'hash'} size={20} />
<h1>{title}</h1>
{#if topic}<span class="topic" title={topic}>{topic}</span>{/if}
<span class="grow"></span>
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header>
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
{#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
{:else if session.status === 'connecting'}
<h2>Connecting...</h2>
{:else}
<h2>mumh5</h2>
<p>Choose a server to get started.</p>
{/if}
</div>
{/snippet}
</ChatLog>
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
</section>
<style>
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topic { flex: 1 1 0; font-size: 13px; padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.grow { flex: 1; }
.topic + .grow { flex: 0; }
/* On phones the channel name matters more than its description */
@media (max-width: 600px) { .topic { display: none; } h1 { max-width: none; } .topic + .grow { flex: 1; } }
.empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; }
.empty p { margin: 0; color: var(--text-dim); }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Icon from './Icon.svelte';
import Message from './Message.svelte';
import { session } from '../lib/session.svelte.ts';
// Message list for one chat (channel or DM), with drag and drop upload into that chat
let { key, empty }: { key: string; empty: Snippet } = $props();
const entries = $derived(session.logs[key] ?? []);
const title = $derived(session.viewTitle(key));
let list: HTMLDivElement;
let pinned = true; // stick to the bottom unless the user scrolled up
let dragging = $state(0);
$effect(() => {
void entries.length;
void key;
if (pinned) tick().then(() => list && (list.scrollTop = list.scrollHeight));
});
// Media loads after insertion and grows the list; keep the bottom pinned
function onload() { if (pinned) list.scrollTop = list.scrollHeight; }
function ondrop(e: DragEvent) {
e.preventDefault();
dragging = 0;
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && session.status === 'connected') session.uploadFiles(files, key);
}
const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
};
</script>
<div class="wrap" role="log"
ondragenter={e => { if (e.dataTransfer?.types.includes('Files')) dragging++; }}
ondragleave={() => (dragging = Math.max(0, dragging - 1))}
ondragover={e => e.preventDefault()} {ondrop}>
<div class="list" bind:this={list} onscroll={() => (pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 40)}
onloadcapture={onload} onloadedmetadatacapture={onload}>
{#if !entries.length}{@render empty()}{/if}
{#each entries as entry, i (entry.id)}
<Message {entry} grouped={grouped(i)} />
{/each}
<div class="end"></div>
</div>
{#if dragging}
<div class="drop"><div><Icon name="download" size={40} /><p>Drop to upload to {key.startsWith('dm:') ? '@' : '#'}{title}</p></div></div>
{/if}
</div>
<style>
.wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.list { flex: 1; overflow-y: auto; overflow-x: hidden; padding-bottom: 12px; }
.end { height: 4px; }
.drop { position: absolute; inset: 0; z-index: 10; background: rgb(13 14 16 / 0.8); display: flex; align-items: center; justify-content: center; padding: 16px; pointer-events: none; }
.drop div { border: 2px dashed var(--accent); padding: 32px 48px; text-align: center; color: var(--text); }
.drop p { margin: 8px 0 0; font-weight: 600; }
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { acceptAttr } from '../lib/upload.ts';
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
let text = $state('');
let input: HTMLTextAreaElement;
let picker: HTMLInputElement;
const disabled = $derived(session.status !== 'connected');
const uploads = $derived(session.uploads.filter(u => u.key === target));
// Know the host's allowed types before the picker opens
$effect(() => {
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
});
function submit() {
if (session.sendText(text, target)) text = '';
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
function onpaste(e: ClipboardEvent) {
const files = [...(e.clipboardData?.files ?? [])];
if (files.length) {
e.preventDefault();
session.uploadFiles(files, target);
}
}
// Grow with content up to a limit
$effect(() => {
void text;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
});
$effect(() => {
void target;
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
});
</script>
<div class="composer">
{#if uploads.length}
<ul class="uploads">
{#each uploads as u (u.id)}
<li class:error={u.error}>
<Icon name="file" size={16} />
<span class="name">{u.name}</span>
{#if u.error}<span>{u.error}</span>{:else}<span class="bar"><span style:width="{u.progress * 100}%"></span></span>{/if}
</li>
{/each}
</ul>
{/if}
<div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste}
aria-label="Message"></textarea>
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button>
</div>
</div>
<style>
.composer { flex: none; padding: 0 16px 16px; }
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; }
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; }
.send:not(:disabled) { color: var(--accent-hover); }
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
.uploads li.error { color: var(--danger); border-color: var(--danger); }
.name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar { flex: 1; height: 4px; background: var(--bg-3); }
.bar span { display: block; height: 100%; background: var(--accent); transition: width 120ms; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
const initial = server;
let label = $state(initial?.label ?? '');
let host = $state(initial?.host ?? '');
let port = $state(initial?.port ?? 64738);
let username = $state(initial?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
let icon = $state(initial?.icon ?? '');
let iconError = $state('');
let iconPicker: HTMLInputElement;
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (!file) return;
iconError = '';
try { icon = await makeServerIcon(file); } catch { iconError = 'Could not read this image.'; }
}
const valid = $derived(host.trim() && username.trim() && port > 0 && port < 65536);
function build(): SavedServer {
// Accept "host:port" pasted into the host field
let h = host.trim(), p = Number(port);
const m = /^(.+):(\d+)$/.exec(h);
if (m && !h.includes(']')) { h = m[1]; p = Number(m[2]); }
return { id: initial?.id ?? crypto.randomUUID(), label: label.trim() || h, host: h, port: p, username: username.trim(), password, identityId: identityId || undefined, icon: icon || undefined };
}
function saveAndConnect(e: SubmitEvent) {
e.preventDefault();
if (!valid) return;
const s = build();
store.upsertServer(s);
// Reconnect with the new settings if it was connected
if (sessions.byServer(s.id)) sessions.close(s.id);
sessions.open(s);
onclose();
}
function remove() {
if (!initial) return;
sessions.close(initial.id);
store.removeServer(initial.id);
onclose();
}
</script>
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
<label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1}
<label for="c-id">Identity</label>
<select id="c-id" bind:value={identityId}>
<option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select>
{/if}
<label for="c-label">Label <span class="opt">(optional)</span></label>
<input id="c-label" bind:value={label} placeholder={host || 'My server'} />
<label for="c-icon">Icon <span class="opt">(optional)</span></label>
<div class="icon-row">
<span class="preview">
{#if icon}<img src={icon} alt="" />{:else}{(label || host || '?').trim().slice(0, 2).toUpperCase()}{/if}
</span>
<button id="c-icon" type="button" class="btn" onclick={() => iconPicker.click()}>{icon ? 'Change image' : 'Choose image'}</button>
{#if icon}<button type="button" class="btn" onclick={() => (icon = '')}>Remove</button>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
</div>
{#if iconError}<p class="err">{iconError}</p>{/if}
</form>
{#snippet footer()}
{#if initial}<button class="btn danger" onclick={remove}>Remove</button><span style="flex:1"></span>{/if}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" type="submit" form="connect" disabled={!valid}>Save and connect</button>
{/snippet}
</Modal>
<style>
.row { display: flex; gap: 8px; }
.port { width: 100px; flex: none; }
.icon-row { display: flex; align-items: center; gap: 8px; }
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { menu } from '../lib/menu.svelte.ts';
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
// Keep the menu on screen near the pointer
$effect(() => {
const m = menu.open;
if (!m || !el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
};
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
function onkeydown(e: KeyboardEvent) {
if (!menu.open) return;
if (e.key === 'Escape') menu.close();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const buttons = [...el!.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
buttons[(i + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
}
}
</script>
<svelte:window {onkeydown} onblur={() => menu.close()} onresize={() => menu.close()} />
{#if menu.open}
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#each menu.open.items as item, i (i)}
{#if item.separator}
<div class="sep" role="separator"></div>
{:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}>
<span class="label">{item.label}</span>
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
</button>
{/if}
{/each}
</div>
{/if}
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.menu {
position: fixed; z-index: 201; min-width: 200px; max-width: 280px; padding: 6px;
background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
}
button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 10px; font-size: 14px; text-align: left; color: var(--text); }
button:hover:not(:disabled), button:focus-visible { background: var(--accent); color: var(--accent-text); outline: none; }
button.danger { color: var(--danger); }
button.danger:hover:not(:disabled), button.danger:focus-visible { background: var(--danger); color: #fff; }
.label { flex: 1; }
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
</style>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { ICON_PATHS as paths } from '../lib/icons.ts';
let { name, size = 20 }: { name: string; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="square" stroke-linejoin="miter" aria-hidden="true">
<path d={paths[name] ?? ''} />
</svg>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { native, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
let message = $state('');
const usedBy = (id: string) => store.servers.filter(s => s.identityId === id).map(s => s.label);
async function act(task: () => Promise<unknown>) {
message = '';
try {
await task();
await identities.refresh();
} catch (e) {
message = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
}
function exportId(i: IdentitySummary) {
ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password',
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw);
if (path) message = `Saved to ${path}`;
})
};
}
function rename(i: IdentitySummary) {
ui.prompt = { title: 'Rename identity', label: 'Name', confirm: 'Rename', initial: i.name, onsubmit: n => act(() => native!.identities.rename(i.id, n)) };
}
function remove(i: IdentitySummary) {
const servers = usedBy(i.id);
ui.prompt = {
title: `Delete ${i.name}?`, label: '', confirm: 'Delete', danger: true, input: 'none',
text: `Registrations on servers are tied to this certificate and cannot be recovered without a backup.${servers.length ? ` Used by: ${servers.join(', ')}; they will use the default identity instead.` : ''}`,
onsubmit: () => act(() => native!.identities.remove(i.id))
};
}
</script>
<ul class="ids">
{#each identities.list as i (i.id)}
<li class:default={i.isDefault}>
<div class="head">
<strong>{i.name}</strong>
{#if i.isDefault}<span class="tag">default</span>{/if}
</div>
<code>{i.fingerprint}</code>
{#if usedBy(i.id).length}<p class="help">Used for {usedBy(i.id).join(', ')}</p>{/if}
<div class="actions">
{#if !i.isDefault}<button class="link" onclick={() => act(() => native!.identities.setDefault(i.id))}>Make default</button>{/if}
<button class="link" onclick={() => (ui.cert = { title: `Certificate: ${i.name}`, chain: [i.cert] })}>View certificate</button>
<button class="link" onclick={() => exportId(i)}>Back up</button>
<button class="link" onclick={() => rename(i)}>Rename</button>
<button class="link danger" onclick={() => remove(i)}>Delete</button>
</div>
</li>
{:else}
<li class="help">No identity yet; one is created when you first connect.</li>
{/each}
</ul>
<button class="btn" onclick={() => (ui.wizard = 'add')}>Add identity</button>
{#if message}<p class="help msg">{message}</p>{/if}
<style>
.ids { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
li { padding: 10px 12px; background: var(--bg-0); border: 1px solid var(--line); }
li.default { border-color: var(--accent); }
.head { display: flex; align-items: center; gap: 8px; }
.tag { font-size: 11px; font-weight: 600; padding: 0 6px; background: var(--accent); color: var(--accent-text); }
code { display: block; margin-top: 4px; font: 11px var(--mono); color: var(--text-dim); overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; }
.link { color: var(--link); font-size: 13px; }
.link:hover { text-decoration: underline; }
.link.danger { color: var(--danger); }
.help { margin: 4px 0 0; font-size: 13px; color: var(--text-dim); }
.msg { overflow-wrap: anywhere; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
let { mode = 'setup', onclose }: { mode?: 'setup' | 'add'; onclose: () => void } = $props();
type Step = 'welcome' | 'create' | 'import' | 'backup' | 'done';
let step = $state<Step>('welcome');
let busy = $state(false);
let error = $state('');
let result = $state<IdentitySummary | null>(null);
let mumble = $state<{ name: string } | null>(null);
// create
let name = $state('');
let email = $state('');
// import
let file = $state<File | null>(null);
let filePassword = $state('');
// backup
let backupPassword = $state('');
let backupConfirm = $state('');
let savedTo = $state('');
// svelte-ignore state_referenced_locally
const existing = mode === 'setup' ? identities.default : undefined;
native?.identities.findMumble().then(m => (mumble = m));
async function run(task: () => Promise<IdentitySummary>, next: Step) {
busy = true;
error = '';
try {
result = await task();
await identities.refresh();
step = next;
} catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
} finally {
busy = false;
}
}
const create = (e: SubmitEvent) => {
e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
};
const importFile = async (e: SubmitEvent) => {
e.preventDefault();
if (!file) return;
const bytes = new Uint8Array(await file.arrayBuffer());
run(() => native!.identities.importP12(bytes, filePassword, ''), 'done');
};
const importMumble = () => run(() => native!.identities.importMumble(''), 'done');
function keepExisting() {
result = existing ?? null;
step = 'backup';
}
async function saveBackup() {
if (!result) return;
busy = true;
error = '';
try {
const path = await native!.identities.exportP12(result.id, backupPassword);
if (path) {
savedTo = path;
step = 'done';
}
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function finish() {
if (mode === 'setup') await native?.identities.finishSetup();
await identities.refresh();
onclose();
}
const title = $derived(mode === 'setup' ? 'Set up your identity' : 'Add an identity');
const passwordsOk = $derived(backupPassword.length >= 8 && backupPassword === backupConfirm);
</script>
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'}
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
<div class="choices">
<button class="choice" onclick={() => (step = 'create')}>
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
</button>
{#if mumble}
<button class="choice" disabled={busy} onclick={importMumble}>
<Icon name="download" /><span><strong>Use my Mumble desktop identity</strong><small>Found "{mumble.name}" from the desktop client; keeps your registrations</small></span>
</button>
{/if}
<button class="choice" onclick={() => (step = 'import')}>
<Icon name="file" /><span><strong>Import a certificate file</strong><small>A .p12 or .pfx backup, for example exported from Mumble</small></span>
</button>
{#if existing}
<button class="choice" onclick={keepExisting}>
<Icon name="shield" /><span><strong>Keep the current identity</strong><small>"{existing.name}", created automatically earlier</small></span>
</button>
{/if}
</div>
{:else if step === 'create'}
<form id="w-create" onsubmit={create}>
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
<label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" />
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} />
</form>
{:else if step === 'import'}
<form id="w-import" onsubmit={importFile}>
<label for="w-file">Certificate file</label>
<input id="w-file" type="file" accept=".p12,.pfx,application/x-pkcs12" onchange={e => (file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null)} />
<label for="w-fpass">Password <span class="opt">(leave empty if it has none)</span></label>
<input id="w-fpass" type="password" bind:value={filePassword} autocomplete="off" />
</form>
{:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</p>
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label>
<input id="w-bconf" type="password" bind:value={backupConfirm} autocomplete="new-password" />
{:else if step === 'done'}
<p>You are set up{result ? ` as ${result.name}` : ''}.</p>
{#if result}<p class="mono">{result.fingerprint}</p>{/if}
{#if savedTo}<p class="ok">Backup saved to {savedTo}</p>{/if}
<p class="dim">Manage identities, pick one per server and export backups in Settings.</p>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
{#if step === 'welcome'}
{#if mode === 'add'}<button class="btn" onclick={onclose}>Cancel</button>{/if}
{:else if step === 'create'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-create" disabled={busy || !name.trim()}>{busy ? 'Generating...' : 'Create'}</button>
{:else if step === 'import'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-import" disabled={busy || !file}>Import</button>
{:else if step === 'backup'}
<button class="btn" onclick={() => (step = 'done')}>Skip for now</button>
<button class="btn primary" disabled={busy || !passwordsOk} onclick={saveBackup}>Save backup...</button>
{:else}
<button class="btn primary" onclick={finish}>Done</button>
{/if}
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.choice { display: flex; align-items: center; gap: 14px; padding: 12px 14px; text-align: left; background: var(--bg-3); border: 1px solid var(--line); }
.choice:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-hover); }
.choice span { display: flex; flex-direction: column; }
.choice small { color: var(--text-dim); font-size: 13px; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
.mono { font: 12px var(--mono); overflow-wrap: anywhere; color: var(--text); }
.ok { color: var(--speaking); overflow-wrap: anywhere; }
.dim { font-size: 13px; }
.error { color: var(--danger); }
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const here = $derived(session.self ? session.usersIn(session.self.channelId) : []);
const elsewhere = $derived(session.allUsers().filter(u => u.channelId !== session.self?.channelId));
</script>
<aside class="members" aria-label="Members">
{#snippet group(title: string, users: typeof here)}
{#if users.length}
<h3>{title} ({users.length})</h3>
<ul>
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
<li>
<button onclick={() => session.showUser(u.session)} title={isSelf ? 'You' : `${u.name} (double-click to message)`}
ondblclick={() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={30} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="who">
<span class="name">{store.displayName(u)}</span>
<span class="sub">{u.userId != null ? 'Registered' : 'Guest'}{title !== 'In channel' ? ` in ${session.channel(u.channelId)?.name || 'Root'}` : ''}</span>
</span>
{#if u.selfDeaf || u.deaf}<Icon name="headphones-off" size={14} />{:else if u.selfMute || u.mute}<Icon name="mic-off" size={14} />{/if}
</button>
</li>
{/each}
</ul>
{/if}
{/snippet}
{@render group('In channel', here)}
{@render group('Elsewhere', elsewhere)}
</aside>
<style>
.members { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 6px; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 14px 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 8px; text-align: left; color: var(--text-dim); }
button:hover { background: var(--bg-hover); color: var(--text); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sub { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import YouTubeEmbed from './YouTubeEmbed.svelte';
import { store } from '../lib/settings.svelte.ts';
import type { ChatEntry } from '../lib/session.svelte.ts';
let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props();
const time = $derived(new Date(entry.time));
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
</script>
{#if entry.kind === 'system'}
<div class="system">
<span class="mark" aria-hidden="true"></span>
<div class="html">{@html entry.html} <span class="time">{hhmm}</span></div>
</div>
{:else}
<article class="msg" class:grouped>
<div class="gutter">
{#if grouped}
<span class="time hover">{hhmm}</span>
{:else}
<Avatar name={entry.author} size={38} />
{/if}
</div>
<div class="content">
{#if !grouped}
<header>
<strong class:self={entry.self}>{entry.author}</strong>
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
</header>
{/if}
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
{#each entry.embeds as m (m.url)}
{#if m.kind === 'youtube'}
{#if store.settings.youtube !== 'off'}<div class="embed"><YouTubeEmbed videoId={m.videoId!} start={m.start} url={m.url} /></div>{/if}
{:else}
<div class="embed">
{#if m.kind === 'image'}
<a href={m.url} target="_blank" rel="noopener noreferrer"><img src={m.url} alt={m.name} loading="lazy" /></a>
{:else if m.kind === 'video'}
<video src={m.url} controls preload="metadata"><track kind="captions" /></video>
{:else}
<audio src={m.url} controls preload="metadata"></audio>
{/if}
</div>
{/if}
{/each}
</div>
</article>
{/if}
<style>
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
.msg.grouped { margin-top: 0; }
.msg:hover { background: rgb(255 255 255 / 0.02); }
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
.content { flex: 1; min-width: 0; }
header { display: flex; align-items: baseline; gap: 8px; }
header strong { font-weight: 600; }
header strong.self { color: var(--accent-hover); }
time, .time { font-size: 11px; color: var(--text-faint); }
.time.hover { visibility: hidden; line-height: 22px; }
.msg:hover .time.hover { visibility: visible; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(p) { margin: 0; }
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
.html :global(pre), .html :global(code) { font-family: var(--mono); font-size: 13px; background: var(--bg-0); padding: 1px 4px; }
.html :global(pre) { padding: 8px 10px; overflow-x: auto; }
.html :global(blockquote) { margin: 4px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--text-dim); }
.embed { margin-top: 6px; max-width: min(520px, 100%); }
.embed img, .embed video { display: block; max-width: 100%; max-height: 360px; background: var(--bg-0); border: 1px solid var(--line); }
.embed audio { width: 100%; }
.system { display: flex; gap: 14px; padding: 4px 16px; margin-top: 8px; color: var(--text-dim); font-size: 13px; }
.system .mark { width: 38px; flex: none; display: flex; justify-content: center; padding-top: 7px; }
.system .mark::before { content: ''; width: 6px; height: 6px; background: var(--text-faint); }
.system .time { margin-left: 6px; }
.system .html { flex: 1; min-width: 0; }
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
let { title, onclose, children, footer, width = 460 }:
{ title: string; onclose: () => void; children: Snippet; footer?: Snippet; width?: number } = $props();
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') onclose(); }} />
<div class="backdrop" role="presentation" onmousedown={e => { if (e.target === e.currentTarget) onclose(); }}>
<div class="modal" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}px">
<header>
<h2>{title}</h2>
<button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" /></button>
</header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 100; background: rgb(0 0 0 / 0.6);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
width: 100%; max-height: calc(100vh - 32px); display: flex; flex-direction: column;
background: var(--bg-1); border: 1px solid var(--line); box-shadow: 0 16px 48px rgb(0 0 0 / 0.5);
}
header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 0 20px; }
h2 { margin: 0; font-size: 18px; }
.body { padding: 4px 20px 20px; overflow-y: auto; }
footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; background: var(--bg-0); border-top: 1px solid var(--line); }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Modal from './Modal.svelte';
let { title, label, confirm, danger = false, input = 'text', initial = '', text = '', onsubmit, onclose }:
{ title: string; label: string; confirm: string; danger?: boolean; input?: 'text' | 'password' | 'none'; initial?: string;
text?: string; onsubmit: (value: string) => void; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
let value = $state(initial);
</script>
<Modal {title} {onclose}>
<form id="prompt" onsubmit={e => { e.preventDefault(); onsubmit(value); onclose(); }}>
{#if text}<p>{text}</p>{/if}
{#if input !== 'none'}
<label for="p-val">{label}</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="p-val" type={input} bind:value autofocus autocomplete="off" />
{/if}
</form>
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn" class:primary={!danger} class:danger type="submit" form="prompt">{confirm}</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
const q = $derived(ui.quickAudio!);
const s = voice.settings;
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
voice.refreshDevices();
// Open above the button it came from, kept on screen
$effect(() => {
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(q.x - r.width / 2, window.innerWidth - r.width - 8)),
y: Math.max(8, q.y - r.height - 8)
};
});
const close = () => (ui.quickAudio = null);
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const modes: { id: TransmitMode; label: string }[] = [
{ id: 'vad', label: 'Activity' }, { id: 'ptt', label: 'Push to talk' }, { id: 'continuous', label: 'Always on' }
];
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') close(); }} onresize={close} />
<div class="catcher" role="presentation" onmousedown={close} oncontextmenu={e => { e.preventDefault(); close(); }}></div>
<div class="pop" role="dialog" aria-label={q.kind === 'input' ? 'Microphone' : 'Output'} bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#if q.kind === 'input'}
<label for="qa-in">Microphone</label>
<select id="qa-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-gain">Input volume: {Math.round(s.inputGain * 100)}%</label>
<input id="qa-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
<div class="meter" aria-hidden="true"><div class="fill" class:open={s.mode !== 'vad' || voice.level > s.vadThreshold} style:width="{pct(voice.level)}%"></div></div>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>{m.label}</button>
{/each}
</div>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
{:else}
<label for="qa-out">Output device</label>
<select id="qa-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="qa-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
{/if}
<button class="more" onclick={() => { close(); ui.settingsTab = 'voice'; ui.settingsOpen = true; }}>Voice settings...</button>
</div>
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.pop { position: fixed; z-index: 201; width: 260px; padding: 10px 12px 8px; background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); }
label { margin-top: 10px; }
label:first-child { margin-top: 0; }
select { font-size: 13px; padding: 6px 8px; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.meter { height: 6px; background: var(--bg-3); margin-top: 4px; overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.fill.open { background: var(--speaking); }
.modes { display: flex; gap: 2px; margin-top: 10px; }
.modes button { flex: 1; padding: 5px 4px; font-size: 12px; background: var(--bg-3); color: var(--text-dim); }
.modes button.active { background: var(--accent); color: var(--accent-text); }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 13px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
.more { display: block; width: 100%; margin-top: 10px; padding: 6px 0 0; border-top: 1px solid var(--line); text-align: left; font-size: 13px; color: var(--link); }
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts';
// Rich text in the subset Mumble clients render (Qt rich text): bold, italic, underline,
// strikethrough, colors, lists, links and inline data: images. `limit` is the server's
// size limit for the whole HTML, pictures included.
let { value = '', limit, placeholder = '', onchange }:
{ value?: string; limit: number; placeholder?: string; onchange: (html: string) => void } = $props();
let editor: HTMLDivElement;
let picker: HTMLInputElement;
let source = $state(false);
let sourceText = $state('');
let html = $state('');
let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit);
onMount(() => {
document.execCommand('defaultParagraphSeparator', false, 'p');
document.execCommand('styleWithCSS', false, 'true');
editor.innerHTML = toMumbleHtml(value) || '<p><br></p>';
sync();
});
function sync() {
html = toMumbleHtml(editor.innerHTML);
onchange(html);
}
function exec(cmd: string, arg?: string) {
editor.focus();
document.execCommand(cmd, false, arg);
sync();
}
// window.prompt does not exist in Electron; use the app's dialog and restore the selection after
function link() {
const sel = window.getSelection();
const range = sel && sel.rangeCount && editor.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
ui.prompt = {
title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
onsubmit: raw => {
const url = raw.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
editor.focus();
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
if (range && !range.collapsed) exec('createLink', url);
else exec('insertHTML', `<a href="${url.replace(/"/g, '&quot;')}">${url.replace(/</g, '&lt;')}</a>`);
}
};
}
async function insertImage(file: File) {
error = '';
// Room left for the picture inside the server's limit
const room = (limit || 131072) - size - 64;
const uri = await makeInlineImage(file, room, [640, 480, 320, 200, 120]);
if (!uri) {
error = room < 2000 ? 'No room left for a picture within the server limit.' : 'Could not make this picture small enough.';
return;
}
exec('insertImage', uri);
}
function onpaste(e: ClipboardEvent) {
const img = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
e.preventDefault();
if (img) return insertImage(img);
const pasted = e.clipboardData?.getData('text/html');
if (pasted) exec('insertHTML', toMumbleHtml(pasted));
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
}
function toggleSource() {
if (!source) {
sourceText = html;
source = true;
} else {
editor.innerHTML = toMumbleHtml(sourceText) || '<p><br></p>';
source = false;
sync();
}
}
function onkeydown(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
const map: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
const cmd = map[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); exec(cmd); }
}
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
{#if source}
<textarea class="area src" bind:value={sourceText} oninput={() => onchange(toMumbleHtml(sourceText))} aria-label="HTML source" spellcheck="false"></textarea>
{/if}
<div class="foot">
{#if error}<span class="err">{error}</span>{/if}
<span class="grow"></span>
<span class="count" title="Size of the description as the server counts it">{formatBytes(source ? sourceText.length : size)}{limit ? ` / ${formatBytes(limit)}` : ''}</span>
</div>
</div>
<style>
.rich { border: 1px solid var(--line); background: var(--bg-0); }
.rich:focus-within { border-color: var(--accent); }
.rich.over { border-color: var(--danger); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
.area.hidden { display: none; }
.area :global(p) { margin: 0 0 6px; }
.area :global(img) { max-width: 100%; }
.area.blank::before { content: attr(data-placeholder); color: var(--text-faint); position: absolute; left: 10px; top: 8px; pointer-events: none; }
.src { width: 100%; border: 0; resize: vertical; font: 12px var(--mono); background: transparent; }
.foot { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-top: 1px solid var(--line); font-size: 12px; color: var(--text-faint); }
.over .count { color: var(--danger); font-weight: 600; }
.err { color: var(--danger); }
</style>
+239
View File
@@ -0,0 +1,239 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import Members from './Members.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu, changeNamePrompt, nicknamePrompt } from '../lib/actions.ts';
import { voice, userKey } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte';
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
const panel = $derived(session.panel);
const channel = $derived(panel.kind === 'channel' ? session.channel(panel.id) : undefined);
const user = $derived(panel.kind === 'user' ? session.user(panel.session) : undefined);
const isSelf = $derived(!!user && user.session === session.self?.session);
// Description or comment arrive later when they were only announced by hash
// Re-runs when the hash changes, e.g. right after saving a long description
$effect(() => { if (channel?.descriptionHash && !channel.description) session.loadDescription(channel.id); });
$effect(() => { if (user?.commentHash && !user.comment) session.loadComment(user.session); });
const description = $derived(channel?.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
const comment = $derived(user?.comment ? renderIncoming(user.comment, store.trustedHosts).html : '');
const pendingDescription = $derived(!!channel?.descriptionHash && !channel.description);
const pendingComment = $derived(!!user?.commentHash && !user.comment);
// User-resizable width, remembered between sessions
const MIN_W = 240, DEFAULT_W = 300;
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(MIN_W, Math.min(maxW(), w)));
try { localStorage.setItem('mumh5.panelWidth', String(width)); } catch { /* storage unavailable */ }
}
function startResize(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
const right = (handle.parentElement as HTMLElement).getBoundingClientRect().right;
const move = (ev: PointerEvent) => setWidth(right - ev.clientX);
const up = () => {
resizing = false;
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function resizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') setWidth(width + 16);
else if (e.key === 'ArrowRight') setWidth(width - 16);
else return;
e.preventDefault();
}
let editing = $state(false);
let draft = $state('');
$effect(() => { void panel; editing = false; });
function startEdit() {
draft = user?.comment ?? '';
editing = true;
}
function saveComment() {
session.setComment(draft);
editing = false;
}
</script>
<svelte:window onresize={() => setWidth(width)} />
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
{#if panel.kind === 'members' || (!channel && !user)}
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
<Members {onnavigate} />
{:else if channel && panel.kind === 'channel'}
<header>
<Icon name="channel" size={18} />
<h2>{channel.name || 'Root'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={panel.tab === 'info'} class:active={panel.tab === 'info'} onclick={() => session.showChannel(channel.id, 'info')}>Info</button>
<button role="tab" aria-selected={panel.tab === 'chat'} class:active={panel.tab === 'chat'} onclick={() => session.showChannel(channel.id, 'chat')}>
Chat{#if session.unread[`ch:${channel.id}`]}<span class="badge">{session.unread[`ch:${channel.id}`]}</span>{/if}
</button>
</div>
{#if panel.tab === 'info'}
<div class="body">
{#if description}
<div class="html">{@html description}</div>
{:else if pendingDescription}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">This channel has no description.</p>
{/if}
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
<div class="actions">
{#if session.self?.channelId === channel.id}
<span class="here">You are in this channel</span>
{:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
{channel.canEnter ? 'Join channel' : 'No access'}
</button>
{/if}
<button class="btn" onclick={() => session.showChannel(channel.id, 'chat')}>Open chat</button>
</div>
</div>
{:else}
<ChatLog key={`ch:${channel.id}`}>
{#snippet empty()}<p class="dim pad">Messages sent here go to everyone in #{channel.name || 'Root'}, even if you are not in it.</p>{/snippet}
</ChatLog>
<Composer target={`ch:${channel.id}`} placeholder={`Message #${channel.name || 'Root'}`} />
{/if}
{:else if user}
<header>
<h2>{isSelf ? 'Your profile' : 'Profile'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="body">
<div class="who" role="presentation" oncontextmenu={e => menu.show(e, userMenu(user))}>
<Avatar name={user.name} size={56} speaking={voice.talking[user.session] || (isSelf && voice.transmitting)} />
<div>
<h3>{store.displayName(user)}</h3>
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
<p class="badges">
<span class="badge-reg" class:registered={user.userId != null}>{user.userId != null ? 'Registered' : 'Guest'}</span>
{#if isSelf && user.userId == null}
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
{/if}
<span class="dim small">in {session.channel(user.channelId)?.name || 'Root'}</span>
</p>
<p class="flags">
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
{#if user.prioritySpeaker}<span>priority speaker</span>{/if}
{#if user.recording}<span class="bad">recording</span>{/if}
</p>
</div>
</div>
{#if isSelf}
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
{:else}
<h4>Voice</h4>
<label for="p-vol" class="vol">Volume for me: {Math.round((voice.settings.userVolumes[userKey(user)] ?? 1) * 100)}%</label>
<input id="p-vol" type="range" min="0" max="3" step="0.05" value={voice.settings.userVolumes[userKey(user)] ?? 1}
oninput={e => voice.setUserVolume(user, Number(e.currentTarget.value))} />
<div class="actions">
<button class="btn" class:danger={!!voice.settings.localMutes[userKey(user)]} onclick={() => voice.setLocalMute(user, !voice.settings.localMutes[userKey(user)])}>
{voice.settings.localMutes[userKey(user)] ? 'Unmute for me' : 'Mute for me'}
</button>
<button class="btn" onclick={() => nicknamePrompt(user)}>Set nickname</button>
</div>
{/if}
<h4>Description</h4>
{#if editing}
<RichEditor value={draft} limit={session.htmlLimit} placeholder="Tell people about yourself" onchange={h => (draft = h)} />
<div class="actions">
<button class="btn primary" onclick={saveComment}>Save</button>
<button class="btn" onclick={() => (editing = false)}>Cancel</button>
</div>
{:else}
{#if comment}
<div class="html">{@html comment}</div>
{:else if pendingComment}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">{isSelf ? 'You have no description yet.' : 'No description.'}</p>
{/if}
{#if isSelf}<div class="actions"><button class="btn" onclick={startEdit}>Edit description</button></div>{/if}
{/if}
{#if !isSelf}
<div class="actions">
<button class="btn primary" onclick={() => { session.selectView(`dm:${user.session}`); onnavigate(); }}>Send message</button>
<button class="btn" onclick={e => menu.show(e, userMenu(user))}>More</button>
</div>
{/if}
</div>
{/if}
</aside>
<style>
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
.panel.resizing { user-select: none; }
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
.tabs button { flex: 1; padding: 10px; color: var(--text-dim); font-weight: 600; font-size: 14px; display: inline-flex; justify-content: center; gap: 6px; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.badge { background: var(--danger); color: #fff; font-size: 11px; padding: 0 5px; line-height: 16px; }
.body { flex: 1; overflow-y: auto; padding: 14px; }
.pad { padding: 14px; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 13px; margin-top: 8px; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(img) { max-width: 100%; }
.html :global(p) { margin: 0 0 6px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
.here { color: var(--speaking); font-weight: 600; font-size: 14px; }
.who { display: flex; gap: 14px; align-items: center; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.badges { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; }
.badges .small { margin: 0; }
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.register { font-size: 12px; color: var(--link); }
.register:hover { text-decoration: underline; }
.vol { margin-top: 0; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
.flags .bad { border-color: var(--danger); color: var(--danger); }
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props();
const initials = (label: string) =>
label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
const unreadOf = (id: string) => {
const s = sessions.byServer(id);
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
};
// Drag and drop reordering
let dragging = $state<string | null>(null);
let dropIndex = $state<number | null>(null);
function onDragOver(e: DragEvent, i: number) {
if (!dragging) return;
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropIndex = e.clientY < r.top + r.height / 2 ? i : i + 1;
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (dragging && dropIndex !== null) {
const from = store.servers.findIndex(s => s.id === dragging);
store.moveServer(dragging, dropIndex > from ? dropIndex - 1 : dropIndex);
}
dragging = null;
dropIndex = null;
}
// Icon picked from the tile's context menu
let iconPicker: HTMLInputElement;
let iconFor = '';
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
}
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
menu.show(e, [
state === 'idle'
? { label: 'Connect', action: () => sessions.open(s) }
: { label: 'Disconnect', action: () => sessions.close(s.id) },
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
{ separator: true, label: '' },
{ label: 'Move up', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
{ label: 'Move down', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
{ separator: true, label: '' },
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
{ label: 'Edit...', action: () => onedit(s) }
]);
}
</script>
<nav class="rail" aria-label="Servers" ondrop={onDrop} ondragover={e => { if (dragging) e.preventDefault(); }}>
{#each store.servers as s, i (s.id)}
{@const state = stateOf(s.id)}
{@const unread = unreadOf(s.id)}
{#if dropIndex === i && dragging}<div class="drop-line"></div>{/if}
<button class="tile" class:active={sessions.activeId === s.id} class:live={state === 'connected'}
class:connecting={state === 'connecting'} class:voice={sessions.voiceId === s.id && state === 'connected'}
class:dragged={dragging === s.id}
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
draggable="true"
ondragstart={e => { dragging = s.id; e.dataTransfer?.setData('text/plain', s.id); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; }}
ondragend={() => { dragging = null; dropIndex = null; }}
ondragover={e => onDragOver(e, i)}
onclick={() => sessions.open(s)}
oncontextmenu={e => serverMenu(e, s, i)}>
{#if s.icon}<img class="icon" src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}
{#if unread}<span class="unread">{unread > 99 ? '99+' : unread}</span>{/if}
</button>
{/each}
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
<style>
.rail {
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
}
.tile {
position: relative; width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
background: var(--bg-3); font-weight: 700; font-size: 15px; transition: background 100ms, color 100ms;
}
.tile:hover { background: var(--accent); color: var(--accent-text); }
.tile.active { background: var(--accent); color: var(--accent-text); }
.tile.active::before {
content: ''; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 4px; background: var(--text);
}
.tile.dragged { opacity: 0.4; }
.icon { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
/* Connection state: grey dot connecting, green connected, green with ring where voice runs */
.tile.live::after, .tile.connecting::after {
content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px;
background: var(--speaking); border: 2px solid var(--bg-0);
}
.tile.connecting::after { background: var(--text-faint); }
.tile.voice::after { box-shadow: 0 0 0 2px var(--speaking); }
.unread {
position: absolute; right: -6px; top: -6px; min-width: 18px; padding: 0 4px; line-height: 16px;
font-size: 10px; background: var(--danger); color: #fff; border: 2px solid var(--bg-0);
}
.drop-line { width: 44px; height: 3px; flex: none; background: var(--accent); margin: -5px 0; }
.add { color: var(--speaking); background: var(--bg-2); }
.add:hover { background: var(--speaking); color: var(--bg-0); }
.ghost { background: transparent; color: var(--text-dim); }
.ghost:hover { background: var(--bg-3); color: var(--text); }
.spacer { flex: 1; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import Modal from './Modal.svelte';
import VoiceSettings from './VoiceSettings.svelte';
import IdentityList from './IdentityList.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
// Changes apply right away
function saveFiles() {
s.uploadHost = s.uploadHost.trim().replace(/\/+$/, '');
s.uploadKey = s.uploadKey.trim();
s.embedHosts = embedHosts.split(/[\s,]+/).map(h => h.trim()).filter(Boolean);
store.saveSettings();
}
async function test() {
saveFiles();
testResult = 'Checking...';
try {
const info = await fetchUploadInfo(s.uploadHost, s.uploadKey);
testResult = `Key works. Allowed: ${info.allowed_extensions.join(', ')} (max ${Math.round(info.max_bytes / 1048576)} MB)`;
} catch (e) {
testResult = (e as Error).message;
}
}
type Tab = 'voice' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
</script>
<Modal title="Settings" onclose={() => { saveFiles(); onclose(); }} width={640}>
<div class="tabs" role="tablist">
{#each tabs as t (t.id)}
<button role="tab" aria-selected={tab === t.id} class:active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'voice'}
<VoiceSettings />
{:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label>
<input id="s-host" bind:value={s.uploadHost} onchange={saveFiles} placeholder="https://f0ck.dev" />
<label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row">
<div>
<label for="s-exp">Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}>
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button>
</div>
{#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label>
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
<label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</option>
</select>
{:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
<IdentityList />
{/if}
{#snippet footer()}
<button class="btn primary" onclick={() => { saveFiles(); onclose(); }}>Done</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
h3 { margin: 22px 0 0; font-size: 14px; }
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: flex-end; gap: 8px; }
.row > div { flex: 1; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; }
textarea { resize: vertical; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const roots = $derived(session.childrenOf(null));
const self = $derived(session.self);
const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized);
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
function showServerCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`,
chain: cert.chain,
status: trusted
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
const dms = $derived(
Object.keys(session.logs).filter(k => k.startsWith('dm:')).map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
);
</script>
<aside class="sidebar">
<header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
<span class="status">
{#if session.status === 'connected'}
<span class="dot"></span>Connected{#if session.rtt} , {session.rtt} ms{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
<div class="scroll">
{#if session.status === 'connected'}
<ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
</ul>
{#if dms.length}
<h3>Direct messages</h3>
<ul class="tree">
{#each dms as dm (dm.key)}
<li>
<button class="dm" class:viewing={session.view === dm.key}
onclick={() => { session.selectView(dm.key); onnavigate(); }}>
<Avatar name={dm.user?.name ?? '?'} size={22} />
<span class="label" class:offline={!dm.user}>{dm.user?.name ?? 'Offline user'}</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.error}
<p class="error">{session.error}</p>
{:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
{/if}
</div>
{#if self && sessions.voiceSession && sessions.voiceSession !== sessions.active}
<div class="elsewhere">
<span>Voice is on <strong>{sessions.voiceSession.server?.label}</strong>, deafened here</span>
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div>
{/if}
{#if self}
<footer class="me">
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} />
<span class="who">
<strong>{self.name}</strong>
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span>
</button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</footer>
{/if}
</aside>
<style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
header.trusted .dot { background: #fff; }
.title { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-left: 14px; text-align: left; }
.title:disabled { opacity: 1; }
.title:hover:not(:disabled) { background: rgb(255 255 255 / 0.05); }
.title strong { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.scroll { flex: 1; overflow-y: auto; padding: 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 18px 8px 6px; }
.dm { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; color: var(--text-dim); text-align: left; }
.dm:hover { background: var(--bg-hover); color: var(--text); }
.dm.viewing { background: var(--bg-active); color: var(--text); }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offline { color: var(--text-faint); font-style: italic; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.hint, .error { padding: 8px; color: var(--text-dim); font-size: 14px; }
.error { color: var(--danger); }
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { native } from '../lib/native.ts';
import { formatBytes } from '../lib/html.ts';
import type { CertDetails } from '../core/transport.ts';
let { target, onclose }: { target: number; onclose: () => void } = $props();
// The dialog is remounted per user ({#key} in App), so the session is fixed for its lifetime
// svelte-ignore state_referenced_locally
const who = target;
// The server fills in only what we may see: details for ourselves and admins,
// packet statistics for people in our channel, times and pings for everyone.
let stats = $state<any>(null);
let certs = $state<CertDetails[]>([]);
let updated = $state(0);
const client = session.client;
const user = $derived(session.user(who));
const off = client?.on('userStats', msg => {
if (msg.session !== who) return;
// Refreshes ask for statistics only; keep the details from the first answer
stats = stats && !msg.version ? { ...stats, ...msg, version: stats.version, certificates: stats.certificates, address: stats.address } : msg;
updated = Date.now();
if (msg.certificates?.length && !certs.length && native) {
native.describeCerts(msg.certificates).then(c => (certs = c));
}
});
client?.requestUserStats(who);
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
onDestroy(() => { off?.(); clearInterval(timer); });
function duration(secs?: number): string {
if (secs == null) return 'unknown';
const d = Math.floor(secs / 86400), h = Math.floor(secs / 3600) % 24, m = Math.floor(secs / 60) % 60, s = secs % 60;
return [d && `${d}d`, (d || h) && `${h}h`, (d || h || m) && `${m}m`, `${s}s`].filter(Boolean).join(' ');
}
function address(bytes?: Uint8Array): string {
if (!bytes?.length) return '';
const b = [...bytes];
// IPv4-mapped IPv6 (::ffff:a.b.c.d)
if (b.length === 16 && b.slice(0, 10).every(x => x === 0) && b[10] === 255 && b[11] === 255) return b.slice(12).join('.');
const groups = [];
for (let i = 0; i < b.length; i += 2) groups.push(((b[i] << 8) | b[i + 1]).toString(16));
return groups.join(':').replace(/(^|:)0(:0)+(:|$)/, '::');
}
const version = $derived.by(() => {
const v = stats?.version;
if (!v) return '';
const n = v.version_v2 ? Number(v.version_v2) : 0;
const num = n ? `${Math.floor(n / 2 ** 48)}.${Math.floor(n / 2 ** 32) % 65536}.${Math.floor(n / 2 ** 16) % 65536}`
: v.version_v1 ? `${v.version_v1 >> 16}.${(v.version_v1 >> 8) & 255}.${v.version_v1 & 255}` : '';
return [v.release, num && !String(v.release ?? '').includes(num) ? `(protocol ${num})` : ''].filter(Boolean).join(' ');
});
const ping = (avg?: number, v?: number) => avg ? `${avg.toFixed(1)} ms${v ? ` (variance ${Math.sqrt(v).toFixed(1)})` : ''}` : 'n/a';
const loss = (s: any) => {
const total = (s?.good ?? 0) + (s?.late ?? 0) + (s?.lost ?? 0);
return total ? `${(((s?.lost ?? 0) / total) * 100).toFixed(1)}%` : '-';
};
</script>
<Modal title="Information: {user ? store.displayName(user) : `#${target}`}" {onclose} width={560}>
{#if !stats}
<p class="dim">Asking the server...</p>
{:else}
<h3>Connection</h3>
<dl>
<dt>Online for</dt><dd>{duration(stats.onlinesecs)}</dd>
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs)}</dd>{/if}
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
</dl>
{#if stats.version}
<h3>Client</h3>
<dl>
<dt>Version</dt><dd>{version || 'unknown'}</dd>
{#if stats.version.os}<dt>System</dt><dd>{stats.version.os} {stats.version.os_version ?? ''}</dd>{/if}
<dt>Opus</dt><dd>{stats.opus ? 'supported' : 'no (legacy codecs only)'}</dd>
</dl>
{/if}
{#if stats.from_client || stats.from_server}
<h3>Voice packets</h3>
<table>
<thead><tr><th></th><th>Good</th><th>Late</th><th>Lost</th><th>Resync</th><th>Loss</th></tr></thead>
<tbody>
<tr><th>From client</th><td>{stats.from_client?.good ?? 0}</td><td>{stats.from_client?.late ?? 0}</td><td>{stats.from_client?.lost ?? 0}</td><td>{stats.from_client?.resync ?? 0}</td><td>{loss(stats.from_client)}</td></tr>
<tr><th>To client</th><td>{stats.from_server?.good ?? 0}</td><td>{stats.from_server?.late ?? 0}</td><td>{stats.from_server?.lost ?? 0}</td><td>{stats.from_server?.resync ?? 0}</td><td>{loss(stats.from_server)}</td></tr>
</tbody>
</table>
<p class="dim small">Counted on the encrypted UDP channel; clients sending voice over TCP show zeros.</p>
{/if}
{#if stats.certificates?.length}
<h3>Certificate</h3>
<dl>
<dt>Subject</dt><dd>{certs[0]?.subject.replace(/\n/g, ', ') ?? '...'}</dd>
<dt>Verified</dt><dd>{stats.strong_certificate ? 'yes, signed by a trusted authority' : 'no, self-signed'}</dd>
</dl>
{#if certs.length}
<button class="btn cert" onclick={() => (ui.cert = { title: `Certificate of ${user?.name ?? target}`, chain: certs })}>View certificate</button>
{/if}
{:else if user?.hash}
<h3>Certificate</h3>
<dl><dt>SHA-1</dt><dd class="mono">{user.hash}</dd></dl>
<p class="dim small">Only admins and the user themselves can see the full certificate.</p>
{/if}
{#if !stats.version}
<p class="dim small note">Client details, address and certificate are only shown to admins and to the user themselves.</p>
{/if}
{/if}
{#snippet footer()}
{#if updated}<span class="dim small upd">Updates every 5 seconds</span>{/if}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--line); }
th:first-child { text-align: left; color: var(--text-faint); font-weight: 400; }
thead th { color: var(--text-faint); font-weight: 600; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 12px; margin-top: 6px; }
.note { margin-top: 16px; }
.cert { margin-top: 10px; }
.upd { margin-right: auto; align-self: center; }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { session } from '../lib/session.svelte.ts';
const s = voice.settings;
let capturing = $state(false);
// Opening the settings starts audio so the meter and device names work before connecting
voice.start();
onDestroy(() => { if (voice.testing) voice.setTesting(false); if (session.status !== 'connected') voice.stop(); });
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const open = $derived(voice.level > s.vadThreshold);
const keyName = (code: string) => code
.replace(/^Key/, '').replace(/^Digit/, '').replace(/^Numpad/, 'Num ')
.replace('Backquote', 'Backtick (`)').replace(/([a-z])([A-Z])/g, '$1 $2');
function captureKey(e: KeyboardEvent) {
if (!capturing) return;
e.preventDefault();
e.stopPropagation();
if (e.code !== 'Escape') voice.update({ pttKey: e.code });
capturing = false;
}
const modes: { id: TransmitMode; label: string; help: string }[] = [
{ id: 'vad', label: 'Voice activity', help: 'Sends when you speak louder than the threshold' },
{ id: 'ptt', label: 'Push to talk', help: 'Sends while you hold the key' },
{ id: 'continuous', label: 'Always on', help: 'Sends all the time' }
];
</script>
<svelte:window onkeydowncapture={captureKey} />
<div class="voice">
{#if voice.error}<p class="error">{voice.error}</p>{/if}
<h3>Devices</h3>
<div class="grid">
<div>
<label for="v-in">Microphone</label>
<select id="v-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
<div>
<label for="v-out">Speakers / headphones</label>
<select id="v-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
</div>
<h3>Transmit</h3>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>
<strong>{m.label}</strong><small>{m.help}</small>
</button>
{/each}
</div>
{#if s.mode === 'ptt'}
<label for="v-ptt">Push-to-talk key</label>
<div class="row">
<button id="v-ptt" class="btn key" class:capturing onclick={() => (capturing = true)}>
{capturing ? 'Press a key... (Esc cancels)' : keyName(s.pttKey)}
</button>
<span class="hint" class:on={voice.pttDown}>{voice.pttDown ? 'Talking' : 'Hold the key to talk'}</span>
</div>
<p class="help">Works while mumh5 is focused. System-wide push to talk is planned.</p>
{/if}
<label for="v-level">Input level{s.mode === 'vad' ? ' and threshold' : ''}</label>
<div class="meter" class:open={s.mode !== 'vad' || open} aria-hidden="true">
<div class="fill" style:width="{pct(voice.level)}%"></div>
{#if s.mode === 'vad'}<div class="mark" style:left="{pct(s.vadThreshold)}%"></div>{/if}
</div>
{#if s.mode === 'vad'}
<input id="v-level" type="range" min={METER_MIN} max="0" step="1" value={s.vadThreshold}
oninput={e => voice.update({ vadThreshold: Number(e.currentTarget.value) })} aria-label="Voice activity threshold" />
<p class="help">Speak normally and put the marker just below your voice; the bar turns green when you would be sending. Threshold {s.vadThreshold} dB.</p>
<label for="v-hold">Keep sending after speech: {s.vadHoldMs} ms</label>
<input id="v-hold" type="range" min="100" max="1500" step="50" value={s.vadHoldMs}
oninput={e => voice.update({ vadHoldMs: Number(e.currentTarget.value) })} />
{/if}
<div class="grid">
<div>
<label for="v-gain">Microphone volume: {Math.round(s.inputGain * 100)}%</label>
<input id="v-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
</div>
<div>
<label for="v-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="v-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
</div>
</div>
<div class="row test">
<button class="btn" class:primary={voice.testing} onclick={() => voice.setTesting(!voice.testing)}>
{voice.testing ? 'Stop test' : 'Test microphone'}
</button>
<span class="help">{voice.testing ? 'You should hear yourself. Use headphones to avoid feedback.' : 'Plays your microphone back to you'}</span>
</div>
<h3>Processing</h3>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
<h3>Quality</h3>
<div class="grid">
<div>
<label for="v-br">Bitrate: {Math.round(s.bitrate / 1000)} kbit/s</label>
<input id="v-br" type="range" min="8000" max="128000" step="4000" value={s.bitrate}
oninput={e => voice.update({ bitrate: Number(e.currentTarget.value) })} />
{#if voice.effectiveBitrate && voice.effectiveBitrate < s.bitrate && session.status === 'connected'}
<p class="help warn">Limited to {Math.round(voice.effectiveBitrate / 1000)} kbit/s by this server</p>
{/if}
</div>
<div>
<label for="v-frame">Audio per packet</label>
<select id="v-frame" value={String(s.frameMs)} onchange={e => voice.update({ frameMs: Number(e.currentTarget.value) as 10 | 20 | 40 | 60 })}>
<option value="10">10 ms (lowest latency)</option>
<option value="20">20 ms (recommended)</option>
<option value="40">40 ms</option>
<option value="60">60 ms (least bandwidth)</option>
</select>
</div>
</div>
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label>
<input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
<p class="help">Higher values smooth out bad connections at the cost of delay.</p>
{#if session.status === 'connected'}
<h3>Statistics</h3>
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: TCP tunnel.</p>
{/if}
</div>
<style>
h3 { margin: 22px 0 0; font-size: 14px; }
h3:first-of-type { margin-top: 8px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
.modes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
@media (max-width: 560px) { .modes { grid-template-columns: 1fr; } }
.modes button { display: flex; flex-direction: column; gap: 2px; padding: 10px; text-align: left; background: var(--bg-0); border: 1px solid var(--line); }
.modes button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.modes small { color: var(--text-dim); font-size: 12px; }
.row { display: flex; align-items: center; gap: 12px; }
.key { min-width: 160px; }
.key.capturing { border-color: var(--accent); color: var(--accent-hover); }
.hint { font-size: 13px; color: var(--text-dim); }
.hint.on { color: var(--speaking); font-weight: 600; }
.meter { position: relative; height: 12px; background: var(--bg-0); border: 1px solid var(--line); overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.meter.open .fill { background: var(--speaking); }
.mark { position: absolute; top: -1px; bottom: -1px; width: 2px; background: var(--warn); }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.test { margin-top: 14px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.warn { color: var(--warn); }
.error { color: var(--danger); margin: 8px 0 0; }
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store } from '../lib/settings.svelte.ts';
let { videoId, start = 0, url }: { videoId: string; start?: number; url: string } = $props();
// Nothing is loaded from YouTube until the user clicks (unless thumbnails are enabled)
let playing = $state(false);
const src = $derived(`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0${start ? `&start=${start}` : ''}`);
const thumb = $derived(store.settings.youtube === 'thumbnail' ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : '');
</script>
<div class="yt">
{#if playing}
<iframe {src} title="YouTube video" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen
referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"></iframe>
{:else}
<button class="facade" style:background-image={thumb ? `url(${thumb})` : undefined} onclick={() => (playing = true)}
title="Play here (loads from YouTube)">
<span class="play"><svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4l14 8-14 8z" fill="currentColor" /></svg></span>
<span class="label">
<strong>YouTube</strong>
<span>{start ? `from ${Math.floor(start / 60)}:${String(start % 60).padStart(2, '0')}, ` : ''}click to play</span>
</span>
</button>
{/if}
<a class="open" href={url} target="_blank" rel="noopener noreferrer" title="Open on YouTube"><Icon name="link" size={14} /></a>
</div>
<style>
.yt { position: relative; width: min(480px, 100%); aspect-ratio: 16 / 9; background: #000; border: 1px solid var(--line); }
iframe { display: block; width: 100%; height: 100%; border: 0; }
.facade { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
background: #111 center / cover no-repeat; color: #fff; }
.facade::before { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgb(0 0 0 / 0.7)); }
.play { position: relative; width: 64px; height: 44px; display: flex; align-items: center; justify-content: center; background: #e0443e; transition: transform 100ms; }
.facade:hover .play { transform: scale(1.08); }
.label { position: relative; display: flex; flex-direction: column; align-items: center; font-size: 13px; text-shadow: 0 1px 2px #000; }
.label span { color: #ddd; }
.open { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 0.6); color: #fff; }
.open:hover { background: rgb(0 0 0 / 0.85); text-decoration: none; }
</style>