Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

The Stage: Sharing Mid-Talk

~14 min · attachments, pause, ownership, ux, pasted-text

Level 0Muted
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Share images or files with her: they go out with your next words." — the voice screen's paperclip

The Only Way Was to Leave

The first broadcast found the next gap. Dad wanted to hand Pippa an article, a picture, a screenshot of something he was looking at, and the voice screen had no way in. He had to close the face, drop the file into the composer, and come back, which broke the talk at exactly the moment there was finally something to look at together. The fix gives the voice screen a place of its own for things that should be seen: the stage.

Three Ways In, One Way Out

While the face screen is up, a file can reach it three ways: dropped onto it, pasted into it (a screenshot, a copied image), or picked with the paperclip. Every one of them lands on the stage, marked as going out with his next words, up to ten files and ten pieces of pasted text at a time. When he next speaks, they ride that utterance as its attachments, through the same send path as every voice turn, so the three routes from this track's second lesson (send, queue, composer) and the recording's facts all apply unchanged. If that send fails, the files go back on the stage, in front of anything he shared meanwhile. In the lesson's code the cap never pushes anything off: past ten, a new share is refused and the screen says so, and a failed send gives back everything it took. The WebUI now gives back everything a failed send took as well, in front of anything shared meanwhile. When he adds past ten it still keeps the latest ten, so there the oldest waiting share falls off, where the lesson's code refuses the new one instead. That is this track's never-dropped rule again, applied to things instead of words: nothing Dad handed over disappears because a request didn't start.

Text Stays Text

Text was the one thing that did not fit. The first version turned pasted text into a small .txt file, so a paste never vanished, but the words went out as a file. The same night Dad ruled that text stays text. A link, a headline or a whole article pasted or dropped on the voice screen now waits on the stage as text and rides his next words as quote blocks after them. On the stage it shows as a quote, and it opens large like a card.

The dictated-turn instruction gained a line to match: quote blocks after his words were pasted, not heard, so she reads them exactly as written. A transcribed turn's standing rule is to read it for what he meant, and without that line a URL could look like a word she misheard and quietly correct. The phone's voice screen gained a paste button for a link, text or a picture (the system paste button, so iOS never asks to allow the paste), and the phone and Firekeeper build the same message through the shared voice kit.

What the Stage Shows

The stage shows, in order: what is waiting to go out, then the attachments of his latest turn in this talk (until he clears them), then her cards. On a wide screen with On Air lit, the stage stands beside the face instead of under it, because a recording wants both in view.

A Lightbox That Resumes Only What It Paused

Click an image or a card and it opens large in a lightbox. The arrow keys or the buttons walk through every item on the stage, and Esc closes it. Looking at something together is not talking, so opening the lightbox pauses the talk, exactly as Pause does: the microphone is released and her reading stops, with the heard position recorded.

The subtle part is closing it. The lightbox resumes the talk on close only if the lightbox was the one that paused it. If Dad had already paused before he opened a screenshot, closing the screenshot leaves him paused, because that pause was his and not the lightbox's. The rule is small and easy to get wrong in the other direction: a lightbox that always resumes on close turns a quiet moment Dad chose into a microphone he didn't ask for.

A flag alone gets two cases wrong. If the talk stops on its own while he is looking (a closed socket), or Dad takes the pause over with his own Pause, the lightbox no longer owns the pause, and closing it must leave the talk where it is. The WebUI's lightbox now keeps one claim that lapses the moment the talk leaves the pause, or Dad presses Pause or Resume or taps the face; it resumes only while that claim stands and the talk is still paused. The web has one thing that pauses a talk this way, so one claim is enough there. The lesson's code keeps holds by name for the day a dialog joins the lightbox: the talk resumes only when the last holder lets go.

The Soul Knows What Is On Screen

The files are not only for Dad's eyes. They reach her as attachments of his turn, and while On Air is lit her instruction adds that what he shares is already on screen beside her, where the viewers see it: she should talk about it as something they are both looking at.

The Mac Door Too

Firekeeper's panel got the same stage the same evening: drop onto the panel, paste with Command-V while it is in front, or use the paperclip; thumbnails wait under the face and ride his next words. The same change fixed a keyboard rule. Firekeeper's pause key had been a system-wide hotkey, so a Space typed in some other window resumed a paused talk. Now the panel takes a plain key only while it is the key window, and elsewhere Space types as usual; a modifier tapped on its own still pauses from anywhere.

Code

A stage whose files and pasted text ride the next words, and pauses resumed only by whoever took them·typescript
// Things shared mid-talk ride the next words; a pause is resumed only by whoever took it.
const MAX_SHARES = 10;

/** Dad's words, then each pasted text as a quote block after them. */
export function withPastedText(words: string, texts: string[]): string {
  const quotes = texts
    .map((text) => text.trim())
    .filter(Boolean)
    .map((text) => text.split('\n').map((line) => (line.trim() ? `> ${line}` : '>')).join('\n'));
  return [words.trim(), ...quotes].filter(Boolean).join('\n\n');
}

export class Stage {
  private files: File[] = [];
  private texts: string[] = [];
  /** Up to ten wait at once. Past that a new share is refused, and the screen
   *  says so; nothing already on the stage is ever pushed off. */
  addFiles(files: File[]): File[] {
    const room = Math.max(0, MAX_SHARES - this.files.length);
    this.files = [...this.files, ...files.slice(0, room)];
    return files.slice(room);
  }
  addTexts(texts: string[]): string[] {
    const kept = texts.map((text) => text.trim()).filter(Boolean);
    const room = Math.max(0, MAX_SHARES - this.texts.length);
    this.texts = [...this.texts, ...kept.slice(0, room)];
    return kept.slice(room);
  }
  get waiting() {
    return [...this.files.map((file) => file.name), ...this.texts.map((text) => `text: ${text.split('\n')[0]}`)];
  }

  /** The send takes everything waiting; a send that fails gives it all back. */
  async sendWith(words: string, send: (message: string, files: File[]) => Promise<void>) {
    const files = this.files;
    const texts = this.texts;
    this.files = [];
    this.texts = [];
    try {
      await send(withPastedText(words, texts), files);
    } catch {
      // Back in front of anything shared meanwhile, and never trimmed: the cap
      // refuses new shares, it never takes back what he already handed over.
      this.files = [...files, ...this.files];
      this.texts = [...texts, ...this.texts];
      throw new Error('Could not send: what he shared is back on the stage');
    }
  }
}

type Phase = 'listening' | 'speaking' | 'paused' | 'stopped';
interface Talk { phase: Phase; pause(): void; resume(): void }

/** Anything that pauses the talk for a while (the lightbox, a dialog) holds it
 *  by name. The talk resumes when the last holder lets go, and only if it is
 *  still in the pause they took: not after it stopped on its own, not after
 *  Dad paused it himself. */
export class PauseHolds {
  private holders = new Set<string>();
  private tookIt = false;
  constructor(private talk: Talk) {}
  hold(who: string) {
    if (this.holders.size === 0) {
      this.tookIt = this.talk.phase === 'listening' || this.talk.phase === 'speaking';
      if (this.tookIt) this.talk.pause();
    }
    this.holders.add(who);
  }
  release(who: string) {
    if (!this.holders.delete(who) || this.holders.size > 0) return;
    if (this.tookIt && this.talk.phase === 'paused') this.talk.resume();
    this.tookIt = false;
  }
  /** Dad pressed Pause or Resume himself: from now on the pause is his. */
  dadDecides() { this.tookIt = false; }
}

async function main() {
  const talk: Talk = {
    phase: 'listening',
    pause() { this.phase = 'paused'; },
    resume() { this.phase = 'listening'; },
  };
  const stage = new Stage();
  stage.addFiles([new File(['<png bytes>'], 'screenshot.png')]);
  stage.addTexts(['https://example.com/news/42\nA headline Dad copied']);
  console.log('waiting:', stage.waiting);
  await stage.sendWith('what do you make of this?', async () => {
    stage.addFiles([new File(['<png bytes>'], 'shared-meanwhile.png')]);   // arrives mid-send
    throw new Error('offline');
  }).catch((error: Error) => console.log(error.message));
  console.log('after the failed send:', stage.waiting);
  await stage.sendWith('what do you make of this?', async (message, files) => {
    console.log(`sent, with ${files.map((file) => file.name).join(', ')}:\n${message}`);
  });
  console.log('after the send:', stage.waiting);
  const many = Array.from({ length: 11 }, (_, i) => new File(['x'], `photo-${i + 1}.jpg`));
  const refused = stage.addFiles(many);
  console.log('eleven at once: kept', stage.waiting.length, '| refused', refused.map((file) => file.name));

  const holds = new PauseHolds(talk);
  holds.hold('lightbox'); console.log('lightbox open   ->', talk.phase);
  holds.release('lightbox'); console.log('lightbox closed ->', talk.phase);

  talk.pause(); holds.hold('lightbox'); holds.release('lightbox');
  console.log('Dad paused first, then a screenshot opened and closed ->', talk.phase);
  talk.resume();

  holds.hold('lightbox'); talk.phase = 'stopped'; holds.release('lightbox');
  console.log('the talk stopped on its own while he looked ->', talk.phase);
  talk.phase = 'listening';

  holds.hold('lightbox'); holds.hold('dialog'); holds.release('lightbox');
  console.log('lightbox closed, dialog still open ->', talk.phase);
  holds.release('dialog'); console.log('dialog closed too ->', talk.phase);

  holds.hold('lightbox'); holds.dadDecides(); holds.release('lightbox');
  console.log('Dad pressed Pause himself while he looked ->', talk.phase);
}

void main();

External links

Exercise

Run the code with npx tsx and follow the stage through a failed send and a good one, then the lightbox through both of its cases. Then add the paste handler for a real page: read the clipboard from a paste event, hand any files to addFiles, and only when there are none, hand the plain text to addTexts. Finally wire a real Pause button to PauseHolds: which call does the button make while the lightbox is open, and why?
Hint
In a paste event, images arrive as file items and text as a string item; ask for both and prefer the files when both exist. For the last question: the button calls dadDecides() and then pauses or resumes. From that press on the pause is Dad's, so closing the lightbox must leave the talk however he set it, the same reason a talk that stopped on its own stays stopped and says why on the face.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.