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:
+1
-3
@@ -3,7 +3,6 @@
|
||||
import ChatLog from './ChatLog.svelte';
|
||||
import Composer from './Composer.svelte';
|
||||
import VoiceStage from './VoiceStage.svelte';
|
||||
import ShareView from './ShareView.svelte';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
|
||||
@@ -28,8 +27,7 @@
|
||||
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||
</header>
|
||||
|
||||
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
|
||||
{#if session.status === 'connected'}<ShareView />{/if}
|
||||
{#if session.status === 'connected'}<VoiceStage always={showStage} />{/if}
|
||||
|
||||
{#if session.status === 'idle' && session.disconnectInfo}
|
||||
{@const d = session.disconnectInfo}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
import { renderIncoming } from '../lib/html.ts';
|
||||
import RichEditor from './RichEditor.svelte';
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
import { sessions } from '../lib/session.svelte.ts';
|
||||
|
||||
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
|
||||
|
||||
@@ -124,6 +126,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if session.sharing[user.session]}
|
||||
<div class="sharing">
|
||||
<Icon name="screen" size={16} />
|
||||
<span>{isSelf ? 'You are sharing your screen' : 'Sharing their screen'}</span>
|
||||
{#if isSelf}<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
|
||||
{:else if share.watching?.session === user.session}<button class="btn" onclick={() => share.unwatch()}>Stop watching</button>
|
||||
{:else}<button class="btn primary" onclick={() => share.watch(sessions.active, user.session)}>Watch</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isSelf}
|
||||
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
||||
{/if}
|
||||
@@ -158,6 +170,9 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sharing { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 10px; font-size: 13px; color: var(--speaking); border: 1px solid var(--line); background: var(--bg-0); }
|
||||
.sharing span { flex: 1; min-width: 0; color: var(--text); }
|
||||
.sharing .btn { padding: 4px 10px; font-size: 12px; }
|
||||
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
|
||||
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
||||
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { share, QUALITY } from '../lib/share.svelte.ts';
|
||||
import { desktop, type ScreenSource } from '../lib/native.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
|
||||
// Everything is chosen here, with a preview, before anything is sent: what to show, which
|
||||
// sound goes with it and the quality. In a browser, and on Wayland, the system's own dialog
|
||||
// picks the screen or window.
|
||||
let { onclose }: { onclose: () => void } = $props();
|
||||
const target = share.dialog!;
|
||||
|
||||
let info = $state<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'browser' | 'none'; micLabel: string }>({ portal: true, audio: desktop ? 'none' : 'browser', micLabel: '' });
|
||||
let sources = $state<ScreenSource[]>([]);
|
||||
let selected = $state('');
|
||||
let preview = $state.raw<MediaStream | null>(null);
|
||||
let apps = $state<string[]>([]);
|
||||
// '' none, 'all' everything, 'app:<name>' one program
|
||||
let audio = $state('');
|
||||
let browserAudio = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let handedOver = false;
|
||||
let video = $state<HTMLVideoElement>();
|
||||
$effect(() => { if (video) video.srcObject = preview; });
|
||||
|
||||
const fps = () => QUALITY[store.settings.shareQuality].fps;
|
||||
|
||||
let listing = $state(false);
|
||||
async function loadSources() {
|
||||
if (!desktop) return;
|
||||
listing = true;
|
||||
try {
|
||||
sources = (await desktop.screenSources(false)).sources;
|
||||
} finally {
|
||||
listing = false;
|
||||
}
|
||||
// Pictures follow when they are ready
|
||||
const pictures = new Map((await desktop.screenSources(true)).sources.map(s => [s.id, s.thumbnail]));
|
||||
sources = sources.map(s => ({ ...s, thumbnail: pictures.get(s.id) ?? s.thumbnail }));
|
||||
}
|
||||
const loadApps = async () => { if (desktop && info.audio === 'pipewire') apps = await desktop.shareAudioApps(); };
|
||||
|
||||
if (desktop) {
|
||||
desktop.shareInfo().then(async i => {
|
||||
info = i;
|
||||
loadApps();
|
||||
if (!i.portal) await loadSources();
|
||||
});
|
||||
}
|
||||
|
||||
function dropPreview() {
|
||||
preview?.getTracks().forEach(t => t.stop());
|
||||
preview = null;
|
||||
}
|
||||
|
||||
async function show(id: string) {
|
||||
error = '';
|
||||
dropPreview();
|
||||
selected = id;
|
||||
try {
|
||||
desktop!.pickScreenSource(id);
|
||||
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: false });
|
||||
} catch (e) {
|
||||
error = `Could not capture this: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// The system's or the browser's picker
|
||||
async function chooseElsewhere() {
|
||||
error = '';
|
||||
try {
|
||||
if (desktop) {
|
||||
const { sources: picked } = await desktop.screenSources(false);
|
||||
if (!picked.length) return;
|
||||
sources = picked;
|
||||
await show(picked[0].id);
|
||||
} else {
|
||||
dropPreview();
|
||||
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: browserAudio });
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'NotAllowedError') error = `Could not capture the screen: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// The virtual microphone shows up as a recording device a moment after it is created
|
||||
async function virtualMic(): Promise<MediaStreamTrack> {
|
||||
const find = async () => (await navigator.mediaDevices.enumerateDevices()).find(d => d.kind === 'audioinput' && d.label.includes(info.micLabel));
|
||||
let device = await find();
|
||||
for (let i = 0; i < 20 && !device; i++) {
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
device = await find();
|
||||
}
|
||||
if (!device) throw new Error('The audio capture device did not appear.');
|
||||
const s = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: device.deviceId }, echoCancellation: false, autoGainControl: false, noiseSuppression: false, channelCount: 2 }
|
||||
});
|
||||
return s.getAudioTracks()[0];
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!preview) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
let usesVirtualMic = false;
|
||||
try {
|
||||
if (audio && info.audio === 'pipewire') {
|
||||
usesVirtualMic = true;
|
||||
await desktop!.shareAudioStart(audio === 'all' ? { kind: 'all' } : { kind: 'app', name: audio.slice(4) });
|
||||
preview.addTrack(await virtualMic());
|
||||
} else if (audio && info.audio === 'loopback') {
|
||||
// System sound comes with a capture request; only its audio track is kept
|
||||
desktop!.pickScreenSource(selected);
|
||||
const extra = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
extra.getVideoTracks().forEach(t => t.stop());
|
||||
const track = extra.getAudioTracks()[0];
|
||||
if (track) preview.addTrack(track);
|
||||
}
|
||||
} catch (e) {
|
||||
if (usesVirtualMic) desktop!.shareAudioStop();
|
||||
error = `Could not capture the sound: ${(e as Error).message}`;
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
handedOver = true;
|
||||
share.go(target, preview, usesVirtualMic);
|
||||
onclose();
|
||||
}
|
||||
|
||||
onDestroy(() => { if (!handedOver) dropPreview(); });
|
||||
</script>
|
||||
|
||||
<Modal title="Share your screen" {onclose} width={760}>
|
||||
<p class="help">mumh5 users in your channel can watch. Nothing is sent until you press Start sharing.</p>
|
||||
|
||||
{#if !desktop && info.audio === 'browser'}
|
||||
<label class="check"><input type="checkbox" bind:checked={browserAudio} disabled={!!preview} /> Include sound (the browser offers what it can share, usually a tab's sound)</label>
|
||||
{/if}
|
||||
|
||||
{#if desktop && !info.portal}
|
||||
<div class="row head"><label for="sh-src">Screen or window</label><button class="link" onclick={loadSources}>Refresh</button></div>
|
||||
<ul class="sources" id="sh-src">
|
||||
{#each sources as s (s.id)}
|
||||
<li>
|
||||
<button class:active={selected === s.id} aria-pressed={selected === s.id} onclick={() => show(s.id)}>
|
||||
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
|
||||
<span class="name">{s.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="dim">{listing ? 'Looking for screens and windows...' : 'Nothing found. Try Refresh.'}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="row"><button class="btn" onclick={chooseElsewhere}>{preview ? 'Choose another screen or window...' : 'Choose a screen or window...'}</button></div>
|
||||
{/if}
|
||||
|
||||
<span class="label">Preview</span>
|
||||
<div class="preview">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={video} autoplay playsinline muted></video>
|
||||
{#if !preview}<p>Nothing chosen yet</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="row two">
|
||||
{#if desktop}
|
||||
<div>
|
||||
<label for="sh-audio">Sound</label>
|
||||
{#if info.audio === 'pipewire'}
|
||||
<select id="sh-audio" bind:value={audio} onfocus={loadApps}>
|
||||
<option value="">No sound</option>
|
||||
<option value="all">Everything except mumh5</option>
|
||||
{#each apps as a (a)}<option value={`app:${a}`}>{a}</option>{/each}
|
||||
</select>
|
||||
{:else if info.audio === 'loopback'}
|
||||
<select id="sh-audio" bind:value={audio}>
|
||||
<option value="">No sound</option>
|
||||
<option value="all">Whole system (viewers also hear the voice chat again)</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select id="sh-audio" disabled><option>Not available on this system</option></select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label for="sh-quality">Quality</label>
|
||||
<select id="sh-quality" bind:value={store.settings.shareQuality} onchange={() => store.saveSettings()}>
|
||||
{#each Object.entries(QUALITY) as [id, q] (id)}<option value={id}>{q.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={onclose}>Cancel</button>
|
||||
<button class="btn primary" disabled={!preview || busy} onclick={start}>{busy ? 'Starting...' : 'Start sharing'}</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.row.head { justify-content: space-between; margin-top: 0; }
|
||||
.row.two { align-items: flex-end; gap: 12px; }
|
||||
.row.two > div { flex: 1; min-width: 0; }
|
||||
.label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
|
||||
.link { color: var(--link); font-size: 13px; margin-top: 8px; }
|
||||
.sources { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: 190px; overflow-y: auto; }
|
||||
.sources button { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 6px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||
.sources button:hover { border-color: var(--text-faint); }
|
||||
.sources button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
|
||||
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
||||
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 12px; }
|
||||
.check input { width: auto; }
|
||||
.dim { color: var(--text-dim); font-size: 13px; }
|
||||
.err { color: var(--danger); font-size: 13px; margin: 10px 0 0; }
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
|
||||
// Desktop app only: choose the screen or window to share
|
||||
const picker = $derived(share.picker);
|
||||
</script>
|
||||
|
||||
{#if picker}
|
||||
<Modal title="Share your screen" onclose={() => picker.choose(null)} width={720}>
|
||||
<p class="help">Pick a screen or a window. mumh5 users in your channel can then watch it.</p>
|
||||
<ul class="sources">
|
||||
{#each picker.sources as s (s.id)}
|
||||
<li>
|
||||
<button onclick={() => picker.choose(s.id)}>
|
||||
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
|
||||
<span class="name">{s.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => picker.choose(null)}>Cancel</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.sources { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||
.sources button { width: 100%; display: flex; flex-direction: column; gap: 6px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||
.sources button:hover { border-color: var(--accent); }
|
||||
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||
.name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
|
||||
// Above the chat: the stream being watched, and a note while we are sharing ourselves
|
||||
const watching = $derived(share.watching && share.watching.host.server?.id === session.server?.id ? share.watching : null);
|
||||
const sharer = $derived(watching ? session.user(watching.session) : undefined);
|
||||
const mine = $derived(share.stream && share.host?.server?.id === session.server?.id);
|
||||
|
||||
let video = $state<HTMLVideoElement>();
|
||||
let frame = $state<HTMLElement>();
|
||||
$effect(() => { if (video) video.srcObject = watching?.stream ?? null; });
|
||||
|
||||
function fullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else frame?.requestFullscreen().catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mine}
|
||||
<div class="bar" role="status">
|
||||
<Icon name="screen" size={16} />
|
||||
<span>You are sharing your screen{share.viewers ? `, ${share.viewers} watching` : ''}</span>
|
||||
<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||
|
||||
{#if watching}
|
||||
<section class="watch" aria-label="Screen share" bind:this={frame}>
|
||||
<header>
|
||||
<Icon name="screen" size={16} />
|
||||
<span class="who">{sharer ? store.displayName(sharer) : 'Screen share'}</span>
|
||||
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||
<button class="icon-btn" title="Stop watching" aria-label="Stop watching" onclick={() => share.unwatch()}><Icon name="x" size={16} /></button>
|
||||
</header>
|
||||
<div class="stagebox">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={video} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||
{#if watching.state === 'connecting'}<p class="note">Connecting...</p>
|
||||
{:else if watching.state === 'failed'}<p class="note">Could not connect. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.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); color: var(--text-dim); }
|
||||
.bar span { flex: 1; min-width: 0; }
|
||||
.bar .btn { padding: 4px 10px; font-size: 12px; }
|
||||
.bar.bad { color: var(--danger); }
|
||||
.watch { flex: none; display: flex; flex-direction: column; max-height: 62%; min-height: 160px; background: #000; border-bottom: 1px solid var(--line); }
|
||||
.watch header { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-1); color: var(--text-dim); font-size: 13px; }
|
||||
.who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
|
||||
.stagebox { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||
video { width: 100%; height: 100%; min-height: 0; object-fit: contain; background: #000; display: block; }
|
||||
.note { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px; color: #ddd; font-size: 14px; }
|
||||
.watch:fullscreen { max-height: none; }
|
||||
</style>
|
||||
+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