From 5c7942d6003cb208bae4d6579f9b71e09d1ffa4d Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 2 Oct 2026 20:30:54 +0200 Subject: [PATCH] Add the Rooms interface: one screen at a time, made for phones A second shell on the same state, chosen in Settings, Appearance. Rooms as a list with the people inside, a voice dock, a voice screen, people and a You screen. Dialogs and menus are shared with the standard interface. Moving channels and people by dragging is not in it yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CLAUDE.md | 1 + README.md | 2 +- src/App.svelte | 5 + src/lib/icons.ts | 5 + src/lib/settings.svelte.ts | 3 + src/ui/AppearanceSettings.svelte | 38 ++++ src/ui/VoiceStage.svelte | 18 +- src/ui2/ChatScreen.svelte | 72 ++++++ src/ui2/Dock.svelte | 50 +++++ src/ui2/People.svelte | 91 ++++++++ src/ui2/Rooms.svelte | 368 +++++++++++++++++++++++++++++++ src/ui2/Shell.svelte | 125 +++++++++++ src/ui2/VoiceControls.svelte | 53 +++++ src/ui2/VoiceScreen.svelte | 54 +++++ src/ui2/You.svelte | 117 ++++++++++ test/e2e/app.e2e.ts | 84 +++++++ 17 files changed, 1079 insertions(+), 8 deletions(-) create mode 100644 src/ui2/ChatScreen.svelte create mode 100644 src/ui2/Dock.svelte create mode 100644 src/ui2/People.svelte create mode 100644 src/ui2/Rooms.svelte create mode 100644 src/ui2/Shell.svelte create mode 100644 src/ui2/VoiceControls.svelte create mode 100644 src/ui2/VoiceScreen.svelte create mode 100644 src/ui2/You.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 76636f4..039cc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- A second interface, "Rooms" (Settings, Appearance, Interface), made for phones first: one screen at a time with a bar for Rooms, Chat, Voice, People and You. Rooms are a list with the people inside shown inline and a Join button; your room and a mute and deafen dock stay in reach; the voice screen shows the room as tiles with large controls. Wide windows keep the rooms next to the chosen screen. It uses the same dialogs and menus as the standard interface. Not in it yet: moving channels and people by dragging. - Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them. - Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it. - Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there. diff --git a/CLAUDE.md b/CLAUDE.md index b12e600..ef120b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets); where a browser has no WebCodecs audio it uses libopus in WebAssembly (`audio/opus-wasm.ts`, `localStorage mumh5.forceWasmOpus = 1` forces it). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). +- `src/ui2/`: the Rooms interface (`settings.interface === 'rooms'`), a second shell on the same state: `Shell.svelte` (screens and bar, rooms beside the screen from 900px), `Rooms`, `ChatScreen`, `VoiceScreen`, `People`, `You`, `Dock`. It reuses `ChatLog`, `Composer`, `VoiceStage` (`fill`), `RightPanel` (as a sheet) and every dialog and menu from `src/ui/`. A feature added to one interface needs its place in the other. Class names there must not collide with ones `themes.css` styles (`.tabs`, `.rail`, `.tile`). - Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`. diff --git a/README.md b/README.md index f603097..6a9fe80 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim - System-wide push to talk - Whisper and shout - Text channels: private ones, and history while nobody is online (needs a helper on the server) -- A second, mobile-first interface with the same features in a different layout +- Rooms interface: moving channels and people without dragging, gestures, more polish - Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8) - Release builds for all platforms diff --git a/src/App.svelte b/src/App.svelte index e5ef036..5b2347b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,7 @@ import ShareDialog from './ui/ShareDialog.svelte'; import { share } from './lib/share.svelte.ts'; import ResizeHandle from './ui/ResizeHandle.svelte'; + import Shell from './ui2/Shell.svelte'; import { identities } from './lib/identities.svelte.ts'; import { session } from './lib/session.svelte.ts'; import { ui } from './lib/ui.svelte.ts'; @@ -92,6 +93,9 @@ onmenu={() => (navOpen = true)} onpanel={togglePanel} /> {/snippet} +{#if store.settings.interface === 'rooms'} + (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} /> +{:else}
{#if arrangement === 'classic'} {@render rail()} @@ -127,6 +131,7 @@ {/if} {/if}
+{/if} {#if editing} (editing = null)} />{/if} {#if ui.addServer} (ui.addServer = null)} />{/if} diff --git a/src/lib/icons.ts b/src/lib/icons.ts index 8695eff..e4d66c6 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -35,5 +35,10 @@ export const ICON_PATHS: Record = { 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', + more: 'M4 11h2v2H4zM11 11h2v2h-2zM18 11h2v2h-2z', + back: 'M19 12H5M11 6l-6 6 6 6', + search: 'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zM16 16l5 5', + user: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1', + rooms: 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z', eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7' }; diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index d8e7fb6..19c5918 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -24,6 +24,8 @@ export interface Settings { theme: ThemeId; publicListUrl: string; // where the public server list comes from arrangement: Arrangement; + // standard: the channel tree in one of the layouts above. rooms: one screen at a time, made for phones. + interface: 'standard' | 'rooms'; shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream autoCameras: boolean; // show other people's cameras as they come on shareVolume: number; // volume of streams we watch, 0 to 1 @@ -58,6 +60,7 @@ const defaults: Settings = { theme: 'dark', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', arrangement: 'side', + interface: 'standard', shareQuality: 'medium', autoCameras: true, shareVolume: 1, diff --git a/src/ui/AppearanceSettings.svelte b/src/ui/AppearanceSettings.svelte index 5bcd4af..fac2c7e 100644 --- a/src/ui/AppearanceSettings.svelte +++ b/src/ui/AppearanceSettings.svelte @@ -7,6 +7,15 @@ { id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' } ]; + const INTERFACES: { id: 'standard' | 'rooms'; name: string; note: string }[] = [ + { id: 'standard', name: 'Standard', note: 'Channel tree, chat and members side by side' }, + { id: 'rooms', name: 'Rooms', note: 'One screen at a time, made for phones. New and not complete yet' } + ]; + function choose(id: 'standard' | 'rooms') { + store.settings.interface = id; + store.saveSettings(); + } + function arrange(id: Arrangement) { store.settings.arrangement = id; store.saveSettings(); @@ -39,6 +48,30 @@ {/each} +

Interface

+
+ {#each INTERFACES as f (f.id)} + {@const on = (store.settings.interface ?? 'standard') === f.id} + + {/each} +
+ +{#if (store.settings.interface ?? 'standard') === 'standard'}

Layout

{#each ARRANGEMENTS as a (a.id)} @@ -64,6 +97,7 @@ {/each}

Narrow windows use the phone layout, except Stacked, which stays as it is.

+{/if} diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 68ccdac..b17fdda 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -18,7 +18,8 @@ // as are on, and a click enlarges one. // videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list // above already shows who is here) - let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props(); + // fill: take all the room the parent gives, without a height of its own (the voice screen) + let { always = false, videoOnly = false, fill = false }: { always?: boolean; videoOnly?: boolean; fill?: boolean } = $props(); const self = $derived(session.self); const channel = $derived(self ? session.channel(self.channelId) : undefined); @@ -119,13 +120,15 @@ return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34)))); }); - let collapsed = $state(load()); + let folded = $state(load()); + const collapsed = $derived(folded && !fill); function load(): boolean { try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } } function toggle() { - collapsed = !collapsed; - try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ } + if (fill) return; + folded = !folded; + try { localStorage.setItem('mumh5.stageCollapsed', folded ? '1' : '0'); } catch { /* storage unavailable */ } } const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting); @@ -349,9 +352,9 @@ {/if} {:else if visible && self} -
- {#if !collapsed} +
+ {#if !collapsed && !fill} {#if focused} {:else} @@ -404,6 +407,7 @@ {/if} diff --git a/src/ui2/Dock.svelte b/src/ui2/Dock.svelte new file mode 100644 index 0000000..940b8dc --- /dev/null +++ b/src/ui2/Dock.svelte @@ -0,0 +1,50 @@ + + +
+ + {#if elsewhere} + + {:else} + + {/if} +
+ + diff --git a/src/ui2/People.svelte b/src/ui2/People.svelte new file mode 100644 index 0000000..36d4131 --- /dev/null +++ b/src/ui2/People.svelte @@ -0,0 +1,91 @@ + + +
+
+

People

+ {session.status === 'connected' ? `${total} online` : 'Not connected'} +
+ {#if session.status === 'connected'} + +
+ {#each groups as g (g.id)} +

{g.name}{#if g.id === self?.channelId} your room{/if} {g.users.length}

+
    + {#each g.users as u (u.session)} + {@const me = u.session === self?.session} + + {/each} +
+ {:else} +

Nobody matches "{query}".

+ {/each} +
+ {/if} +
+ + diff --git a/src/ui2/Rooms.svelte b/src/ui2/Rooms.svelte new file mode 100644 index 0000000..02e2883 --- /dev/null +++ b/src/ui2/Rooms.svelte @@ -0,0 +1,368 @@ + + +
+ + +
+ + {#if connected}{/if} + {#if session.status !== 'idle'} + + {/if} +
+ + {#if connected} + + {/if} + + +
+ + diff --git a/src/ui2/Shell.svelte b/src/ui2/Shell.svelte new file mode 100644 index 0000000..af9b2e2 --- /dev/null +++ b/src/ui2/Shell.svelte @@ -0,0 +1,125 @@ + + + { if (e.key === 'Escape' && sheet) closeSheet(); }} /> + +
+ + + {#if wide} +
+ (chosen = 'chat')} /> + (chosen = 'voice')} /> +
+ {/if} + +
+
+ {#if screen === 'rooms'} (chosen = 'chat')} /> + {:else if screen === 'chat'} (chosen = 'rooms')} /> + {:else if screen === 'voice'} (chosen = 'rooms')} onchat={() => (chosen = 'chat')} /> + {:else if screen === 'people'} (chosen = 'chat')} /> + {:else}{/if} +
+ {#if !wide && screen !== 'voice'} (chosen = 'voice')} />{/if} +
+ + {#if sheet} + +
{ closeSheet(); chosen = 'chat'; }} onclose={closeSheet} />
+ {/if} +
+ + diff --git a/src/ui2/VoiceControls.svelte b/src/ui2/VoiceControls.svelte new file mode 100644 index 0000000..eb4be35 --- /dev/null +++ b/src/ui2/VoiceControls.svelte @@ -0,0 +1,53 @@ + + +
+ + + {#if large} + + {/if} +
+ + diff --git a/src/ui2/VoiceScreen.svelte b/src/ui2/VoiceScreen.svelte new file mode 100644 index 0000000..1d59be8 --- /dev/null +++ b/src/ui2/VoiceScreen.svelte @@ -0,0 +1,54 @@ + + +
+ {#if self} +
+
+

{room?.name || 'Root'}

+ {session.usersIn(self.channelId).length} here, voice over {transport.udp ? 'UDP' : 'TCP'}{#if voice.error}, voice unavailable{/if} +
+ +
+ {#if voice.error}

{voice.error}

{/if} + {#if elsewhere} +

Voice is on {sessions.voiceSession?.server?.label}; you are deafened here. +

+ {/if} +
+
+ {:else} +
+

Voice

+

Connect to a server and join a room to talk.

+ +
+ {/if} +
+ + diff --git a/src/ui2/You.svelte b/src/ui2/You.svelte new file mode 100644 index 0000000..1d62097 --- /dev/null +++ b/src/ui2/You.svelte @@ -0,0 +1,117 @@ + + +
+
+
+ {#if self} + +
+

{self.name}

+ {self.userId != null ? 'Registered' : 'Guest'} on {session.server?.label}, in {session.channel(self.channelId)?.name || 'Root'} +
+ {:else} +
+

You

+ {session.status === 'connecting' ? 'Connecting...' : 'Not connected'} +
+ {/if} +
+ {#if self} +
+ + + + {#if self.userId == null}{/if} + +
+ {/if} + +

Servers

+
    + {#each store.servers as s (s.id)} + {@const state = stateOf(s.id)} +
  • + + {#if state !== 'idle'}{/if} + +
  • + {/each} +
+
+ + {#if desktop}{/if} + {#if session.status === 'connected'}{/if} +
+ +

Settings

+
    + {#each SETTINGS as item (item.tab)} +
  • + +
  • + {/each} +
+
+
+ + diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 34a93fa..db4f84c 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -1109,6 +1109,90 @@ try { await page.keyboard.press('Escape'); console.log('ok: registered on the server'); + // ─── The Rooms interface: the same app arranged for phones ───────────────── + { + const before = page.viewportSize()!; + await page.getByTitle('Settings').click(); + await page.getByRole('tab', { name: 'Appearance' }).click(); + await page.getByRole('radio', { name: 'Rooms' }).click(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.setViewportSize({ width: 390, height: 800 }); + const nav = page.getByRole('navigation', { name: 'Screens' }); + const rooms = page.getByRole('region', { name: 'Rooms' }); + await rooms.getByRole('heading', { name: 'Test Server' }).waitFor(); + assert.equal(await page.locator('.sidebar').count(), 0, 'the standard interface is gone'); + // People show in their room without opening it; the dock names the room you are in + await rooms.locator('.room.here .person', { hasText: 'bob' }).waitFor(); + await page.locator('.dock', { hasText: 'Root' }).waitFor(); + if (admin) { + await rooms.getByRole('button', { name: 'Join Games' }).click(); + await until2('alice in Games', () => bob.channels.get(userState(newName)!.channelId)?.name === 'Games'); + await page.locator('.dock', { hasText: 'Games' }).waitFor(); + await rooms.getByRole('button', { name: 'Join Root' }).click(); + await until2('alice back in Root', () => userState(newName)!.channelId === 0); + await page.locator('.dock', { hasText: 'Root' }).waitFor(); + } + console.log('ok: rooms interface lists rooms with their people and joins from the list'); + + // Mute from the dock + const wasMuted = !!userState(newName)!.selfMute; + await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Unmute' : 'Mute', exact: true }).click(); + await until2('mute toggled from the dock', () => !!userState(newName)!.selfMute === !wasMuted); + await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Mute' : 'Unmute', exact: true }).click(); + await until2('mute restored', () => !!userState(newName)!.selfMute === wasMuted); + + // Chat is a screen of its own + await nav.getByRole('button', { name: 'Chat' }).click(); + const roomsMsg = new Promise(res => bob.on('text', m => { if (m.html === 'hello from rooms') res(); })); + await page.locator('.chat2 .composer [contenteditable]').fill('hello from rooms'); + await page.locator('.chat2 .composer [contenteditable]').press('Enter'); + await within(roomsMsg, 'message from the rooms interface'); + await page.locator('.chat2 .msg', { hasText: 'hello from rooms' }).waitFor(); + await page.getByRole('button', { name: 'Back to rooms' }).click(); + await rooms.waitFor(); + + // The voice screen shows the room as tiles with the large controls + await nav.getByRole('button', { name: 'Voice' }).click(); + const voiceScreen = page.getByRole('region', { name: 'Voice', exact: true }); + await voiceScreen.locator('.tile', { hasText: 'bob' }).waitFor(); + await voiceScreen.getByRole('button', { name: /^Share your screen or camera$/ }).waitFor(); + assert.equal(await page.locator('.dock').count(), 0, 'no dock on the voice screen'); + + // People: a profile opens over the screen, a message opens the chat + await nav.getByRole('button', { name: 'People' }).click(); + const people = page.getByRole('region', { name: 'People' }); + await people.locator('.entry', { hasText: 'bob' }).locator('.main').click(); + await page.locator('.sheet h3', { hasText: 'bob' }).waitFor(); + await page.keyboard.press('Escape'); + await page.locator('.sheet').waitFor({ state: 'detached' }); + await people.getByRole('button', { name: 'Message bob' }).click(); + await page.locator('.chat2 h1', { hasText: 'bob' }).waitFor(); + if (shots) await page.screenshot({ path: path.join(shots, 'rooms-phone.png') }); + + // Wide windows keep the rooms next to the screen + await page.setViewportSize({ width: 1280, height: 800 }); + await rooms.waitFor(); + await page.locator('.chat2').waitFor(); + assert.equal(await nav.getByRole('button', { name: 'Rooms' }).count(), 0); + if (notesId != null) { + await rooms.locator('.entry', { hasText: 'notes' }).locator('.main').click(); + await page.locator('.chat2 .msg', { hasText: 'text hello' }).waitFor(); + } + if (shots) await page.screenshot({ path: path.join(shots, 'rooms-wide.png') }); + console.log('ok: rooms interface: dock, chat, voice screen, people, profile sheet, wide layout'); + + // Back to the standard interface from the You screen + await nav.getByRole('button', { name: 'You' }).click(); + await page.getByRole('region', { name: 'You' }).getByRole('button', { name: /^Appearance/ }).click(); + await page.getByRole('radio', { name: 'Standard' }).click(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.locator('.sidebar').waitFor(); + await page.setViewportSize(before); + await page.locator('.channel .name', { hasText: 'Root' }).dblclick(); + await page.locator('.chat h1', { hasText: 'Root' }).waitFor(); + console.log('ok: back to the standard interface from the You screen'); + } + // Kicked: the reason is shown with a Reconnect button if (admin) { const me = [...admin.users.values()].find(u => u.name === newName)!;