5b4c7c1a18
AudioPlayerProvider publishes CascadingValue<StreamingAudioPlayerService> (concrete) but all consumers request interface types; cascade resolves null, controls render disabled. PLAYER_ANALYSIS.md has the deep-dive; TODO.md has the actionable bugs.
319 lines
17 KiB
Markdown
319 lines
17 KiB
Markdown
# PLAYER_ANALYSIS.md — Streaming player controls: root cause + improvement map
|
||
|
||
Analysis-only document. No code changed. Produced 2026-06-03 against the
|
||
`DeepDrftPublic.Client` player stack and the `DeepDrftPublic/Interop/audio/`
|
||
TypeScript engine.
|
||
|
||
---
|
||
|
||
## 1. TL;DR
|
||
|
||
- **Root cause (controls dead): cascade type mismatch.** `AudioPlayerProvider`
|
||
publishes the player as `CascadingValue<StreamingAudioPlayerService>` (the field
|
||
`_audioPlayerService` is typed as the concrete class). Blazor matches cascading
|
||
values **by the declared `TValue` type**, not the runtime type. Every consumer
|
||
asks for an *interface* — `AudioPlayerBar` and `SpectrumVisualizer` want
|
||
`IStreamingPlayerService`, `TracksView` and `Home` want `IPlayerService` — so
|
||
**none of them match the cascade. They all receive `null`.** The bar's controls
|
||
are guarded by `PlayerService?` / `?? false`, so a null player renders disabled,
|
||
silent buttons with no error. This single fault explains the reported symptom.
|
||
- **Second, independent fault on the same path:** even if the cascade resolved,
|
||
`TracksView.PlayerService` is typed `IPlayerService`, which has **no
|
||
`SelectTrackStreaming`** — but `PlayTrack` calls `SelectTrack`, which the
|
||
streaming subclass overrides to route into streaming. That part is fine. The
|
||
real latent issue is that `AudioPlayerBar` requires the *streaming* interface
|
||
while `TracksView` only needs the base — two different cascade types are
|
||
expected from one published value. Both must be satisfiable. (See §2.)
|
||
- **`@rendermode` is not the cause but is adjacent.** The app root is
|
||
`InteractiveAuto`. During SSR prerender the JS engine does not exist; the design
|
||
already defers init to first user gesture, so this is handled. But the cascade
|
||
bug would *also* manifest identically in pure WASM, so render mode is a red
|
||
herring for this specific symptom.
|
||
- **The streaming engine itself is in good shape.** The TS decoder, scheduler,
|
||
and context manager are carefully written (suspended-context resume awaited,
|
||
tail-drain on EOF, typed decode errors, generation-safe seek teardown). The
|
||
break is entirely in the **C#→component wiring layer**, not the audio path.
|
||
- **Biggest product gaps once controls work:** no queue/next-track model
|
||
(blocks preload, crossfade, gapless — all already in PLAN Phase 1), backward
|
||
seek lands in the wrong place (PLAN 1.1), and `Home.razor`'s play affordances
|
||
share the same cascade fault.
|
||
|
||
---
|
||
|
||
## 2. Root cause analysis — why controls do not activate
|
||
|
||
### 2.1 The play call chain (what *should* happen)
|
||
|
||
```
|
||
User clicks TrackCard in TracksGallery
|
||
→ TracksView.PlayTrack(track) [TracksView.razor.cs:50]
|
||
→ PlayerService.SelectTrack(track) [CascadingParameter IPlayerService]
|
||
→ StreamingAudioPlayerService.SelectTrack (override) [routes to streaming]
|
||
→ SelectTrackStreaming(track)
|
||
→ EnsureInitializedAsync() → AudioInteropService.CreatePlayerAsync
|
||
→ JS window.DeepDrftAudio.createPlayer(playerId) [index.ts:17]
|
||
→ _audioInterop.EnsureAudioContextReady(playerId)
|
||
→ NotifyTrackSelected() → fires OnTrackSelected
|
||
→ AudioPlayerBar.Expand() un-minimises the dock [AudioPlayerBar.razor.cs:49]
|
||
→ LoadTrackStreaming(track)
|
||
→ TrackMediaClient.GetTrackMedia(entryKey) [proxy → DeepDrftAPI]
|
||
→ _audioInterop.InitializeStreaming(playerId, len)
|
||
→ StreamAudioWithEarlyPlayback(...) loops:
|
||
ReadAsync → ProcessStreamingChunk(playerId,bytes) → JS processStreamingChunk
|
||
when CanStartStreaming → StartStreamingPlayback → JS startStreamingPlayback
|
||
→ MarkStreamCompleteAsync(playerId)
|
||
```
|
||
|
||
### 2.2 Where it actually breaks
|
||
|
||
**Primary break — the cascade never reaches any consumer.**
|
||
|
||
`AudioPlayerProvider.razor`:
|
||
```razor
|
||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||
```
|
||
`_audioPlayerService` is declared `private StreamingAudioPlayerService?`
|
||
(`AudioPlayerProvider.razor.cs:14`). Razor infers `CascadingValue<TValue>` from
|
||
the *compile-time* type of the `Value` expression, so this publishes a
|
||
`CascadingValue<StreamingAudioPlayerService>`.
|
||
|
||
Consumers:
|
||
| Component | Declared `[CascadingParameter]` type | Matches? |
|
||
|---|---|---|
|
||
| `TracksView` | `IPlayerService` | **No** |
|
||
| `Home` | `IPlayerService?` | **No** |
|
||
| `AudioPlayerBar` | `IStreamingPlayerService?` | **No** |
|
||
| `SpectrumVisualizer` | `IStreamingPlayerService?` | **No** |
|
||
|
||
Per the Blazor docs: *"Cascading values are bound to cascading parameters by
|
||
type."* The bind is to the `TValue` of the published `CascadingValue<>`, which is
|
||
the concrete class here. An interface-typed parameter does not match a
|
||
concrete-typed cascade. **All four consumers get `null`.**
|
||
|
||
Consequences that exactly match the symptom:
|
||
- `AudioPlayerBar.IsLoaded` etc. are all `PlayerService?.X ?? false` → every
|
||
control renders `Disabled` and does nothing.
|
||
- `TracksView.PlayTrack` calls `PlayerService.SelectTrack(...)` on a **`required`
|
||
non-nullable** cascade. If the cascade is null, this is an
|
||
`ArgumentNullException` / NRE on first click — or, because `required` only
|
||
guarantees set-at-init for *parameters* (not cascades), a null-ref at call
|
||
time. Either way the click does nothing visible and may throw silently into the
|
||
Blazor error UI.
|
||
- `OnTrackSelected` is never wired (set in `AudioPlayerBar.OnParametersSet`, which
|
||
guards `if (PlayerService != null)`), so the dock never expands.
|
||
|
||
**This is the whole symptom.** The backend works because the proxy + API path is
|
||
independent of the cascade; the failure is purely in component resolution.
|
||
|
||
### 2.3 The fix shape (for staff-engineer, not done here)
|
||
|
||
Two viable shapes — a product/architecture call:
|
||
|
||
- **Shape A — publish the interface.** Change the provider field/cascade to a
|
||
single shared type. Because two different interfaces are consumed
|
||
(`IPlayerService` and `IStreamingPlayerService`), publish the **most-derived
|
||
one** (`IStreamingPlayerService`) and **widen the base consumers**
|
||
(`TracksView`, `Home`) to also accept `IStreamingPlayerService` — or publish
|
||
*both* as two named/typed cascades. Simplest correct move: type the field as
|
||
`IStreamingPlayerService`, publish `CascadingValue<IStreamingPlayerService>`,
|
||
and change `TracksView`/`Home` cascade params to `IStreamingPlayerService` (or
|
||
to `IPlayerService` *and* add a second cascade — avoid; one type is cleaner).
|
||
- **Shape B — DI + root cascade.** Register `StreamingAudioPlayerService` once as
|
||
a scoped service behind both interfaces and cascade it via
|
||
`AddCascadingValue<IStreamingPlayerService>(...)`. Removes the provider
|
||
component entirely. Heavier change; better long-term if the player should be a
|
||
first-class service rather than a component-owned object.
|
||
|
||
Recommend **Shape A** as the minimal correctness fix, with Shape B noted as the
|
||
cleaner end-state once a queue/`PlaylistService` lands (PLAN 1.3).
|
||
|
||
### 2.4 Other plausible break points (ruled in / out)
|
||
|
||
| Candidate | Verdict |
|
||
|---|---|
|
||
| JS module not on `window.DeepDrftAudio` at call time | **Not the cause** but real risk. `index.ts` assigns `window.DeepDrftAudio` at module load. Nothing in the C# path *awaits* module readiness — `CreatePlayerAsync` will throw "could not find 'DeepDrftAudio'" if the script tag hasn't executed. Worth a guard (see §4). |
|
||
| SSR prerender firing interop | Handled — init is deferred to first gesture via `EnsureInitializedAsync`. |
|
||
| `IsFixed="true"` stale reference | Not a correctness bug here; the instance is stable for the component lifetime. Becomes a problem only if the service is ever swapped. |
|
||
| Throttled `NotifyStateChanged` swallowing the first render | Critical state changes (playback start, error) use immediate `NotifyStateChanged`, not the throttled path. Fine. |
|
||
| `required` on a cascade that resolves null | Aggravates the primary bug — see §2.2. |
|
||
|
||
---
|
||
|
||
## 3. Architecture map — call chains
|
||
|
||
### 3.1 Play / pause (after a track is loaded)
|
||
```
|
||
AudioPlayerBar TogglePlayPause button
|
||
→ PlayerService.TogglePlayPause() [AudioPlayerService.cs:206]
|
||
if IsPlaying: _audioInterop.PauseAsync → JS pause → AudioPlayer.pause() → scheduler.pause()
|
||
else: _audioInterop.PlayAsync → JS play → AudioPlayer.play()
|
||
→ contextManager.ensureReady() (awaited; resumes suspended ctx)
|
||
→ scheduler.playFromPosition(pausePosition)
|
||
→ NotifyStateChanged → provider StateHasChanged → bar re-renders
|
||
```
|
||
No JS→C# callback on play/pause. State is push-from-C#.
|
||
|
||
### 3.2 Progress (JS → C#)
|
||
```
|
||
AudioPlayer.startProgressTracking() setInterval(100ms)
|
||
→ onProgressCallback(currentTime)
|
||
→ DotNetObjectReference.invokeMethodAsync("OnProgressCallback", t) [index.ts:131]
|
||
→ AudioPlayerCallback.OnProgressCallback [AudioInteropService.cs:295]
|
||
→ AudioPlayerService.OnProgressCallback → CurrentTime = t → NotifyStateChanged
|
||
```
|
||
|
||
### 3.3 End of track (JS → C#)
|
||
```
|
||
PlaybackScheduler.handleSourceEnded → onPlaybackEnded
|
||
→ AudioPlayer.handlePlaybackEnded → onEndCallback
|
||
→ invokeMethodAsync("OnEndCallback")
|
||
→ AudioPlayerService.OnPlaybackEndCallback → IsPlaying=false, CurrentTime=0
|
||
```
|
||
**Gap:** end-of-track resets state but does **not** advance to a next track —
|
||
there is no queue. (PLAN 1.3 / 1.5 territory.)
|
||
|
||
### 3.4 Seek
|
||
```
|
||
MudSlider pointer up → AudioPlayerBar.OnSeekEnd(pos)
|
||
→ StreamingAudioPlayerService.Seek(pos)
|
||
→ _audioInterop.SeekAsync → JS seek → AudioPlayer.seek(pos)
|
||
if pos <= bufferedDuration: seekWithinBuffer (re-schedule from offset)
|
||
else: seekBeyondBuffer → returns { seekBeyondBuffer, byteOffset }
|
||
→ if SeekBeyondBuffer: SeekBeyondBuffer(pos, offset)
|
||
cancel + drain current loop → GetTrackMedia(offset) → ReinitializeFromOffset
|
||
→ StreamAudioWithEarlyPlayback(newStream)
|
||
```
|
||
**Known gap (PLAN 1.1):** backward seek below `playbackOffset` is not handled as a
|
||
re-request; `seek()` only triggers the offset path when `position > bufferedDuration`
|
||
(forward). A backward target inside `[0, playbackOffset)` after a prior
|
||
seek-beyond clamps via `Math.max(0, bufferRelativePosition)` to the buffer start —
|
||
lands in the wrong place.
|
||
|
||
### 3.5 Volume
|
||
```
|
||
VolumeControls slider → AudioPlayerBar.OnVolumeChange
|
||
→ PlayerService.SetVolume(v) → _audioInterop.SetVolumeAsync → JS setVolume
|
||
→ contextManager.setVolume (gainNode.gain.setValueAtTime)
|
||
```
|
||
`Volume` is stored in C# and applied at init; volume changes before a track loads
|
||
update C# state but only reach JS once `IsLoaded` (see `SetVolume` guard) —
|
||
acceptable, but the slider appears live before load with no audible target.
|
||
|
||
---
|
||
|
||
## 4. Gaps and improvements
|
||
|
||
### Bugs (broken now)
|
||
|
||
1. **[CRITICAL] Cascade type mismatch — controls dead.** §2.2. The headline.
|
||
2. **`TracksView.PlayerService` is `required` but resolves null.** Same root
|
||
cause; will NRE/throw on first click once anyone fixes only the bar.
|
||
3. **No JS-module-readiness guard.** `CreatePlayerAsync` assumes
|
||
`window.DeepDrftAudio` exists. If the compiled module hasn't loaded (slow WASM
|
||
boot, cache miss), the first interop call throws a JS-not-found error that
|
||
surfaces as a generic init failure. Add a `whenReady`/poll or load-order
|
||
guarantee.
|
||
4. **`AudioPlayerService.SetVolume` no-ops silently before load.** Volume set
|
||
while idle updates C# `Volume` but the `if (IsLoaded)` guard skips the JS call;
|
||
on the *next* track, init applies the stored value — correct, but the gap is
|
||
undocumented and looks like a bug from the UI.
|
||
5. **Dead legacy buffered path still reachable.** `AudioPlayerService.LoadTrack` /
|
||
`StreamAudio` call `InitializeBufferedPlayerAsync` / `AppendAudioBlockAsync` /
|
||
`FinalizeAudioBufferAsync`, which are **no-ops** in `index.ts` (lines 208–218).
|
||
The base `SelectTrack` path (non-streaming) therefore silently loads nothing.
|
||
Only the streaming override works. This is a latent trap: any future consumer
|
||
that constructs a non-streaming `AudioPlayerService` gets a player that reports
|
||
success and plays silence.
|
||
|
||
### High-value additions (product payoff)
|
||
|
||
6. **Queue / next-track model.** Prerequisite for preload, crossfade, gapless
|
||
(PLAN 1.3–1.5) *and* for end-of-track auto-advance, which listeners will
|
||
expect immediately. Today the player is a single-slot device. This is the
|
||
single highest-leverage addition — it unblocks four roadmap items.
|
||
7. **Skip / previous controls.** Trivial UX once a queue exists; conspicuously
|
||
absent from `PlayerControls` (only play/pause + stop today).
|
||
8. **Backward seek (PLAN 1.1).** Already roadmapped; reiterated because it is the
|
||
most-felt correctness gap in the scrub bar.
|
||
9. **Keyboard shortcuts.** Space = play/pause, arrows = seek, M = mute. Zero new
|
||
architecture; a `@onkeydown` handler on the layout dispatching to the player.
|
||
10. **Loading affordance during seek-beyond-buffer.** `IsSeekingBeyondBuffer`
|
||
exists on the service but the bar never reads it — a backward/forward
|
||
long-seek looks frozen. Surface a spinner on the seek handle.
|
||
11. **Persisted volume + mute toggle.** Volume resets to 0.8 every session;
|
||
cookie-persist it like dark mode. Add an explicit mute button (the icon
|
||
already changes at volume 0 but there is no click-to-mute).
|
||
|
||
### UX issues in the current control structure
|
||
|
||
12. **Two play-icon implementations.** `AudioPlayerBar.GetPlayIcon` and
|
||
`PlayerControls.GetPlayIcon` are duplicated; drift risk. Consolidate.
|
||
13. **`FormatTime` duplicated** across `AudioPlayerBar` and `TimestampLabel`.
|
||
14. **Minimized dock shows a play icon that toggles minimize, not play.** The
|
||
minimized button uses `GetPlayIcon()` but `OnClick=ToggleMinimized` — a user
|
||
will read it as "play" and get "expand." Misleading affordance.
|
||
15. **No now-playing track title/artist in the bar.** `CurrentTrack` is populated
|
||
but the bar renders only time/seek/spectrum. The most basic player metadata
|
||
(what's playing) is absent from the dock.
|
||
|
||
### Streaming pipeline improvements
|
||
|
||
16. **Error recovery / track-skip (PLAN 1.6).** A single failed chunk aborts the
|
||
whole load. Once a queue exists, advance on fatal decode error.
|
||
17. **HTTP Range + native caching (PLAN 4.1) / stream-from-disk (4.2).** Already
|
||
roadmapped; the `?offset=` param defeats CDN caching.
|
||
18. **Preload of next track (PLAN 1.3).** Needs the queue model (#6).
|
||
19. **Adaptive-buffer telemetry.** `AdaptBufferSize` tunes silently; no surfaced
|
||
signal when it collapses to 16 KB (a slow-connection indicator that could
|
||
drive a "buffering" UI state).
|
||
|
||
### TS engine — incomplete / fragile
|
||
|
||
20. **Two parallel buffer implementations.** `audio/PlaybackScheduler.ts` (live)
|
||
and `audiobuffermanager.ts` (orphaned, not imported by the `audio/` engine).
|
||
`webaudio.ts` is a legacy shim re-exporting `audio/index.ts`. Dead code that
|
||
will mislead the next reader — `audiobuffermanager.ts` even lacks the
|
||
`isActive_` sentinel and `playbackOffset` fixes the live scheduler has.
|
||
Recommend deletion of `audiobuffermanager.ts` and `webaudio.ts` (confirm no
|
||
build reference first).
|
||
21. **`getEstimatedDuration` falls back to `totalStreamLength - headerSize`**
|
||
when `dataSize` is 0. For offset streams the synthesised header's `dataSize`
|
||
drives duration; verify the offset-stream duration stays anchored to the
|
||
*original* full-track duration, not the post-offset remainder (otherwise the
|
||
scrub bar's max collapses after a long seek).
|
||
22. **Decode timeout is a fixed 5 s.** Under heavy tab throttling a legitimate
|
||
large segment could exceed it; the retry helps but the constant is not
|
||
adaptive. Low priority.
|
||
|
||
---
|
||
|
||
## 5. Recommended implementation sequence
|
||
|
||
Strict ordering by dependency and payoff. Steps 1–2 are the *only* things needed
|
||
to make controls work; everything after is improvement.
|
||
|
||
1. **Fix the cascade type (Bug #1, #2).** Publish one interface type from
|
||
`AudioPlayerProvider` and align all four consumers. This alone restores
|
||
controls. Smallest possible diff; highest value. *(staff-engineer)*
|
||
2. **Add a JS-module-readiness guard (Bug #3)** so a slow WASM boot can't produce
|
||
a spurious init failure. *(staff-engineer)*
|
||
3. **Surface now-playing metadata + fix the minimized-dock affordance (#15, #14).**
|
||
Cheap, visible polish that makes the working player feel finished.
|
||
4. **Persist volume + mute toggle (#11), de-dup icons/FormatTime (#12, #13).**
|
||
Low-risk consolidation.
|
||
5. **Delete dead TS (#20) and the dead buffered C# path (#5).** Removes traps
|
||
before any new contributor touches the engine. *(staff-engineer; verify build
|
||
refs)*
|
||
6. **Backward seek (PLAN 1.1)** and **seek-in-progress spinner (#10).**
|
||
7. **Queue / next-track model (#6).** The pivot. Settle the open question in
|
||
PLAN 1.3 first (does the queue live in `IPlayerService` or a separate
|
||
`PlaylistService`?). Once landed it unblocks skip/prev (#7), auto-advance,
|
||
preload (1.3), crossfade (1.4), gapless (1.5), and track-skip-on-error (1.6).
|
||
8. **Keyboard shortcuts (#9).** Trivial once the queue + transport API is stable.
|
||
9. **Roadmapped infra:** HTTP Range / stream-from-disk (PLAN 4.1/4.2), format
|
||
diversity (1.2) — already sequenced in PLAN.md.
|
||
|
||
**Note for the roadmap:** items 1–5 are *correctness and hygiene*, not features —
|
||
they belong in `TODO.md` (bugs) rather than `PLAN.md` (forward features). Item 7
|
||
onward maps onto existing PLAN Phase 1 entries; no new phase is needed.
|