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:
@@ -10,6 +10,8 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
[Inject] public required BeaconInterop Beacon { get; set; }
|
||||
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _audioPlayerService;
|
||||
private QueueService? _queueService;
|
||||
@@ -23,7 +25,16 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
// EnsureInitializedAsync — that path is correct because audio contexts
|
||||
// require a user gesture anyway. Initializing eagerly here causes 4+
|
||||
// SignalR round-trips before any content is stable.
|
||||
_audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
||||
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
||||
|
||||
// Phase 16: bind the play-session tracker to the player after construction, the same way the
|
||||
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
|
||||
// constructor would force the provider to over-resolve. The tracker owns the floor/bucket logic
|
||||
// and emits via the injected sink (the beacon in production); the beacon also drives the
|
||||
// page-unload close so a mid-play tab-close still records the listen. Attached on the concrete
|
||||
// type before it is exposed through the IStreamingPlayerService field.
|
||||
player.AttachTracker(new PlayTracker(PlayEventSink), Beacon);
|
||||
_audioPlayerService = player;
|
||||
|
||||
// Provider is the SOLE owner of OnStateChanged. When the service fires,
|
||||
// the provider re-renders, which cascades to its children automatically.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
@@ -27,6 +28,7 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
[Inject] public required ShareTracker ShareTracker { get; set; }
|
||||
|
||||
private bool IsReleaseMode => ReleaseEntryKey is not null;
|
||||
|
||||
@@ -67,6 +69,14 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
if (await CopyToClipboard(LinkUrl))
|
||||
{
|
||||
// Record a share only after the clipboard write succeeds (§1b). Release mode targets the
|
||||
// release EntryKey; track mode targets the track EntryKey. The tracker debounces repeat
|
||||
// copies of the same (target, channel) into one event.
|
||||
if (IsReleaseMode)
|
||||
ShareTracker.RecordShare(ShareTargetType.Release, ReleaseEntryKey!, ShareChannel.Link);
|
||||
else if (!string.IsNullOrWhiteSpace(EntryKey))
|
||||
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Link);
|
||||
|
||||
_linkCopied = true;
|
||||
await ResetAfterDelay(() => _linkCopied = false);
|
||||
}
|
||||
@@ -76,6 +86,11 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
if (await CopyToClipboard(EmbedSnippet))
|
||||
{
|
||||
// Embed is a single-track affordance only (release mode hides it), so this always targets a
|
||||
// track with channel = embed.
|
||||
if (!string.IsNullOrWhiteSpace(EntryKey))
|
||||
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Embed);
|
||||
|
||||
_embedCopied = true;
|
||||
await ResetAfterDelay(() => _embedCopied = false);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
private async Task OnProgressCallback(double currentTime)
|
||||
{
|
||||
CurrentTime = currentTime;
|
||||
// Telemetry hook (Phase 16 §2.1): a subclass advances the play-session high-water mark here, on
|
||||
// the same throttled tick the UI already consumes. Base implementation is a no-op.
|
||||
OnProgressTick(currentTime);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
@@ -270,6 +273,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
IsLoaded = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
// Telemetry hook: organic end closes the play session (the bucket reflects how far they got)
|
||||
// BEFORE the state notification and TrackEnded fan-out, so the session that just ended is the
|
||||
// one recorded — not whatever a queue auto-advance opens next. Base implementation is a no-op.
|
||||
OnPlaybackEnded();
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||
@@ -279,6 +286,18 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
TrackEnded?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry seam (Phase 16): called on each progress tick with the current playback position. The
|
||||
/// streaming subclass overrides this to advance the play-session high-water mark. No-op in the base.
|
||||
/// </summary>
|
||||
protected virtual void OnProgressTick(double currentTime) { }
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry seam (Phase 16): called on organic end-of-stream, before <see cref="TrackEnded"/> fires.
|
||||
/// The streaming subclass overrides this to close the play session. No-op in the base.
|
||||
/// </summary>
|
||||
protected virtual void OnPlaybackEnded() { }
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
|
||||
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
|
||||
/// touches <see cref="IJSRuntime"/> string identifiers directly. All calls are best-effort: a JS
|
||||
/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
|
||||
/// never throw into the UI or the playback path.
|
||||
/// </summary>
|
||||
public sealed class BeaconInterop
|
||||
{
|
||||
private readonly IJSRuntime _js;
|
||||
|
||||
public BeaconInterop(IJSRuntime js)
|
||||
{
|
||||
_js = js;
|
||||
}
|
||||
|
||||
/// <summary>Queue a fire-and-forget POST of a JSON body to the given absolute URL.</summary>
|
||||
public async Task SendAsync(string url, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Module not loaded / not interactive yet — drop the event silently.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register a .NET unload callback (fires on pagehide / visibility→hidden) under a key.</summary>
|
||||
public async Task RegisterUnloadAsync<T>(string key, DotNetObjectReference<T> dotNetRef, string methodName)
|
||||
where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — without the unload handler, mid-play tab-close simply isn't recorded.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Detach a previously-registered unload callback.</summary>
|
||||
public async Task UnregisterUnloadAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
|
||||
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
|
||||
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
|
||||
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
|
||||
/// awaiting and its failure is irrelevant. No <c>anonId</c> is sent in wave 16.1.
|
||||
/// </summary>
|
||||
public sealed class BeaconPlayEventSink : IPlayEventSink
|
||||
{
|
||||
private readonly BeaconInterop _beacon;
|
||||
private readonly string _playUrl;
|
||||
|
||||
public BeaconPlayEventSink(BeaconInterop beacon, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
|
||||
// trailing slash; the route does not lead with one.
|
||||
_playUrl = $"{navigation.BaseUri}api/event/play";
|
||||
}
|
||||
|
||||
public void EmitPlay(string trackEntryKey, PlayBucket bucket)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new PlayEventDto
|
||||
{
|
||||
TrackEntryKey = trackEntryKey,
|
||||
Bucket = bucket,
|
||||
});
|
||||
|
||||
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
|
||||
// act on the result either way.
|
||||
_ = _beacon.SendAsync(_playUrl, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The emit seam for the <see cref="PlayTracker"/> (Phase 16 §2.1). The tracker owns the session
|
||||
/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
|
||||
/// it hands a finished classification to a sink. The production sink fires a <c>sendBeacon</c> POST to
|
||||
/// <c>api/event/play</c>; tests substitute a fake sink to assert floor and bucket behaviour with no
|
||||
/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
|
||||
/// </summary>
|
||||
public interface IPlayEventSink
|
||||
{
|
||||
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
|
||||
void EmitPlay(string trackEntryKey, PlayBucket bucket);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> 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).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
|
||||
public bool HasOpenSession => _trackEntryKey is not null && !_closed;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Close"/> still records the
|
||||
/// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
|
||||
/// </summary>
|
||||
public void OnPlaybackStarted(string trackEntryKey)
|
||||
{
|
||||
if (HasOpenSession)
|
||||
Close();
|
||||
|
||||
_trackEntryKey = trackEntryKey;
|
||||
_duration = null;
|
||||
_highWater = 0;
|
||||
_closed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Duration</c> exactly once.
|
||||
/// </summary>
|
||||
public void SetDuration(double durationSeconds)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (_duration is null && durationSeconds > 0)
|
||||
_duration = durationSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public void OnProgress(double currentTime)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (currentTime > _highWater)
|
||||
_highWater = currentTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
|
||||
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
|
||||
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
|
||||
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
|
||||
///
|
||||
/// <para>
|
||||
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
|
||||
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
|
||||
/// The beacon send is fire-and-forget; no <c>anonId</c> is sent in wave 16.1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ShareTracker
|
||||
{
|
||||
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
|
||||
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
||||
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly BeaconInterop _beacon;
|
||||
private readonly string _shareUrl;
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
||||
|
||||
public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
|
||||
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
|
||||
/// debounce without reaching into the beacon.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
|
||||
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
|
||||
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetKey))
|
||||
return false;
|
||||
|
||||
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
|
||||
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
|
||||
return false;
|
||||
|
||||
_lastSent[dedupeKey] = now;
|
||||
|
||||
var json = JsonSerializer.Serialize(new ShareEventDto
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
});
|
||||
|
||||
// Fire-and-forget — a dropped share telemetry event is acceptable.
|
||||
_ = _beacon.SendAsync(_shareUrl, json);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ public static class Startup
|
||||
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
|
||||
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
|
||||
services.AddScoped<WaveformVisualizerControlState>();
|
||||
|
||||
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
|
||||
// share tracker fire events through it. The play tracker itself is NOT registered — the player
|
||||
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
|
||||
// is scoped so its per-(target,channel) debounce memory lives for the session.
|
||||
services.AddScoped<BeaconInterop>();
|
||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||
services.AddScoped<ShareTracker>();
|
||||
}
|
||||
|
||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||
|
||||
Reference in New Issue
Block a user