Screen sharing: start dialog, sound, video tiles and a meeting view
- Start dialog with source list, live preview, sound and quality choices - Sound on Linux through PipeWire: one program or everything except this app, fed into a virtual microphone and kept linked as programs come and go; system loopback on Windows; stereo Opus - A sharer's tile shows the picture; clicking it opens a large view with the other tiles as a strip. Own stream is previewed in the own tile - Stream volume for viewers, watch and stop from the profile panel - Unit tests for the PipeWire link planner; the proxy test no longer writes to the root channel other tests listen in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+140
-10
@@ -7,11 +7,45 @@
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { userMenu } from '../lib/actions.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
import { sessions } from '../lib/session.svelte.ts';
|
||||
|
||||
// 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();
|
||||
|
||||
// People in your voice channel as tiles, for windows too narrow for the member list
|
||||
const self = $derived(session.self);
|
||||
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
||||
const people = $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 visible = $derived(!!self && (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 {
|
||||
if (s === self?.session) return mine;
|
||||
return watching?.session === s ? watching.stream : null;
|
||||
}
|
||||
|
||||
// Tile on the large stage
|
||||
let focus = $state<number | null>(null);
|
||||
const focused = $derived(focus != null ? streamOf(focus) : null);
|
||||
const focusUser = $derived(focus != null ? session.user(focus) : undefined);
|
||||
// A stream we just started watching goes to the stage; it leaves when the stream ends
|
||||
let lastWatch: number | null = null;
|
||||
$effect(() => {
|
||||
const now = watching?.session ?? null;
|
||||
if (now !== lastWatch) {
|
||||
if (now != null) focus = now;
|
||||
else if (focus === lastWatch) focus = null;
|
||||
lastWatch = now;
|
||||
}
|
||||
});
|
||||
$effect(() => { if (focus === self?.session && !mine) focus = null; });
|
||||
|
||||
let collapsed = $state(load());
|
||||
function load(): boolean {
|
||||
@@ -23,10 +57,39 @@
|
||||
}
|
||||
|
||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||
|
||||
// The same stream can sit in a tile and on the stage at once
|
||||
function media(node: HTMLMediaElement, stream: MediaStream | null) {
|
||||
node.srcObject = stream;
|
||||
return { update(next: MediaStream | null) { if (node.srcObject !== next) node.srcObject = next; } };
|
||||
}
|
||||
|
||||
// Sound comes from one hidden player, so it plays once however many pictures are shown
|
||||
let player = $state<HTMLAudioElement>();
|
||||
const hasSound = $derived(!!watching?.stream?.getAudioTracks().length);
|
||||
$effect(() => {
|
||||
if (!player) return;
|
||||
player.volume = store.settings.shareVolume;
|
||||
player.muted = store.settings.shareVolume === 0;
|
||||
});
|
||||
|
||||
function tileClick(session_: number) {
|
||||
if (streamOf(session_)) focus = focus === session_ ? null : session_;
|
||||
else if (session.sharing[session_] && session_ !== self?.session) share.watch(sessions.active, session_);
|
||||
else { session.showUser(session_); ui.panelOpen = true; }
|
||||
}
|
||||
|
||||
let frame = $state<HTMLElement>();
|
||||
function fullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else frame?.requestFullscreen().catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if self}
|
||||
<section class="stage" aria-label="Voice channel">
|
||||
{#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}
|
||||
<section class="stage" class:meeting={!!focused} aria-label="Voice channel">
|
||||
<header>
|
||||
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
|
||||
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
||||
@@ -38,6 +101,10 @@
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if mine}
|
||||
<span class="status" role="status">You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''}</span>
|
||||
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
||||
{/if}
|
||||
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
|
||||
onclick={() => session.setSelfMute(!self.selfMute)}>
|
||||
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
|
||||
@@ -47,16 +114,52 @@
|
||||
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
|
||||
|
||||
{#if focused && focus != null}
|
||||
<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}
|
||||
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||
{#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 -->
|
||||
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !collapsed}
|
||||
<ul class="tiles">
|
||||
<ul class="tiles" class:strip={!!focused}>
|
||||
{#each people as u (u.session)}
|
||||
<li>
|
||||
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session}
|
||||
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
|
||||
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
|
||||
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
|
||||
{@const stream = streamOf(u.session)}
|
||||
{@const sharing = !!session.sharing[u.session]}
|
||||
{@const isSelf = u.session === self.session}
|
||||
<li class:wide={!!stream}>
|
||||
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:video={!!stream} class:focused={focus === u.session}
|
||||
onclick={() => tileClick(u.session)}
|
||||
oncontextmenu={e => menu.show(e, userMenu(u))}
|
||||
title={stream ? (focus === u.session ? 'Back to tiles' : 'Show large') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)}
|
||||
aria-label={stream ? `${focus === u.session ? 'Shrink' : 'Enlarge'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}>
|
||||
{#if stream}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video use:media={stream} autoplay playsinline muted></video>
|
||||
{:else}
|
||||
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
|
||||
{/if}
|
||||
<span class="who">{store.displayName(u)}</span>
|
||||
{#if u.deaf || u.selfDeaf}
|
||||
{#if watching?.session === u.session && watching.state !== 'live'}
|
||||
<span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
|
||||
{/if}
|
||||
{#if sharing}<span class="badge live"><Icon name="screen" size={13} /></span>
|
||||
{:else if u.deaf || u.selfDeaf}
|
||||
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
|
||||
{:else if u.mute || u.selfMute || u.suppress}
|
||||
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
|
||||
@@ -65,6 +168,9 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if watching?.state === 'failed'}
|
||||
<p class="failed">Could not connect to the stream. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -89,4 +195,28 @@
|
||||
.tile.self .who { color: var(--text); font-weight: 600; }
|
||||
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
|
||||
.badge.admin { color: var(--danger); }
|
||||
.badge.live { color: var(--speaking); }
|
||||
|
||||
/* Streams */
|
||||
.bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
|
||||
.bar span { flex: 1; min-width: 0; }
|
||||
.bar.bad { color: var(--danger); }
|
||||
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
|
||||
.status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
|
||||
.tiles li.wide { grid-column: span 2; }
|
||||
.tile.video { padding: 0 0 6px; overflow: hidden; }
|
||||
.tile.video video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||
.tile.focused { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.note { font-size: 11px; color: var(--text-faint); }
|
||||
.failed { margin: 0; padding: 0 12px 10px; font-size: 12px; color: var(--danger); }
|
||||
/* Meeting view: the chosen picture large, everyone else in one row below */
|
||||
.stage.meeting { display: flex; flex-direction: column; max-height: 72%; min-height: 220px; }
|
||||
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; }
|
||||
.spot-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-0); color: var(--text-dim); font-size: 13px; }
|
||||
.spot-who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
|
||||
.spot video { flex: 1; min-height: 0; width: 100%; object-fit: contain; background: #000; display: block; }
|
||||
.vol { width: 110px; flex: none; padding: 0; }
|
||||
.tiles.strip { flex: none; display: flex; overflow-x: auto; overflow-y: hidden; padding-top: 8px; max-height: none; }
|
||||
.tiles.strip li { flex: 0 0 96px; }
|
||||
.tiles.strip li.wide { flex-basis: 150px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user