File

src/managers/session-manager/session-manager.ts

Description

Host capabilities the SessionManager needs from the EventDisplay.

Extends

RecorderHost PlayerHost

import { ActiveVariable } from '../../helpers/active-variable';
import {
  decodeSessionFromBase64,
  decodeSessionFromJson,
  encodeSessionToBase64,
  encodeSessionToBlob,
} from './session-codec';
import { SessionSource, SessionV1 } from './session-format';
import { PlayerHost, SessionPlayer } from './session-player';
import { RecorderHost, SessionRecorder } from './session-recorder';

/**
 * Discriminated union the {@link SessionManager.state} observable carries.
 * UI components subscribe to this to render the floating session pill.
 */
export type SessionState =
  | { kind: 'idle' }
  | { kind: 'recording'; recorder: SessionRecorder }
  | { kind: 'recorded'; session: SessionV1 }
  | { kind: 'pending'; session: SessionV1; sourceLabel: string }
  | { kind: 'playing'; player: SessionPlayer; currentTime: number }
  | { kind: 'paused'; player: SessionPlayer; currentTime: number }
  | { kind: 'finished'; player: SessionPlayer }
  | { kind: 'error'; message: string };

/** Host capabilities the SessionManager needs from the EventDisplay. */
export interface SessionManagerHost extends RecorderHost, PlayerHost {}

/**
 * Coordinator that wires {@link SessionRecorder} and {@link SessionPlayer}
 * to a single observable state. Exposed via EventDisplay.getSessionManager.
 * Mirrors the singleton pattern of {@link StateManager}.
 */
export class SessionManager {
  /** Reactive state for the UI pill. */
  public readonly state = new ActiveVariable<SessionState>({ kind: 'idle' });

  /** Active recorder instance, or null when not recording. */
  private recorder: SessionRecorder | null = null;
  /** Active player instance, or null when no replay is loaded. */
  private player: SessionPlayer | null = null;
  /** Decoded-but-not-yet-played session awaiting explicit user confirmation. */
  private pendingSession: SessionV1 | null = null;
  /**
   * Share-link base64 precomputed when a recording stops, so the UI can copy
   * it synchronously inside the click gesture (the Clipboard API rejects a
   * write that happens after an await, which would consume the gesture).
   */
  private sharableBase64Cache: string | null = null;
  /** Unsubscribe for the active player's tick callback. */
  private tickUnsub: (() => void) | null = null;
  /** Unsubscribe for the active player's complete callback. */
  private completeUnsub: (() => void) | null = null;

  /**
   * Create a session manager bound to a host adapter.
   * @param host Adapter exposing the event bus, state, and camera apply.
   */
  constructor(private host: SessionManagerHost) {}

  /** True when a recording is currently capturing events. */
  public get isRecording(): boolean {
    return this.recorder?.isRecording === true;
  }

  /** True when a player is currently in the playing state. */
  public get isPlaying(): boolean {
    return this.player?.isPlaying === true;
  }

  /** Active recorder, if any. */
  public getRecorder(): SessionRecorder | null {
    return this.recorder;
  }

  /** Active player, if any. */
  public getPlayer(): SessionPlayer | null {
    return this.player;
  }

  /**
   * Start a new recording (no-op if one is already running).
   * @param source Optional dataset/route metadata recorded in the session.
   */
  public startRecording(source?: SessionSource): void {
    if (this.recorder?.isRecording) return;
    this.stopPlaybackIfAny();
    this.recorder = new SessionRecorder(this.host);
    this.recorder.start({ source });
    this.state.update({ kind: 'recording', recorder: this.recorder });
  }

  /**
   * Stop the active recording and transition state to 'recorded' so the UI
   * can offer download / share. Returns the finalized session.
   */
  public stopRecording(): SessionV1 | null {
    if (!this.recorder) return null;
    const session = this.recorder.stop();
    this.recorder = null;
    this.state.update({ kind: 'recorded', session });
    // Precompute the share link so the pill can copy it synchronously inside
    // the user's click (compression is async and would otherwise expire the
    // gesture, making navigator.clipboard.writeText reject).
    this.sharableBase64Cache = null;
    encodeSessionToBase64(session)
      .then((b64) => {
        this.sharableBase64Cache = b64;
      })
      .catch(() => {
        this.sharableBase64Cache = null;
      });
    return session;
  }

  /**
   * Synchronously return the precomputed share-link base64 for the recorded
   * session, or null if not recorded or the encode has not finished yet.
   */
  public getCachedSharableBase64(): string | null {
    if (this.state.value?.kind !== 'recorded') return null;
    return this.sharableBase64Cache;
  }

  /**
   * Convenience: if recording, stop. Otherwise start.
   * Bound to the Shift+Ctrl+R keyboard shortcut.
   * @param source Optional source metadata used when starting a recording.
   */
  public toggleRecording(source?: SessionSource): void {
    if (this.recorder?.isRecording) {
      this.stopRecording();
    } else {
      this.startRecording(source);
    }
  }

  /** Discard the current recorded session and return to idle. */
  public clearRecorded(): void {
    this.sharableBase64Cache = null;
    if (this.state.value?.kind === 'recorded') {
      this.state.update({ kind: 'idle' });
    }
  }

  /**
   * Decode + validate a base64 `?replay=` payload and stage it for playback,
   * WITHOUT starting it. Sets state to 'pending' so the UI can require an
   * explicit user click before replaying untrusted, link-supplied content.
   * @param base64 Base64 input from the URL.
   * @param sourceLabel Human-readable origin shown in the confirm UI.
   * @throws Error if decoding/validation fails (also exposed via state.error).
   */
  public async prepareFromBase64(
    base64: string,
    sourceLabel: string,
  ): Promise<void> {
    try {
      const session = await decodeSessionFromBase64(base64);
      this.setPending(session, sourceLabel);
    } catch (e) {
      this.state.update({ kind: 'error', message: errorMessage(e) });
      throw e;
    }
  }

  /**
   * Decode + validate a `.phnxreplay` JSON payload and stage it for playback
   * without starting it (see {@link prepareFromBase64}).
   * @param text JSON text contents.
   * @param sourceLabel Human-readable origin shown in the confirm UI.
   * @throws Error if validation fails.
   */
  public prepareFromJsonText(text: string, sourceLabel: string): SessionV1 {
    try {
      const session = decodeSessionFromJson(text);
      this.setPending(session, sourceLabel);
      return session;
    } catch (e) {
      this.state.update({ kind: 'error', message: errorMessage(e) });
      throw e;
    }
  }

  /**
   * Start the pending session after explicit user confirmation. No-op if the
   * manager is not in the 'pending' state.
   */
  public playPending(): void {
    if (this.state.value?.kind !== 'pending' || !this.pendingSession) return;
    const session = this.pendingSession;
    this.pendingSession = null;
    this.attachPlayer(new SessionPlayer(this.host, session));
    this.player?.play();
  }

  /**
   * Load a session from a base64 ?replay= URL parameter and start playing.
   * For programmatic/trusted callers; the URL path uses
   * {@link prepareFromBase64} so link-supplied content is not auto-run.
   * @param base64 Base64 input from the URL.
   * @throws Error if decoding/validation fails (also exposed via state.error).
   */
  public async loadAndPlayBase64(base64: string): Promise<void> {
    try {
      const session = await decodeSessionFromBase64(base64);
      this.attachPlayer(new SessionPlayer(this.host, session));
      this.player?.play();
    } catch (e) {
      this.state.update({ kind: 'error', message: errorMessage(e) });
      throw e;
    }
  }

  /**
   * Load a session from a `.phnxreplay` JSON file (e.g. uploaded by user).
   * @param text JSON text contents of the file.
   * @throws Error if validation fails.
   */
  public loadFromJsonText(text: string): SessionV1 {
    try {
      const session = decodeSessionFromJson(text);
      this.attachPlayer(new SessionPlayer(this.host, session));
      return session;
    } catch (e) {
      this.state.update({ kind: 'error', message: errorMessage(e) });
      throw e;
    }
  }

  /** Start playback on a loaded player (no-op if none loaded). */
  public play(): void {
    this.player?.play();
  }

  /** Pause the active player. */
  public pause(): void {
    if (!this.player) return;
    this.player.pause();
    this.state.update({
      kind: 'paused',
      player: this.player,
      currentTime: this.player.currentTime,
    });
  }

  /** Seek the active player to a specific playhead position. */
  public seek(t: number): void {
    this.player?.seek(t);
  }

  /** Change the active player's speed multiplier. */
  public setSpeed(speed: 0.5 | 1 | 2 | 4): void {
    this.player?.setSpeed(speed);
  }

  /** Stop and detach any active player. Resets state to idle. */
  public stopPlayback(): void {
    this.stopPlaybackIfAny();
    this.pendingSession = null;
    if (this.state.value?.kind !== 'recording') {
      this.state.update({ kind: 'idle' });
    }
  }

  /**
   * Stage a decoded session as pending (awaiting user confirmation) and
   * publish the 'pending' state for the UI. Stops any active playback first.
   * @param session The validated session to stage.
   * @param sourceLabel Human-readable origin (host or 'shared link').
   */
  private setPending(session: SessionV1, sourceLabel: string): void {
    this.stopPlaybackIfAny();
    this.pendingSession = session;
    this.state.update({ kind: 'pending', session, sourceLabel });
  }

  /**
   * Encode the most recently recorded session as base64 for the share URL.
   * @returns Base64 string, or null if there's no recorded session in state.
   */
  public async getSharableBase64(): Promise<string | null> {
    if (this.state.value?.kind !== 'recorded') return null;
    return encodeSessionToBase64(this.state.value.session);
  }

  /**
   * Produce a downloadable Blob of the most recently recorded session.
   * @returns Blob (application/json) or null if no recorded session.
   */
  public getDownloadBlob(): Blob | null {
    if (this.state.value?.kind !== 'recorded') return null;
    return encodeSessionToBlob(this.state.value.session);
  }

  /** Release all resources. Called from EventDisplay.cleanup. */
  public cleanup(): void {
    if (this.recorder?.isRecording) {
      try {
        this.recorder.stop();
      } catch {
        // ignore
      }
    }
    this.recorder = null;
    this.pendingSession = null;
    this.stopPlaybackIfAny();
    this.state.update({ kind: 'idle' });
  }

  /**
   * Replace any active player with a new one and wire its tick/complete
   * callbacks into the reactive state. Transitions state to 'playing'.
   * @param player The player to attach.
   */
  private attachPlayer(player: SessionPlayer): void {
    this.stopPlaybackIfAny();
    this.player = player;
    this.tickUnsub = player.onTick((t) => {
      if (player.isPlaying) {
        this.state.update({ kind: 'playing', player, currentTime: t });
      }
    });
    this.completeUnsub = player.onComplete(() => {
      this.state.update({ kind: 'finished', player });
    });
    this.state.update({ kind: 'playing', player, currentTime: 0 });
  }

  /** Stop and detach the active player and its callbacks, if any. */
  private stopPlaybackIfAny(): void {
    if (this.player) {
      try {
        this.player.stop();
      } catch {
        // ignore
      }
    }
    if (this.tickUnsub) {
      this.tickUnsub();
      this.tickUnsub = null;
    }
    if (this.completeUnsub) {
      this.completeUnsub();
      this.completeUnsub = null;
    }
    this.player = null;
  }
}

/**
 * Extract a human-readable message from an unknown thrown value.
 * @param e The caught value.
 * @returns The error message string.
 */
function errorMessage(e: unknown): string {
  if (e instanceof Error) return e.message;
  return String(e);
}

results matching ""

    No results matching ""