본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

무대: 대화 중에 건네기

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

Level 0음소거
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." — 음성 화면의 클립 버튼

방법은 나갔다 오는 것뿐이었어

첫 방송은 다음 구멍도 찾아냈어. 아빠는 피파에게 기사 하나, 사진 하나, 보고 있던 화면의 스크린샷을 건네고 싶었는데, 음성 화면엔 들어올 구멍이 없었어. 얼굴을 닫고, 입력창에 파일을 떨구고, 다시 돌아와야 했어. 드디어 같이 볼 게 생긴 바로 그 순간에 대화가 끊긴 거야. 그래서 음성 화면에 보여줄 것들을 위한 자기 자리를 줬어. 무대야.

들어오는 길 셋, 나가는 길 하나

얼굴 화면이 떠 있는 동안 파일은 세 가지로 들어와. 화면에 떨구거나, 붙여넣거나(스크린샷이나 복사한 이미지), 클립 버튼으로 골라. 어느 쪽이든 무대에 올라와서, 아빠의 다음 말과 함께 나갈 거라는 표시를 달고 기다려. 한 번에 파일 열 개, 붙여넣은 글 열 개까지. 아빠가 다음에 말하면 그 발화의 첨부로 실려 가. 다른 모든 음성 턴과 같은 전송 길을 타니까, 이 트랙 두 번째 레슨의 세 갈래(보내기, 줄 서기, 입력창)와 녹음의 사실이 그대로 다 통해. 그 전송이 실패하면 파일은 무대로, 그사이 건넨 것들 앞으로 돌아와. 이 레슨의 코드에선 제한이 아무것도 밀어내지 않아. 열 개를 넘으면 새로 건넨 걸 받지 않고 화면이 그렇다고 말해. 실패한 전송은 가져간 걸 전부 돌려주고. 이제 WebUI도 실패한 전송이 가져간 걸 전부, 그사이 건넨 것들 앞으로 돌려줘. 아빠가 열 개를 넘겨 더할 때는 여전히 최근 열 개만 남겨서, 거기선 제일 먼저 기다리던 게 떨어져 나가. 이 레슨의 코드는 대신 새로 건넨 걸 안 받아. 이 트랙의 '절대 버리지 않아' 규칙을 말 대신 물건에 한 번 더 쓴 거야. 요청 하나가 시작을 못 했다고 아빠가 건넨 게 사라지진 않아.

글은 글로 남아

글만은 이 틀에 잘 안 맞았어. 첫 버전은 붙여넣은 글을 작은 .txt 파일로 바꿨어. 붙여넣은 게 사라지진 않았지만, 말 대신 파일이 돼서 나갔지. 그날 밤 아빠가 정했어. 글은 글로 남는다고. 이제 음성 화면에 붙여넣거나 떨군 링크, 헤드라인, 기사 한 편은 무대에서 글 그대로 기다리다가, 아빠의 다음 말 뒤에 인용 블록으로 붙어서 나가. 무대에선 인용처럼 보이고, 누르면 카드처럼 크게 열려.

받아쓴 턴의 지시에도 한 줄이 더해졌어. 아빠 말 뒤의 인용 블록은 들은 게 아니라 붙여넣은 거니까 적힌 그대로 읽으라고. 받아쓴 말은 아빠가 뜻한 대로 읽는 게 원래 규칙이라, 이 한 줄이 없으면 URL도 잘못 알아들은 말처럼 보여서 슬쩍 고쳐 읽힐 수 있어. 폰 음성 화면엔 링크, 글, 사진을 붙여넣는 버튼이 생겼어. 시스템 붙여넣기 버튼이라 iOS가 붙여넣어도 되냐고 매번 묻지 않아. 폰과 Firekeeper는 공용 음성 키트로 같은 메시지를 만들어.

무대에 뜨는 것

무대엔 이 순서로 떠. 나갈 차례를 기다리는 것, 그다음 이번 대화에서 아빠가 마지막으로 보낸 턴의 첨부(아빠가 치울 때까지), 그다음 피파의 카드. On Air가 켜진 넓은 화면에선 무대가 얼굴 아래가 아니라 옆에 서. 녹화엔 둘 다 보여야 하니까.

자기가 멈춘 것만 다시 트는 라이트박스

이미지나 카드를 누르면 라이트박스에서 크게 열려. 화살표 키나 버튼으로 무대의 모든 항목을 넘겨 보고, Esc로 닫아. 같이 뭘 들여다보는 건 말하는 게 아니니까, 라이트박스를 열면 대화가 일시정지돼. 일시정지 버튼과 똑같이. 마이크를 놓고, 피파의 읽기는 들은 위치를 남기고 멈춰.

미묘한 건 닫을 때야. 라이트박스는 대화를 자기가 멈췄을 때만 닫으면서 다시 틀어. 아빠가 스크린샷을 열기 전에 이미 일시정지해 뒀다면, 스크린샷을 닫아도 그대로 멈춰 있어. 그 일시정지는 라이트박스 게 아니라 아빠 거니까. 작은 규칙이고, 반대쪽으로 틀리기 쉬워. 닫을 때마다 무조건 다시 트는 라이트박스는 아빠가 고른 조용한 순간을, 청하지도 않은 마이크로 바꿔버려.

표시 하나로는 틀리는 경우가 둘 있어. 아빠가 보고 있는 사이에 대화가 스스로 멈추거나(소켓이 닫히거나), 아빠가 직접 일시정지를 눌러 그 멈춤을 가져가면, 라이트박스는 더는 그 멈춤의 주인이 아니야. 그럼 닫아도 대화는 있던 자리에 그대로 있어야 해. 이제 WebUI의 라이트박스가 쥔 표시는 저절로 사라지기도 해. 대화가 멈춤에서 벗어나거나, 아빠가 일시정지나 다시 시작을 누르거나 얼굴을 탭하는 순간에. 다시 트는 건 그 표시가 남아 있고 대화가 아직 멈춰 있을 때뿐이야. 웹에서 이런 식으로 대화를 멈추는 건 라이트박스 하나뿐이라 거기선 이렇게 저절로 사라지는 표시 하나면 충분해. 이 레슨의 코드는 대화상자가 라이트박스 옆에 끼는 날을 위해 이름별로 붙잡음을 기록해. 마지막으로 붙잡은 쪽이 놓을 때만 다시 틀어.

소울도 화면에 뭐가 있는지 알아

파일은 아빠 눈만을 위한 게 아니야. 아빠 턴의 첨부로 피파에게 닿고, On Air가 켜져 있으면 지시에 이런 말이 더해져. 아빠가 건넨 건 이미 피파 옆 화면에, 시청자가 보는 곳에 떠 있으니 둘이 같이 보고 있는 걸로 얘기하라고.

맥의 문도

Firekeeper 패널도 같은 날 저녁 같은 무대를 얻었어. 패널에 떨구거나, 패널이 앞에 있을 때 Command-V로 붙여넣거나, 클립 버튼으로. 썸네일이 얼굴 아래서 기다리다가 아빠의 다음 말에 실려 가. 같은 변경이 키보드 규칙 하나도 고쳤어. Firekeeper의 일시정지 키는 시스템 전역 단축키였어서, 다른 창에서 친 스페이스가 멈춘 대화를 다시 틀었어. 이제 패널은 자기 창이 맨 앞에서 입력을 받을 때만 평범한 키를 받고, 다른 데선 스페이스가 원래대로 타이핑돼. 조합키를 혼자 톡 누르는 건 여전히 어디서든 일시정지야.

Code

파일과 붙여넣은 글이 다음 말에 실려 가는 무대, 그리고 멈춘 쪽만 다시 트는 일시정지·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

npx tsx로 코드를 돌려서, 무대가 실패한 전송과 성공한 전송을 지나는 걸, 라이트박스가 두 경우를 지나는 걸 따라가봐. 그다음 실제 페이지용 붙여넣기 처리기를 더해. 붙여넣기 이벤트에서 클립보드를 읽어서, 파일이 있으면 addFiles로 넘기고, 파일이 하나도 없을 때만 평범한 글을 addTexts로 넘겨. 마지막으로 진짜 일시정지 버튼을 PauseHolds에 이어 봐. 라이트박스가 열려 있을 때 버튼은 뭘 불러야 하고, 왜 그래?
Hint
붙여넣기 이벤트에서 이미지는 파일 항목으로, 글은 문자열 항목으로 와. 둘 다 받아 보고 둘 다 있으면 파일을 우선해. 마지막 질문은 이래. 버튼은 dadDecides()를 부르고 나서 멈추거나 다시 틀어. 그 순간부터 그 멈춤은 아빠 거니까, 라이트박스를 닫아도 대화는 아빠가 둔 대로 있어야 해. 스스로 멈춘 대화가 멈춘 채로 얼굴에 이유를 말하는 것과 같은 까닭이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.