Skip to content

Discord's call stage: stream tiles, a focused stream, and a screen picker - #153

Merged
itz4blitz merged 1 commit into
mainfrom
feat/discord-stage
Nov 3, 2026
Merged

itz4blitz merged 1 commit into
mainfrom
feat/discord-stage

Conversation

@itz4blitz

Copy link
Copy Markdown
Collaborator

The call stage now works like Discord's for screen shares and cameras.

Stage

  • Everyone sharing their screen or camera gets a stream tile beside their own tile, with "Watch stream" until it is watched. Clicking it sends ShareWatch and fills the stage with it.
  • The focused stream has a strip of participants under it. Buttons on the stream hide the strip, lay it over the stream's foot, go full screen, or stop watching.
  • Clicking the focused stream again, or pressing Escape, goes back to the grid. In full screen, Escape leaves full screen first.
  • Your own share is a tile too. It comes from what your call sends (or your seat, when the call's list lags), with Stop and a dimmed picture of what you picked. Your own share isn't played back to you.
  • Pure layout logic lives in gui/tiny/stage.rs, with unit tests.

Screen picker

  • Share opens a picker with Screens and Windows tabs. Each entry has a thumbnail, listed by mediad --screens off the main thread. Double-click shares straight away.
  • MediaRequest::ShareSend gets an optional screen field (#[serde(default)], left off the wire when it's empty, with a round-trip test). chatd passes it through as before. mediad captures the chosen screen with GDI at per-monitor DPI, or the chosen window with PrintWindow.
  • macOS and X11 still share the primary screen and list only that. Phones keep the system picker, and the dock's share button still shares the primary screen.

Fix along the way: clicking a stage tile focused that tile, then the redraw removed it. After that, Escape had no binding to match. The stage now moves keyboard focus back to the window root.

Checks

  • cargo build -p tinyclient --features gui -p chatd -p mediad is clean.
  • cargo test -p tinyclient --features gui: 538 passed. 1 failed: invite_ipc::second_launch_delivers_to_the_primary, because the installed tinyclient on the test machine already holds that named pipe. Unrelated to this change.
  • protocol 95 passed, mediad lib 240 passed, chatd e2e share tests passed.
  • Local e2e against a real server with two desktop clients: grid, focused, strip over, strip hidden, full screen and Escape all checked. One client picked Screen 2 in the picker and the other watched it live.

🤖 Generated with Claude Code

…cker

A share or camera used to show as a "X is sharing · Watch" pill, then a 16:9 box above the
tiles, and whoever shared saw nothing of it. Now everyone sharing their screen or camera has a
stream tile beside their own, "Watch stream" until it is watched. Clicking one watches it and
fills the stage with it, the people in a strip under it, over it or hidden, with a full-screen
button; clicking it again, or Escape, is the grid again. My own share is a tile too, drawn from
what my call sends (or my seat, when my call's list lags), with Stop and a dimmed picture of
what I picked.

Share opens a picker of every screen and, on Windows, every window, each with a small picture
(`mediad --screens`, off the main thread). The choice rides on `ShareSend` as an optional
`screen` (left off the wire when empty, so older frames still read), and mediad captures that
screen (GDI, per-monitor DPI) or window (PrintWindow). macOS and X11 still share the primary
screen; a phone keeps the system's own picker.

A click that redraws the stage removed the tile it focused, and Escape then found no binding,
so the stage puts the keys back on the window's root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@unRekable unRekable left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved ✅ (0 critical, 4 robustness warnings worth a follow-up, several suggestions) — read the full diff (26 files) plus surrounding code.

⚠️ Warnings (robustness, not correctness — nothing blocking, see the follow-up note)

  • share.rs:206 — no timeout on mediad --screens; a wedged mediad leaves the picker spinning with Share disabled indefinitely (cancel and reopens stack extra blocking jobs).
  • share.rs:30 — the off-thread listing is fire-and-forget without a generation token; close-then-reopen can deliver a stale listing (old window handles, old thumbnails) into the new picker.
  • screens.rs:110 — Windows recycles HWNDs, so IsWindow can pass on a different window after the listed one closed — sharing the wrong window; a malformed window: id silently falls through to the primary display instead of erroring.
  • screens.rs:41 — the listing sweep deletes every *.jpg in the shared folder while an open (or stale) picker may still hold those paths, rendering a broken thumbnail.

💡 Suggestions

  • screens.rs:218 — the Program Manager filter is hard-coded English; on localized Windows the shell desktop would be listed as shareable. Filtering by window class (Progman/WorkerW) is locale-proof.
  • mod.rs:1058 — Escape clears focus but leaves the strip (and per-list screen_windows) as-is, so a hidden strip silently persists for the next focused stream. Confirm that's intentional Discord parity.
  • A test for the read_screens -> Some(empty) -> share primary fallback and captures/enumeration is the main coverage gap (understandable for window-bound Windows code; stage.rs, serde round-trip and BGRA are well covered).
  • screens.rs:47 — thumbnails are captured full-res then downscaled (up to ~30 MB churn per grab at 4K); capping capture size would soften that.

✅ Looks Good

  • The stage state machine is pure and unit-tested, including the merge order call-knows-first / seat-lag fallback / seat-missing synthesis, and unfocus on end/leave.
  • screen is wire-compatible in both directions — skip_serializing_if out, #[serde(default)] in — with round-trip tests for the old shape; old builds safely ignore the field.
  • GDI capture is disciplined: every SelectObject/DeleteObject/DeleteDC/ReleaseDC has its match including error paths, and the DPI guard restores correctly.
  • Platform parity is honest: macOS/X11 and phones fall back to primary screen or the system picker rather than pretending to support picks they cannot do.
  • The focus-restore fix matches the codebase's idiom and every stage entry point funnels through it.

Suggested follow-up (not in this PR): a bounded timeout plus a job-generation guard in the picker path, an error instead of primary-screen fall-through for malformed ids, and the locale-proof shell-window filter.

/// and the share is then the primary screen.
fn read_screens() -> Vec<ScreenSource> {
let folder = protocol::support_dir().join("screens");
let stdout = protocol::media_host::output(protocol::launch::no_console(&mut std::process::Command::new(crate::gui::settings::mediad_path())).arg("--screens").arg(&folder).stderr(std::process::Stdio::null())).unwrap_or_default();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning: media_host::output() has no deadline, so if the spawned mediad wedges, this stays at "Looking for screens and windows…" forever with the share button disabled — cancel is the only exit, and each reopen stacks another blocking off-thread job. A timeout with a "share primary screen / retry" fallback would keep the modal usable. Non-blocking, but high value.

self.tiny_state.screen_pick = None;
self.tiny_state.screen_windows = false;
self.tiny_open_modal(Modal::Share, window, cx);
self.off_thread(cx, read_screens, |this, screens, cx| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning: off_thread is fire-and-forget with no generation token, so closing the picker quickly and reopening leaves two read_screens jobs racing; the earlier one can finish last and populate the new modal with stale windows/thumbnails. Guard with a job-generation counter (or only accept a result while the current modal is still Modal::Share).

/// One screen or window, by id; the primary screen when the id is `None` or names nothing that
/// is still there as a screen. A window that closed is said in words, so the share ends.
#[cfg(windows)]
pub(super) fn capture(id: Option<&str>) -> Result<Rgba, String> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning: two edge cases here: Windows recycles HWNDs, so after a listed window closes IsWindow may still pass on a different window with the recycled handle — the share then captures the wrong window. And a window: id that fails parse::<isize>() silently falls through to the primary display instead of erroring, which shares the whole screen where the user picked a window. Consider erroring on a malformed id; the reuse hazard is inherently racy but worth a comment.

if std::fs::create_dir_all(folder).is_err() {
return found;
}
for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: this sweep deletes every *.jpg in the shared folder at listing time, while an already-populated picker (or a stale off-thread result) may still hold those exact paths in ScreenSource.thumbnail and then render a broken image. Deleting only files that are not part of the "current" stamp set, or stamping per picker-id, would keep the wipe and the read coordinated.

let got = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as i32);
let title = String::from_utf16_lossy(&title[..got.max(0) as usize]);
// The desktop itself is a titled window too.
if !title.trim().is_empty() && title != "Program Manager" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: "Program Manager" is locale-specific — on a localized Windows the shell desktop shows up as a shareable window. Filtering by window class (Progman/WorkerW) instead is locale-proof.

self.tiny_close_settings(cx);
} else if self.tiny_state.card.is_some() {
self.tiny_state.card = None;
} else if self.tiny_state.focus.is_some() && self.tiny_state.channel.is_some() && matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: Escape clears focus but leaves strip (and the per-list screen_windows) untouched, so a hidden strip silently persists into the next focused stream. If that is deliberate Discord parity, fine — a one-line comment would make it explicit.

@itz4blitz
itz4blitz merged commit bb06a2f into main Nov 3, 2026
17 of 18 checks passed
@itz4blitz
itz4blitz deleted the feat/discord-stage branch November 3, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants