Files
Piik/tests/viewer-playback.test.ts
AAA 28827ec6a2 fix(media): improve sharing continuity and pause recovery (#431)
## Problem and outcome / 问题与结果

Sharing could stall at Browser encoder handoff or retain a pause after a video binding was replaced. Source/audio changes could also overlap offer/answer negotiation, while Native recovery could request redundant keyframes. This phase repairs those existing owners, drains capture output during bounded shutdown, and normalizes configured Site origins consistently with Browser serialization.

## Release notes

### 中文

这是一次稳定性更新,重点改善分享中的画面连续性,以及暂停、换源和恢复播放。

- 修复浏览器编码复用中的重复帧率限制和交接等待,减少由这些问题造成的卡顿。
- 修复换源和音频变更时的协商重叠,并防止已关闭连接的异步操作干扰恢复。
- 修复房主暂停期间重连后无法恢复播放,以及编码复用回退后仍停在暂停状态的问题;正常取消播放不再被当作媒体故障。
- 减少原生采集的重复关键帧请求,并修复停止采集时输出未排空导致的退出等待问题。
- 修复部分国际化域名和 IPv6 站点地址的识别,并改善窄屏下分享设置项的文字排版。

升级 Piik App 或自部署 Server 后刷新页面即可,无需修改配置。部分设备和网络环境下报告的开播失败、偶发断流仍需现场日志确认,本次更新不代表所有此类反馈均已解决。

### English

This stability update improves picture continuity and recovery when pausing, changing sources, or resuming playback.

- Fix duplicate frame-rate limiting and unnecessary handoff waits in Browser encoding reuse, reducing stalls caused by these issues.
- Serialize negotiation across source and audio changes, and prevent asynchronous work on closed connections from interfering with recovery.
- Fix playback remaining paused after reconnecting while the Host is paused, or after falling back from shared encoding. Normal playback cancellation is no longer treated as a media failure.
- Avoid redundant Native capture keyframe requests and drain pending capture output during bounded shutdown.
- Fix recognition of some internationalized domain names and IPv6 Site addresses, and improve sharing-setting labels on narrow screens.

Update Piik App or your self-hosted Server, then refresh the page. No configuration changes are required. Some device- and network-specific reports of sharing startup failure or intermittent stream loss still need matching diagnostics; this update does not claim to resolve every reported case.

## Verification / 验证

- `npm run check`: 76 test files / 1,152 tests, TypeScript and production Web build passed.
- Real Chrome VP8/H264 checks with two viewers passed live/paused profile changes, quiet-source recovery, source replacement and ordinary-sender fallback after producer failure.
- New pause-owner regressions fail against the previous implementation; actual Viewer effects preserve same-binding local pause and resume replacement bindings.
- Independent cumulative Browser and Native/config review found no confirmed release blocker.
- `npm run check:go`: formatting, Go tests/vet, Windows/Linux builds and Windows native capture compile/probes passed. Darwin packaging and Go race detection run on the native CI runners.
- Windows App H264 and VP8 Host gates passed decoded playback, live/paused quality changes, quiet/background-source recovery, source replacement, failure cleanup and subsequent sharing; experiment-owned processes, ports and profiles were cleaned up.
- Documentation, repository hygiene and release-note extraction passed.

## Impact / 影响

Patch release v1.6.7. Browser/server v23, Native control v9, capture v7 and SQLite schema 2 remain unchanged. Existing WebRTC adaptation and route policy remain unchanged; no quality floor is introduced. Interaction and Windows x86 prototypes are excluded. Managed Server restart interrupts active media and preserves room authority. Physical macOS/Linux capture, broad public-network endurance and unmatched reporter environments remain outside local acceptance.
2026-09-27 17:14:28 +08:00

72 lines
3.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { nextViewerMediaBinding, playbackFailure, prepareViewerPlayback } from "../src/client/media/viewer-playback.ts";
describe("Viewer playback binding", () => {
it("distinguishes canceled playback from autoplay denial and actual failures", () => {
expect(playbackFailure(new DOMException("Paused", "AbortError"))).toBeNull();
expect(playbackFailure({ name: "AbortError" })).toBeNull();
expect(playbackFailure(new DOMException("Blocked", "NotAllowedError"))).toBe("autoplay-blocked");
expect(playbackFailure(new DOMException("Unsupported", "NotSupportedError"))).toBe("playback-failed");
expect(playbackFailure(new Error("decode failed"))).toBe("playback-failed");
expect(playbackFailure(null)).toBe("playback-failed");
});
it("retains video identity across audio arrival, replacement and removal", () => {
let audio: { id: string }[] = [];
const stream = { getVideoTracks: () => [{ id: "video" }], getAudioTracks: () => audio } as MediaStream;
const original = nextViewerMediaBinding(null, stream, 3, 7);
let binding = original;
for (const nextAudio of [[{ id: "audio-a" }], [{ id: "audio-b" }], []]) {
audio = nextAudio;
binding = nextViewerMediaBinding(binding, stream, 4, 8);
expect(binding).toMatchObject({ generation: 7, boundAtRevision: 3, videoTrackKey: "video", audioTrackKey: nextAudio[0]?.id ?? "" });
expect(nextViewerMediaBinding(binding, stream, 5, 8)).toBe(binding);
}
expect(original.audioTrackKey).toBe("");
});
it("creates a new picture identity for a new video or stream, including after retirement", () => {
let id = "video-a";
const stream = { getVideoTracks: () => [{ id }], getAudioTracks: () => [] } as unknown as MediaStream;
const first = nextViewerMediaBinding(null, stream, 1, 1);
id = "video-b";
const changed = nextViewerMediaBinding(first, stream, 2, 2);
expect(changed).toMatchObject({ generation: 2, boundAtRevision: 2, videoTrackKey: "video-b" });
expect(nextViewerMediaBinding(changed, { ...stream }, 3, 3).generation).toBe(3);
expect(nextViewerMediaBinding(null, stream, 4, 4).generation).toBe(4);
});
it("arms every media generation even when the MediaStream identity is reused", () => {
const stream = {} as MediaStream;
const video = {
srcObject: null as MediaProvider | null,
pause: vi.fn(),
};
const pauseState = { active: false, resume: false };
expect(prepareViewerPlayback(video, stream, false, pauseState)).toBe(true);
expect(video.srcObject).toBe(stream);
expect(prepareViewerPlayback(video, stream, false, pauseState)).toBe(true);
expect(video.pause).not.toHaveBeenCalled();
expect(prepareViewerPlayback(video, stream, true, pauseState)).toBe(false);
expect(video.pause).toHaveBeenCalledOnce();
});
it.each([false, true])("arms Host resume on a new binding regardless of the old local pause (%s)", (resume) => {
const stream = {} as MediaStream;
const video = { srcObject: stream, pause: vi.fn() };
const pauseState = { active: true, resume };
// A new video generation can reuse the same MediaStream object.
expect(prepareViewerPlayback(video, stream, true, pauseState)).toBe(false);
expect(video.pause).toHaveBeenCalledOnce();
expect(pauseState).toEqual({ active: true, resume: true });
// If the Host has already resumed, the new binding starts immediately.
expect(prepareViewerPlayback(video, stream, false, pauseState)).toBe(true);
expect(pauseState).toEqual({ active: false, resume: false });
});
});