dbd90ee52a
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.
129 lines
4.7 KiB
C#
129 lines
4.7 KiB
C#
using DeepDrftModels.Enums;
|
|
using DeepDrftPublic.Client.Common;
|
|
using DeepDrftPublic.Client.Services;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.JSInterop;
|
|
|
|
namespace DeepDrftPublic.Client.Controls;
|
|
|
|
/// <summary>
|
|
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
|
|
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
|
|
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
|
|
/// it copies the absolute form of the release's canonical detail URL and hides the embed
|
|
/// affordance, since a release page is not a single-track embed. Clipboard writes go through
|
|
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
|
|
/// short delay.
|
|
/// </summary>
|
|
public partial class SharePopover : ComponentBase, IDisposable
|
|
{
|
|
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
|
|
[Parameter] public string? EntryKey { get; set; }
|
|
|
|
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
|
|
[Parameter] public string? ReleaseEntryKey { get; set; }
|
|
|
|
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
|
|
[Parameter] public ReleaseMedium ReleaseMedium { get; set; }
|
|
|
|
[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;
|
|
|
|
private bool _open;
|
|
private bool _embed;
|
|
private bool _linkCopied;
|
|
private bool _embedCopied;
|
|
|
|
private readonly CancellationTokenSource _cts = new();
|
|
|
|
private bool Embed
|
|
{
|
|
get => _embed;
|
|
set
|
|
{
|
|
_embed = value;
|
|
if (!value) _embedCopied = false;
|
|
}
|
|
}
|
|
|
|
// The URL "Copy link" places on the clipboard. Release mode resolves the canonical detail
|
|
// route (which carries a leading slash) and composes it against BaseUri (which carries a
|
|
// trailing slash) — trim one to avoid a doubled separator.
|
|
private string LinkUrl => IsReleaseMode
|
|
? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseEntryKey!, ReleaseMedium)}"
|
|
: TrackUrl;
|
|
|
|
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
|
|
|
|
private string EmbedSnippet =>
|
|
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
|
|
|
private void Toggle() => _open = !_open;
|
|
|
|
private void Close() => _open = false;
|
|
|
|
private async Task CopyLink()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
private async Task CopyEmbed()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> CopyToClipboard(string text)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
|
return true;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task ResetAfterDelay(Action reset)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(1500, _cts.Token);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
reset();
|
|
StateHasChanged();
|
|
}
|
|
|
|
public void Dispose() => _cts.Cancel();
|
|
}
|