Using the PlayerTracking Events

Tracking

Analytics in the PanelWave Player — consent-aware TrackingService, event whitelisting, batching, the endpoint payload, and player events.

The player's TrackingService is a privacy-conscious analytics pipeline: nothing is recorded without consent (when required), events can be whitelisted by the work's manifest, and delivery is batched to a configurable endpoint. Integrators can also bypass it entirely and forward the shell's Angular outputs to their own analytics.

How the manifest configures tracking

When a manifest with a tracking section loads, the shell configures the service automatically:

{
  "tracking": {
    "enabled": true,
    "consent": { "required": true, "defaultOptIn": false },
    "eventWhitelist": ["panelChange", "decision", "videoPlay", "videoEnded"],
    "endpoint": "https://analytics.example.com/pw/events"
  }
}

Mapping (verified in the shell's configureTracking):

Manifest fieldEffect
consent.requiredSets consentRequired (defaults to true when absent)
consent.defaultOptInIf true, consent is granted immediately (until an explicit consent UI overrides it)
eventWhitelistOnly listed event types are recorded; all others are dropped
endpointWhere batches are POSTed; without it, events stay in the local queue/observable only

See Tracking (schema) for the format definition.

import { inject } from '@angular/core';
import { TrackingService } from '@panelwave/player';

const tracking = inject(TrackingService);

// Wire this to your cookie/consent banner:
tracking.setConsent(true);
tracking.getConsent(); // → boolean

While consentRequired is true and consent has not been given, track() is a no-op. Revoking consent (setConsent(false)) also clears any queued, unsent events.

Recording events

tracking.track('decision', {
  panelId: 'p-12',
  choiceId: 'hs-door-left',
});

track(type, data?) applies, in order: consent check → whitelist check → debouncing for high-frequency types (scroll, mousemove, resize, progress; 300 ms default) → queue.

Each recorded event has this shape (TrackingEvent):

interface TrackingEvent {
  type: string;
  timestamp: number;              // Date.now()
  data?: Record<string, any>;
  sessionId?: string;             // anonymized, random per session
}

The session id is a crypto.randomUUID() generated when the service is created — it identifies a session, not a user.

Batching and delivery

Events are sent when the queue reaches 10 events (batchSize) or every 5 seconds (batchInterval), whichever comes first. Delivery is a JSON POST:

{
  "sessionId": "6f0d…",
  "events": [
    { "type": "panelChange", "timestamp": 1751712000000, "data": { "panelId": "p-2" }, "sessionId": "6f0d…" }
  ],
  "timestamp": 1751712001234
}

A non-2xx response is logged; failed batches are not re-queued (deliberately, to avoid memory buildup). Useful controls:

MethodPurpose
configure(partialConfig)Override endpoint, consentRequired, eventWhitelist, batchSize, batchInterval, debounceMs
flush()Send the current queue immediately (e.g. on beforeunload)
clear()Drop queued events and pending debounce timers
getSessionId() / getQueueSize()Introspection

Events the player emits

The PlayerEvent enum defines the event vocabulary used by the player internals:

EventValueMeaning
READYreadyPlayer initialized
PANEL_CHANGEpanelChangeNavigation to a panel
DECISIONdecisionA branch choice was made
PAYWALL_SHOWNpaywallShownA gate was displayed
ERRORerrorAn error occurred
STATE_CHANGE / PREFERENCE_CHANGE / LOCALE_CHANGEstateChange / preferenceChange / localeChangeState, preference, locale updates
VIDEO_PLAYvideoPlayA video layer started
VIDEO_PAUSEvideoPauseA video layer paused
VIDEO_ENDEDvideoEndedA video finished — or was force-skipped after stalling
VIDEO_LOOPvideoLoopA loop/pingpong cycle completed

Video events carry a VideoTrackingPayload:

interface VideoTrackingPayload {
  panelId?: string;
  assetId?: string;
  trigger: 'view' | 'hover' | 'click' | 'sequencer';
  reason?: 'ended' | 'stall-skip';   // qualifies VIDEO_ENDED
}

reason: 'stall-skip' marks videos the autoplay watchdog or the page-view sequencer skipped because the source never finished — valuable for spotting broken encodes in the wild.

Reading-session and hotspot events

Beyond video events, the shell instruments the reading session automatically — every event below flows through the same consent/whitelist pipeline:

EventWhenPayload highlights
session_startOnce, when tracking is configureddeviceType, locale, totalPanels (reading-order count)
panel_viewEach panel the reader lands on (deduplicated)panelId, chapterId, panelOrder
work_completeOnce, when the reader reaches an end panel of the last chapter (in a chapter without edges: its last panel in reading order)panelId, chapterId
hotspot_clickEvery click/tap on panel content (panel, page and canvas view)see below
branch_choiceThe reader picked a path in the branch chooserchapterId, from, to, index (position in the edge list)
likeLike toggledworkId, liked
bookmarkBookmark set or clearedworkId, chapterId, panelId, bookmarked
speech_toggle / audio_toggle / sfx_toggleA sound toggle changed (toolbar or Settings)enabled
session_endOn page hide / destroy, followed by a synchronous flush (sendBeacon)—

hotspot_click

Activating a hotspot — and, deliberately, also clicking panel artwork that hits no hotspot — records a hotspot_click:

{
  "type": "hotspot_click",
  "data": {
    "panelId": "p-12",
    "chapterId": "ch-1",
    "hotspotId": "hs-door",
    "x": 0.3142,
    "y": 0.6108,
    "hit": true
  }
}
  • x / y are normalized (0–1), panel-relative coordinates of the click.
  • hit: true carries the hotspotId; hit: false marks a dead click — a tap that missed every hotspot — and omits hotspotId.
  • Keyboard activations report the shape's centroid.

Dead clicks are recorded on purpose: they show where readers try to interact, which powers the click heatmap in the CMS — see Analytics (CMS). Like every event, hotspot_click respects consent and must be present in the manifest's eventWhitelist to be recorded.

Forwarding player activity yourself

If you already have an analytics stack, subscribe to the shell's outputs and forward what you need — no TrackingService required:

import { Component } from '@angular/core';
import { PlayerShellComponent } from '@panelwave/player';
import type { PlayerPanelChangeEvent } from '@panelwave/player';

@Component({
  selector: 'app-reader',
  standalone: true,
  imports: [PlayerShellComponent],
  template: `
    <pw-player-shell
      [manifest]="manifest"
      (ready)="analytics.event('pw_ready')"
      (panelChange)="onPanel($event)"
      (navigationAttempt)="analytics.event('pw_nav', $event)"
      (localeChange)="analytics.event('pw_locale', { locale: $event })"
      (likeChange)="analytics.event('pw_like', $event)"
      (bookmarkChange)="analytics.event('pw_bookmark', $event)"
      (error)="analytics.event('pw_error', { message: $event.message })">
    </pw-player-shell>
  `,
})
export class ReaderComponent {
  // panelChange fires exactly once per navigation — no de-duplication needed.
  // panelId / previousPanelId are part of the payload from player 1.2.0.
  onPanel(e: PlayerPanelChangeEvent): void {
    this.analytics.event('pw_panel_view', {
      chapterId: e.chapter.id,
      panelId: e.panelId,
      from: e.previousPanelId,
    });
  }
}

Creators publishing through the PanelWave platform get aggregated dashboards in the CMS — see Analytics (CMS).