fix(media): preserve sharing startup and viewing continuity

## Problem and outcome / 问题与结果

Native sharing could start capture before discovering that the browser could not create its local media connection. Connected Viewer routes could also be retired solely because decoded frames stopped advancing, including quiet or backgrounded game sources. This maintenance phase acquires the browser bridge before capture, preserves connected routes through frame silence, scopes Native keyframe recovery to the requested outputs, and reports terminal Browser sender failures to the existing connection owner. It also includes the accepted narrow-screen layout and tooltip corrections.

## Release notes

### 中文

这是一次维护更新,重点修复分享启动、观看连续性和手机窄屏布局。

- 修复浏览器媒体连接无法初始化时,App 仍启动采集后立即停止的问题,并保留更明确的失败原因。
- 避免仅因画面暂时没有更新,就断开已连接的观看线路;适用于游戏切后台等静止画面场景。
- 修正原生画面恢复请求的范围,避免某一路请求关键帧时影响其他输出;浏览器发送失败也会及时通知现有连接处理流程。
- 修复窄屏下房间号与操作按钮的排布,改善长标题和提示浮层的显示。

短时模糊与部分用户报告的偶发断流仍在调查,本次更新不代表所有相关情况均已解决。升级 App 或自部署 Server 后刷新页面即可,无需修改配置。

### English

This maintenance update focuses on sharing startup, viewing continuity, and compact layouts on phones.

- Fix capture starting and immediately stopping when the browser cannot initialize its local media connection, and preserve clearer failure details.
- Keep connected viewing routes alive when the picture temporarily stops updating, including quiet or backgrounded game sources.
- Keep Native keyframe recovery scoped to the requested outputs and report terminal Browser sender failures to the existing connection handler.
- Fix room-code and action layout on narrow screens, and improve long headings and tooltip placement.

Brief quality drops and some reports of intermittent stream loss remain under investigation; this update does not claim to resolve every reported case. Update Piik App or your self-hosted Server and refresh the page. No configuration changes are required.

## Verification / 验证

- `npm run check`: 76 files / 1,133 tests, TypeScript and production Web build passed.
- Targeted Browser sender/pool checks: 97 tests passed on maintenance and interaction candidates.
- Go mediaedge/nativehost/nativecapture/forwarding tests and scoped vet passed; output-scope regression tests reject the old behavior.
- Matched Chrome 152 VP8/H264 ordinary/pool tests with two viewers and a one-second network constraint completed with healthy sibling isolation. VP8's deeper pooled quality dip remains open.
- Physical startup/resource-exhaustion and quiet-game reproduction boundaries are documented in the existing research owners.
- `npm run check:go`: formatting, Go tests/vet, Windows/Linux builds, Windows native capture compilation/probes and regressions passed. Darwin packaging and Go race checks run on the native CI runners.
- Actual Windows App H264 and VP8 Host gates passed: decoded video, live/paused profile changes, quiet/background-source recovery, source replacement, failure cleanup and subsequent sharing; all experiment-owned processes and ports closed.
- Website build and repository hygiene checks passed. Independent full-phase frontend and native reviews found no release-blocking regression.

## Impact / 影响

Patch release v1.6.6. Browser/server v23, Native control v9, capture v7 and SQLite schema 2 remain unchanged. No interaction prototype, route-selection policy, new quality heuristic, or configuration migration is included. Real-phone and heterogeneous network/device coverage remain bounded by the existing verification record.
This commit is contained in:
AAA authored and GitHub committed 2026-09-27 11:03:25 +08:00
1 parent 04cbb2c9f4
commit 59d21702fe
41 files changed
+713 -429

No files matched your search

@@ -246,9 +246,11 @@ as another application route candidate.
### Recovery, Pause, And Quality
WebRTC and LiveKit first own ICE/consent and transient reconnect on the current
route. Only framework recovery exhaustion, hard failure, a non-paused decoded-
frame stall, confirmed departure, or capacity invalidation enters route
reconciliation. Manual reconnect remains on the exact current route.
route. Only framework recovery exhaustion, hard failure, confirmed departure,
or capacity invalidation enters route reconciliation. Decoded-frame silence
alone cannot locate a route failure: a live source can stop repainting, and
missing decoded frames do not distinguish source, decoder and transport faults.
Manual reconnect remains on the exact current route.
The retained connection and a same-parent candidate keep separate signaling
authority. A replacement offer from the retained parent supersedes optional
preparation for that child at adoption, including an SFU bootstrap carrier's
@@ -256,9 +258,9 @@ trial. This retires its reservation and fences late candidate signaling;
required availability work keeps priority and candidate budgets do not reopen.
Authoritative Host pause aborts the pending operation and reservations, preserves
the committed graph, suppresses decoded-stall authority, and leaves new Viewers
waiting. Resume starts reconciliation from the current graph. Page-hidden wall
time is rebaselined before it can contribute to a stall decision.
the committed graph, and leaves new Viewers waiting. Resume starts
reconciliation from the current graph. Missing or suspended observations do
not create route-failure authority.
Native-edge convergence runs inside the same graph, reconcile loop, and
room-serial child operation when its per-share gate is enabled. Availability
@@ -146,6 +146,27 @@ spikes and shared budget changes; a genuinely weaker child can still need its
own producer. No delayed-budget policy, bitrate floor or manual recovery probe
was added. Probe padding remains framework-owned traffic under native limits.
Matched short-pulse checks on Windows Chrome 152 used the same synthetic
1080p30 source, two children, played audio and `balanced` preference in both
paths: 45 healthy seconds, A constrained to 400 kbps for one second, then 40
seconds of recovery. B was unconstrained. Each codec's ordinary/pool pair used
the same probe bundle; these are single-run observations, not averages.
| Codec / path | A's lowest decoded size | A regained 1080p after pulse start |
| --- | --- | --- |
| VP8 / ordinary | 1280x720 | 20.2 s |
| VP8 / pool | 480x270 | 12.7 s |
| H264 / ordinary | 1280x720 | 23.3 s |
| H264 / pool | 1280x720 | 7.7 s |
B retained 1080p throughout all four runs, with no reported connection failure.
The pooled traces created one separate producer for constrained A, then rejoined
the retained healthy producer; they did not show repeated producer churn.
The deeper VP8 dip remains a quality cost to investigate. These results do not
establish the cause of an unknown reporter's blur or stream loss, nor test Native
capture's separate keyframe-request path. Any further change must preserve
healthy-child isolation and framework-owned adaptation.
Budget attribution remains unchanged: targetBitrate is the encoder's allocated
target, not raw link bandwidth ([upstream stats correction](https://webrtc.googlesource.com/src/+/fe25b0e928ea4e64aa134f5dc8012343320deec5%5E%21/)).
Replacing it with availableOutgoingBitrate would bypass native allocation and
+26
View File
@@ -246,6 +246,32 @@ Aspect references: [active display paths](https://learn.microsoft.com/en-us/wind
[active signal size](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-displayconfig_video_signal_info),
and [independent panel fitting/MPO](https://learn.microsoft.com/en-us/windows/win32/direct3ddxgi/for-best-performance--use-dxgi-flip-model).
## Quiet Sources And Viewer Recovery
A later Windows 11/Chrome recheck used the installed CS2 in an offline map,
with H264 and VP8 window capture and H264 display capture. A minimized VP8
source started with zero decoded frames for over ten seconds without ending
the share. H264 and VP8 profile changes remained pending beyond the old
five-second timeout, then delivered after restoration. This confirms that the
native quiet-source preparation repair remains in place.
The 1920x1440 fullscreen window case exposed a separate Viewer rule: fifteen
seconds without a decoded frame sent an active `route-failed` despite connected
transport. This retired the Viewer route and its unproved quality candidate;
the Host source remained alive. That sample also recorded packet loss, so it
does not establish whether its original decode interruption came from source
silence, forwarding or decoding. The decoded-only failure rule is removed for
both P2P and SFU: silence cannot establish which owner failed. Framework
failure/recovery, first-frame admission and candidate proof remain unchanged.
The repeated fullscreen case retained the connection and resumed decoded
720p output after restoration. A controlled production-path check then held
Native output for 35 seconds without announcing Host pause: the visible Viewer
decoded no new frames, kept the same connected media, and decoded again when
output resumed. This is bounded device evidence, not proof of
all game/driver recovery or the separate immediate share-start failure report.
Some focus changes did not remain foreground throughout a sampling window;
only recorded window states and receiver progress establish those transitions.
## Windows Capture Borders
The accepted scope keeps WGC for Windows capture and previews. Piik does not
+3
View File
@@ -204,6 +204,9 @@ needed output and its lower fallbacks and stops unused upper outputs. Each child
receives only its selected output. The forwarding SFU adds no server transcoder.
Downstream attachment or sending failure and retirement belong to that edge,
never its shared video/audio source or healthy siblings.
Native keyframe requests retain their output scope through coalescing; a relay
requests its single upstream stream once per batch while keeping local output
recovery separate.
Native capture uses the same screen-share output construction within Host
ceilings. Its source, source clock and higher outputs remain independent of
+11 -3
View File
@@ -59,6 +59,11 @@ borrowed, never reopened or stopped by preview. Optional native ingress uses an
available connection without another scan. If it is not ready at Browser
startup, that share keeps Browser senders.
Native Host startup acquires its required Browser media connection before
starting capture. Cancellation owns that connection immediately, while native
share ownership and required audio tracks come from the accepted App response.
Browser initialization failure remains distinct from capture permission failure.
A reachable Piik App with an incompatible control protocol is distinct from an
absent App. Discovery continues looking for a compatible process before reporting
the mismatch; the source selector retains Browser operation and offers update/
@@ -207,9 +212,12 @@ black screen, ICE-connected state, or unproved `playing` event.
- An actionable overlay may appear while fullscreen remains active; application
state never forces the user out of fullscreen.
Visibility, page freeze, and pagehide suppress application decoded-stall
authority and invalidate quality observations, not already-proved playback.
Returning to the page rebaselines time and re-arms frame observation; an existing
Decoded-frame silence is not a route-failure signal. A live source can stop
repainting; media-framework failures, not an application silence timer, own
recovery. First-frame admission and candidate-relative proof still require
decoded progress. Visibility, page freeze, and pagehide invalidate quality
observations, not already-proved playback.
Returning to the page re-arms frame observation; an existing
recovery state still requires a fresh current frame to clear. SFU media is
retained independently of transient room-signaling loss, but Browser or OS
suspension and page reclamation remain outside Web guarantees.
+7
View File
@@ -50,6 +50,11 @@ Hover, keyboard focus and touch receive equivalent guidance. Keep information
order and interaction ownership consistent across languages, themes and viewport
sizes. Reflow or bounded scrolling must not hide a primary action, truncate an
essential value, overlap controls or change meaning.
Keep a compact value and its direct actions together when rows wrap, including
the room code and its replacement confirmation. Let independent groups reflow;
do not stretch short values to fill a phone row. Check actual control bounds
around breakpoints, in both orientations and all presentation modes; page-level
overflow clipping is not evidence that the content fits.
Keep the application language control's segmented Chinese, English and visual
shortcuts. When additional catalogs exist, insert one language menu
@@ -80,6 +85,8 @@ the whole playback bar. A wrapped option row is also one area to avoid. Status,
headers and bottom-of-picker controls prefer below; other controls prefer above.
Flip to fit the viewport and keep the caret aimed at the trigger. Placement and
hover timing belong to the shared component.
Constrain oversized hints to the visible viewport and let their content scroll
without dismissing the panel or clipping its caret.
Add a tooltip when it explains an action, a limitation or a status that needs
context. Plain names, decorative participants and self-explanatory text do not
+35 -21
View File
@@ -1,12 +1,21 @@
# Current TODO Ledger
Last reviewed: 2026-09-25
Last reviewed: 2026-09-27
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
## Now
- [ ] **Investigate Browser short-pulse quality cost.** Matched ordinary/pool
checks with two 1080p30 children, audio and a one-second bandwidth constraint
reproduce a deeper VP8 dip in the pool (270p versus 720p), with faster recovery.
H264 reaches 720p on both paths; unaffected children stay at 1080p. The
[pool evidence](./research/browser-local-encoding-pool.md#balanced-startup-and-recovery)
distinguishes one cold producer handoff from repeated churn. Assess a smaller
adaptation cost without a custom bitrate floor, delay policy or weakening
healthy-child isolation. These bounded Browser results do not identify the
unknown reporters' capture paths or establish their causes.
- [ ] **Passive App attachment: design hold.** Site mode authorizes one selected
origin and supplies native media without starting a local room server. A
passive replacement needs an accepted site-consent/discovery flow; it must not
@@ -90,7 +99,11 @@ history. A parked idea is not implementation authority.
retired event delivery and SFU replacement have locally reproduced defects
and regression checks, but paired diagnostics and device/network details are
still needed to establish this reporter's cause. Include an upstream relay's
report when present. The existing five-second Viewer membership grace during
report when present. Terminal Browser sender failures now notify their existing
preparation owner instead of silently disposing; expected retirement stays
silent. Native per-output keyframe requests no longer wake unrelated outputs.
These are confirmed local defects, not matched causes of the field reports.
The existing five-second Viewer membership grace during
signaling loss is unchanged. This is distinct from the first-frame report below.
- [ ] **Windows 11 capture border remains visible.** Identify the App/Browser
capture path, Windows build and capture-border permission result. Local checks
@@ -108,31 +121,32 @@ history. A parked idea is not implementation authority.
- [ ] **App discovery and share-start field acceptance.** Retest the missing-window
report, an unreachable App despite its process running, and generic share-start
failure. Distinguish site authorization, browser permission, control capacity,
enumeration and capture startup. A complete v1.6.3 Windows report confirms
Auto/Window/1080p30 balanced startup: hardware MFT event timeout, then software
probing, then cancellation because capture readiness did not arrive. The stall
within probing, teardown or WGC initialization remains unknown; one initial
VP8 statistics sample cannot establish its complete output history. Auto now
skips the redundant comparison when only VP8 remains. Injected unavailable
hardware and exhausted-probe-budget cases both capture and decode locally;
the affected machine still needs same-source Auto versus explicit VP8
acceptance. Do not extend deadlines without identifying the blocked stage.
Existing H264-retention/ICE-candidate repairs do not establish this cause; other
discovery/manual-H264 reports still need paired App/Browser diagnostics.
enumeration and capture startup. The v1.6.5 report reaches Auto-to-VP8 startup
after a hardware MFT timeout, then receives Browser `stop-share` before any
local-edge request. App handler timestamps are about 1.5 ms apart; this is not
a Browser-measured ACK interval or a no-frame deadline. Deliberately exhausting
real Chrome connection objects reproduces that sequence with physical capture;
bounded normal restarts showed no retained connections. Startup now acquires
the Browser connection first: the same exhaustion rejects before capture, and
releasing fixture-held objects permits sharing again. This establishes neither
a Piik resource leak nor the reporter's cause. The actual Browser exception,
loaded asset identity and reporter VP8 output remain unknown. Zero dimensions/
FPS in `starting` are valid. Diagnostics preserve preview initialization/
negotiation causes and distinguish MFT input/output waits; obtain matched
evidence before changing recovery or deadlines. Other discovery/manual-H264
reports still need paired evidence.
- [ ] **Share ends after entering a game.** Screen sharing reportedly works
until entering a game freezes the picture, followed seconds later by share
termination. Version, capture path, codec and matched diagnostics are unknown.
Locate the first capture/output, preview-bridge, control or authority failure;
distinguish ordinary source silence/resize from target replacement, exclusive
fullscreen, display-mode change and device loss. A reproduced downstream
retirement race no longer stops the shared source. Controlled-window checks
do not reproduce the reporting game/device environment.
- [ ] **Brief blur followed by immediate recovery.** Compare the same source,
codec, peers and network with ordinary WebRTC before attributing the report
to pooling. The [pool checks](./research/browser-local-encoding-pool.md#balanced-startup-and-recovery)
cover rate-owner retention, publication startup, native recovery probing and
ordinary fallback. They do not establish this reporter's capture path or result;
correlate actual receiver dimensions, QP, resource adaptation and packet loss.
retirement race no longer stops the shared source. Actual CS2 checks found
and removed a separate decoded-silence rule that retired a connected Viewer
route while the Host stayed live; [capture research](./research/native-client-lifecycle.md#quiet-sources-and-viewer-recovery)
distinguishes that repair and successful restoration from the unknown cause
of the reported Host termination. Do not conflate source silence, receiver
decode interruption and capture-process failure.
- [ ] **Windows 32-bit candidate acceptance.** Verify the isolated
`spike/windows-x86-capture` candidate's launch, capture/audio, memory pressure,
source replacement and update links on a 32-bit Windows device. WOW64
+2 -2
View File
@@ -37,7 +37,7 @@ func TestOutputPlanRetiresOnlyFailedLayerConsumers(t *testing.T) {
}
t.Cleanup(func() { _ = engine.Close() })
wake := make(chan struct{}, 8)
source, err := engine.NewSource("vp8", 2, 2, func() { wake <- struct{}{} })
source, err := engine.NewSource("vp8", 2, 2, func([]int) { wake <- struct{}{} })
if err != nil {
t.Fatal(err)
}
@@ -263,7 +263,7 @@ func testEncodedSourceFanout(t *testing.T, codec string) {
}
t.Cleanup(func() { _ = engine.Close() })
keyFrames := make(chan struct{}, 1)
source, err := engine.NewSource(codec, 2, 1, func() {
source, err := engine.NewSource(codec, 2, 1, func([]int) {
select {
case keyFrames <- struct{}{}:
default:
+3 -1
View File
@@ -267,7 +267,9 @@ func (engine *Engine) ListenAddress() string {
return engine.listenAddress
}
func (engine *Engine) NewSource(codec string, capacity, layers int, requestKeyFrame func()) (*Source, error) {
// NewSource coalesces recovery requests by physical output slot; [-1] requests
// all outputs. The callback runs outside the source lock and forwarding path.
func (engine *Engine) NewSource(codec string, capacity, layers int, requestKeyFrame func([]int)) (*Source, error) {
_, supported := videoCodecs[codec]
if !supported {
return nil, errors.New("native video codec is unsupported")
+2 -3
View File
@@ -237,11 +237,10 @@ func (source *Source) newGroup(slot int) (*outputGroup, error) {
switch packet.(type) {
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
if layer == 0 {
source.groupRecovery.Or(uint32(1) << slot)
source.requestLayerKeyFrame(slot)
} else {
source.groupRecovery.Or(2)
source.requestLayerKeyFrame(1)
}
source.RequestRecoveryFrame()
}
}
},
+4 -1
View File
@@ -82,7 +82,10 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se
if _, supported := relayBackend(videoCodec, options.Relay); supported {
layers = 2
}
receiver.source, err = engine.NewSource(videoCodec, options.EdgeCapacity, layers, receiver.RequestKeyFrame)
receiver.source, err = engine.NewSource(videoCodec, options.EdgeCapacity, layers, func([]int) {
// Local output slots all derive from this one upstream encoded stream.
receiver.RequestKeyFrame()
})
if err != nil {
_ = connection.Close()
return nil, webrtc.SessionDescription{}, err
+29 -3
View File
@@ -20,6 +20,7 @@ const videoClockRate = 90_000
const videoPacketMTU = 1200
const h264PayloadType = 102
const vp8PayloadType = 96
const allRecoveryLayers = ^uint32(0)
var ErrSourceCapacity = errors.New("native media source capacity is exhausted")
@@ -38,12 +39,13 @@ type Source struct {
formats []atomic.Uint64
outputBitrates []uint32
capacity int
requestKeyFrame func()
requestKeyFrame func([]int)
relayProfile *nativecapture.VideoProfile
relay *relayDerivation
groups map[int]*outputGroup
memberships map[groupConsumer]*outputGroup
groupRecovery atomic.Uint32
pendingRecovery atomic.Uint32
recoveryRequests chan struct{}
recoveryStopped chan struct{}
inputPTS time.Duration
@@ -607,11 +609,22 @@ func (source *Source) detach(edge *Edge) {
func (source *Source) requestLayerKeyFrame(layer int) {
source.groupRecovery.Or(uint32(1) << layer)
source.RequestRecoveryFrame()
source.requestRecovery(layer)
}
func (source *Source) RequestRecoveryFrame() {
source.requestRecovery(-1)
}
func (source *Source) requestRecovery(layer int) {
if source.requestKeyFrame != nil {
mask := allRecoveryLayers
if layer >= 0 {
mask = uint32(1) << layer
}
// Coalesce notifications without dropping another output's request.
// groupRecovery separately belongs to the next input's output plan.
source.pendingRecovery.Or(mask)
select {
case source.recoveryRequests <- struct{}{}:
default:
@@ -637,7 +650,20 @@ func (source *Source) runRecovery() {
closed := source.closed
source.mu.Unlock()
if !closed {
source.requestKeyFrame()
mask := source.pendingRecovery.Swap(0)
if mask == 0 {
continue
}
layers := []int{-1}
if mask != allRecoveryLayers {
layers = nil
for layer := 0; mask != 0; mask, layer = mask>>1, layer+1 {
if mask&1 != 0 {
layers = append(layers, layer)
}
}
}
source.requestKeyFrame(layers)
}
}
}
+87
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"testing"
"time"
@@ -15,6 +16,92 @@ import (
"google.golang.org/protobuf/proto"
)
func TestSourceRecoveryCoalescesTargetsWithoutWakingUnrelatedOutputs(t *testing.T) {
for _, all := range []bool{false, true} {
t.Run(fmt.Sprint(all), func(t *testing.T) {
requested := make(chan []int, 8)
// Queue several requests before starting the existing notification worker.
source := &Source{engine: &Engine{}, requestKeyFrame: func(layers []int) { requested <- layers },
recoveryRequests: make(chan struct{}, 1), recoveryStopped: make(chan struct{})}
source.requestLayerKeyFrame(0)
source.requestLayerKeyFrame(2)
source.requestLayerKeyFrame(0)
if all {
source.RequestRecoveryFrame()
}
done := make(chan struct{})
go func() { defer close(done); source.runRecovery() }()
defer func() { close(source.recoveryStopped); <-done }()
want := []int{0, 2}
if all {
want = []int{-1}
}
select {
case layers := <-requested:
if !slices.Equal(layers, want) {
t.Fatalf("requested outputs %v instead of %v", layers, want)
}
case <-time.After(time.Second):
t.Fatal("lost output recovery request")
}
if source.groupRecovery.Load() != 5 {
t.Fatal("wake callback consumed the frame-boundary recovery plan")
}
select {
case layers := <-requested:
t.Fatalf("unexpected extra callback: %v", layers)
default:
}
})
}
}
func TestSourcePLIUsesPhysicalOutputIdentity(t *testing.T) {
requested := make(chan []int, 16)
source, err := (&Engine{}).NewSource("vp8", 2, 2, func(layers []int) { requested <- layers })
if err != nil {
t.Fatal(err)
}
defer source.Close()
if err = source.ConfigureOutputs([]uint32{300_000, 1_200_000, 300_000}); err != nil {
t.Fatal(err)
}
if _, err = source.planGroups(nil); err != nil {
t.Fatal(err)
}
group, err := source.newGroup(2)
if err != nil {
t.Fatal(err)
}
for _, media := range []*forwarding.EncodedSource{source.media, group.media} {
if err = media.BeginFrame(time.Second, time.Now()); err != nil {
t.Fatal(err)
}
for layer := range 2 {
if err = media.WriteFrame(layer, encoded.Frame{Data: sfu.VP8KeyFrame8x8, PTS: time.Second,
Duration: time.Second / 30, Recovery: true}); err != nil {
t.Fatal(err)
}
}
}
for _, request := range []struct {
media *forwarding.EncodedSource
layer, physical int
}{
{source.media, 0, 0}, {source.media, 1, 1}, {group.media, 0, 2}, {group.media, 1, 1},
} {
request.media.SendPLI(int32(request.layer), true)
select {
case layers := <-requested:
if !slices.Equal(layers, []int{request.physical}) {
t.Fatalf("logical %d requested physical %v, want %d", request.layer, layers, request.physical)
}
case <-time.After(time.Second):
t.Fatal("PLI did not wake its output")
}
}
}
func TestSourceFrameSurvivesRetiringEdge(t *testing.T) {
for _, layers := range []int{1, 2} {
t.Run(fmt.Sprint(layers), func(t *testing.T) {
+4 -2
View File
@@ -748,9 +748,11 @@ func (session *Session) runVideo() error {
session.mu.Unlock()
return fail(errors.New("native capture codec was not applied"))
}
source, sourceErr := session.engine.NewSource(status.Codec, session.edgeCapacity, min(2, len(status.Outputs)), func() {
source, sourceErr := session.engine.NewSource(status.Codec, session.edgeCapacity, min(2, len(status.Outputs)), func(layers []int) {
if stream := session.currentStream(); stream != nil {
_ = stream.RequestKeyFrame(-1)
for _, layer := range layers {
_ = stream.RequestKeyFrame(layer)
}
}
})
if sourceErr != nil {
+5 -4
View File
@@ -657,7 +657,7 @@ EncodedAccessUnit LiveEncoder::Encode(
const auto deadline = std::min(probe_deadline,
EncoderClock::now() + std::chrono::seconds(2));
while (std::chrono::steady_clock::now() < deadline) {
MediaEventType type = NextEvent(deadline);
MediaEventType type = NextEvent(deadline, "mft-output-timeout");
if (type == METransformNeedInput) {
++input_requests_;
continue;
@@ -699,7 +699,8 @@ EncodedAccessUnit LiveEncoder::Encode(
Fail("mft-output-timeout", "hardware MFT did not produce live output in time");
}
MediaEventType LiveEncoder::NextEvent(std::chrono::steady_clock::time_point deadline) {
MediaEventType LiveEncoder::NextEvent(
EncoderClock::time_point deadline, const char* timeout_stage) {
while (std::chrono::steady_clock::now() < deadline) {
ComPtr<IMFMediaEvent> event;
HRESULT result = selected_.events->GetEvent(MF_EVENT_FLAG_NO_WAIT,
@@ -717,14 +718,14 @@ MediaEventType LiveEncoder::NextEvent(std::chrono::steady_clock::time_point dead
if (type == MEError) Fail("mft-error-event", "hardware MFT emitted MEError");
return type;
}
Fail("mft-event-timeout", "hardware MFT event wait timed out");
Fail(timeout_stage, "hardware MFT event wait timed out");
}
void LiveEncoder::WaitForInput(EncoderClock::time_point probe_deadline) {
const auto deadline = std::min(probe_deadline,
EncoderClock::now() + std::chrono::seconds(2));
while (input_requests_ == 0) {
MediaEventType type = NextEvent(deadline);
MediaEventType type = NextEvent(deadline, "mft-input-timeout");
if (type == METransformNeedInput) {
++input_requests_;
} else if (type == METransformHaveOutput) {
+1 -1
View File
@@ -225,7 +225,7 @@ class LiveEncoder final : public VideoEncoder {
EncoderClock::time_point deadline = EncoderClock::time_point::max()) override;
private:
MediaEventType NextEvent(EncoderClock::time_point deadline);
MediaEventType NextEvent(EncoderClock::time_point deadline, const char* timeout_stage);
void WaitForInput(EncoderClock::time_point deadline);
SelectedTransform selected_;
+2 -1
View File
@@ -22,7 +22,8 @@ button,a,input { outline-offset:5px; }
.masthead nav a,.masthead button,#language { min-height:44px; display:inline-flex; align-items:center; text-decoration:none; }
.masthead button { font-size:12px; }
.masthead nav :is(a,button):hover { color:var(--accent); }
main { width:calc(100% - 64px); max-width:min(1664px,calc((100svh - 248px) * 16 / 9)); margin:auto; }
/* A short landscape viewport may scroll; it must not shrink the player controls. */
main { width:calc(100% - 64px); max-width:clamp(640px,calc((100svh - 248px) * 16 / 9),1664px); margin:auto; }
.cinema { position:relative; background:#f7f4e9; color:#22303e; border-radius:24px; box-shadow:0 6px 0 #22303e12; overflow:hidden; isolation:isolate; }
.picture { position:relative; aspect-ratio:16/9; }
#film-art { display:none; width:100%; height:100%; }
+3 -1
View File
@@ -86,6 +86,9 @@ h1 { font-size: clamp(49px, 5.7vw, 76px); line-height: 1.06; margin-block: 22px
.film-preview > summary::after { content:none; }
.film-collapse { margin-left:auto; font-size:12px; color:var(--muted); }
.film-preview iframe { display:block; width:100%; height:calc(56.25cqw + 144px); border:0; }
@container (max-width:740px) {
.film-preview iframe { height:calc(56.25cqw + 196px); }
}
.download-help { margin-top:22px; text-align:center; }
.download-links { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px 24px; margin-bottom:14px; }
.download-help p { max-width:760px; margin:6px auto 0; color:var(--muted); font-size:13px; }
@@ -214,7 +217,6 @@ html[lang="zh-CN"] .eyebrow { letter-spacing: .13em; }
.footer-identity p { display: none; }
}
@media (max-width: 740px) {
.film-preview iframe { height:calc(56.25cqw + 196px); }
.wrap { width: calc(100% - 40px); }
.site-header { min-height: 83px; gap: 0 14px; flex-wrap:wrap; padding-top:12px; }
.site-header nav { order:3; flex-basis:100%; justify-content:space-between; gap:0 12px; }
+2 -2
View File
@@ -130,7 +130,7 @@ export function RoomChip({
}
return (
<>
<span className="lr-room-chip">
<Lcd code={roomId} />
{onReplace ? (
confirming ? (
@@ -161,7 +161,7 @@ export function RoomChip({
/>
)
) : null}
</>
</span>
);
}
+33 -15
View File
@@ -162,8 +162,14 @@ export function Tooltip({
const pickAlign = () => {
const wrap = wrapRef.current;
if (!wrap) return;
const vw = window.innerWidth;
const viewport = window.visualViewport;
const vw = viewport?.width ?? window.innerWidth;
if (!vw) return; // no layout (SSR/test): keep the prop alignment
const vh = viewport?.height ?? window.innerHeight;
const x = viewport?.offsetLeft ?? 0, y = viewport?.offsetTop ?? 0;
const right = x + vw, bottom = y + vh;
tipRef.current?.style.setProperty("--tooltip-max-width", `${Math.max(0, vw - EDGE_MARGIN * 2)}px`);
tipRef.current?.style.setProperty("--tooltip-max-height", `${Math.max(0, vh - EDGE_MARGIN * 2)}px`);
const rect = wrap.getBoundingClientRect();
// A wrapped control row remains one working area, including on narrow screens.
const controlBar = wrap.closest<HTMLElement>(".lr-playback, .lr-source-picker-options, .lr-host-share-controls");
@@ -177,11 +183,10 @@ export function Tooltip({
const width = Math.max(0, Math.min(measured || 320, vw - EDGE_MARGIN * 2));
// Same idea vertically: a control scrolled near the top has no room above,
// and the panel would be cut off by the viewport edge.
const vh = window.innerHeight;
const panelHeight =
(panelMounted ? tipRef.current?.offsetHeight : 0) || FALLBACK_PANEL_HEIGHT;
const fitsAbove = avoid.top - panelHeight - EDGE_MARGIN >= EDGE_MARGIN;
const fitsBelow = !vh || below + panelHeight + EDGE_MARGIN <= vh - EDGE_MARGIN;
const fitsAbove = avoid.top - panelHeight - EDGE_MARGIN >= y + EDGE_MARGIN;
const fitsBelow = !vh || below + panelHeight + EDGE_MARGIN <= bottom - EDGE_MARGIN;
const preferredPlace = wrap.closest(".lr-tv-chin, .lr-host-share-controls") ? "below" : place;
let livePlace: Placement =
preferredPlace === "above"
@@ -192,8 +197,8 @@ export function Tooltip({
? "below"
: "above";
const centerY = rect.top + rect.height / 2;
const sideTop = Math.max(EDGE_MARGIN, Math.min(centerY - panelHeight / 2, vh - panelHeight - EDGE_MARGIN));
const fitsSideVertically = sideTop >= EDGE_MARGIN && sideTop + panelHeight <= (vh || Infinity) - EDGE_MARGIN;
const sideTop = Math.max(y + EDGE_MARGIN, Math.min(centerY - panelHeight / 2, bottom - panelHeight - EDGE_MARGIN));
const fitsSideVertically = sideTop >= y + EDGE_MARGIN && sideTop + panelHeight <= (bottom || Infinity) - EDGE_MARGIN;
// Use empty side space only when the whole panel fits close to its control.
// Otherwise clear the entire playback bar, including its narrow second row.
if (playback) {
@@ -202,8 +207,8 @@ export function Tooltip({
const sideAvoid = player ?? avoid;
const nearLeft = rect.left - sideAvoid.left <= rect.width;
const nearRight = sideAvoid.right - rect.right <= rect.width;
const fitsLeft = nearLeft && sideAvoid.left - width - EDGE_MARGIN >= EDGE_MARGIN;
const fitsRight = nearRight && sideAvoid.right + width + EDGE_MARGIN <= vw - EDGE_MARGIN;
const fitsLeft = nearLeft && sideAvoid.left - width - EDGE_MARGIN >= x + EDGE_MARGIN;
const fitsRight = nearRight && sideAvoid.right + width + EDGE_MARGIN <= right - EDGE_MARGIN;
// Prefer space below the television (including its status strip) to
// covering the picture. Fullscreen falls back above the entire bar.
livePlace = fitsSideVertically && (fitsLeft || fitsRight) ? fitsLeft ? "left" : "right"
@@ -216,16 +221,17 @@ export function Tooltip({
end: { left: rect.right - width, right: rect.right },
};
const clipped = (box: { left: number; right: number }): number =>
Math.max(0, EDGE_MARGIN - box.left) +
Math.max(0, box.right - (vw - EDGE_MARGIN));
Math.max(0, x + EDGE_MARGIN - box.left) +
Math.max(0, box.right - (right - EDGE_MARGIN));
const order: Align[] = [
align,
...(["center", "start", "end"] as Align[]).filter((a) => a !== align),
];
const fits = order.find((a) => clipped(boxes[a]) === 0);
const selected = fits ?? order.reduce((a, b) => (clipped(boxes[a]) <= clipped(boxes[b]) ? a : b));
let left = Math.max(EDGE_MARGIN, Math.min(boxes[selected].left, vw - width - EDGE_MARGIN));
let left = Math.max(x + EDGE_MARGIN, Math.min(boxes[selected].left, right - width - EDGE_MARGIN));
let top = livePlace === "below" ? below + EDGE_MARGIN : avoid.top - panelHeight - EDGE_MARGIN;
top = Math.max(y + EDGE_MARGIN, Math.min(top, bottom - panelHeight - EDGE_MARGIN));
let caret = Math.max(14, Math.min(center - left, width - 14));
if (livePlace === "left") {
left = (player ?? avoid).left - width - EDGE_MARGIN;
@@ -270,10 +276,15 @@ export function Tooltip({
frame = window.requestAnimationFrame(() => pickAlignRef.current());
};
schedulePick();
const viewport = window.visualViewport;
viewport?.addEventListener("resize", schedulePick);
viewport?.addEventListener("scroll", schedulePick);
window.addEventListener("resize", schedulePick);
window.addEventListener("scroll", schedulePick, true);
return () => {
window.cancelAnimationFrame(frame);
viewport?.removeEventListener("resize", schedulePick);
viewport?.removeEventListener("scroll", schedulePick);
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
@@ -413,6 +424,13 @@ export function Tooltip({
// completed long-press. A later touch starts a new, actionable gesture.
longPressed.current = false;
cancelPress();
if (tipRef.current?.contains(event.target as Node)) {
// Reading/scrolling the panel is not another trigger long-press.
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = null;
pressPoint.current = null;
return;
}
touchGesture.current = event.pointerType === "touch";
if (!enabled || event.pointerType !== "touch") return;
pressPoint.current = { x: event.clientX, y: event.clientY };
@@ -436,7 +454,7 @@ export function Tooltip({
}, TOUCH_HIDE_MS);
}
}}
onPointerCancel={() => {
onPointerCancel={(event) => {
cancelPress();
pressPoint.current = null;
longPressed.current = false;
@@ -444,7 +462,7 @@ export function Tooltip({
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setPressOpen(false);
if (!tipRef.current?.contains(event.target as Node)) setPressOpen(false);
}}
onPointerMove={(event) => {
// Only a real drag (scroll intent) cancels the hold — Chrome emits
@@ -514,12 +532,12 @@ export function Tooltip({
data-tone={resolvedTone}
style={{ ...comicStyle(resolvedTone, resolvedMotion), "--comic-repeat": "infinite", left: position.left, top: position.top, "--tooltip-caret": `${position.caret}px` } as CSSProperties}
className={`lr-comic-tip${kind ? " has-comic" : ""}${caption !== undefined ? " is-text" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
{panelMounted ? <>
{panelMounted ? <span className="lr-comic-tip-content">
{kind ? isHintKind(kind)
? <HintComic kind={kind} size={200} tone={resolvedTone} motion={resolvedMotion} />
: <Comic kind={kind} theme="paper" size={200} tone={resolvedTone} motion={resolvedMotion} /> : null}
{caption !== undefined ? <span className="lr-comic-tip-caption">{caption}</span> : null}
</> : null}
</span> : null}
</span>
</span>
);
+1
View File
@@ -141,6 +141,7 @@ export const en: Record<CopyKey, string> = {
"host.sourcePicker.failed": "Could not read the App's screen and window list. Refresh to try again; if it keeps failing, collect a Debug report.",
"native.incompatible": "Piik App and this page are incompatible. Update Piik App and reload this page, or use Browser.",
"native.fail.edge": "Piik App media connection failed",
"native.fail.browserMedia": "The browser could not create a media connection. Refresh the page or try another browser.",
"native.fail.signaling": "Piik App signaling failed",
"native.fail.receiver": "Piik App media receive failed",
"host.sourcePicker.window": "Share window: {title}",
+1
View File
@@ -141,6 +141,7 @@ export const zh = {
"host.sourcePicker.failed": "未能读取 App 的窗口和屏幕列表。请刷新重试;若持续失败,请导出诊断报告。",
"native.incompatible": "Piik App 与当前页面不兼容。请更新 Piik App 并刷新页面,或使用浏览器。",
"native.fail.edge": "Piik App 媒体连接失败",
"native.fail.browserMedia": "浏览器无法建立媒体连接。请刷新页面,或换一个浏览器重试。",
"native.fail.signaling": "Piik App 信令失败",
"native.fail.receiver": "Piik App 媒体接收失败",
"host.sourcePicker.window": "分享窗口:{title}",
-62
View File
@@ -1,62 +0,0 @@
export const ROUTE_DECODED_FRAME_STALL_MS = 15_000;
export class DecodedFrameStallDetector {
private identity: string | null = null;
private lastDecodedAt = 0;
private reported = false;
private paused = false;
setPaused(paused: boolean, nowMs = Date.now()): void {
if (this.paused === paused) return;
this.paused = paused;
this.rebaseline(nowMs);
}
rebaseline(nowMs = Date.now()): void {
this.lastDecodedAt = nowMs;
this.reported = false;
}
allowReportRetry(): void {
this.reported = false;
}
observe(
identity: string,
framesDecodedDelta: number | null,
nowMs = Date.now(),
): boolean {
if (identity !== this.identity) {
this.identity = identity;
this.lastDecodedAt = nowMs;
this.reported = false;
return false;
}
if (this.paused) {
this.lastDecodedAt = nowMs;
this.reported = false;
return false;
}
if (framesDecodedDelta === null) {
this.lastDecodedAt = nowMs;
this.reported = false;
return false;
}
if (framesDecodedDelta > 0) {
this.lastDecodedAt = nowMs;
this.reported = false;
return false;
}
if (this.reported || nowMs - this.lastDecodedAt < ROUTE_DECODED_FRAME_STALL_MS) {
return false;
}
this.reported = true;
return true;
}
reset(): void {
this.identity = null;
this.lastDecodedAt = 0;
this.reported = false;
}
}
-20
View File
@@ -34,7 +34,6 @@ interface ViewerSubscriberTransport {
}
interface ViewerSubscriberSlot {
mediaIdentity: string;
connectionId: string;
revision: number;
phase: MediaRoutePhase;
@@ -82,12 +81,6 @@ interface ViewerSfuRouteEvents {
onSfuUpdate?: (metrics: ConnectionMetrics | null, revision: number) => void;
currentPeerMetrics?: () => ConnectionMetrics | null;
qualityProbeEligible?: () => boolean;
onSfuDecodedFrameSample?: (
framesDecodedDelta: number | null,
revision: number,
mediaIdentity: string,
connectionId: string,
) => void;
onSfuState?: (
state: "connected" | "reconnecting",
revision: number,
@@ -98,7 +91,6 @@ interface ViewerSfuRouteEvents {
onStream: (stream: MediaStream | null) => void;
onVideoAvailability: (available: boolean) => void;
onStats: (metrics: ConnectionMetrics) => void;
onDecodedFrameSample: (framesDecodedDelta: number | null) => void;
onFirstDecodedFrame: () => boolean;
onState: (state: "connected" | "reconnecting") => void;
onDisconnected: () => void;
@@ -115,7 +107,6 @@ export class ViewerSfuRoute {
private transitionTail: Promise<void> = Promise.resolve();
private resyncGeneration = 0;
private resyncing = false;
private subscriberGeneration = 0;
private paused = false;
private closed = false;
@@ -469,16 +460,6 @@ export class ViewerSfuRoute {
}
}
},
onDecodedFrameSample: (framesDecodedDelta: number | null) => {
if (this.active === slot && !slot.failed) {
this.events.onSfuDecodedFrameSample?.(
framesDecodedDelta,
slot.revision,
slot.mediaIdentity,
slot.connectionId,
);
}
},
onFirstDecodedFrame: () => this.handlePendingDecodedFrame(slot),
onState: (state: "connected" | "reconnecting") => {
if (this.active === slot && !slot.failed) {
@@ -492,7 +473,6 @@ export class ViewerSfuRoute {
new SfuSubscriber(subscriberEvents);
const preparedCandidate = this.route.getPreparedCandidate();
slot = {
mediaIdentity: `${publicationGeneration}:${++this.subscriberGeneration}`,
connectionId: message.connectionId,
revision: message.revision,
phase,
+29 -14
View File
@@ -15,6 +15,7 @@ const BRIDGE_TIMEOUT_MS = 8_000;
const MAX_PENDING_CANDIDATES = 64;
export class NativeMediaBridgeError extends Error {}
export class NativeMediaBridgeInitializationError extends NativeMediaBridgeError {}
export interface NativeMediaBridgeControl {
prepareLocalEdge(
@@ -32,12 +33,13 @@ export interface NativeMediaBridgeControl {
}
export class NativeMediaBridge {
readonly stream = new MediaStream();
readonly stream: MediaStream;
readonly connectionId = createOpaqueId();
private readonly peer = new RTCPeerConnection();
private readonly peer: RTCPeerConnection;
private readonly pendingCandidates: SignalCandidate[] = [];
private unsubscribe: (() => void) | null = null;
private edgeRequested = false;
private remoteDescriptionSet = false;
private ready = false;
private disposed = false;
@@ -48,10 +50,18 @@ export class NativeMediaBridge {
private readonly shareId: string,
private readonly control: NativeMediaBridgeControl,
private readonly onFailed: () => void,
private readonly expectedAudio = false,
private readonly sourceConnectionId?: string,
) {
observeDebugConnection(this.peer, { connectionId: this.connectionId, sourceConnectionId, shareId, role: "native-preview" });
let peer: RTCPeerConnection | undefined;
try {
this.stream = new MediaStream();
this.peer = peer = new RTCPeerConnection();
observeDebugConnection(this.peer, { connectionId: this.connectionId, sourceConnectionId, shareId, role: "native-preview" });
} catch (error) {
peer?.close();
debugError("native-bridge", "initialization-failed", error);
throw new NativeMediaBridgeInitializationError("Native media bridge could not initialize", { cause: error });
}
}
collectMetrics(accumulator: StatsAccumulator): Promise<ConnectionMetrics> {
@@ -66,7 +76,7 @@ export class NativeMediaBridge {
);
}
async start(): Promise<MediaStream> {
async start(expectedAudio = false): Promise<MediaStream> {
if (this.disposed || this.unsubscribe) {
throw new NativeMediaBridgeError("Native media bridge is unavailable");
}
@@ -78,7 +88,7 @@ export class NativeMediaBridge {
!this.ready &&
this.peer.connectionState === "connected" &&
this.stream.getVideoTracks().length > 0 &&
(!this.expectedAudio || this.stream.getAudioTracks().length > 0)
(!expectedAudio || this.stream.getAudioTracks().length > 0)
) {
this.ready = true;
this.rejectStart = null;
@@ -127,7 +137,7 @@ export class NativeMediaBridge {
}
: null,
},
).catch(() => this.fail("candidate-signal"));
).catch((error) => this.fail("candidate-signal", error));
});
this.unsubscribe = this.control.onEvent((event) => {
if (event.shareId !== this.shareId) return;
@@ -151,7 +161,7 @@ export class NativeMediaBridge {
return;
}
void addRemoteIceCandidate(this.peer, event.candidate)
.catch(() => this.fail("candidate-apply"));
.catch((error) => this.fail("candidate-apply", error));
} else if (
event.type === "edge-state" &&
(event.state === "failed" || event.state === "closed")
@@ -161,6 +171,7 @@ export class NativeMediaBridge {
});
const negotiate = async () => {
this.edgeRequested = true;
const offer = await this.control.prepareLocalEdge(
this.shareId,
this.connectionId,
@@ -195,7 +206,7 @@ export class NativeMediaBridge {
try {
return await Promise.race([negotiate(), started]);
} catch (error) {
this.fail("negotiation");
this.fail("negotiation", error);
throw error instanceof NativeMediaBridgeError
? error
: new NativeMediaBridgeError("Native media bridge failed", { cause: error });
@@ -213,14 +224,18 @@ export class NativeMediaBridge {
this.pendingCandidates.length = 0;
this.peer.close();
this.stream.getTracks().forEach((track) => track.stop());
void this.control.closeEdge(this.shareId, this.connectionId).catch(
() => undefined,
);
if (this.edgeRequested) {
void this.control.closeEdge(this.shareId, this.connectionId).catch(
() => undefined,
);
}
}
private fail(reason: string): void {
private fail(reason: string, error?: unknown): void {
if (this.disposed) return;
debugEvent("native-bridge", "failed", { type: reason, state: this.ready ? "active" : "starting" });
const details = { type: reason, state: this.ready ? "active" : "starting" };
if (error === undefined) debugEvent("native-bridge", "failed", details);
else debugError("native-bridge", "failed", error, details);
void this.reportFailureDiagnostics();
if (this.ready) this.onFailed();
this.dispose();
+1 -2
View File
@@ -487,11 +487,10 @@ class NativeViewerPeer implements ViewerMediaPeer {
this.sessionId,
this.client,
() => this.handlePlaybackFailure(bridge, connectionId),
result.audio,
connectionId,
);
this.bridge = bridge;
void bridge.start().then((stream) => {
void bridge.start(result.audio).then((stream) => {
if (this.disposed || this.bridge !== bridge || this.connectionId !== connectionId) {
bridge.dispose();
return;
+5
View File
@@ -44,6 +44,7 @@ export function ControlsPreview() {
const [launchMode, setLaunchMode] = useState<AppMode>("link");
const [launchSite, setLaunchSite] = useState("");
const [launchPassword, setLaunchPassword] = useState("");
const [launchLan, setLaunchLan] = useState("192.0.2.10");
const [preset, setPreset] = useState<QualityProfileId>("1080p30");
const [policy, setPolicy] = useState<"open" | "private">("open");
const [roomPassword, setRoomPassword] = useState(false);
@@ -208,6 +209,10 @@ export function ControlsPreview() {
<div className="lr-client-launch">
<LauncherForm mode={launchMode} onModeChange={setLaunchMode}
site={launchSite} onSiteChange={setLaunchSite}
lan={{ selected: launchLan, onChange: setLaunchLan, addresses: [
{ address: "192.0.2.10", name: "Wi-Fi" },
{ address: "192.0.2.20", name: "vEthernet (Example Virtual Network Adapter with a Long Name)" },
] }}
localAccessPassword={launchPassword} onLocalAccessPasswordChange={setLaunchPassword}
onSubmit={event => event.preventDefault()} />
</div>
+22 -16
View File
@@ -164,7 +164,7 @@ import {
} from "../native/native-sender-peer";
import { NativeSfuPublisher } from "../native/native-sfu-publisher";
import { SfuPublisher } from "../sfu/publisher";
import { NativeMediaBridge } from "../native/media-bridge";
import { NativeMediaBridge, NativeMediaBridgeError } from "../native/media-bridge";
import { NativeMediaIngress } from "../native/media-ingress";
import {
defaultNativeCapturePath,
@@ -492,6 +492,7 @@ export function HostPage({
action: HostAction,
target: NoticeValue["target"] = "operation",
): void {
if (error instanceof NativeMediaBridgeError) action = "connection";
const permissionMissing = isCapturePermissionFailure(error, action);
setNoticeValue({ kind: "text", text: readableError(error, action), target, tone: permissionMissing ? "warn" : "bad",
comic: permissionMissing ? "hint-capture-browser" : action === "connection" ? "route-failed" : action === "capture" || action === "source"
@@ -1164,6 +1165,21 @@ export function HostPage({
await nativePreviewTailRef.current;
if (!isCurrentShare(generation, shareGeneration)) return null;
if (nativeClientRef.current !== client) throw new Error("Piik App is unavailable");
// Acquire the required Browser peer before starting capture. Pending
// cancellation owns this bridge, but still closes control to abort startup.
bridge = new NativeMediaBridge(
shareGeneration,
client,
() => {
if (
nativeMediaBridgeRef.current === bridge &&
isCurrentShare(generation, shareGeneration)
) {
endSharing({ key: "host.shareEnded" }, true, "source-failed", "bad");
}
},
);
nativeMediaBridgeRef.current = bridge;
nativeSourceAudioRef.current = undefined;
nativeEventCleanup = client.onEvent((event) => {
if (event.shareId !== shareGeneration || !isCurrentShare(generation, shareGeneration) || nativeClientRef.current !== client) return;
@@ -1196,33 +1212,21 @@ export function HostPage({
});
shareStarted = true;
if (!isCurrentShare(generation, shareGeneration)) {
if (nativeMediaBridgeRef.current === bridge) nativeMediaBridgeRef.current = null;
bridge.dispose();
await client.stopShare(shareGeneration).catch(() => discardNativeClient(client));
return null;
}
if (nativeClientRef.current !== client) throw new Error("Piik App is unavailable");
nativeSourceAudioRef.current ??= started.sourceAudio ?? started.audio;
videoCodecRef.current = manualVideoCodecPreference(started.codec);
bridge = new NativeMediaBridge(
shareGeneration,
client,
() => {
if (
nativeMediaBridgeRef.current === bridge &&
isCurrentShare(generation, shareGeneration)
) {
endSharing({ key: "host.shareEnded" }, true, "source-failed", "bad");
}
},
started.audio,
);
// Register ownership before waiting for the local bridge. A native edge
// may fail immediately after becoming ready.
ownNativeClient(client);
nativeShareGenerationRef.current = shareGeneration;
nativeMediaBridgeRef.current = bridge;
nativeModeRef.current = true;
setNativeActive(true);
const stream = await bridge.start();
const stream = await bridge.start(started.audio);
if (!isCurrentShare(generation, shareGeneration)) {
if (nativeMediaBridgeRef.current === bridge) disposeNativeShare();
else bridge.dispose();
@@ -1238,6 +1242,7 @@ export function HostPage({
) {
disposeNativeShare();
} else {
if (nativeMediaBridgeRef.current === bridge) nativeMediaBridgeRef.current = null;
bridge?.dispose();
if (shareStarted) {
nativeShareCleanupRef.current = client
@@ -2858,6 +2863,7 @@ export function HostPage({
}
function setCaptureError(error: unknown, source: BrowserCaptureSource | undefined, action: "source" | "capture") {
debugError("capture", "failed", error, { action, source });
const target = action === "source" ? "operation" : "television";
if (source !== "camera") {
setNoticeError(error, action, target);
-71
View File
@@ -63,7 +63,6 @@ import {
P2pQualityProbe,
type CandidateQualityProbeResult,
} from "../media/candidate-quality-probe";
import { DecodedFrameStallDetector } from "../media/decoded-frame-stall";
import type { QualitySettings } from "../media/quality";
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
import {
@@ -491,7 +490,6 @@ export function ViewerPage({
Extract<ServerMessage, { type: "signal" }>
> = [];
let pendingPeer: PendingPeerRoute | null = null;
const decodedFrameStall = new DecodedFrameStallDetector();
const messageAuthority = new ViewerMessageAuthority();
const relayChildEvidenceStore = new ViewerQualityEvidenceStore((values) => {
if (active) setRelayChildEvidence(values);
@@ -543,9 +541,6 @@ export function ViewerPage({
}
let pageSuspended = document.visibilityState !== "visible";
const syncDecodedFrameStallPause = (): void => {
decodedFrameStall.setPaused(currentHostPaused || pageSuspended);
};
const suspendForPageLifecycle = (): void => {
// Losing observation does not invalidate media already proved playable.
invalidateQualityPresentation();
@@ -555,7 +550,6 @@ export function ViewerPage({
viewerSfuRoute?.resetQualityProbe();
pendingPeer?.qualityProbe?.reset();
if (pendingPeer) pendingPeer.qualityResult = "pending";
syncDecodedFrameStallPause();
if (newlySuspended) {
invalidateSenderQualityEvidence();
if (currentRoutePolicy.topologyOptimization) {
@@ -567,9 +561,6 @@ export function ViewerPage({
invalidateQualityPresentation();
rearmCurrentFrameProof();
pageSuspended = document.visibilityState !== "visible";
syncDecodedFrameStallPause();
if (pageSuspended) return;
decodedFrameStall.rebaseline();
};
const handleVisibilityChange = (): void => {
if (document.visibilityState === "visible") {
@@ -578,7 +569,6 @@ export function ViewerPage({
suspendForPageLifecycle();
}
};
syncDecodedFrameStallPause();
document.addEventListener("freeze", suspendForPageLifecycle);
document.addEventListener("resume", recoverFromPageLifecycle);
document.addEventListener("visibilitychange", handleVisibilityChange);
@@ -601,7 +591,6 @@ export function ViewerPage({
{
onStatus: (status) => {
if (active) {
if (status !== "connected") decodedFrameStall.allowReportRetry();
dispatchPresentation({ type: "signal", signal: status });
}
},
@@ -922,32 +911,6 @@ export function ViewerPage({
);
}
function observeActiveDecodedFrames(
route: "peer" | "sfu",
identity: string,
framesDecodedDelta: number | null,
authorityRevision: number,
connectionId: string,
): void {
if (
currentHostPaused ||
!decodedFrameStall.observe(`${route}:${identity}`, framesDecodedDelta)
) {
return;
}
if (authorityRevision !== currentRouteRevision) {
decodedFrameStall.allowReportRetry();
return;
}
const sent = signal.send({
type: "route-failed",
revision: authorityRevision,
phase: "active",
connectionId,
});
if (!sent) decodedFrameStall.allowReportRetry();
}
function pendingPeerHasDecodedFrame(
probe: PendingPeerRoute,
): probe is PendingPeerRoute & { peer: ViewerMediaPeer; snapshot: PeerSnapshot } {
@@ -1118,22 +1081,6 @@ export function ViewerPage({
);
reconcileRelayChildren(previousChildPeerIds, revision);
},
onSfuDecodedFrameSample: (
framesDecodedDelta,
revision,
mediaIdentity,
connectionId,
) => {
if (active && viewerSfuRoute === route) {
observeActiveDecodedFrames(
"sfu",
mediaIdentity,
framesDecodedDelta,
revision,
connectionId,
);
}
},
onSfuUpdate: (metrics, revision) => {
if (active && viewerSfuRoute === route) {
if (metrics) {
@@ -1386,13 +1333,6 @@ export function ViewerPage({
provePendingPeer();
} else if (active && peerRef.current === peer) {
activePeerMetrics = snapshot.metrics;
observeActiveDecodedFrames(
"peer",
`${probe.parentPeerId}:${snapshot.connectionId}`,
snapshot.metrics.intervalFramesDecoded,
currentRouteRevision,
snapshot.connectionId,
);
offerPeerQualityEvidence(snapshot, peer);
setPeerSnapshot(snapshot);
dispatchPresentation({
@@ -1481,13 +1421,6 @@ export function ViewerPage({
onUpdate: (snapshot) => {
if (active) {
activePeerMetrics = snapshot.metrics;
observeActiveDecodedFrames(
"peer",
`${snapshot.peerId}:${snapshot.connectionId}`,
snapshot.metrics.intervalFramesDecoded,
currentRouteRevision,
snapshot.connectionId,
);
offerPeerQualityEvidence(snapshot, peer);
if (
!currentHostOnline &&
@@ -1579,7 +1512,6 @@ export function ViewerPage({
invalidatePresentedMedia();
}
currentHostPaused = sharingPaused;
syncDecodedFrameStallPause();
dispatchPresentation({
type: "host",
host: sharingPaused
@@ -1800,7 +1732,6 @@ export function ViewerPage({
invalidatePresentedMedia();
}
currentHostPaused = message.paused;
syncDecodedFrameStallPause();
dispatchPresentation({
type: "host",
host: message.paused
@@ -1830,8 +1761,6 @@ export function ViewerPage({
setAssignedRoute(null);
currentHostOnline = false;
currentHostPaused = false;
decodedFrameStall.reset();
syncDecodedFrameStallPause();
clearViewerSfuRoute();
clearPeerState(true);
clearHostPresence();
+3 -2
View File
@@ -1,5 +1,5 @@
import type { ServerMessage } from "../../shared/protocol";
import { NativeMediaBridgeError } from "../native/media-bridge";
import { NativeMediaBridgeError, NativeMediaBridgeInitializationError } from "../native/media-bridge";
import { currentLang, joinSentences, say, type CopyKey } from "../ui/copy";
export type HostAction =
@@ -44,7 +44,8 @@ export function hostActionErrorNotice(
error: unknown,
action: HostAction,
): string {
if (error instanceof NativeMediaBridgeError) return say(HOST_ACTION_FALLBACK.connection);
if (error instanceof NativeMediaBridgeInitializationError) return say("native.fail.browserMedia");
if (error instanceof NativeMediaBridgeError) return say("native.fail.edge");
if (isCapturePermissionFailure(error, action)) return say("host.capture.cancelled");
if (
error instanceof DOMException &&
+1 -8
View File
@@ -15,7 +15,6 @@ interface SubscriberEvents {
onStream: (stream: MediaStream | null) => void;
onVideoAvailability?: (available: boolean) => void;
onStats?: (metrics: ConnectionMetrics) => void;
onDecodedFrameSample?: (framesDecodedDelta: number | null) => void;
onFirstDecodedFrame?: () => boolean;
onState?: (state: "connected" | "reconnecting") => void;
onDisconnected?: () => void;
@@ -228,10 +227,7 @@ export class SfuSubscriber {
const video = this.video;
const stats = this.stats;
if (!peer || !this.active) return;
if (!video || this.statsInFlight === stats) {
this.events.onDecodedFrameSample?.(null);
return;
}
if (!video || this.statsInFlight === stats) return;
this.statsInFlight = stats;
try {
const report = await peer.pc.getStats();
@@ -243,12 +239,9 @@ export class SfuSubscriber {
"receive",
stats,
);
this.events.onDecodedFrameSample?.(metrics.intervalFramesDecoded);
this.events.onStats?.(metrics);
} catch (error) {
debugRtcFailure(peer.pc, error);
if (this.peer === peer && this.stats === stats)
this.events.onDecodedFrameSample?.(null);
} finally {
if (this.statsInFlight === stats) this.statsInFlight = null;
}
+26 -11
View File
@@ -116,6 +116,7 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-brand {
display: grid;
flex: none;
width: 44px; height: 44px;
place-items: center;
border: 0;
@@ -128,6 +129,7 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-brand:active { transform: scale(.92); }
.lr-top-right { display: inline-flex; gap: 10px; align-items: center; }
.lr-top > .lr-top-right { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
/* LED state strip */
.lr-leds {
@@ -915,8 +917,10 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
and the zoom never returns. Pointer devices keep the dense line. */
@media (pointer: coarse) { .lr-invite-url { font-size: 16px; } }
/* LCD room code */
.lr-room-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; }
.lr-lcd {
display: inline-flex;
flex: none;
align-items: center;
gap: 10px;
padding: 8px 12px;
@@ -1532,7 +1536,7 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
.lr-client-lan legend { padding: 0; font-size: 14px; font-weight: 750; }
.lr-client-lan-hint { margin: 5px 0 10px; color: var(--muted); font-size: 12px; }
.lr-client-lan-options { display: grid; gap: 8px; max-height: 192px; overflow-y: auto; padding: 3px; }
.lr-client-lan-option { width: 100%; min-width: 0; justify-content: flex-start; border-radius: var(--control-radius); cursor: pointer; }
.lr-client-lan-option { width: 100%; min-width: 0; height: auto; min-height: 52px; grid-template-columns: auto minmax(0, 1fr); justify-content: stretch; white-space: normal; border-radius: var(--control-radius); cursor: pointer; }
.lr-client-lan-option input { flex: 0 0 auto; width: 18px; height: 18px; margin: 0; accent-color: var(--action); }
.lr-client-lan-option span { display: grid; min-width: 0; gap: 3px; text-align: left; }
.lr-client-lan-option strong { font-size: 13px; overflow-wrap: anywhere; }
@@ -1750,7 +1754,9 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
}
.lr-group-actions .lr-btn { flex: 1 1 0; min-width: 0; min-height: 52px; padding: 4px 5px; gap: 6px; }
.lr-group-actions .lr-btn .lr-cap { white-space: normal; text-align: center; line-height: 1.25; font-size: 11px; }
.lr-lcd { flex: 1; justify-content: center; }
.lr-lcd { gap: 6px; padding-inline: 8px; font-size: 22px; letter-spacing: .2em; }
.lr-room-chip .lr-btn { min-width: 44px; width: 44px; height: 44px; padding: 0; }
.lr-room-chip .lr-cap { display: none; }
.lr-tiles { display: flex; width: 100%; gap: 8px; }
.lr-tiles .lr-tile { flex: 1 1 0; min-width: 0; padding: 10px 6px 8px; }
.lr-meter { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
@@ -1773,6 +1779,9 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-source-option { min-height: 78px; padding: 6px 8px; }
.lr-source-option-icon { width: 34px; height: 34px; }
.lr-leds-label { display: none; }
.lr-top-right { gap: 8px; }
.lr-leds { gap: 4px; padding: 6px; }
.lr-header-controls .lr-btn { width: 40px; min-width: 40px; padding: 0; }
.lr-header-controls .lr-btn .lr-cap { display: none; }
}
@@ -1879,11 +1888,10 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
}
}
@media (max-width: 360px) {
.lr-lcd { gap: 4px; padding-inline: 6px; font-size: 18px; }
.lr-room-chip .lr-btn { min-width: 36px; width: 36px; }
.lr-top { gap: 6px; padding-inline: 10px; }
.lr-brand { flex-shrink: 0; }
.lr-top-right { gap: 6px; }
.lr-top > .lr-top-right { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.lr-leds { gap: 4px; padding: 6px; }
.lr-leds i { width: 8px; height: 8px; }
.lr-lang { padding: 2px; border-width: 2px; }
.lr-lang::before { top: 2px; bottom: 2px; left: 2px; width: calc((100% - 4px) / var(--lang-count)); }
@@ -1946,7 +1954,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
overflow: visible;
transform: translate(var(--tooltip-offset-x, 0px), var(--tooltip-offset, 4px));
width: max-content;
max-width: 320px;
max-width: min(320px, var(--tooltip-max-width, calc(100vw - 16px)));
z-index: 30;
background: var(--paper);
border: 2.5px solid var(--comic-tone, var(--ink));
@@ -1960,7 +1968,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
.lr-comic-tip * { animation-play-state: paused !important; }
.lr-comic-tip.is-text {
padding: 10px 14px;
max-width: min(320px, calc(100vw - 16px));
max-width: min(320px, var(--tooltip-max-width, calc(100vw - 16px)));
color: var(--ink);
font: 600 14px/1.6 var(--font);
text-align: start;
@@ -1969,7 +1977,14 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
text-wrap: pretty;
}
.lr-comic-tip.has-comic.is-text { width: 252px; }
.lr-comic-tip > svg {
.lr-comic-tip-content {
display: block;
max-height: max(0px, calc(var(--tooltip-max-height, 100dvh - 16px) - 25px));
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.lr-comic-tip-content > svg {
display: block;
max-width: 100%;
height: auto;
@@ -2071,13 +2086,13 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
@media (max-width: 640px) {
.lr-group-actions .lr-comic-tip-wrap { flex: 1 1 0; min-width: 0; }
.lr-group-actions .lr-comic-tip-wrap .lr-btn { width: 100%; }
.lr-comic-tip { max-width: min(320px, 78vw); }
.lr-comic-tip { max-width: min(320px, 78vw, var(--tooltip-max-width, calc(100vw - 16px))); }
}
.lr-comic-tip-caption {
display: block;
}
.lr-comic-tip > svg + .lr-comic-tip-caption { margin-top: 8px; }
.lr-comic-tip-content > svg + .lr-comic-tip-caption { margin-top: 8px; }
/* ---------- reduced motion: everything collapses to instant ---------- */
@@ -2129,7 +2144,7 @@ html[data-theme="dark"] .lr-tile:not(.is-selected) small { color: #8ba0b4; }
@media (max-width: 640px) {
.lr-tv-screen .lr-comic-tip { max-width: 62vw; }
.lr-tv-screen .lr-comic-tip { max-width: min(62vw, var(--tooltip-max-width, calc(100vw - 16px))); }
}
/* ---------- glyph draw-in (一笔一划) ---------- */
+15 -7
View File
@@ -182,7 +182,7 @@ export class HostPeer {
this.videoSender = videoTransceiver.sender;
if (this.encodedStreamsEnabled) {
this.encodedOutput = new BrowserEncodingOutput(this.videoSender, () => {
if (!this.disposed) { this.dispose(); this.emit(); }
this.fail();
}, { connectionId: this.connectionId, peerId: this.peerId });
}
this.attachVideoPool(this.stream.getVideoTracks()[0]!);
@@ -194,9 +194,7 @@ export class HostPeer {
if (this.encodedStreamsEnabled) {
// The connection flag also owns audio, including a later source with audio.
const audio = encodedStreams(this.audioSender);
void audio.readable.pipeTo(audio.writable).catch(() => {
if (!this.disposed) { this.dispose(); this.emit(); }
});
void audio.readable.pipeTo(audio.writable).catch((error) => this.fail(error));
}
await this.enqueueSenderMutation(async () => {
if (this.disposed || !this.videoSender || !this.audioSender) {
@@ -275,7 +273,7 @@ export class HostPeer {
audioTransceiver.direction = previousAudioDirection;
}
if (videoRollback.status === "rejected") {
this.dispose();
this.fail(videoRollback.reason);
}
this.setError(error, "host.fail.source");
return false;
@@ -589,7 +587,7 @@ export class HostPeer {
return true;
} catch (error) {
if (!this.disposed && sender.track !== previous) {
try { await this.waitForOperation(() => sender.replaceTrack(previous)); } catch { this.dispose(); }
try { await this.waitForOperation(() => sender.replaceTrack(previous)); } catch (rollbackError) { this.fail(rollbackError); }
}
if (next !== previous && next !== this.encodedOutput?.track) next.stop();
if (this.disposed) return false;
@@ -598,7 +596,7 @@ export class HostPeer {
}
}),
requestKey,
() => { if (owns()) { this.dispose(); this.emit(); } });
() => { if (owns()) this.fail(); });
this.pooledVideo = binding;
if (!binding) this.encodedOutput.passthrough(requestKey);
binding?.setPaused(this.paused);
@@ -818,6 +816,16 @@ export class HostPeer {
this.emit();
}
private fail(error?: unknown): void {
if (this.disposed) return;
debugError("webrtc", "sender-media-failed", error, { connectionId: this.connectionId });
this.snapshot = { ...this.snapshot, connectionState: "failed", error: { key: "host.fail.connection" } };
this.dispose();
// Expected disposal is silent; terminal media failure must still reach
// its preparation/recovery owner after owned resources have retired.
this.events.onUpdate(this.getSnapshot());
}
private async configureSender(
sender: RTCRtpSender,
audioSender?: RTCRtpSender,
-102
View File
@@ -1,102 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DecodedFrameStallDetector,
ROUTE_DECODED_FRAME_STALL_MS,
} from "../src/client/media/decoded-frame-stall.ts";
describe("DecodedFrameStallDetector", () => {
it("reports one exact active stall and resets on progress or identity change", () => {
const detector = new DecodedFrameStallDetector();
expect(detector.observe("peer:a", 0, 0)).toBe(false);
expect(
detector.observe("peer:a", 0, ROUTE_DECODED_FRAME_STALL_MS),
).toBe(true);
expect(
detector.observe("peer:a", 0, ROUTE_DECODED_FRAME_STALL_MS + 1),
).toBe(false);
expect(detector.observe("peer:a", 1, ROUTE_DECODED_FRAME_STALL_MS + 2)).toBe(
false,
);
expect(
detector.observe("peer:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 2 + 2),
).toBe(true);
expect(detector.observe("sfu:b", 0, ROUTE_DECODED_FRAME_STALL_MS * 3)).toBe(
false,
);
});
it("does not turn unknown samples into elapsed stall evidence", () => {
const detector = new DecodedFrameStallDetector();
expect(detector.observe("peer:a", 0, 0)).toBe(false);
expect(
detector.observe("peer:a", null, ROUTE_DECODED_FRAME_STALL_MS),
).toBe(false);
expect(
detector.observe(
"peer:a",
0,
ROUTE_DECODED_FRAME_STALL_MS * 2 - 1,
),
).toBe(false);
expect(
detector.observe("peer:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 2),
).toBe(true);
});
it("suppresses and rebases progress while paused", () => {
const detector = new DecodedFrameStallDetector();
detector.observe("peer:1:a", 0, 0);
detector.setPaused(true, ROUTE_DECODED_FRAME_STALL_MS);
expect(
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 3),
).toBe(false);
detector.setPaused(false, ROUTE_DECODED_FRAME_STALL_MS * 3);
expect(
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 4 - 1),
).toBe(false);
expect(
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 4),
).toBe(true);
});
it("rebases elapsed time after the page resumes", () => {
const detector = new DecodedFrameStallDetector();
detector.observe("peer:1:a", 0, 0);
detector.rebaseline(ROUTE_DECODED_FRAME_STALL_MS * 4);
expect(
detector.observe(
"peer:1:a",
null,
ROUTE_DECODED_FRAME_STALL_MS * 5 - 1,
),
).toBe(false);
expect(
detector.observe(
"peer:1:a",
0,
ROUTE_DECODED_FRAME_STALL_MS * 5,
),
).toBe(false);
expect(
detector.observe(
"peer:1:a",
0,
ROUTE_DECODED_FRAME_STALL_MS * 6 - 1,
),
).toBe(true);
});
it("retries an exact stall when its failure message was not sent", () => {
const detector = new DecodedFrameStallDetector();
expect(detector.observe("peer:1:a", 0, 0)).toBe(false);
expect(
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS),
).toBe(true);
detector.allowReportRetry();
expect(
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS + 1),
).toBe(true);
});
});
+17 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { setCopy } from "../src/client/ui/copy.ts";
import { NativeMediaBridgeError } from "../src/client/native/media-bridge";
import { NativeMediaBridgeError, NativeMediaBridgeInitializationError } from "../src/client/native/media-bridge";
import {
hostActionErrorNotice,
hostServerErrorNotice,
@@ -12,9 +12,24 @@ import {
setCopy({ lang: "zh" });
describe("host error notices", () => {
it.each([
["zh", "浏览器无法建立媒体连接。请刷新页面,或换一个浏览器重试。"],
["en", "The browser could not create a media connection. Refresh the page or try another browser."],
] as const)("identifies Browser initialization failure in %s without blaming capture permission", (lang, notice) => {
setCopy({ lang });
try {
const failure = new NativeMediaBridgeInitializationError("private detail", {
cause: new DOMException("private cause", "NotAllowedError"),
});
expect(hostActionErrorNotice(failure, "capture")).toBe(notice);
} finally {
setCopy({ lang: "zh" });
}
});
it("classifies the native preview bridge as a connection failure", () => {
expect(hostActionErrorNotice(new NativeMediaBridgeError("private detail"), "capture"))
.toBe(hostActionErrorNotice(new Error("private detail"), "connection"));
.toBe("Piik App 媒体连接失败");
});
it("maps every server error code without exposing server text", () => {
+80
View File
@@ -19,6 +19,8 @@ import {
type ViewerRelayPeerFactory,
} from "../src/client/webrtc/viewer-relay.ts";
import { setCopy } from "../src/client/ui/copy.ts";
import * as encodingOutput from "../src/client/media/browser-encoding-output";
import type { BrowserEncodingPool } from "../src/client/media/browser-encoding-pool";
import type {
IceConfig,
ParticipantRouteAssignment,
@@ -527,6 +529,84 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function encodedPeerFixture() {
let failVideo!: () => void;
let audio!: ReadableStreamDefaultController<RTCEncodedAudioFrame>;
const output = { track: createTrack("video", "carrier"), dispose: vi.fn(), passthrough: vi.fn(), setPaused: vi.fn() };
const binding = { dispose: vi.fn(), carrierScale: vi.fn<() => number | undefined>(() => undefined),
setPaused: vi.fn(), updateProfile: vi.fn(async () => undefined), metrics: (value: unknown) => value };
const create = vi.fn<BrowserEncodingPool["create"]>(() => binding as never);
const pool = { create } as unknown as BrowserEncodingPool;
vi.spyOn(encodingOutput, "supportsBrowserEncoding").mockReturnValue(true);
vi.spyOn(encodingOutput, "BrowserEncodingOutput").mockImplementation(function (_sender, onFailure) {
failVideo = onFailure;
return output as unknown as encodingOutput.BrowserEncodingOutput;
});
vi.spyOn(encodingOutput, "encodedStreams").mockReturnValue({
readable: new ReadableStream({ start(controller) { audio = controller; } }),
writable: new WritableStream(),
} as never);
return { pool, create, binding, output, failVideo: () => failVideo(),
failAudio: () => audio.error(new Error("audio transform failed")) };
}
describe("HostPeer terminal media failure", () => {
it.each(["video", "audio", "fallback", "rollback"])("reports %s failure once and keeps borrowed source tracks alive", async (kind) => {
const fixture = encodedPeerFixture();
const source = createTrack("video", "source"), audio = createTrack("audio", "audio");
const onUpdate = vi.fn();
const peer = new HostPeer("child", { iceServers: [] }, createStream(source, audio), QUALITY_PROFILES["720p30"],
{ sendSignal: () => true, onUpdate }, VP8_ONLY_VIDEO_CODEC, "exact-connection", false, fixture.pool);
await peer.start();
const connection = FakePeerConnection.latest!, owned = connection.senders[0]!.track!;
onUpdate.mockClear();
if (kind === "video") fixture.failVideo();
else if (kind === "audio") fixture.failAudio();
else if (kind === "fallback") fixture.create.mock.calls[0]![7]();
else {
fixture.binding.carrierScale.mockReturnValue(1);
connection.senders[0]!.failNextSetParameters = true;
connection.senders[0]!.replaceTrack.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("rollback failed"));
// Reflect that the first attachment succeeded before configuration failed.
connection.senders[0]!.track = fixture.output.track;
await fixture.create.mock.calls[0]![5]();
}
await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
expect(onUpdate.mock.calls[0]![0]).toMatchObject({ peerId: "child", connectionId: "exact-connection", connectionState: "failed" });
expect(peer.getSnapshot().connectionState).toBe("failed");
expect(connection.connectionState).toBe("closed");
expect(owned.stop).toHaveBeenCalledOnce();
expect(source.stop).not.toHaveBeenCalled();
expect(audio.stop).not.toHaveBeenCalled();
fixture.failVideo();
fixture.create.mock.calls[0]![7]();
expect(onUpdate).toHaveBeenCalledOnce();
});
it("keeps expected disposal and late failures silent", async () => {
const fixture = encodedPeerFixture(), onUpdate = vi.fn();
const peer = new HostPeer("child", { iceServers: [] }, createStream(createTrack("video", "source"), null),
QUALITY_PROFILES["720p30"], { sendSignal: () => true, onUpdate }, VP8_ONLY_VIDEO_CODEC, undefined, false, fixture.pool);
await peer.start();
onUpdate.mockClear();
peer.dispose();
fixture.failVideo();
fixture.failAudio();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(onUpdate).not.toHaveBeenCalled();
});
it("reports the exact prepared child immediately instead of waiting for the route deadline", async () => {
const fixture = encodedPeerFixture(), onPreparedChildFailed = vi.fn();
const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildFailed });
owner.prepare({ ...hostProvisionalInput(7, ["candidate-child"], createStream(createTrack("video", "source"), null)), videoPool: fixture.pool });
await vi.waitFor(() => expect(FakePeerConnection.latest!.localDescription).not.toBeNull());
fixture.failVideo();
expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(7, "candidate-connection-7");
owner.discard();
});
});
describe("HostPeer source replacement", () => {
it("does not expose WebRTC exception text in connection errors", async () => {
const updates: PeerSnapshot[] = [];
+80 -5
View File
@@ -9,6 +9,7 @@ import {
} from "../src/client/media/quality";
import { NativeSenderPeer } from "../src/client/native/native-sender-peer";
import { NativeCompatibilityError } from "../src/client/native/client";
import { NativeMediaBridgeError } from "../src/client/native/media-bridge";
import { reconcileBoundedMediaChildren } from "../src/client/webrtc/media-assignment";
import { debugError, debugEvent, debugOperation } from "../src/client/lib/debug";
import { hostActionErrorNotice, isCapturePermissionFailure } from "../src/client/pages/host-page-notices";
@@ -55,6 +56,7 @@ function fixture(launchedByClient = true) {
let physical: QualitySettings = original;
const track = { stop: vi.fn() };
const stream = { getTracks: () => [track] };
const bridge = { start: vi.fn(async (_audio: boolean) => stream), dispose: vi.fn() };
const peer = { updateCaptureProfile: vi.fn(async () => true), updateProfile: vi.fn(async () => true),
isConnected: vi.fn(() => true) };
const client = { updateShare: vi.fn(async () => undefined), replaceShareSource: vi.fn(async () => undefined),
@@ -66,7 +68,7 @@ function fixture(launchedByClient = true) {
stopReceive: vi.fn(async () => undefined), stopShare: vi.fn(async () => undefined) };
const route = { updateProfile: vi.fn(async () => true), resyncAuthoritative: vi.fn(async (): Promise<void> => undefined) };
const state = {
debugError, debugEvent, debugOperation, NativeCompatibilityError, isCapturePermissionFailure, DOMException,
debugError, debugEvent, debugOperation, NativeCompatibilityError, NativeMediaBridgeError, isCapturePermissionFailure, DOMException,
launchedByClient, NativeClient: { connect: vi.fn(async (): Promise<typeof client | null> => null) },
nativeClientConnectRef: ref<Promise<typeof client | null> | null>(null),
ownNativeClient: vi.fn(), setJoiningRoom: vi.fn(), startSharing: vi.fn(), openCaptureSourcePicker: vi.fn(),
@@ -81,7 +83,7 @@ function fixture(launchedByClient = true) {
setCopiedRoomLink: vi.fn(), setPhase: vi.fn(), roomInitializationRef: ref(Promise.resolve()), roomRef: ref(null),
createRoom: vi.fn(async () => { throw new Error("must not create an empty room"); }),
readPreferredRoomId: () => null, disposeResources: vi.fn(), ApiError: class extends Error {},
NativeMediaBridge: vi.fn(), manualVideoCodecPreference: vi.fn(),
NativeMediaBridge: vi.fn(function () { return bridge; }), manualVideoCodecPreference: vi.fn(),
phase: "live", qualitySettingsRef: ref<QualitySettings>(original), advancedQualityRef: ref<QualitySettings>(original),
qualityChangeRef: ref<object | null>(null), pendingQualityChangeRef: ref<QualitySettings | null>(null),
activeGenerationRef: ref<number | null>(1), streamRef: ref<typeof stream | null>(stream), sourceSwitchRef: ref<object | null>(null),
@@ -124,7 +126,7 @@ function fixture(launchedByClient = true) {
context.startSharing = state.startSharing;
context.startPeer = state.startPeer;
context.openCaptureSourcePicker = state.openCaptureSourcePicker;
return { ...state, peer, client, route, track, physical: () => physical,
return { ...state, peer, client, route, track, bridge, physical: () => physical,
context, startChild, openPicker: openPicker as () => Promise<void>,
closePicker: context.closeCaptureSourcePicker as () => void,
releaseUnused: context.releaseUnusedNativeClient as () => void,
@@ -436,6 +438,76 @@ describe("Host quality ownership", () => {
expect(current.track.stop).not.toHaveBeenCalled();
});
it("rejects unavailable Browser media before starting native capture or creating a room", async () => {
const current = fixture();
const failure = new NativeMediaBridgeError("Preview unavailable", {
cause: new DOMException("Peer connection rejected", "NotAllowedError"),
});
const report = vi.fn();
Object.assign(current.context, { phase: "idle", debugError: report });
current.activeGenerationRef.current = null;
current.nativeShareGenerationRef.current = null;
current.NativeMediaBridge.mockImplementationOnce(function () { throw failure; });
await current.start();
expect(current.client.startShare).not.toHaveBeenCalled();
expect(current.client.stopShare).not.toHaveBeenCalled();
expect(current.client.close).toHaveBeenCalledOnce();
expect(current.createRoom).not.toHaveBeenCalled();
expect(current.roomMutationRef.current).toBeNull();
expect(current.setPhase).toHaveBeenLastCalledWith("error");
expect(current.setNoticeValue).toHaveBeenLastCalledWith(expect.objectContaining({
target: "television", comic: "route-failed", tone: "bad",
}));
expect(report).toHaveBeenCalledWith("capture", "failed", failure, { action: "capture", source: undefined });
});
it.each(["reject", "cancel", "accept"])("owns the Browser reservation while native startup is pending: %s", async (outcome) => {
const current = fixture();
current.nativeShareGenerationRef.current = null;
const started = deferred<{ audio: boolean; codec: "h264" }>();
current.client.startShare.mockReturnValue(started.promise);
const starting = current.startNative();
const result = outcome === "accept" ? expect(starting).resolves.toBeDefined()
: expect(starting).rejects.toThrow("capture unavailable");
await vi.waitFor(() => expect(current.client.startShare).toHaveBeenCalledOnce());
expect(current.nativeMediaBridgeRef.current).toBe(current.bridge);
expect(current.nativeShareGenerationRef.current).toBeNull();
expect(current.nativeModeRef.current).toBe(false);
expect(current.bridge.start).not.toHaveBeenCalled();
if (outcome === "accept") {
// The App can supply its mixed audio track even when source audio was off.
started.resolve({ audio: true, codec: "h264" });
} else {
if (outcome === "cancel") {
current.activeGenerationRef.current = null;
current.disposeNative();
expect(current.bridge.dispose).toHaveBeenCalledOnce();
expect(current.client.close).toHaveBeenCalledOnce();
}
started.reject(new Error("capture unavailable"));
}
await result;
if (outcome === "accept") {
expect(current.bridge.start).toHaveBeenCalledExactlyOnceWith(true);
expect(current.nativeShareGenerationRef.current).toBe("share");
expect(current.bridge.dispose).not.toHaveBeenCalled();
} else {
expect(current.bridge.dispose).toHaveBeenCalled();
expect(current.nativeMediaBridgeRef.current).toBeNull();
expect(current.client.stopShare).not.toHaveBeenCalled();
}
});
it.each(["camera", "browser"])("retains the %s capture cause when presenting its notice", (source) => {
const current = fixture();
const report = vi.fn();
current.context.debugError = report;
const cause = new DOMException("Device unavailable", "NotReadableError");
current.context.setCaptureError(cause, source, "capture");
expect(report).toHaveBeenCalledWith("capture", "failed", cause, { action: "capture", source });
});
it.each(["success", "replace-failed", "stopped"])("retires the whole microphone operation after stream replacement: %s", async outcome => {
const current = fixture();
const permission = deferred<MediaStream>();
@@ -759,7 +831,7 @@ describe("Host quality ownership", () => {
expect(current.roomMutationRef.current).toBeNull();
});
it("retires a cancelled start ACK before publishing audio, codec or bridge state", async () => {
it("retires a cancelled start ACK before publishing audio, codec or connecting the bridge", async () => {
const current = fixture();
current.nativeShareGenerationRef.current = null;
const started = deferred<{ audio: boolean; codec: "h264" }>();
@@ -772,7 +844,10 @@ describe("Host quality ownership", () => {
expect(current.client.stopShare).toHaveBeenCalledWith("share");
expect(current.nativeSourceAudioRef.current).toBeUndefined();
expect(current.manualVideoCodecPreference).not.toHaveBeenCalled();
expect(current.NativeMediaBridge).not.toHaveBeenCalled();
expect(current.NativeMediaBridge).toHaveBeenCalledOnce();
expect(current.bridge.start).not.toHaveBeenCalled();
expect(current.bridge.dispose).toHaveBeenCalledOnce();
expect(current.nativeMediaBridgeRef.current).toBeNull();
expect(current.setNativeActive).not.toHaveBeenCalled();
});
+68 -4
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
NativeMediaBridge,
NativeMediaBridgeError,
NativeMediaBridgeInitializationError,
type NativeMediaBridgeControl,
} from "../src/client/native/media-bridge";
import type { NativeClientEvent } from "../src/client/native/wire";
@@ -64,7 +65,6 @@ class FakePeerConnection extends EventTarget {
function fixture(options: {
hangPreparation?: boolean;
expectedAudio?: boolean;
} = {}) {
let listener: ((event: NativeClientEvent) => void) | null = null;
let peer: FakePeerConnection | null = null;
@@ -109,7 +109,6 @@ function fixture(options: {
"share_123456",
control,
onFailed,
options.expectedAudio,
);
return {
bridge,
@@ -131,6 +130,70 @@ afterEach(() => {
});
describe("native media bridge", () => {
it("releases an unstarted Browser reservation without sending an edge command", async () => {
const current = fixture();
current.bridge.dispose();
current.bridge.dispose();
await expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
expect(current.peer().close).toHaveBeenCalledOnce();
expect(current.control.prepareLocalEdge).not.toHaveBeenCalled();
expect(current.control.closeEdge).not.toHaveBeenCalled();
expect(current.onFailed).not.toHaveBeenCalled();
});
it.each(["MediaStream", "RTCPeerConnection"])("preserves %s initialization failure without preparing an edge", (api) => {
const current = fixture();
current.bridge.dispose();
vi.clearAllMocks();
const cause = new DOMException("Browser media initialization rejected", "NotAllowedError");
vi.stubGlobal(api, class { constructor() { throw cause; } });
let failure: unknown;
try { new NativeMediaBridge("share_123456", current.control, current.onFailed); }
catch (error) { failure = error; }
expect(failure).toBeInstanceOf(NativeMediaBridgeError);
expect(failure).toBeInstanceOf(NativeMediaBridgeInitializationError);
expect((failure as Error).cause).toBe(cause);
expect(debugError).toHaveBeenCalledWith("native-bridge", "initialization-failed", cause);
expect(current.control.prepareLocalEdge).not.toHaveBeenCalled();
expect(current.control.closeEdge).not.toHaveBeenCalled();
expect(current.onFailed).not.toHaveBeenCalled();
});
it("records the negotiation cause before retiring a failed bridge", async () => {
const current = fixture();
const cause = new DOMException("Remote offer rejected", "OperationError");
current.peer().setRemoteDescription.mockRejectedValueOnce(cause);
await expect(current.bridge.start()).rejects.toMatchObject({ cause });
expect(debugError).toHaveBeenCalledWith("native-bridge", "failed", cause,
{ type: "negotiation", state: "starting" });
expect(current.peer().close).toHaveBeenCalledOnce();
expect(current.control.closeEdge).toHaveBeenCalledOnce();
expect(current.onFailed).not.toHaveBeenCalled();
});
it.each(["candidate-signal", "candidate-apply"])("preserves the active %s failure and retires once", async (stage) => {
const current = fixture();
const starting = current.bridge.start();
await vi.waitFor(() => expect(current.peer().setLocalDescription).toHaveBeenCalledOnce());
current.peer().emitTrack({ kind: "video", stop: vi.fn() } as unknown as MediaStreamTrack);
current.peer().setState("connected");
await starting;
const cause = new DOMException("Connection is unavailable", "InvalidStateError");
if (stage === "candidate-signal") {
vi.mocked(current.control.acceptSignal).mockRejectedValueOnce(cause);
current.peer().dispatchEvent(Object.assign(new Event("icecandidate"), { candidate: null }));
} else {
current.peer().addIceCandidate.mockRejectedValueOnce(cause);
current.emit({ version: 9, type: "edge-candidate", shareId: "share_123456",
connectionId: current.bridge.connectionId, candidate: null });
}
await vi.waitFor(() => expect(current.onFailed).toHaveBeenCalledOnce());
expect(debugError).toHaveBeenCalledWith("native-bridge", "failed", cause, { type: stage, state: "active" });
expect(current.peer().close).toHaveBeenCalledOnce();
expect(current.control.closeEdge).toHaveBeenCalledOnce();
});
it.each(["queued", "active"])("keeps media alive after a %s candidate is rejected", async (phase) => {
const current = fixture();
const peer = current.peer();
@@ -224,8 +287,8 @@ describe("native media bridge", () => {
});
it("does not publish a declared audio share before its audio track arrives", async () => {
const current = fixture({ expectedAudio: true });
const starting = current.bridge.start();
const current = fixture();
const starting = current.bridge.start(true);
let resolved = false;
void starting.then(() => {
resolved = true;
@@ -347,6 +410,7 @@ describe("native media bridge", () => {
expect(current.onFailed).not.toHaveBeenCalled();
expect(current.peer().close).toHaveBeenCalledOnce();
expect(current.control.closeEdge).toHaveBeenCalledOnce();
expect(debugError).not.toHaveBeenCalled();
});
it.each(["prepare", "answer"])("does not signal after disposal during %s", async (stage) => {
+9 -3
View File
@@ -384,10 +384,16 @@ it("still disables native on a genuine failure after a signaling failure", async
}
});
it.each(["route", "viewer"] as const)("keeps %s bridge failure unavailable across replacement peers", async (recoveryOwner) => {
it.each([
["route", "initialize"], ["route", "start"],
["viewer", "initialize"], ["viewer", "start"],
] as const)("keeps %s bridge %s failure unavailable across replacement peers", async (recoveryOwner, failureStage) => {
vi.stubGlobal("window", globalThis);
vi.stubGlobal("MediaStream", class { getTracks() { return []; } });
vi.stubGlobal("RTCPeerConnection", class { close() {} });
vi.stubGlobal("RTCPeerConnection", class {
constructor() { if (failureStage === "initialize") throw new DOMException("Peer unavailable", "NotSupportedError"); }
close() {}
});
const browserSignal = vi.spyOn(ViewerPeer.prototype, "acceptSignal").mockResolvedValue();
const bridgeStart = vi.spyOn(NativeMediaBridge.prototype, "start").mockRejectedValue(new Error("bridge failed"));
const receiveOffer = vi.fn(async () => ({
@@ -440,7 +446,7 @@ it.each(["route", "viewer"] as const)("keeps %s bridge failure unavailable acros
await replacement.acceptSignal("parent", offer("replacement"));
expect(browserSignal).toHaveBeenCalledTimes(2);
expect(receiveOffer).toHaveBeenCalledOnce();
expect(bridgeStart).toHaveBeenCalledOnce();
expect(bridgeStart).toHaveBeenCalledTimes(failureStage === "initialize" ? 0 : 1);
expect(acquire).toHaveBeenCalledTimes(3);
} finally {
replacement.dispose();
-34
View File
@@ -1109,23 +1109,11 @@ describe("minimal route transition contracts", () => {
const log: string[] = [];
const messages: ClientMessage[] = [];
const streams: MediaStream[] = [];
const decodedSamples: Array<{
framesDecodedDelta: number | null;
revision: number;
mediaIdentity: string;
connectionId: string;
}> = [];
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
const route = new ViewerSfuRoute("viewer_12345678", {
activatePeer: () => true,
reconcileSfuChildren: () => undefined,
onSfuStream: (stream) => streams.push(stream),
onSfuDecodedFrameSample: (
framesDecodedDelta,
revision,
mediaIdentity,
connectionId,
) => decodedSamples.push({ framesDecodedDelta, revision, mediaIdentity, connectionId }),
send: (message) => {
messages.push(message);
return true;
@@ -1151,15 +1139,6 @@ describe("minimal route transition contracts", () => {
subscribers[0]?.events.onStream(firstStream);
subscribers[0]?.events.onFirstDecodedFrame();
await vi.waitFor(() => expect(streams).toEqual([firstStream]));
subscribers[0]?.events.onDecodedFrameSample(3);
expect(decodedSamples).toEqual([
{
framesDecodedDelta: 3,
revision: 1,
mediaIdentity: expect.stringContaining("publication_generation_12345678:"),
connectionId: sfuConfig(1).connectionId,
},
]);
route.accept({
revision: 2,
@@ -1173,9 +1152,7 @@ describe("minimal route transition contracts", () => {
subscribers[1]?.events.onStats?.({
intervalFramesDecoded: 2,
} as ConnectionMetrics);
subscribers[1]?.events.onDecodedFrameSample(null);
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
expect(decodedSamples).toHaveLength(1);
subscribers[1]?.events.onFirstDecodedFrame();
subscribers[1]?.events.onFirstDecodedFrame();
@@ -1190,17 +1167,6 @@ describe("minimal route transition contracts", () => {
assignment: viewerSfuAssignment([], "publication-generation-2"),
});
await vi.waitFor(() => expect(streams).toEqual([firstStream, pendingStream]));
subscribers[1]?.events.onDecodedFrameSample(null);
subscribers[0]?.events.onDecodedFrameSample(9);
expect(decodedSamples).toHaveLength(2);
expect(decodedSamples[1]).toMatchObject({
framesDecodedDelta: null,
revision: 2,
connectionId: sfuConfig(2).connectionId,
});
expect(decodedSamples[1]!.mediaIdentity).not.toBe(
decodedSamples[0]!.mediaIdentity,
);
expect(subscribers[0]?.deactivate).toHaveBeenCalledOnce();
expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce();
});
+67
View File
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { createContext, runInContext } from "node:vm";
import ts from "typescript";
import { afterEach, expect, it, vi } from "vitest";
import { EMPTY_METRICS } from "../src/client/types";
import type { ViewerPeerEvents } from "../src/client/webrtc/viewer-peer";
// Exercise the page's actual media callbacks, without needing a physical game.
const source = ts.createSourceFile("ViewerPage.tsx", readFileSync(
new URL("../src/client/pages/ViewerPage.tsx", import.meta.url), "utf8",
), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const owners = new Set(["ensurePeer", "reportActivePeerFailure"]);
const functions: string[] = [];
function collect(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name && owners.delete(node.name.text)) {
functions.push(node.getText(source));
}
ts.forEachChild(node, collect);
}
collect(source);
if (owners.size) throw new Error("Viewer route owner was not found");
const executable = ts.transpile(functions.join("\n"), { target: ts.ScriptTarget.ES2022 });
afterEach(() => vi.useRealTimers());
it("retains a connected route through quiet video and still reports actual recovery exhaustion", () => {
vi.useFakeTimers();
let events!: ViewerPeerEvents;
const peer = {
isRecovering: () => false,
getConnectionIdentity: () => ({ parentPeerId: "host", connectionId: "edge" }),
hasConnectionId: (id: string) => id === "edge",
};
const send = vi.fn();
const reportPeerFailure = vi.fn(() => true);
const setPeerSnapshot = vi.fn();
const context = createContext({
Date, active: true, currentHostOnline: true,
currentIceConfig: { iceServers: [] }, currentRoutePolicy: { natPrediction: false },
currentRouteRevision: 1, currentRouteAssignment: { upstream: { kind: "peer", peerId: "host" } },
activePeerMetrics: null, peerRef: { current: null },
signal: { send }, viewerSfuRoute: { reportPeerFailure },
createViewerMediaPeer: (_config: unknown, callbacks: ViewerPeerEvents) => { events = callbacks; return peer; },
createOwnedViewerRestartSender: () => vi.fn(),
offerPeerQualityEvidence: vi.fn(), setPeerSnapshot,
dispatchPresentation: vi.fn(), connectionFact: (state: string) => state,
});
runInContext(executable + "\nensurePeer();", context);
const snapshot = {
peerId: "host", connectionId: "edge", connectionState: "connected" as const,
metrics: { ...EMPTY_METRICS, intervalFramesDecoded: 0 },
};
// Silence also occurs for a live, minimized source; elapsed time cannot locate
// the fault in a route, even if audio or incomplete video packets still arrive.
for (let second = 0; second < 40; second += 2) {
vi.advanceTimersByTime(2_000);
events.onUpdate(snapshot as Parameters<ViewerPeerEvents["onUpdate"]>[0]);
}
events.onUpdate({ ...snapshot, metrics: { ...snapshot.metrics, intervalFramesDecoded: 30 } } as Parameters<ViewerPeerEvents["onUpdate"]>[0]);
expect(send).not.toHaveBeenCalled();
expect(reportPeerFailure).not.toHaveBeenCalled();
expect(setPeerSnapshot).toHaveBeenLastCalledWith(expect.objectContaining({
metrics: expect.objectContaining({ intervalFramesDecoded: 30 }),
}));
events.onRecoveryExhausted?.("host", "edge");
expect(reportPeerFailure).toHaveBeenCalledExactlyOnceWith("host", "edge");
});