diff --git a/README.md b/README.md index 2886218..495f57d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience. - Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent - Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers -- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume +- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A sharer's tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting, and it can move to a window of its own. Viewers set the stream's volume - No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams - Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you enter one in Settings, Voice (the proxy's address works there too), and none is contacted unless you do - Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested diff --git a/electron/main.ts b/electron/main.ts index 2c5465e..8c12706 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -201,7 +201,11 @@ function createWindow() { }); // Links from chat open in the system browser, never inside the app - win.webContents.setWindowOpenHandler(({ url }) => { + win.webContents.setWindowOpenHandler(({ url, frameName }) => { + // The stream window: an empty page the app fills with the video itself + if (frameName === 'mumh5-stream' && (url === 'about:blank' || url === '')) { + return { action: 'allow', overrideBrowserWindowOptions: { width: 960, height: 600, minWidth: 320, minHeight: 200, backgroundColor: '#000000', autoHideMenuBar: true, title: 'mumh5' } }; + } if (/^https?:\/\//i.test(url)) shell.openExternal(url); return { action: 'deny' }; }); diff --git a/src/lib/icons.ts b/src/lib/icons.ts index d9a0f6e..cffde88 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -7,6 +7,7 @@ export const ICON_PATHS: Record = { settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', plus: 'M12 5v14M5 12h14', screen: 'M3 4h18v12H3zM8 20h8M12 16v4', + popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6', expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', filter: 'M3 4h18l-7 8v7l-4 2v-9z', info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01', diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 440a25d..6d8f4f9 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -9,6 +9,7 @@ import { voice } from '../lib/audio/voice.svelte.ts'; import { share } from '../lib/share.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; + import ResizeHandle from './ResizeHandle.svelte'; // People in your voice channel as tiles. Shown on windows too narrow for the member list // (always), and on any window while someone in the channel shares a screen: a sharer's tile @@ -17,12 +18,12 @@ const self = $derived(session.self); const channel = $derived(self ? session.channel(self.channelId) : undefined); - const people = $derived(self ? session.usersIn(self.channelId) : []); + const inChannel = $derived(self ? session.usersIn(self.channelId) : []); const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id; const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null); const mine = $derived(share.stream && here(share.host) ? share.stream : null); - const anySharing = $derived(people.some(u => session.sharing[u.session])); + const anySharing = $derived(inChannel.some(u => session.sharing[u.session])); const visible = $derived(!!self && (always || anySharing || !!watching || !!mine)); // The picture a tile can show: our own stream, or the one we are watching @@ -31,6 +32,10 @@ return watching?.session === s ? watching.stream : null; } + // Tiles with a picture come first, then other people who share, so streams sit at the top left + const rank = (s: number) => streamOf(s) ? 0 : session.sharing[s] ? 1 : 2; + const people = $derived([...inChannel].sort((a, b) => rank(a.session) - rank(b.session))); + // Tile on the large stage let focus = $state(null); const focused = $derived(focus != null ? streamOf(focus) : null); @@ -47,6 +52,32 @@ }); $effect(() => { if (focus === self?.session && !mine) focus = null; }); + // The stage is as tall as the user drags it; the tiles grow and shrink to fill it. The + // meeting view (one picture large) remembers its own height. + const stored = (key: string, fallback: number) => { try { return Number(localStorage.getItem(key)) || fallback; } catch { return fallback; } }; + let tilesHeight = $state(stored('mumh5.stageHeight', 210)); + let meetingHeight = $state(stored('mumh5.stageHeightMeeting', 460)); + const maxHeight = () => Math.max(160, window.innerHeight - 220); + + // Largest 16:9 tiles that fit everyone into the space, like a meeting's tile view + let gridW = $state(0), gridH = $state(0); + const GAP = 8; + const tileW = $derived.by(() => { + const n = people.length; + if (!n || !gridW || !gridH) return 160; + let best = 0; + for (let cols = 1; cols <= n; cols++) { + const rows = Math.ceil(n / cols); + const w = Math.min((gridW - GAP * (cols - 1)) / cols, ((gridH - GAP * (rows - 1)) / rows) * 16 / 9); + if (w > best) best = w; + } + return Math.max(72, Math.floor(Math.min(best, 520))); + }); + // In the meeting view the tiles sit in one row of fixed height + const STRIP_W = 150; + const shownW = $derived(focused ? STRIP_W : tileW); + const avatar = $derived(Math.max(24, Math.min(96, Math.round(shownW * 9 / 16 * 0.45)))); + let collapsed = $state(load()); function load(): boolean { try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } @@ -84,12 +115,59 @@ if (document.fullscreenElement) document.exitFullscreen(); else frame?.requestFullscreen().catch(() => {}); } + + // The large picture in a window of its own, to put on another screen. The window is an + // empty page that gets the same stream; sound keeps coming from the main window. + let popup: Window | null = null; + let popVideo: HTMLVideoElement | null = null; + let popped = $state(false); + function popOut() { + if (popup && !popup.closed) return popup.focus(); + const w = window.open('', 'mumh5-stream', 'popup,width=960,height=600'); + if (!w) { share.error = 'The window could not be opened. Allow pop-ups for this page.'; return; } + popup = w; + popped = true; + w.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden'; + const v = w.document.createElement('video'); + v.autoplay = true; + v.muted = true; + v.playsInline = true; + v.style.cssText = 'width:100vw;height:100vh;object-fit:contain;display:block'; + v.ondblclick = () => { if (w.document.fullscreenElement) w.document.exitFullscreen(); else v.requestFullscreen().catch(() => {}); }; + w.document.body.replaceChildren(v); + popVideo = v; + w.addEventListener('pagehide', () => { if (popup === w) { popup = null; popVideo = null; popped = false; } }); + } + function popIn() { + popup?.close(); + popup = null; + popVideo = null; + popped = false; + } + // The window follows the picture on the stage and closes with it + $effect(() => { + const stream = focused; + const title = focus === self?.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'; + if (!popped) return; + if (!stream) return popIn(); + if (popVideo && popVideo.srcObject !== stream) popVideo.srcObject = stream; + if (popup) popup.document.title = `${title} - mumh5`; + }); + $effect(() => () => popup?.close()); {#if share.error}{/if} {#if visible && self} -
+
+ {#if !collapsed} + {#if focused} + + {:else} + + {/if} + {/if}
+ {#if popped} + + {:else} + + + {/if} {#if focus !== self.session}{/if} - + {#if popped} +

Shown in a separate window.

+ {:else} + + {/if} {/if} {#if !collapsed} -
    +
      {#each people as u (u.session)} {@const stream = streamOf(u.session)} {@const sharing = !!session.sharing[u.session]} {@const isSelf = u.session === self.session} -
    • +