File

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

Description

Coordinator that wires SessionRecorder and SessionPlayer to a single observable state. Exposed via EventDisplay.getSessionManager. Mirrors the singleton pattern of StateManager.

Index

Properties
Methods
Accessors

Constructor

constructor(host: SessionManagerHost)

Create a session manager bound to a host adapter.

Parameters :
Name Type Optional Description
host SessionManagerHost No

Adapter exposing the event bus, state, and camera apply.

Properties

Private completeUnsub
Type : unknown | null
Default value : null

Unsubscribe for the active player's complete callback.

Private pendingSession
Type : SessionV1 | null
Default value : null

Decoded-but-not-yet-played session awaiting explicit user confirmation.

Private player
Type : SessionPlayer | null
Default value : null

Active player instance, or null when no replay is loaded.

Private recorder
Type : SessionRecorder | null
Default value : null

Active recorder instance, or null when not recording.

Private sharableBase64Cache
Type : string | null
Default value : 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).

Public Readonly state
Type : unknown
Default value : new ActiveVariable<SessionState>({ kind: 'idle' })

Reactive state for the UI pill.

Private tickUnsub
Type : unknown | null
Default value : null

Unsubscribe for the active player's tick callback.

Methods

Private attachPlayer
attachPlayer(player: SessionPlayer)

Replace any active player with a new one and wire its tick/complete callbacks into the reactive state. Transitions state to 'playing'.

Parameters :
Name Type Optional Description
player SessionPlayer No

The player to attach.

Returns : void
Public cleanup
cleanup()

Release all resources. Called from EventDisplay.cleanup.

Returns : void
Public clearRecorded
clearRecorded()

Discard the current recorded session and return to idle.

Returns : void
Public getCachedSharableBase64
getCachedSharableBase64()

Synchronously return the precomputed share-link base64 for the recorded session, or null if not recorded or the encode has not finished yet.

Returns : string | null
Public getDownloadBlob
getDownloadBlob()

Produce a downloadable Blob of the most recently recorded session.

Returns : Blob | null

Blob (application/json) or null if no recorded session.

Public getPlayer
getPlayer()

Active player, if any.

Public getRecorder
getRecorder()

Active recorder, if any.

Public Async getSharableBase64
getSharableBase64()

Encode the most recently recorded session as base64 for the share URL.

Returns : Promise<string | null>

Base64 string, or null if there's no recorded session in state.

Public Async loadAndPlayBase64
loadAndPlayBase64(base64: string)

Load a session from a base64 ?replay= URL parameter and start playing. For programmatic/trusted callers; the URL path uses prepareFromBase64 so link-supplied content is not auto-run.

Parameters :
Name Type Optional Description
base64 string No

Base64 input from the URL.

Returns : Promise<void>
Public loadFromJsonText
loadFromJsonText(text: string)

Load a session from a .phnxreplay JSON file (e.g. uploaded by user).

Parameters :
Name Type Optional Description
text string No

JSON text contents of the file.

Returns : SessionV1
Public pause
pause()

Pause the active player.

Returns : void
Public play
play()

Start playback on a loaded player (no-op if none loaded).

Returns : void
Public playPending
playPending()

Start the pending session after explicit user confirmation. No-op if the manager is not in the 'pending' state.

Returns : void
Public Async prepareFromBase64
prepareFromBase64(base64: string, sourceLabel: string)

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.

Parameters :
Name Type Optional Description
base64 string No

Base64 input from the URL.

sourceLabel string No

Human-readable origin shown in the confirm UI.

Returns : Promise<void>
Public prepareFromJsonText
prepareFromJsonText(text: string, sourceLabel: string)

Decode + validate a .phnxreplay JSON payload and stage it for playback without starting it (see prepareFromBase64).

Parameters :
Name Type Optional Description
text string No

JSON text contents.

sourceLabel string No

Human-readable origin shown in the confirm UI.

Returns : SessionV1
Public seek
seek(t: number)

Seek the active player to a specific playhead position.

Parameters :
Name Type Optional
t number No
Returns : void
Private setPending
setPending(session: SessionV1, sourceLabel: string)

Stage a decoded session as pending (awaiting user confirmation) and publish the 'pending' state for the UI. Stops any active playback first.

Parameters :
Name Type Optional Description
session SessionV1 No

The validated session to stage.

sourceLabel string No

Human-readable origin (host or 'shared link').

Returns : void
Public setSpeed
setSpeed(speed: "0.5" | "1" | "2" | "4")

Change the active player's speed multiplier.

Parameters :
Name Type Optional
speed "0.5" | "1" | "2" | "4" No
Returns : void
Public startRecording
startRecording(source?: SessionSource)

Start a new recording (no-op if one is already running).

Parameters :
Name Type Optional Description
source SessionSource Yes

Optional dataset/route metadata recorded in the session.

Returns : void
Public stopPlayback
stopPlayback()

Stop and detach any active player. Resets state to idle.

Returns : void
Private stopPlaybackIfAny
stopPlaybackIfAny()

Stop and detach the active player and its callbacks, if any.

Returns : void
Public stopRecording
stopRecording()

Stop the active recording and transition state to 'recorded' so the UI can offer download / share. Returns the finalized session.

Returns : SessionV1 | null
Public toggleRecording
toggleRecording(source?: SessionSource)

Convenience: if recording, stop. Otherwise start. Bound to the Shift+Ctrl+R keyboard shortcut.

Parameters :
Name Type Optional Description
source SessionSource Yes

Optional source metadata used when starting a recording.

Returns : void

Accessors

isRecording
getisRecording()

True when a recording is currently capturing events.

Returns : boolean
isPlaying
getisPlaying()

True when a player is currently in the playing state.

Returns : boolean
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 ""