Files
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

146 lines
4.8 KiB
Go

package config
import (
"errors"
"fmt"
"net"
"net/url"
"slices"
"strings"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// LocalOptions describes App-selected Local composition. It never reads the
// process environment.
type LocalOptions struct {
// Port 0 selects the default 8787.
Port int
PublicAddress string
PublicOrigin string
AllowedAddresses []string
SiteAccessPassword string
STUNURLs []string
NATPredictionSTUNURLs []string
}
func Local(options LocalOptions) (Config, error) {
port := options.Port
if port == 0 {
port = defaultPort
}
if port < 1 || port > maxPort {
return Config{}, fmt.Errorf("Local server port must be an integer between 1 and %d", maxPort)
}
publicAddress := ""
if options.PublicAddress != "" {
parsed, err := localIPv4(options.PublicAddress, "public address")
if err != nil {
return Config{}, err
}
if parsed == "0.0.0.0" || strings.HasPrefix(parsed, "127.") {
return Config{}, errors.New("Local server public address must be reachable from the LAN")
}
publicAddress = parsed
} else if options.PublicOrigin == "" {
return Config{}, errors.New("Local server public address must be reachable from the LAN")
}
allowedAddresses := make([]string, 0, 1+len(options.AllowedAddresses))
if publicAddress != "" {
allowedAddresses = append(allowedAddresses, publicAddress)
}
for _, address := range options.AllowedAddresses {
normalized, err := localIPv4(address, "allowed address")
if err != nil {
return Config{}, err
}
if !slices.Contains(allowedAddresses, normalized) {
allowedAddresses = append(allowedAddresses, normalized)
}
}
// Copy non-nil lists so configured-empty prediction does not derive auxiliary URLs.
stunURLs := append([]string{}, options.STUNURLs...)
natPredictionSTUNURLs := append([]string{}, options.NATPredictionSTUNURLs...)
if len(stunURLs)+len(natPredictionSTUNURLs) > protocol.MaxIceServerURLs ||
!allValidStunURLs(stunURLs) ||
!allValidStunURLs(natPredictionSTUNURLs) ||
(len(natPredictionSTUNURLs) != 0 &&
len(natPredictionSTUNURLs) != protocol.MaxNatPredictionAuxiliaryStunURLs) ||
(len(natPredictionSTUNURLs) > 0 && len(stunURLs) == 0) {
return Config{}, errors.New("Local STUN URLs are invalid")
}
// Local is loopback/LAN HTTP; its origin always spells out the port.
origin := func(host string) string { return fmt.Sprintf("http://%s:%d", host, port) }
var publicBaseURL *url.URL
if options.PublicOrigin != "" {
parsed, err := publicHTTPSOrigin(options.PublicOrigin)
if err != nil {
return Config{}, err
}
publicBaseURL = parsed
} else {
publicBaseURL = &url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", publicAddress, port), Path: "/"}
}
allowedOrigins := map[string]struct{}{
origin("localhost"): {},
origin("127.0.0.1"): {},
}
for _, address := range allowedAddresses {
allowedOrigins[origin(address)] = struct{}{}
}
allowedOrigins[Origin(publicBaseURL)] = struct{}{}
return Config{
Env: EnvironmentProduction,
Port: port,
ListenHost: "0.0.0.0",
PublicBaseURL: publicBaseURL,
AllowedOrigins: allowedOrigins,
SiteAccessPassword: options.SiteAccessPassword,
MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit,
EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity,
STUNURLs: stunURLs,
NATPredictionEnabled: len(natPredictionSTUNURLs) == protocol.MaxNatPredictionAuxiliaryStunURLs,
NATPredictionSTUNURLs: natPredictionSTUNURLs,
}, nil
}
func allValidStunURLs(values []string) bool {
for _, value := range values {
if !protocol.ValidStunURLValue(value) {
return false
}
}
return true
}
// publicHTTPSOrigin uses one error message for all invalid origins.
func publicHTTPSOrigin(value string) (*url.URL, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") ||
parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("Local public origin must be an HTTPS origin")
}
host := normalizedHost(parsed)
if host == "" {
return nil, errors.New("Local public origin must be an HTTPS origin")
}
return &url.URL{Scheme: "https", Host: host, Path: "/"}, nil
}
// Node's isIP(value) === 4 accepts only a dotted
// quad, so an IPv4-mapped IPv6 literal such as "::ffff:192.168.1.10" — which
// net.ParseIP(...).To4() does resolve — is rejected here as well.
func localIPv4(value string, name string) (string, error) {
address := strings.TrimFunc(value, protocol.IsJSWhitespace)
parsed := net.ParseIP(address)
if parsed == nil || parsed.To4() == nil || strings.ContainsRune(address, ':') {
return "", fmt.Errorf("Local server %s must be an IPv4 address", name)
}
return address, nil
}