Camera sharing, and the proxy's relay for the desktop app
- A camera is a second source in the share dialog; streams carry their kind, so icons and wording follow. Browsers without screen capture (phones) start on the camera. - Settings, Voice: the address of a mumh5 web version as relay; its STUN and relay are then offered with every stream and take over when no direct connection comes up. - The proxy hands out relay credentials at GET /api/relay, readable from any origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -69,7 +69,7 @@ export function userMenu(u: User): MenuItem[] {
|
||||
);
|
||||
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
|
||||
} else {
|
||||
if (session.sharing[u.session]) items.push({ label: 'Watch screen', action: () => share.watch(sessions.active, u.session) });
|
||||
if (session.sharing[u.session]) items.push({ label: `Watch ${session.sharing[u.session]}`, action: () => share.watch(sessions.active, u.session) });
|
||||
items.push(
|
||||
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
|
||||
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ICON_PATHS: Record<string, string> = {
|
||||
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
||||
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3',
|
||||
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
||||
popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
||||
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCodec } from '../core/proto.ts';
|
||||
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
|
||||
import { native, openTransport } from './native.ts';
|
||||
import { share } from './share.svelte.ts';
|
||||
import { share, type ShareKind } from './share.svelte.ts';
|
||||
import type { ServerCertInfo } from '../core/transport.ts';
|
||||
import { store, type SavedServer } from './settings.svelte.ts';
|
||||
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
|
||||
@@ -62,7 +62,7 @@ export class Session {
|
||||
certPrompt = $state<CertPrompt | null>(null);
|
||||
rtt = $state(0);
|
||||
// Who in our channel is sharing their screen, by session (mumh5 clients only)
|
||||
sharing = $state<Record<number, boolean>>({});
|
||||
sharing = $state<Record<number, ShareKind>>({});
|
||||
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
|
||||
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
|
||||
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
|
||||
@@ -687,8 +687,8 @@ export class Session {
|
||||
|
||||
// ─── Voice state and moderation ─────────────────────────────────────────────
|
||||
|
||||
setSharing(session: number, on: boolean): void {
|
||||
if (on) this.sharing[session] = true;
|
||||
setSharing(session: number, kind: ShareKind | false): void {
|
||||
if (kind) this.sharing[session] = kind;
|
||||
else delete this.sharing[session];
|
||||
}
|
||||
clearSharing(): void { this.sharing = {}; }
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Settings {
|
||||
arrangement: Arrangement;
|
||||
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
|
||||
shareVolume: number; // volume of streams we watch, 0 to 1
|
||||
relayServer: string; // a mumh5 web proxy whose STUN and relay to use for screen sharing
|
||||
stunServer: string; // helps screen sharing connect through routers; empty contacts nobody
|
||||
shareNoticeSeen: boolean; // the note that screen sharing reveals IP addresses was confirmed
|
||||
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
|
||||
@@ -58,6 +59,7 @@ const defaults: Settings = {
|
||||
arrangement: 'side',
|
||||
shareQuality: 'medium',
|
||||
shareVolume: 1,
|
||||
relayServer: '',
|
||||
stunServer: '',
|
||||
shareNoticeSeen: false,
|
||||
hints: false
|
||||
|
||||
+22
-14
@@ -6,7 +6,7 @@ import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } fro
|
||||
import type { User } from '../core/client.ts';
|
||||
import type { Session } from './session.svelte.ts';
|
||||
import { desktop, isWeb } from './native.ts';
|
||||
import { proxyIce } from './web.svelte.ts';
|
||||
import { proxyIce, relayIce } from './web.svelte.ts';
|
||||
import { store } from './settings.svelte.ts';
|
||||
import { ui } from './ui.svelte.ts';
|
||||
import { sounds } from './audio/sounds.svelte.ts';
|
||||
@@ -23,6 +23,9 @@ export const QUALITY = {
|
||||
const stereo = (sdp: string) => sdp.replace(/a=fmtp:(\d+) ([^\r\n]*useinbandfec=1[^\r\n]*)/g,
|
||||
(line, pt, rest) => rest.includes('stereo=1') ? line : `a=fmtp:${pt} ${rest};stereo=1;sprop-stereo=1;maxaveragebitrate=192000`);
|
||||
|
||||
// What a stream shows. Sent along with the announcement, so others see the right icon.
|
||||
export type ShareKind = 'screen' | 'camera';
|
||||
|
||||
export interface Watching {
|
||||
host: Session;
|
||||
session: number;
|
||||
@@ -34,6 +37,7 @@ class ScreenShare {
|
||||
// The server we are sharing on, and what we send
|
||||
host = $state.raw<Session | null>(null);
|
||||
stream = $state.raw<MediaStream | null>(null);
|
||||
kind = $state<ShareKind>('screen');
|
||||
viewers = $state(0);
|
||||
watching = $state.raw<Watching | null>(null);
|
||||
error = $state('');
|
||||
@@ -58,9 +62,11 @@ class ScreenShare {
|
||||
private async iceServers(): Promise<RTCIceServer[]> {
|
||||
const stun = store.settings.stunServer.trim();
|
||||
const chosen: RTCIceServer[] = stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
|
||||
// The browser build also has the proxy it is served from: its STUN, and its relay for
|
||||
// networks and browsers that allow no direct connection
|
||||
return isWeb ? [...chosen, ...await proxyIce()] : chosen;
|
||||
// A mumh5 proxy adds its STUN and its relay, which takes over by itself when no direct
|
||||
// connection comes up (mobile and company networks, browsers that forbid direct UDP). The
|
||||
// browser build has the proxy it is served from; the desktop app the one set in Settings.
|
||||
const relay = store.settings.relayServer.trim();
|
||||
return [...chosen, ...(relay ? await relayIce(relay) : isWeb ? await proxyIce() : [])];
|
||||
}
|
||||
|
||||
// For testing the relay: localStorage mumh5.iceDebug = "relay" uses only relayed routes,
|
||||
@@ -112,7 +118,7 @@ class ScreenShare {
|
||||
}
|
||||
|
||||
// The dialog hands over what it captured
|
||||
go(s: Session, stream: MediaStream, usesVirtualMic: boolean): void {
|
||||
go(s: Session, stream: MediaStream, usesVirtualMic: boolean, kind: ShareKind = 'screen'): void {
|
||||
this.dialog = null;
|
||||
// Disconnected while the dialog was open
|
||||
if (!s.client || this.stream) {
|
||||
@@ -121,15 +127,17 @@ class ScreenShare {
|
||||
return;
|
||||
}
|
||||
for (const t of stream.getVideoTracks()) {
|
||||
t.contentHint = 'detail';
|
||||
// Text on a screen must stay sharp; a face may blur a little but should move smoothly
|
||||
t.contentHint = kind === 'camera' ? 'motion' : 'detail';
|
||||
// The system's "stop sharing" control
|
||||
t.addEventListener('ended', () => this.stop());
|
||||
}
|
||||
this.usesVirtualMic = usesVirtualMic;
|
||||
this.kind = kind;
|
||||
this.host = s;
|
||||
this.stream = stream;
|
||||
this.send(s, this.others(s), SHARE.on);
|
||||
s.setSharing(s.client.session!, true);
|
||||
this.send(s, this.others(s), SHARE.on, kind);
|
||||
s.setSharing(s.client.session!, kind);
|
||||
sounds.play('shareStarted');
|
||||
}
|
||||
|
||||
@@ -228,7 +236,7 @@ class ScreenShare {
|
||||
if (track.kind !== 'video') continue;
|
||||
const params = sender.getParameters();
|
||||
// Keep the picture sharp and let the frame rate drop when the connection is tight
|
||||
params.degradationPreference = 'maintain-resolution';
|
||||
params.degradationPreference = this.kind === 'camera' ? 'maintain-framerate' : 'maintain-resolution';
|
||||
for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate;
|
||||
await sender.setParameters(params).catch(() => {});
|
||||
}
|
||||
@@ -283,14 +291,14 @@ class ScreenShare {
|
||||
switch (msg.type) {
|
||||
case SHARE.on:
|
||||
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
|
||||
s.setSharing(sender, true);
|
||||
s.setSharing(sender, msg.text === 'camera' ? 'camera' : 'screen');
|
||||
break;
|
||||
case SHARE.off:
|
||||
if (s.sharing[sender]) sounds.play('shareStopped');
|
||||
s.setSharing(sender, false);
|
||||
if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
|
||||
break;
|
||||
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on); break;
|
||||
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on, this.kind); break;
|
||||
case SHARE.watch: if (this.host === s) this.offerTo(s, sender).catch(() => this.dropPeer(sender)); break;
|
||||
case SHARE.leave: if (this.host === s) this.dropPeer(sender); break;
|
||||
case SHARE.offer: if (msg.text) this.answerTo(s, sender, msg.text); break;
|
||||
@@ -309,8 +317,8 @@ class ScreenShare {
|
||||
if (this.watching?.host === s) this.unwatch();
|
||||
if (this.host === s) {
|
||||
this.dropPeers();
|
||||
s.setSharing(self, true);
|
||||
this.send(s, this.others(s), SHARE.on);
|
||||
s.setSharing(self, this.kind);
|
||||
this.send(s, this.others(s), SHARE.on, this.kind);
|
||||
}
|
||||
this.send(s, this.others(s), SHARE.who);
|
||||
}
|
||||
@@ -319,7 +327,7 @@ class ScreenShare {
|
||||
userMoved(s: Session, user: User, gone = false): void {
|
||||
if (!s.client?.self || user.session === s.client.session) return;
|
||||
if (!gone && this.inMyChannel(s, user.session)) {
|
||||
if (this.host === s) this.send(s, [user.session], SHARE.on);
|
||||
if (this.host === s) this.send(s, [user.session], SHARE.on, this.kind);
|
||||
return;
|
||||
}
|
||||
// A sharer who left or disconnected: their stream is over for us
|
||||
|
||||
+18
-18
@@ -137,8 +137,6 @@ class ProxyInfo {
|
||||
any = $state(false);
|
||||
// UDP port of the proxy's STUN responder, if it runs one
|
||||
stun = $state<number | null>(null);
|
||||
// Whether it also relays streams for browsers that cannot connect directly
|
||||
turn = $state(false);
|
||||
loaded = $state(false);
|
||||
error = $state('');
|
||||
|
||||
@@ -148,7 +146,6 @@ class ProxyInfo {
|
||||
this.servers = c.servers;
|
||||
this.any = c.any;
|
||||
this.stun = c.stun ?? null;
|
||||
this.turn = !!c.turn;
|
||||
this.error = '';
|
||||
} catch (e) {
|
||||
this.error = (e as Error).message;
|
||||
@@ -158,24 +155,27 @@ class ProxyInfo {
|
||||
}
|
||||
export const proxyInfo = new ProxyInfo();
|
||||
|
||||
// STUN and relay on the proxy itself: a host the user already uses, so nothing new is contacted.
|
||||
// The relay needs credentials, which the proxy hands out for a few hours at a time.
|
||||
let relay: { servers: RTCIceServer[]; until: number } | null = null;
|
||||
export async function proxyIce(): Promise<RTCIceServer[]> {
|
||||
if (!proxyInfo.loaded) await proxyInfo.load();
|
||||
if (!proxyInfo.stun || !base) return [];
|
||||
let host: string;
|
||||
try { host = new URL(base).hostname; } catch { return []; }
|
||||
const stun: RTCIceServer = { urls: `stun:${host}:${proxyInfo.stun}` };
|
||||
if (!proxyInfo.turn) return [stun];
|
||||
if (relay && relay.until > Date.now()) return relay.servers;
|
||||
// STUN and the relay of a mumh5 proxy, for screen sharing. The browser build uses the proxy it
|
||||
// is served from, a host the user already uses; the desktop app one the user entered. The relay
|
||||
// needs credentials, which the proxy hands out for a few hours at a time.
|
||||
const relays = new Map<string, { servers: RTCIceServer[]; until: number }>();
|
||||
export async function relayIce(address: string): Promise<RTCIceServer[]> {
|
||||
let url: URL;
|
||||
try { url = new URL(/^https?:\/\//i.test(address) ? address : `https://${address}`); } catch { return []; }
|
||||
const root = url.href.replace(/\/*$/, '/');
|
||||
const known = relays.get(root);
|
||||
if (known && known.until > Date.now()) return known.servers;
|
||||
try {
|
||||
const c = await call<{ username: string; credential: string; ttl: number; port: number }>('turn', {});
|
||||
const res = await fetch(`${root}api/relay`);
|
||||
if (!res.ok) return [];
|
||||
const c = await res.json() as { port: number; username?: string; credential?: string; ttl?: number };
|
||||
const servers: RTCIceServer[] = [{ urls: `stun:${url.hostname}:${c.port}` }];
|
||||
// UDP where it is allowed, TCP for networks and browsers that forbid it
|
||||
const servers = [stun, { urls: [`turn:${host}:${c.port}?transport=udp`, `turn:${host}:${c.port}?transport=tcp`], username: c.username, credential: c.credential }];
|
||||
relay = { servers, until: Date.now() + Math.max(60, c.ttl - 3600) * 1000 };
|
||||
if (c.username) servers.push({ urls: [`turn:${url.hostname}:${c.port}?transport=udp`, `turn:${url.hostname}:${c.port}?transport=tcp`], username: c.username, credential: c.credential });
|
||||
relays.set(root, { servers, until: Date.now() + Math.max(60, (c.ttl ?? 3600) - 3000) * 1000 });
|
||||
return servers;
|
||||
} catch {
|
||||
return [stun];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export const proxyIce = (): Promise<RTCIceServer[]> => base ? relayIce(base) : Promise.resolve([]);
|
||||
|
||||
@@ -169,14 +169,15 @@
|
||||
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
|
||||
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
|
||||
{#if session.sharing[u.session]}
|
||||
{@const kind = session.sharing[u.session]}
|
||||
{#if isSelf}
|
||||
<span class="state sharing" title="You are sharing your screen"><Icon name="screen" size={14} /></span>
|
||||
<span class="state sharing" title="You are sharing your {kind}"><Icon name={kind} size={14} /></span>
|
||||
{:else}
|
||||
<!-- A button cannot sit inside the row's button -->
|
||||
<span class="state sharing" role="button" tabindex="0" title="Sharing their screen. Click to watch" aria-label="Watch the screen of {store.displayName(u)}"
|
||||
<span class="state sharing" role="button" tabindex="0" title="Sharing their {kind}. Click to watch" aria-label="Watch the {kind} of {store.displayName(u)}"
|
||||
onclick={e => { e.stopPropagation(); clearTimeout(timer); share.watch(sessions.active, u.session); }}
|
||||
ondblclick={e => e.stopPropagation()}
|
||||
onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}><Icon name="screen" size={14} /></span>
|
||||
onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}><Icon name={kind} size={14} /></span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
|
||||
{#if session.sharing[user.session]}
|
||||
<div class="sharing">
|
||||
<Icon name="screen" size={16} />
|
||||
<span>{isSelf ? 'You are sharing your screen' : 'Sharing their screen'}</span>
|
||||
<Icon name={session.sharing[user.session]} size={16} />
|
||||
<span>{isSelf ? `You are sharing your ${session.sharing[user.session]}` : `Sharing their ${session.sharing[user.session]}`}</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}
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
{#if stunChoice === 'custom'}
|
||||
<input class="stun-custom" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" aria-label="STUN server address" />
|
||||
{/if}
|
||||
<label for="s-relay">Relay <span class="opt">(optional)</span></label>
|
||||
<input id="s-relay" bind:value={s.relayServer} onchange={() => { s.relayServer = s.relayServer.trim(); store.saveSettings(); }}
|
||||
placeholder={isWeb ? 'this site (automatic)' : 'https://voice.example.com'} autocomplete="off" />
|
||||
<p class="help">The address of a mumh5 web version. When a direct connection is not possible (mobile data, strict company networks), the stream goes through that server instead, by itself. {isWeb ? 'Empty uses the site you are on.' : 'Its operator sees your IP address and carries the stream, but cannot watch it.'}</p>
|
||||
<p class="help">Screen sharing connects you directly to the people watching. Across the internet both sides need a STUN server to find a route; it learns your IP address but never sees the stream. {isWeb ? 'By default the site you are on provides it.' : 'With none, no outside server is contacted and streams only work on the same network.'} Networks that block direct connections, like many mobile ones, need more than STUN.</p>
|
||||
{:else if tab === 'sounds'}
|
||||
<SoundSettings />
|
||||
@@ -146,6 +150,7 @@
|
||||
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
h3 { margin: 22px 0 0; font-size: 14px; }
|
||||
.stun-custom { margin-top: 6px; }
|
||||
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
|
||||
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.row { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.row > div { flex: 1; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { share, QUALITY } from '../lib/share.svelte.ts';
|
||||
import { share, QUALITY, type ShareKind } from '../lib/share.svelte.ts';
|
||||
import { desktop, type ScreenSource } from '../lib/native.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
const target = share.dialog!;
|
||||
|
||||
let info = $state<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'browser' | 'none'; micLabel: string }>({ portal: true, audio: desktop ? 'none' : 'browser', micLabel: '' });
|
||||
// What to show: the screen (or a window), or a camera
|
||||
// Phones have no screen capture in the browser; there the camera is the only choice
|
||||
const canScreen = !!navigator.mediaDevices?.getDisplayMedia;
|
||||
let kind = $state<ShareKind>(canScreen ? 'screen' : 'camera');
|
||||
let cameras = $state<MediaDeviceInfo[]>([]);
|
||||
let camera = $state('');
|
||||
let sources = $state<ScreenSource[]>([]);
|
||||
let selected = $state('');
|
||||
let preview = $state.raw<MediaStream | null>(null);
|
||||
@@ -67,6 +73,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function showCamera(deviceId = camera) {
|
||||
error = '';
|
||||
dropPreview();
|
||||
try {
|
||||
preview = await navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: deviceId ? { exact: deviceId } : undefined, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: fps() }
|
||||
});
|
||||
// Names are only given out once a camera was allowed
|
||||
cameras = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
|
||||
camera = preview.getVideoTracks()[0]?.getSettings().deviceId ?? deviceId;
|
||||
} catch (e) {
|
||||
error = (e as Error).name === 'NotFoundError' ? 'No camera was found.' : `Could not open the camera: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canScreen) showCamera();
|
||||
|
||||
function setKind(next: ShareKind) {
|
||||
if (kind === next) return;
|
||||
kind = next;
|
||||
selected = '';
|
||||
dropPreview();
|
||||
error = '';
|
||||
if (next === 'camera') showCamera();
|
||||
}
|
||||
|
||||
// The system's or the browser's picker
|
||||
async function chooseElsewhere() {
|
||||
error = '';
|
||||
@@ -106,7 +138,9 @@
|
||||
error = '';
|
||||
let usesVirtualMic = false;
|
||||
try {
|
||||
if (audio && info.audio === 'pipewire') {
|
||||
// A camera comes without extra sound: your voice already goes through the voice chat
|
||||
if (kind === 'camera') { /* nothing to add */ }
|
||||
else if (audio && info.audio === 'pipewire') {
|
||||
usesVirtualMic = true;
|
||||
await desktop!.shareAudioStart(audio === 'all' ? { kind: 'all' } : { kind: 'app', name: audio.slice(4) });
|
||||
preview.addTrack(await virtualMic());
|
||||
@@ -125,7 +159,7 @@
|
||||
return;
|
||||
}
|
||||
handedOver = true;
|
||||
share.go(target, preview, usesVirtualMic);
|
||||
share.go(target, preview, usesVirtualMic, kind);
|
||||
onclose();
|
||||
}
|
||||
|
||||
@@ -135,11 +169,24 @@
|
||||
<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'}
|
||||
<div class="kinds" role="tablist" aria-label="What to share">
|
||||
<button role="tab" aria-selected={kind === 'screen'} class:active={kind === 'screen'} disabled={!canScreen}
|
||||
title={canScreen ? undefined : 'This browser cannot capture the screen'} onclick={() => setKind('screen')}>Screen or window</button>
|
||||
<button role="tab" aria-selected={kind === 'camera'} class:active={kind === 'camera'} onclick={() => setKind('camera')}>Camera</button>
|
||||
</div>
|
||||
|
||||
{#if kind === 'camera'}
|
||||
<label for="sh-cam">Camera</label>
|
||||
<select id="sh-cam" value={camera} onchange={e => showCamera(e.currentTarget.value)} disabled={!cameras.length}>
|
||||
{#each cameras as c, i (c.deviceId)}<option value={c.deviceId}>{c.label || `Camera ${i + 1}`}</option>{:else}<option value="">{error ? 'None' : 'Asking for the camera...'}</option>{/each}
|
||||
</select>
|
||||
{:else 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}
|
||||
{#if kind === 'camera'}
|
||||
<!-- chosen above -->
|
||||
{:else 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)}
|
||||
@@ -165,7 +212,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row two">
|
||||
{#if desktop}
|
||||
{#if desktop && kind === 'screen'}
|
||||
<div>
|
||||
<label for="sh-audio">Sound</label>
|
||||
{#if info.audio === 'pipewire'}
|
||||
@@ -202,6 +249,9 @@
|
||||
|
||||
<style>
|
||||
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.kinds { display: flex; gap: 2px; margin-top: 12px; border-bottom: 1px solid var(--line); }
|
||||
.kinds button { padding: 6px 14px; color: var(--text-dim); font-weight: 600; }
|
||||
.kinds button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.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; }
|
||||
|
||||
@@ -168,9 +168,9 @@
|
||||
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
|
||||
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
|
||||
</button>
|
||||
<button class="icon-btn" class:live={sharingHere} title={sharingHere ? 'Stop sharing your screen' : 'Share your screen with your channel'}
|
||||
aria-label={sharingHere ? 'Stop sharing your screen' : 'Share your screen'} disabled={!sharingHere && !canShare} onclick={toggleShare}>
|
||||
<Icon name="screen" size={18} />
|
||||
<button class="icon-btn" class:live={sharingHere} title={sharingHere ? `Stop sharing your ${share.kind}` : 'Share your screen or camera with your channel'}
|
||||
aria-label={sharingHere ? `Stop sharing your ${share.kind}` : 'Share your screen or camera'} disabled={!sharingHere && !canShare} onclick={toggleShare}>
|
||||
<Icon name={sharingHere ? share.kind : 'screen'} size={18} />
|
||||
</button>
|
||||
<span class="sep"></span>
|
||||
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
|
||||
|
||||
+12
-10
@@ -43,6 +43,8 @@
|
||||
let focus = $state<number | null>(null);
|
||||
const focused = $derived(focus != null ? streamOf(focus) : null);
|
||||
const focusUser = $derived(focus != null ? session.user(focus) : undefined);
|
||||
// Screen or camera, for the wording and the icons
|
||||
const focusKind = $derived((focus != null && session.sharing[focus]) || 'screen');
|
||||
// A stream we just started watching goes to the stage; it leaves when the stream ends
|
||||
let lastWatch: number | null = null;
|
||||
$effect(() => {
|
||||
@@ -206,7 +208,7 @@
|
||||
// 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';
|
||||
const title = focus === self?.session ? `Your ${focusKind}` : focusUser ? store.displayName(focusUser) : 'Screen share';
|
||||
if (!popped) return;
|
||||
if (!stream) return popIn();
|
||||
if (popVideo && popVideo.srcObject !== stream) popVideo.srcObject = stream;
|
||||
@@ -221,8 +223,8 @@
|
||||
{#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>
|
||||
<Icon name={focusKind} size={16} />
|
||||
<span class="spot-who">{focus === self.session ? `Your ${focusKind}` : 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()}
|
||||
@@ -236,7 +238,7 @@
|
||||
{/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>
|
||||
<span class="status" role="status" title="People watching your {share.kind}">{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}
|
||||
@@ -250,7 +252,7 @@
|
||||
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||
{#if focus !== self.session && watching && watching.state !== 'live'}
|
||||
<p class="pending" class:bad={watching.state === 'failed'}>{watching.state === 'failed'
|
||||
? 'Could not connect to the stream. Across the internet both of you need a STUN server; some mobile and company networks block direct connections altogether.'
|
||||
? 'Could not connect to the stream. Across the internet both of you need a STUN server; networks that block direct connections need a relay. Both are in Settings, Voice.'
|
||||
: 'Connecting to the stream...'}</p>
|
||||
{/if}
|
||||
{#if showStats}<ShareStats own={focus === self.session} onclose={() => (showStats = false)} />{/if}
|
||||
@@ -294,7 +296,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
{#if mine}
|
||||
<span class="status" role="status" title="People watching your screen"><Icon name="screen" size={14} />{share.viewers} watching</span>
|
||||
<span class="status" role="status" title="People watching your {share.kind}"><Icon name={share.kind} size={14} />{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'}
|
||||
@@ -317,19 +319,19 @@
|
||||
<div class="row" role="presentation">
|
||||
{#each row as u (u.session)}
|
||||
{@const stream = streamOf(u.session)}
|
||||
{@const sharing = !!session.sharing[u.session]}
|
||||
{@const sharing = session.sharing[u.session]}
|
||||
{@const isSelf = u.session === self.session}
|
||||
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
|
||||
onclick={() => tileClick(u.session)}
|
||||
oncontextmenu={e => menu.show(e, userMenu(u))}
|
||||
title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)}
|
||||
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}>
|
||||
title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? `Sharing their ${sharing}. Click to watch` : store.displayName(u)}
|
||||
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
|
||||
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
|
||||
<span class="who">{store.displayName(u)}</span>
|
||||
{#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>
|
||||
{#if sharing}<span class="badge live"><Icon name={sharing} 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}
|
||||
|
||||
Reference in New Issue
Block a user