feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
@@ -2,6 +2,7 @@ using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
@@ -32,6 +33,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
|
||||
// most one bucketed play event per session, behind the engagement floor. Attached after construction
|
||||
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
|
||||
// constructor growth propagated through DI, no construction cycle. Null when telemetry is not wired
|
||||
// (e.g. unit tests that construct the player without it), so every call is null-guarded.
|
||||
private PlayTracker? _playTracker;
|
||||
private BeaconInterop? _beacon;
|
||||
private DotNetObjectReference<StreamingAudioPlayerService>? _unloadRef;
|
||||
private string? _unloadKey;
|
||||
|
||||
// One-shot guard so the play session opens exactly once per LoadTrackStreaming — never on the
|
||||
// SeekBeyondBuffer re-stream, which reuses _currentTrackId and re-runs the playback-start transition
|
||||
// with _streamingPlaybackStarted reset. A seek-beyond-buffer is the SAME play (§1d), so it must not
|
||||
// open a new session. Set true when the session opens; reset only by LoadTrackStreaming.
|
||||
private bool _sessionOpened;
|
||||
|
||||
public StreamingAudioPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
TrackMediaClient trackMediaClient,
|
||||
@@ -41,6 +58,41 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
|
||||
/// §2.1). Called once by <c>AudioPlayerProvider</c>. Kept off the constructor deliberately: the player
|
||||
/// is built with <c>new</c> by the provider (not DI), so threading the tracker through the constructor
|
||||
/// would force the provider to resolve it too — instead the provider injects the tracker's collaborators
|
||||
/// and hands a built tracker here, the same post-construction binding QueueService uses. Also registers
|
||||
/// the page-unload handler so a mid-play tab-close still records the play via sendBeacon.
|
||||
/// </summary>
|
||||
public void AttachTracker(PlayTracker tracker, BeaconInterop beacon)
|
||||
{
|
||||
_playTracker = tracker;
|
||||
_beacon = beacon;
|
||||
|
||||
_unloadRef = DotNetObjectReference.Create(this);
|
||||
_unloadKey = PlayerId;
|
||||
// Fire-and-forget: registration only needs to have happened before the listener leaves; it
|
||||
// never gates playback. A failure simply means tab-close mid-play isn't recorded.
|
||||
_ = _beacon.RegisterUnloadAsync(_unloadKey, _unloadRef, nameof(OnPageUnload));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the open play session as the page unloads (pagehide / visibility→hidden). Invoked
|
||||
/// synchronously from the beacon's unload handler so the session's beacon is queued before the page
|
||||
/// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
|
||||
/// </summary>
|
||||
[JSInvokable]
|
||||
public void OnPageUnload() => _playTracker?.Close();
|
||||
|
||||
// Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
|
||||
// lowers it — the tracker takes the max.
|
||||
protected override void OnProgressTick(double currentTime) => _playTracker?.OnProgress(currentTime);
|
||||
|
||||
// Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
|
||||
protected override void OnPlaybackEnded() => _playTracker?.Close();
|
||||
|
||||
public override async Task SelectTrack(TrackDto track)
|
||||
{
|
||||
await SelectTrackStreaming(track);
|
||||
@@ -88,6 +140,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
|
||||
// Save track ID for seek operations
|
||||
_currentTrackId = track.EntryKey;
|
||||
// A fresh load is a fresh play candidate (§1d: replays = multiple plays). Arm the
|
||||
// one-shot session-open guard; the session actually opens at the playback-start transition
|
||||
// below (a track that fails to load never reaches it, so it does not count).
|
||||
_sessionOpened = false;
|
||||
// Expose to UI immediately — Now-Playing surfaces should reflect the selected
|
||||
// track while it's still loading, not only after playback starts.
|
||||
CurrentTrack = track;
|
||||
@@ -303,8 +359,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
|
||||
// Feed the same once-only duration to the play session so it can compute the
|
||||
// completion fraction at close. Safe before/after session open — SetDuration
|
||||
// is a no-op when no session is open and idempotent otherwise.
|
||||
_playTracker?.SetDuration(chunkResult.Duration.Value);
|
||||
}
|
||||
|
||||
|
||||
// Start playback as soon as we can
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
@@ -316,6 +376,20 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
|
||||
ErrorMessage = null;
|
||||
|
||||
// Open the play session exactly once per load, at the moment playback truly
|
||||
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
|
||||
// — which re-enters this transition with _streamingPlaybackStarted reset —
|
||||
// from opening a second session for the same play. Duration may already be
|
||||
// known from a prior chunk, so re-feed it after opening.
|
||||
if (!_sessionOpened && _currentTrackId is { } trackKey)
|
||||
{
|
||||
_sessionOpened = true;
|
||||
_playTracker?.OnPlaybackStarted(trackKey);
|
||||
if (Duration is { } d)
|
||||
_playTracker?.SetDuration(d);
|
||||
}
|
||||
|
||||
await NotifyStateChanged(); // Immediate notification for critical state change
|
||||
}
|
||||
else
|
||||
@@ -533,6 +607,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
/// </summary>
|
||||
private async Task ResetToIdle()
|
||||
{
|
||||
// 0. Close any open play session BEFORE tearing down (§2.1). ResetToIdle is the single funnel
|
||||
// for stop / unload / dispose / track-switch (a new LoadTrackStreaming calls it first), so a
|
||||
// superseded listen is recorded here with its high-water bucket. Close is idempotent — if the
|
||||
// session already closed organically or via the unload beacon, this is a no-op.
|
||||
_playTracker?.Close();
|
||||
_sessionOpened = false;
|
||||
|
||||
// 1. Cancel any ongoing streaming operation and wait for it to exit
|
||||
// before tearing down JS state. Otherwise the loop's pending
|
||||
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
|
||||
@@ -630,12 +711,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
{
|
||||
try
|
||||
{
|
||||
// ResetToIdle closes any open play session, so a dispose mid-play still records the listen.
|
||||
await ResetToIdle();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal must not throw; any failure here is best-effort cleanup.
|
||||
}
|
||||
|
||||
// Detach the page-unload handler so the torn-down circuit is never invoked, then release the
|
||||
// self-reference. Best-effort — the JS side tolerates an absent key.
|
||||
if (_unloadKey is not null && _beacon is not null)
|
||||
{
|
||||
try { await _beacon.UnregisterUnloadAsync(_unloadKey); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_unloadRef?.Dispose();
|
||||
_unloadRef = null;
|
||||
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user