Screen sharing #7
+4
-3
@@ -28,8 +28,8 @@
|
||||
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
|
||||
|
||||
// Arrangement (Appearance setting): side is the default, classic puts the channel list right
|
||||
// of the chat, stacked above it. Phones always use drawers.
|
||||
const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
|
||||
// of the chat, stacked above it. Narrow windows use drawers, except stacked, which works at any width.
|
||||
const arrangement = $derived(store.settings.arrangement === 'stacked' ? 'stacked' : layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
|
||||
// The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays
|
||||
const panelInline = $derived(layout === 'wide' && arrangement === 'side');
|
||||
let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280));
|
||||
@@ -76,7 +76,8 @@
|
||||
onsettings={() => (ui.settingsOpen = true)} />
|
||||
{/snippet}
|
||||
{#snippet chat()}
|
||||
<Chat showMenu={layout === 'narrow'} showStage={!panelInline && arrangement === 'side'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
||||
<Chat showMenu={layout === 'narrow' && arrangement === 'side'} showStage={!panelInline && arrangement === 'side'} videoOnly={arrangement === 'stacked'}
|
||||
onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
||||
{/snippet}
|
||||
|
||||
<div class="app {layout} {arrangement}">
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="help">Narrow windows always use the phone layout.</p>
|
||||
<p class="help">Narrow windows use the phone layout, except Stacked, which stays as it is.</p>
|
||||
|
||||
<style>
|
||||
.gap { margin-top: 22px; }
|
||||
|
||||
+3
-2
@@ -7,7 +7,8 @@
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
|
||||
// showStage: voice tiles above the chat when the window is too narrow for the member list
|
||||
let { onmenu, onpanel, showMenu, showStage = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean } = $props();
|
||||
// videoOnly: streams as a plain picture without the tiles (stacked layout)
|
||||
let { onmenu, onpanel, showMenu, showStage = false, videoOnly = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean; videoOnly?: boolean } = $props();
|
||||
|
||||
const isDm = $derived(session.view.startsWith('dm:'));
|
||||
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
|
||||
@@ -27,7 +28,7 @@
|
||||
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||
</header>
|
||||
|
||||
{#if session.status === 'connected'}<VoiceStage always={showStage} />{/if}
|
||||
{#if session.status === 'connected'}<VoiceStage always={showStage} {videoOnly} />{/if}
|
||||
|
||||
{#if session.status === 'idle' && session.disconnectInfo}
|
||||
{@const d = session.disconnectInfo}
|
||||
|
||||
+62
-30
@@ -14,7 +14,9 @@
|
||||
// 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
|
||||
// carries the picture, and clicking it puts it on a large stage with the tiles as a strip.
|
||||
let { always = false }: { always?: boolean } = $props();
|
||||
// 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();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
||||
@@ -24,7 +26,7 @@
|
||||
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(inChannel.some(u => session.sharing[u.session]));
|
||||
const visible = $derived(!!self && (always || anySharing || !!watching || !!mine));
|
||||
const visible = $derived(!!self && (videoOnly ? !!watching || !!mine : always || anySharing || !!watching || !!mine));
|
||||
|
||||
// The picture a tile can show: our own stream, or the one we are watching
|
||||
function streamOf(s: number): MediaStream | null {
|
||||
@@ -51,6 +53,8 @@
|
||||
}
|
||||
});
|
||||
$effect(() => { if (focus === self?.session && !mine) focus = null; });
|
||||
// Without tiles there is nothing to choose: the watched stream, else our own
|
||||
$effect(() => { if (videoOnly) focus = watching ? watching.session : mine ? self?.session ?? null : 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.
|
||||
@@ -168,7 +172,53 @@
|
||||
|
||||
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||
|
||||
{#if visible && self}
|
||||
{#snippet spot()}
|
||||
{#if self && focus != null && focused}
|
||||
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
|
||||
<div class="spot-head">
|
||||
<Icon name="screen" size={16} />
|
||||
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
|
||||
{#if focus !== self.session && hasSound}
|
||||
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
|
||||
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
|
||||
aria-label="Stream volume" title="Stream volume" />
|
||||
{/if}
|
||||
{#if popped}
|
||||
<button class="btn small" onclick={popIn}>Bring back</button>
|
||||
{:else}
|
||||
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
|
||||
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||
{/if}
|
||||
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>
|
||||
{:else if videoOnly}
|
||||
<span class="status" role="status" title="People watching your screen">{share.viewers} watching</span>
|
||||
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
||||
{/if}
|
||||
{#if !videoOnly}<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>{/if}
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
{#if popped}
|
||||
<p class="away">Shown in a separate window.</p>
|
||||
{:else}
|
||||
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if visible && self && videoOnly}
|
||||
<section class="stage solo" aria-label="Voice channel">
|
||||
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
|
||||
{#if focused}
|
||||
{@render spot()}
|
||||
{:else if watching}
|
||||
<p class="wait" class:bad={watching.state === 'failed'}>
|
||||
<span>{watching.state === 'failed' ? 'Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice.' : 'Connecting to the stream...'}</span>
|
||||
<button class="btn small" onclick={() => share.unwatch()}>{watching.state === 'failed' ? 'Close' : 'Cancel'}</button>
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if visible && self}
|
||||
<section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
|
||||
style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
|
||||
{#if !collapsed}
|
||||
@@ -205,33 +255,7 @@
|
||||
|
||||
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
|
||||
|
||||
{#if focused && focus != null && !collapsed}
|
||||
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
|
||||
<div class="spot-head">
|
||||
<Icon name="screen" size={16} />
|
||||
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
|
||||
{#if focus !== self.session && hasSound}
|
||||
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
|
||||
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
|
||||
aria-label="Stream volume" title="Stream volume" />
|
||||
{/if}
|
||||
{#if popped}
|
||||
<button class="btn small" onclick={popIn}>Bring back</button>
|
||||
{:else}
|
||||
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
|
||||
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||
{/if}
|
||||
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>{/if}
|
||||
<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
{#if popped}
|
||||
<p class="away">Shown in a separate window.</p>
|
||||
{:else}
|
||||
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if focused && focus != null && !collapsed}{@render spot()}{/if}
|
||||
|
||||
{#if !collapsed}
|
||||
<ul class="tiles" class:strip={!!focused} bind:clientWidth={gridW} bind:clientHeight={gridH}>
|
||||
@@ -316,5 +340,13 @@
|
||||
.spot:fullscreen video { height: 100%; }
|
||||
.away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
||||
.vol { width: 110px; flex: none; padding: 0; }
|
||||
/* Video only: the picture as wide as the chat, as tall as its shape asks */
|
||||
.stage.solo { background: #000; }
|
||||
.stage.solo .spot { flex: none; }
|
||||
.stage.solo .spot video { flex: none; height: auto; max-height: 62vh; }
|
||||
.stage.solo .away { flex: none; padding: 24px; }
|
||||
.wait { margin: 0; display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: var(--text-dim); background: var(--bg-1); }
|
||||
.wait span { flex: 1; min-width: 0; }
|
||||
.wait.bad { color: var(--danger); }
|
||||
.tiles.strip { flex: none; flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; }
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,32 @@ try {
|
||||
await ownView.waitFor({ state: 'detached' });
|
||||
console.log('ok: sharing started from the dialog, own tile shows the stream');
|
||||
|
||||
// Stacked layout: only the picture, as wide as the chat, and the layout stays stacked on narrow windows
|
||||
const setLayout = async (name: string) => {
|
||||
await alice.page.getByTitle('Settings').click();
|
||||
await alice.page.getByRole('tab', { name: 'Appearance' }).click();
|
||||
await alice.page.getByRole('radio', { name }).click();
|
||||
await alice.page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
};
|
||||
await setLayout('Stacked');
|
||||
await alice.page.locator('.app.stacked .stage.solo').waitFor();
|
||||
assert.equal(await alice.page.locator('.stage .tiles').count(), 0, 'no participant tiles in the stacked layout');
|
||||
const fits = () => alice.page.evaluate(() => {
|
||||
const v = document.querySelector('.stage.solo video') as HTMLVideoElement, c = document.querySelector('.chat')!;
|
||||
return v.videoWidth > 0 && Math.abs(v.getBoundingClientRect().width - c.getBoundingClientRect().width) < 2;
|
||||
});
|
||||
await alice.page.waitForFunction(() => (document.querySelector('.stage.solo video') as HTMLVideoElement | null)?.videoWidth! > 0);
|
||||
assert.ok(await fits(), 'picture as wide as the chat');
|
||||
await alice.page.setViewportSize({ width: 600, height: 800 });
|
||||
await alice.page.locator('.app.stacked .stage.solo video').waitFor();
|
||||
assert.ok(await fits(), 'still as wide as the chat on a narrow window');
|
||||
if (process.env.SHOTS_DIR) await alice.page.screenshot({ path: `${process.env.SHOTS_DIR}/stacked.png` });
|
||||
await alice.page.getByText('0 watching').waitFor();
|
||||
await alice.page.setViewportSize({ width: 1280, height: 800 });
|
||||
await setLayout('Modern');
|
||||
await alice.page.locator('.stage .tiles').waitFor();
|
||||
console.log('ok: stacked layout shows only the picture and stays stacked when narrow');
|
||||
|
||||
// Bob connects afterwards and still learns about the stream
|
||||
await join(bob.page, bobName);
|
||||
// Next to the name in the channel list, and on the tile above the chat
|
||||
|
||||
Reference in New Issue
Block a user