using DeepDrftModels.Enums; namespace DeepDrftPublic.Client.Services; /// /// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on /// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch /// / stop / page-unload — and emits at most one play event per session, classified into a completion /// bucket, but only once the engagement floor is crossed (§1d / D2). /// /// /// Deliberately free of any player, HTTP, or JS dependency: it takes an and /// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable /// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires /// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a /// seek-beyond-buffer re-fetch is the same play, not a new one (§1d). /// /// /// Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there. /// public sealed class PlayTracker { // Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR // 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything // longer floors on the 3-second wall. Single tunable constant pair; one place to retune. private const double FloorSeconds = 3.0; private const double FloorFraction = 0.05; // Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%]. private const double SampledThreshold = 0.30; private const double CompleteThreshold = 0.80; private readonly IPlayEventSink _sink; private string? _trackEntryKey; private double? _duration; private double _highWater; private bool _closed; public PlayTracker(IPlayEventSink sink) { _sink = sink; } /// True while a session is open (playback started, not yet closed). Drives the unload beacon. public bool HasOpenSession => _trackEntryKey is not null && !_closed; /// /// Open a session for the track whose playback just started. Supersedes any still-open session by /// closing it first — a track-switch that did not route through still records the /// prior listen. Duration is unknown at open and arrives later via . /// public void OnPlaybackStarted(string trackEntryKey) { if (HasOpenSession) Close(); _trackEntryKey = trackEntryKey; _duration = null; _highWater = 0; _closed = false; } /// /// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded /// value is taken, matching the player which sets Duration exactly once. /// public void SetDuration(double durationSeconds) { if (!HasOpenSession) return; if (_duration is null && durationSeconds > 0) _duration = durationSeconds; } /// /// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it, /// so a seek-to-end-then-back still classifies by the furthest point reached (§1d). /// public void OnProgress(double currentTime) { if (!HasOpenSession) return; if (currentTime > _highWater) _highWater = currentTime; } /// /// Close the open session and emit a play event if the engagement floor was crossed; below the floor /// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open — /// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the /// first call emits. /// public void Close() { if (!HasOpenSession) { // Mark closed even if never opened so a stray late callback cannot reopen-then-emit. _closed = true; return; } var key = _trackEntryKey!; _closed = true; // Without a known duration there is no fraction to classify and no floor to test — drop the // session. In practice the WAV header sets duration well before any meaningful listen, so this // only drops listens that ended before the header parsed (i.e. effectively no listen). if (_duration is not { } duration || duration <= 0) return; var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0); if (!CrossesFloor(_highWater, duration)) return; _sink.EmitPlay(key, Classify(fraction)); } // The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2). private static bool CrossesFloor(double highWater, double duration) { var floor = Math.Min(FloorSeconds, FloorFraction * duration); return highWater >= floor; } private static PlayBucket Classify(double fraction) => fraction < SampledThreshold ? PlayBucket.Partial : fraction <= CompleteThreshold ? PlayBucket.Sampled : PlayBucket.Complete; }