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:
@@ -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>
|
||||
Reference in New Issue
Block a user