Files
deepdrft/DeepDrftManager/Components/Pages/Tracks/TrackList.razor
T
daniel-c-harvey c6ef641ab9 feat(cms): medium-aware Add Track on Release Archive tabs (8.E)
Add Track now appears on every Release Archive tab and pre-selects the upload form's medium via ?medium=… (ALL→Cut); the selector stays user-changeable on landing.
2026-06-13 22:33:33 -04:00

252 lines
10 KiB
Plaintext

@page "/tracks"
@page "/tracks/albums"
@page "/tracks/genres"
@page "/tracks/archive"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@inject ILogger<TrackList> Logger
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Tracks</MudText>
@if (VM.Mode == BrowseMode.Tracks)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
}
</MudStack>
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
<MudToggleGroup T="BrowseMode"
Value="VM.Mode"
ValueChanged="OnModeChanged"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
</MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks)
{
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
}
else if (VM.Mode == BrowseMode.Albums)
{
@* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
tab swaps the grid below in place; no navigation to a separate page occurs. *@
@* Medium-aware Add Track (§8.E): the button lives in the tab-strip chrome (not inside any grid
component — 8.C owns those) and reflects the active tab. It pre-selects the upload form to the
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a
seed only — the upload form's selector stays user-changeable after landing. *@
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="@AddTrackHref(ActiveMedium)">
Add Track
</MudButton>
</MudStack>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
@bind-ActivePanelIndex="_activeTabIndex">
<MudTabPanel Text="ALL">
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
</MudTabPanel>
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudTabPanel Text="@MediumTabLabels[medium]">
@MediumGrid(medium)
</MudTabPanel>
}
</MudTabs>
}
else
{
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
Reachable by direct URL; no longer in the toggle group. *@
<CmsGenreBrowser Genres="VM.Genres"
IsLoading="VM.GenresLoading"
ExpandedGenre="@VM.ExpandedGenre"
OnExpandedGenreChanged="OnExpandedGenreChanged" />
}
</MudContainer>
@code {
private CmsTrackGrid? _grid;
// Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in
// order. Drives the medium-aware Add Track button (§8.E).
private int _activeTabIndex;
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut
// (Daniel, 2026-06-13); each medium tab maps to its enum value by position, so a fourth medium tab
// gets a correct Add Track for free — no markup fork.
private ReleaseMedium ActiveMedium =>
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
private static string AddTrackHref(ReleaseMedium medium) =>
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Cut] = "CUTS",
[ReleaseMedium.Session] = "SESSIONS",
[ReleaseMedium.Mix] = "MIXES",
};
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
{
ReleaseMedium.Cut => @<CmsCutBrowser />,
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
};
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged()
{
VM.Invalidate();
StateHasChanged();
}
// Local state for the parent-owned "Generate All Missing" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
protected override async Task OnInitializedAsync()
{
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
var uri = NavigationManager.Uri;
var initial =
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks;
await VM.SwitchModeAsync(initial);
}
private async Task OnModeChanged(BrowseMode mode)
{
await VM.SwitchModeAsync(mode);
var path = mode switch
{
BrowseMode.Albums => "/tracks/albums",
BrowseMode.Genres => "/tracks/genres",
_ => "/tracks"
};
NavigationManager.NavigateTo(path, replace: true);
StateHasChanged();
}
private void OnExpandedGenreChanged(string? genre)
{
VM.SetExpandedGenre(genre);
StateHasChanged();
}
/// <summary>
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
/// status map so the per-row icons reflect the new state.
/// </summary>
private async Task GenerateAllMissingAsync()
{
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
if (!statusResult.Success || statusResult.Value is null)
{
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
return;
}
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
if (missing.Count == 0)
{
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
_grid?.SetBulkRunning(true);
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
_grid?.SetBulkRunning(false);
if (_grid is not null)
{
await _grid.RefreshWaveformStatusAsync();
}
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
}