diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index ffa2cec..93f2d04 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -107,7 +107,17 @@ jobs: if: >- needs.validate.outputs.publish == 'true' || (github.event_name == 'workflow_dispatch' && inputs.app_checks == true) - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + artifact: piik-server + - arch: arm64 + runner: ubuntu-24.04-arm + artifact: piik-server-linux-arm64 + runs-on: ${{ matrix.runner }} timeout-minutes: 15 env: PIIK_BUILD_VERSION: ${{ needs.validate.outputs.version }} @@ -129,22 +139,23 @@ jobs: - name: Package Server release run: >- node scripts/package-server-release.mjs "$RUNNER_TEMP/server-release" - --container-image "piik-candidate:$GITHUB_SHA" + --arch "${{ matrix.arch }}" + --container-image "piik-candidate:$GITHUB_SHA-${{ matrix.arch }}" - name: Check the container and deployment recipe - run: npm run check:container -- "piik-candidate:$GITHUB_SHA" "$GITHUB_SHA" + run: npm run check:container -- "piik-candidate:$GITHUB_SHA-${{ matrix.arch }}" "$GITHUB_SHA" - name: Save the verified container - run: docker save "piik-candidate:$GITHUB_SHA" --output "$RUNNER_TEMP/piik-container.tar" + run: docker save "piik-candidate:$GITHUB_SHA-${{ matrix.arch }}" --output "$RUNNER_TEMP/piik-container-${{ matrix.arch }}.tar" - name: Upload the verified container uses: actions/upload-artifact@v7 with: - name: piik-container-${{ github.sha }} - path: ${{ runner.temp }}/piik-container.tar + name: piik-container-${{ matrix.arch }}-${{ github.sha }} + path: ${{ runner.temp }}/piik-container-${{ matrix.arch }}.tar if-no-files-found: error retention-days: 14 - name: Upload Server release uses: actions/upload-artifact@v7 with: - name: piik-server-${{ github.sha }} + name: ${{ matrix.artifact }}-${{ github.sha }} path: ${{ runner.temp }}/server-release/* if-no-files-found: error retention-days: 14 @@ -164,8 +175,12 @@ jobs: runner: windows-latest - target: linux-amd64 runner: ubuntu-latest + - target: linux-arm64 + runner: ubuntu-24.04-arm - target: darwin-arm64 runner: macos-15 + - target: darwin-amd64 + runner: macos-15-intel runs-on: ${{ matrix.runner }} timeout-minutes: 20 env: @@ -189,7 +204,7 @@ jobs: - name: Install dependencies run: npm ci - name: Install Linux packaging and native capture dependencies - if: matrix.target == 'linux-amd64' + if: startsWith(matrix.target, 'linux-') run: >- sudo apt-get update && sudo apt-get install -y zip @@ -246,8 +261,9 @@ jobs: - name: Download Server release uses: actions/download-artifact@v8 with: - name: piik-server-${{ github.sha }} + pattern: piik-server*-${{ github.sha }} path: ${{ runner.temp }}/publish-release + merge-multiple: true - name: Download App release candidates uses: actions/download-artifact@v8 with: @@ -257,10 +273,14 @@ jobs: - name: Download the verified container uses: actions/download-artifact@v8 with: - name: piik-container-${{ github.sha }} + pattern: piik-container-*-${{ github.sha }} path: ${{ runner.temp }}/container + merge-multiple: true - name: Load the verified container - run: docker load --input "$RUNNER_TEMP/container/piik-container.tar" + run: | + for arch in amd64 arm64; do + docker load --input "$RUNNER_TEMP/container/piik-container-$arch.tar" + done - name: Log in to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b012bf3..8b2cb69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,8 +108,11 @@ does not establish coverage. and deterministic native regressions. The Linux App packaging job includes its headless GStreamer output-profile and retirement test; this does not establish physical screen/audio capture on that runner. + The macOS frame self-test additionally needs an available hardware H.264 + encoder. A runner without one reports that check as skipped and still checks + compilation, capability/source responses and the packaged App's startup. - `npm run check:container -- ` checks the Compose recipe - against a packaged linux/amd64 image: embedded Web, P2P/STUN, optional SFU + against a packaged native Linux image (amd64 or arm64): embedded Web, P2P/STUN, optional SFU startup, room persistence across recreation, diagnostics and clean shutdown. It requires Docker Compose v2 and available recipe ports on an isolated runner; its temporary project and volumes are removed after the check. diff --git a/README.md b/README.md index a48d317..0a65afe 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ watch in their browsers.

- Piik room: a shared game, playback controls and friends on a sofa. The host wears a small gold crown. + Piik room: a shared game, playback controls, chat and friends on a sofa. The host wears a small gold crown.
Interface preview · Sample room with a generated game scene

@@ -58,7 +58,7 @@ you can also create a room in the online version. | --- | --- | | [Use Piik online](https://demo.piik.tv) | Share from a desktop browser, or choose Camera on a phone with browser camera support. | | [Download Piik App](https://piik.tv/#download) | Extract and open the App, choose **Public invite**, then **Open Piik** to create a temporary room. | -| [Host your own site](./docs/operations/self-hosting.md) | Advanced: deploy Piik Server on a Linux x64 server with your own domain, then connect through Piik App or a browser. | +| [Host your own site](./docs/operations/self-hosting.md) | Advanced: deploy Piik Server on a Linux x64 or ARM64 server with your own domain, then connect through Piik App or a browser. |

Choose a screen, send an invitation, watch together.

@@ -79,20 +79,23 @@ Download a ZIP beginning with **`piik-app`**. The filename also identifies the p | --- | --- | | `windows-amd64` | Windows x64 | | `darwin-arm64` | Apple silicon; native capture requires macOS 13+ | +| `darwin-amd64` | Intel Mac; native capture requires macOS 13+ | | `linux-amd64` | Linux x64 | +| `linux-arm64` | Linux ARM64 | [**Full setup guide →**](./docs/guide/getting-started.md) · [**Open online →**](https://demo.piik.tv) Browser capture requires HTTPS or `localhost`. Windows App and desktop browser -sharing are the primary tested paths. The macOS and Linux apps have not yet been -tested on physical devices; [test results and feedback are welcome](https://github.com/TNTcraftHIM/Piik/issues). +sharing are the primary tested paths. Physical-device coverage for the macOS and +Linux apps is still limited; [test results and feedback are welcome](https://github.com/TNTcraftHIM/Piik/issues). P2P-only modes, including the App's temporary public link and the online version, may not connect on restrictive networks. ## Self-hosting Piik Server is a standalone binary with the web interface built in. -Download the Linux x64 Server package (`piik--runtime.tar.gz`), +Download the Server package for Linux x64 (`piik--runtime.tar.gz`) +or ARM64 (`piik--linux-arm64-runtime.tar.gz`), extract it, and run: ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 11131a2..e1d27c6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,7 +40,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看

- Piik 房间:共享的游戏画面、播放控件和坐在沙发上的朋友,房主戴着小金冠。 + Piik 房间:共享的游戏画面、播放控件、聊天入口和坐在沙发上的朋友,房主戴着小金冠。
界面预览 · 示例房间与生成的游戏画面

@@ -55,7 +55,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看 | --- | --- | | [使用在线版](https://demo.piik.tv) | 用电脑浏览器分享画面;手机浏览器支持摄像头访问时,也可选择摄像头分享。 | | [下载 Piik App](https://piik.tv/?lang=zh-CN#download) | 解压并启动 Piik App,选择**公网邀请**,点击**进入 Piik**,创建临时房间。 | -| [部署自己的站点](./docs/operations/self-hosting.zh-CN.md) | 进阶使用:在 Linux x64 服务器上部署 Piik Server、配置自己的域名,再用 Piik App 或浏览器连接。 | +| [部署自己的站点](./docs/operations/self-hosting.zh-CN.md) | 进阶使用:在 Linux x64 或 ARM64 服务器上部署 Piik Server、配置自己的域名,再用 Piik App 或浏览器连接。 |

选择画面,发送邀请,一起观看。

@@ -74,19 +74,22 @@ Piik App 提供本地房间、临时公网邀请和连接已有站点三种模 | --- | --- | | `windows-amd64` | Windows x64 | | `darwin-arm64` | Apple 芯片;原生采集需 macOS 13 及以上 | +| `darwin-amd64` | Intel Mac;原生采集需 macOS 13 及以上 | | `linux-amd64` | Linux x64 | +| `linux-arm64` | Linux ARM64 | [**完整使用教程 →**](./docs/guide/getting-started.zh-CN.md) · [**打开在线版 →**](https://demo.piik.tv) 浏览器采集需要 HTTPS 或 `localhost`。目前主要测试 Windows 版 Piik App 和桌面浏览器分享。 -macOS 和 Linux 版 Piik App 尚未经过实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。 +macOS 和 Linux 版 Piik App 仍需更多实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。 Piik App 的公网邀请和在线版使用纯 P2P 连接, 在受限网络下可能无法连通。 ## 自行部署 Piik Server 是一个内置网页界面的独立程序。 -下载 Linux x64 服务端程序包 `piik--runtime.tar.gz`,解压后运行: +根据服务器架构下载 Linux x64 的 `piik--runtime.tar.gz` 或 ARM64 的 +`piik--linux-arm64-runtime.tar.gz`,解压后运行: ```sh ./piik-server diff --git a/cmd/piik-app/README.md b/cmd/piik-app/README.md index a5c9bea..bdf547d 100644 --- a/cmd/piik-app/README.md +++ b/cmd/piik-app/README.md @@ -16,10 +16,10 @@ in the [Linux capture guide](../../native/capture/linux/README.md). If the page does not open automatically, open the address shown in the terminal or press **O** there to retry. Keep the App running while using that page. -Windows App and browser sharing are the primary tested paths. The macOS and -Linux apps have not yet been tested on physical devices; test results and +Windows App and browser sharing are the primary tested paths. Physical-device +coverage for the macOS and Linux apps is still limited; test results and [feedback](https://github.com/TNTcraftHIM/Piik/issues) are welcome. macOS native -capture requires Apple silicon and macOS 13 or newer. Package construction and public Release +capture requires macOS 13 or newer on Apple silicon or Intel. Package construction and public Release publication are separate steps in [deployment](../../docs/deployment.md). ## Modes @@ -102,25 +102,10 @@ use a configured Site when persistent control availability or SFU fallback matte **Why can the page open, or screen capture succeed, while sharing or viewing fails?** -Chromium-based Browsers can restrict WebRTC UDP through Browser settings, -extensions or managed policies. Disabling non-proxied UDP can prevent even the -local Browser-to-App media connection; successful capture or page loading -does not prove that this separate connection is available. -The viewing browser is affected too: this can lead to **No media route available** -or another connection error. Hiding local IP addresses alone does not necessarily -disable WebRTC; the effective transport policy matters. - -Check the Browser's WebRTC/IP-handling policy and any extension's WebRTC or -IP-leak protection setting. Restore a policy that permits WebRTC UDP, reload -Piik, and verify that another extension or managed policy has not overridden -the choice. Setting names and availability differ between Browsers. For example, -Vivaldi exposes **Settings > Privacy and Security > WebRTC IP Handling > -Broadcast IP for Best WebRTC Performance**. Changing this policy can expose -network addresses to WebRTC peers; do not disable unrelated protections. - -See [Chromium's extension policy API](https://developer.chrome.com/docs/extensions/reference/api/privacy#property-network), -[Vivaldi's setting example](https://help.vivaldi.com/desktop/privacy/privacy-settings/), -and the [verified policy mechanism and field case](../../docs/research/native-client-lifecycle.md). +Browser settings, extensions or managed policies can block WebRTC UDP, including +the local Browser-to-App media connection. Follow the documentation center's +[browser WebRTC checks](../../docs/guide/troubleshooting.md#browser-webrtc-restrictions). +For codec-specific failures, see [Windows H264 troubleshooting](../../docs/guide/troubleshooting.md#h264-sharing-fails-on-windows). ### Diagnostics @@ -206,7 +191,7 @@ node scripts/package-server-release.mjs /outside/repository/app-release node scripts/package-app-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/app-candidate ``` -Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`. +Supported targets are `windows-amd64`, `linux-amd64`, `linux-arm64`, `darwin-arm64` and `darwin-amd64`. Use the matching target name in the command above. Darwin assembly requires a native macOS runner with its SDK and enables cgo; Windows and Linux assembly keep cgo disabled. diff --git a/cmd/piik-app/README.zh-CN.md b/cmd/piik-app/README.zh-CN.md index dd1ee6d..f1cda20 100644 --- a/cmd/piik-app/README.zh-CN.md +++ b/cmd/piik-app/README.zh-CN.md @@ -14,9 +14,9 @@ Windows 运行 `piik-app.exe`,Linux 运行 `./piik-app`,macOS 打开 `Piik A 网页没有自动打开时,可手动打开终端中显示的地址,或在终端按 **O** 重试。 使用网页期间,请保持 App 运行。 -目前主要测试 Windows 版 Piik App 和浏览器分享。macOS 与 Linux 版 Piik App 尚未经过实机测试, +目前主要测试 Windows 版 Piik App 和浏览器分享。macOS 与 Linux 版 Piik App 仍需更多实机测试, 欢迎试用并[反馈结果](https://github.com/TNTcraftHIM/Piik/issues)。macOS 原生采集需要 -Apple 芯片及 macOS 13 或更新版本。构建程序包与公开发布 Release 是两个独立步骤, +Apple 芯片或 Intel Mac,以及 macOS 13 或更新版本。构建程序包与公开发布 Release 是两个独立步骤, 详见[部署与发布](../../docs/deployment.md)。 ## 运行模式 @@ -80,21 +80,9 @@ App 在独立 Windows 控制台中启动时,启动或运行错误会保留在 **为什么网页能打开、屏幕也能采集,却无法分享或观看?** -基于 Chromium 的浏览器可能通过浏览器设置、扩展或管理策略限制 WebRTC UDP。 -禁止未经代理的 UDP,甚至会阻止本机浏览器与 App 之间的媒体连接; -采集成功或页面能打开,并不能证明这条独立连接可用。 -观看端也会受影响,可能看到 **没有可用的媒体线路** 或其他连接失败提示。 -仅隐藏本机 IP 地址不一定会禁用 WebRTC,需要看实际生效的传输策略。 - -检查浏览器的 WebRTC/IP 处理策略,以及扩展中的 WebRTC 或 IP 泄露保护设置。 -恢复允许 WebRTC UDP 的策略,重新加载 Piik,再确认没有其他扩展或管理策略覆盖该选择。 -各浏览器的设置名称与可用选项不同。例如,Vivaldi 提供 -**Settings > Privacy and Security > WebRTC IP Handling > Broadcast IP for Best WebRTC Performance**。 -修改这项策略可能向 WebRTC 对端暴露网络地址,请不要同时关闭无关保护。 - -参考 [Chromium 扩展策略 API](https://developer.chrome.com/docs/extensions/reference/api/privacy#property-network)、 -[Vivaldi 设置说明](https://help.vivaldi.com/desktop/privacy/privacy-settings/)和 -[已核验的策略机制与实际案例](../../docs/research/native-client-lifecycle.md)。 +浏览器设置、扩展或管理策略可能阻止 WebRTC UDP,包括本机网页与 App 之间的媒体连接。 +按文档中心的 [浏览器 WebRTC 排查](../../docs/guide/troubleshooting.zh-CN.md#浏览器限制了-webrtc)操作。 +特定编码失败时,另见 [Windows H264 排查](../../docs/guide/troubleshooting.zh-CN.md#windows-上-h264-分享失败)。 ### 诊断 @@ -168,7 +156,7 @@ node scripts/package-server-release.mjs /outside/repository/app-release node scripts/package-app-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/app-candidate ``` -支持的目标为 `windows-amd64`、`linux-amd64` 和 `darwin-arm64`,请替换命令中的目标名称。 +支持的目标为 `windows-amd64`、`linux-amd64`、`linux-arm64`、`darwin-arm64` 和 `darwin-amd64`,请替换命令中的目标名称。 Darwin 组装需要原生 macOS runner 与 SDK,并启用 cgo;Windows 与 Linux 组装禁用 cgo。 结果是 ZIP 程序包和 SHA-256 文件。手动组装辅助程序、显式 CI 打包、Release 发布与更新, 见[部署与发布](../../docs/deployment.md);创建候选包不会将它发布。 diff --git a/deploy/container/Dockerfile b/deploy/container/Dockerfile index 2f8cfb5..e24706b 100644 --- a/deploy/container/Dockerfile +++ b/deploy/container/Dockerfile @@ -1,4 +1,4 @@ -FROM gcr.io/distroless/static-debian13:nonroot-amd64@sha256:e754765ad9e167b0677b41c617fd44afb7b9818a477f48f17bda08e12cfb98cb +FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 ARG PIIK_VERSION=development ARG PIIK_REVISION ARG PIIK_SERVER_SHA256 diff --git a/deploy/container/compose.yaml b/deploy/container/compose.yaml index 757b900..3c946bc 100644 --- a/deploy/container/compose.yaml +++ b/deploy/container/compose.yaml @@ -3,7 +3,6 @@ name: piik services: piik: image: ${PIIK_IMAGE:-ghcr.io/tntcrafthim/piik:latest} - platform: linux/amd64 restart: unless-stopped stop_grace_period: 20s read_only: true diff --git a/deploy/release-server.sh b/deploy/release-server.sh index fe83d7d..f37d84d 100644 --- a/deploy/release-server.sh +++ b/deploy/release-server.sh @@ -41,8 +41,17 @@ main_asset="$(descriptor_text mainAsset)" [[ "$version" = development || "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] [[ "$release_id" =~ ^[0-9a-f]{7}$ ]] test "$release_id" = "${revision:0:7}" -test "$artifact_name" = "piik-${release_id}-runtime.tar.gz" -test "$manifest_name" = "piik-${release_id}.manifest.tsv" +validate_artifact_names() { + local stem + case "$(uname -m)" in + x86_64) stem="piik-${release_id}" ;; + aarch64|arm64) stem="piik-${release_id}-linux-arm64" ;; + *) printf 'unsupported Server architecture\n' >&2; return 2 ;; + esac + test "$artifact_name" = "${stem}-runtime.tar.gz" && + test "$manifest_name" = "${stem}.manifest.tsv" +} +validate_artifact_names [[ "$artifact_sha" =~ ^[0-9a-f]{64}$ ]] [[ "$manifest_sha" =~ ^[0-9a-f]{64}$ ]] [[ "$file_count" =~ ^[1-9][0-9]*$ ]] diff --git a/docs/README.md b/docs/README.md index 4666b81..1e43eed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,7 +99,7 @@ and [website preview and publishing](./operations/website.md). | Why a design was chosen | [Architecture decisions](./adr/) | | Measurements and platform limits | [Research](./research/) and [verification status](./verification-status.md) | | Camera and Host microphone | [Capture scope and limits](./research/camera-and-microphone.md) | -| Unreleased text, reactions and floating room tools | [Room-interaction candidate](./research/room-interactions.md) | +| Text, reactions and floating room tools | [Room-interaction design](./research/room-interactions.md) | | Stereo verification and HDR-to-SDR assessment | [Media fidelity](./research/media-fidelity.md) | ## Shared conventions diff --git a/docs/adr/0009-optional-nat-prediction.md b/docs/adr/0009-optional-nat-prediction.md index b6ee7dd..28d9ad3 100644 --- a/docs/adr/0009-optional-nat-prediction.md +++ b/docs/adr/0009-optional-nat-prediction.md @@ -31,8 +31,8 @@ connection acquisition reuses the route controller's existing opportunity ledger Browser or Native P2P edge. SFU PeerConnections remain unchanged. 3. For one ICE gathering generation, the adapter observes only UDP `srflx` candidates belonging to the exact three-destination survey set. Browser - candidates are matched by their reported STUN URL. Native Pion uses its - `UniversalUDPMux` to perform the survey on the media socket and emits standard + candidates are matched by their reported STUN URL. Native uses Pion's STUN + client through its ordinary UDP mux to survey the media socket and emits standard srflx-shaped observations marked with an `ns` foundation. Only those explicit survey observations feed prediction; port-mapped candidates remain ordinary ICE inputs. If three or more distinct survey candidates for @@ -42,8 +42,8 @@ connection acquisition reuses the route controller's existing opportunity ledger Every host and observed srflx candidate keeps normal Trickle ICE timing; no ordinary candidate is delayed, rejected, or replaced. A Browser that does not expose candidate source URLs keeps ordinary ICE without prediction. Rejection of a - predicted remote candidate discards only that optional candidate; ordinary - candidate errors retain their normal connection error semantics. + remote candidate discards only that candidate; ICE connection state remains + the authority for transport failure. 4. Candidate observations remain in memory for that connection only. They do not create persistent NAT labels, endpoint addresses, routing scores, hard candidate skips, or periodic probes. Sanitized diagnostics record only @@ -57,7 +57,9 @@ connection acquisition reuses the route controller's existing opportunity ledger peer-only rooms can exhaust that budget without SFU, while mixed rooms use the remaining attempts behind a working SFU route. A new connection can provide another mapping opportunity, not guaranteed independent randomness: - in particular, Native connections may retain the same shared UDP socket. + Native replacements allocate an independent socket, while an ICE restart on + the same connection retains its socket. Neither promises an independent + public NAT mapping. A committed Browser P2P edge uses its existing two-request automatic-recovery budget: first ICE restart on the same connection, then connection rebuild. Recovery does not choose a new parent or add a route candidate; unsuccessful diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md index 2956e78..1f113fc 100644 --- a/docs/adr/0010-cross-platform-client-runtime.md +++ b/docs/adr/0010-cross-platform-client-runtime.md @@ -96,9 +96,11 @@ topologies and makes a saved Site unavailable while another room source runs. existing Browser peer. One isolated platform capture feeds one encoded source and bounded independent Pion transports, with process-loopback audio for windows or system-loopback audio - for screens sharing the same PeerConnection when available. Each Site or one-link share makes one bounded, best-effort PCP, - UPnP, or NAT-PMP mapping for that same Pion UDP socket before its first edge - gathers ICE; pure LAN Local mode does not. Once ordinary STUN observes a + for screens sharing the same PeerConnection when available. Each physical Native + connection owns an independent UDP socket; discovery and media share that + socket, including after ICE restart. Site and one-link P2P connections request + a bounded, best-effort PCP, UPnP, or NAT-PMP mapping alongside gathering; + pure LAN Local connections do not. Once ordinary STUN observes a public address, Native advertises the mapped port as one lower-priority, srflx-shaped candidate on that address. Absence or rejection leaves ordinary ICE/STUN unchanged. Native P2P quality uses Pion's transport-wide diff --git a/docs/assets/quickstart.svg b/docs/assets/quickstart.svg index d94aaac..f890761 100644 --- a/docs/assets/quickstart.svg +++ b/docs/assets/quickstart.svg @@ -2,7 +2,7 @@ Pick a screen, send an invitation, watch together Three illustrated steps: choose a window, send its room invitation, and join friends on the sofa. Each loop pauses to let the scene settle. {[0, 160].map((x, index) => - {mode === "volume" ? <> + {mode === "processing" ? <> + + + : mode === "volume" ? <> {index === 1 && } @@ -352,6 +359,7 @@ export const SET1_SCENES: Record = { "hint-microphone-on": (props) => , "hint-microphone-off": (props) => , "hint-microphone-volume": (props) => , + "hint-microphone-processing": (props) => , "hint-share-start": SceneShareStart, "hint-share-stop": SceneShareStop, "hint-pause": ScenePause, diff --git a/src/client/components/living/room-chat-overlay.css b/src/client/components/living/room-chat-overlay.css index b1a7e25..cc81828 100644 --- a/src/client/components/living/room-chat-overlay.css +++ b/src/client/components/living/room-chat-overlay.css @@ -2,19 +2,20 @@ .lr-chat-overlay { position: absolute; inset: 0; overflow: hidden; pointer-events: none; container-type: size; } .lr-tv-screen:has(> .lr-playback) > .lr-chat-overlay { grid-area: 1 / 1 / 2 / 2; } .lr-chat-flight { - position: absolute; left: 100%; top: calc(12px + var(--chat-lane) * 30px); + position: absolute; left: 100%; top: calc(12px + var(--chat-lane) * 34px); max-width: 80%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 10px; border-radius: 999px; background: rgb(15 25 35 / .68); - color: #fff; font-size: clamp(14px, 2cqw, 18px); font-weight: 600; line-height: 24px; + color: #fff; font-size: calc(clamp(14px, 2cqw, 18px) * var(--chat-scale, 1)); font-weight: 600; line-height: 28px; text-shadow: 0 1px 2px rgb(0 0 0 / .4); animation: lr-chat-flight linear both; } @keyframes lr-chat-flight { to { transform: translateX(calc(-100cqw - 100%)); } } -/* Fit complete 28px lines above playback controls. Visibility preserves the +/* Fixed rows fit the largest supported font without changing shared lanes. + Fit complete 32px lines above playback controls. Visibility preserves the running flight when the available picture space changes. */ -@container (height < 100px) { .lr-chat-flight[data-lane="2"] { visibility: hidden; } } -@container (height < 70px) { .lr-chat-flight[data-lane="1"] { visibility: hidden; } } -@container (height < 40px) { .lr-chat-flight { visibility: hidden; } } +@container (height < 112px) { .lr-chat-flight[data-lane="2"] { visibility: hidden; } } +@container (height < 78px) { .lr-chat-flight[data-lane="1"] { visibility: hidden; } } +@container (height < 44px) { .lr-chat-flight { visibility: hidden; } } .lr-chat-overlay-toggle { display: inline-flex; } @container (max-width: 440px) { .lr-playback:has(.lr-chat-overlay-toggle) .lr-btn { width: clamp(28px, 9cqw, 40px); } diff --git a/src/client/components/living/room-interactions.css b/src/client/components/living/room-interactions.css index 4251464..3688823 100644 --- a/src/client/components/living/room-interactions.css +++ b/src/client/components/living/room-interactions.css @@ -1,8 +1,8 @@ .lr-room-interactions { position: relative; width: min(960px, 100%); margin: 0 auto; } .lr-interaction-couch { position: relative; } -.lr-interaction-toggle { display: flex; justify-content: center; align-items: center; gap: 2px; width: fit-content; margin: 12px auto 4px; padding: 3px; background: var(--paper); border: 1px solid var(--control-edge); border-radius: 18px; } +.lr-couch-footer.has-action { gap: 4px; width: fit-content; margin: 0 auto; padding: 3px; background: var(--paper); border: 1px solid var(--control-edge); border-radius: 18px; color: var(--ink); } +.lr-couch-footer.has-action .lr-couch-count { padding: 0 12px 0 9px; border: 0; border-right: 1px solid var(--control-edge); border-radius: 0; background: transparent; color: var(--muted); } .lr-interaction-chat-toggle { display: flex; position: relative; } -.lr-interaction-toggle .lr-btn { min-height: 44px; } .lr-interaction-unread { align-self: flex-start; margin: -4px 0 0 -8px; z-index: 1; min-width: 22px; padding: 2px 6px; border-radius: 20px; color: var(--paper); background: var(--action); font: 700 11px var(--mono); text-align: center; } .lr-chat-compose, .lr-chat-footer { flex-shrink: 0; } .lr-interaction-effects { display: flex; align-items: center; gap: 8px; min-height: 40px; width: fit-content; margin-top: 8px; font-size: 12px; font-weight: 400; color: var(--muted); } @@ -31,10 +31,21 @@ .lr-chat-compose:has(input:focus-visible) { outline: 3px solid var(--action); outline-offset: 2px; } .lr-chat-compose > input { min-width: 0; flex: 1; width: 0; height: 44px; border: 0; outline: none; border-radius: 14px; padding: 0 10px; background: transparent; color: var(--ink); font-size: 16px; } .lr-chat-compose .lr-btn { flex: none; width: 44px; height: 44px; min-width: 44px; min-height: 44px; padding: 0; border-radius: 15px; } -.lr-chat-footer { display: flex; align-items: center; gap: 10px; min-height: 40px; margin-top: 8px; padding-right: 12px; color: var(--muted); font-size: 11px; line-height: 1.5; } +.lr-chat-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-height: 40px; margin-top: 8px; padding-right: 12px; color: var(--muted); font-size: 11px; line-height: 1.5; } +.lr-chat-actions { display: flex; flex: none; align-items: center; gap: 6px; } .lr-chat-footer .lr-chat-overlay-toggle { flex: none; } .lr-chat-footer .lr-btn { min-height: 36px; padding: 0 8px; } -.lr-chat-footer .lr-interaction-status { flex: 1; min-width: 0; min-height: 0; margin: 0; } +.lr-chat-history[hidden], .lr-chat-compose[hidden] { display: none; } +.lr-chat-settings { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 18px 2px; } +.lr-chat-settings > p { margin: 0 0 20px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.lr-chat-setting { margin-bottom: 22px; } +.lr-chat-setting > label { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 28px; font-size: 13px; } +.lr-chat-setting output { color: var(--muted); font: 12px var(--mono); } +.lr-chat-setting > .lr-comic-tip-wrap { display: flex; width: 100%; } +.lr-chat-setting input[type="range"] { width: 100%; min-height: 40px; margin: 0; accent-color: var(--action); cursor: pointer; } +.lr-chat-export { display: grid; justify-items: start; gap: 8px; padding-top: 16px; border-top: 1px solid var(--wall-2); } +.lr-chat-export small { color: var(--muted); font-size: 12px; line-height: 1.6; } +.lr-chat-footer .lr-interaction-status { flex: 1 1 140px; min-width: 0; min-height: 0; margin: 0; overflow-wrap: anywhere; } .lr-chat-count { flex: none; margin-left: auto; font-variant-numeric: tabular-nums; } .lr-interaction-status { display: flex; align-items: center; gap: 6px; min-height: 24px; margin-top: 6px; font-size: 11px; color: var(--muted); } .lr-person-menu { transform-origin: center bottom; } @@ -61,8 +72,8 @@ .lr-person-menu .lr-interaction-effects { font-size: 11px; } /* Theater keeps the same top-layer chat; the picture never changes size. */ .lr-room.is-theater .lr-room-interactions { position: absolute; top: 12px; right: 12px; z-index: 5; width: auto; } -.lr-room.is-theater .lr-interaction-toggle { margin: 0 0 0 auto; } -.lr-room.is-theater .lr-interaction-couch { display: none; } +.lr-room.is-theater .lr-room-interactions .lr-couch { display: block; width: auto; margin: 0; } +.lr-room.is-theater .lr-room-interactions :is(.lr-couch-seating, .lr-couch-count, .lr-reaction-flight) { display: none; } .lr-pawn[aria-expanded="true"] { background: color-mix(in srgb, var(--paper) 72%, transparent); border-radius: 18px; } .lr-reaction-flight { position: absolute; inset: 0; pointer-events: none; z-index: 5; overflow: visible; } /* The path positions the center; percentage translation centers any glyph size. */ diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index c791777..c376773 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -58,9 +58,11 @@ function parseSiteAccessStatus(value: unknown): SiteAccessStatus { }; } -export async function getSiteAccess(): Promise { +export async function getSiteAccess(signal?: AbortSignal): Promise { const response = await fetch("/api/site-access", { headers: { Accept: "application/json" }, + cache: "no-store", + signal, }); const body = await responseBody(response); if (!response.ok) { diff --git a/src/client/lib/debug.ts b/src/client/lib/debug.ts index 18e27b3..a5c20bb 100644 --- a/src/client/lib/debug.ts +++ b/src/client/lib/debug.ts @@ -1,4 +1,5 @@ import { SIGNALING_PROTOCOL } from "../../shared/protocol"; +import { downloadBlob } from "./download"; type DebugValue = null | boolean | number | string | DebugValue[] | { [key: string]: DebugValue }; type DebugDetails = object; @@ -187,14 +188,8 @@ export async function exportBrowserDebug(): Promise { } export async function downloadBrowserDebug(): Promise { - const blob = new Blob([await exportBrowserDebug()], { type: "application/json" }); - const url = URL.createObjectURL(blob); - try { - const link = document.createElement("a"); - link.href = url; - link.download = `piik-browser-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; - link.click(); - } finally { setTimeout(() => URL.revokeObjectURL(url), 0); } + downloadBlob(new Blob([await exportBrowserDebug()], { type: "application/json" }), + `piik-browser-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); } export function installBrowserDebug(): (() => void) | undefined { diff --git a/src/client/lib/download.ts b/src/client/lib/download.ts new file mode 100644 index 0000000..72b2291 --- /dev/null +++ b/src/client/lib/download.ts @@ -0,0 +1,10 @@ +/** A user-requested download; release the temporary URL after browser dispatch. */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + try { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + } finally { setTimeout(() => URL.revokeObjectURL(url), 0); } +} diff --git a/src/client/lib/release-update.ts b/src/client/lib/release-update.ts index 3fd20b3..0fe1183 100644 --- a/src/client/lib/release-update.ts +++ b/src/client/lib/release-update.ts @@ -14,6 +14,7 @@ interface PublishedRelease { version: string; revision: string | null; url: string; + publishedAt?: string; } export interface ReleaseUpdateNotice extends PublishedRelease { kind: "update-available" | "different-build" | "official-release"; @@ -53,11 +54,14 @@ export function parseReleaseMetadata(value: unknown, packageTarget?: string): Pu const version = normalizeVersion(release.tag_name); if (!version || version !== release.tag_name || !/^v\d+\.\d+\.\d+$/.test(version) || release.html_url !== RELEASE_PAGE + version) return null; + const publishedTime = typeof release.published_at === "string" && /^\d{4}-\d{2}-\d{2}T/.test(release.published_at) + ? Date.parse(release.published_at) : NaN; const normalized = { version, // GitHub permits a branch here; only our publisher's full SHA is provenance. revision: normalizeReleaseRevision(release.target_commitish), url: RELEASE_PAGE + version, + ...(Number.isFinite(publishedTime) ? { publishedAt: new Date(publishedTime).toISOString() } : {}), }; return { ...normalized, url: packageDownloadURL(normalized, release.assets, packageTarget) }; } diff --git a/src/client/lib/room-chat-export.ts b/src/client/lib/room-chat-export.ts new file mode 100644 index 0000000..b13bbfa --- /dev/null +++ b/src/client/lib/room-chat-export.ts @@ -0,0 +1,10 @@ +import type { RoomInteraction } from "./room-interactions"; + +/** Export received text only: no invite, peer IDs, reactions or pending sends. */ +export function formatChatTranscript(messages: readonly RoomInteraction[]): string { + const lines = messages.flatMap(message => message.payload.kind === "chat" + ? [`[${new Date(message.occurredAt).toISOString()}] ${message.sender.displayName}`, message.payload.text, ""] + : []); + // Explicit UTF-8 signature and CRLF also work in older Windows text viewers. + return "\uFEFFPiik chat\r\n\r\n" + lines.join("\r\n"); +} diff --git a/src/client/lib/room-interactions.ts b/src/client/lib/room-interactions.ts index fe29185..073c41f 100644 --- a/src/client/lib/room-interactions.ts +++ b/src/client/lib/room-interactions.ts @@ -7,6 +7,9 @@ export type RoomInteraction = Extract["reason"]; export type InteractionError = Rejection | "offline" | "unconfirmed"; export const CHAT_OVERLAY_DURATION_MS = 6000; +export const CHAT_HISTORY_LIMIT = 1000; +export const CHAT_OVERLAY_LIMITS = { scale: [.8, 1.3], opacity: [.4, 1] } as const; +type OverlayAppearance = { scale: number; opacity: number }; const CHAT_OVERLAY_LANES = 3; interface InteractionState { ready: boolean; @@ -14,6 +17,7 @@ interface InteractionState { messages: (RoomInteraction & { isSelf: boolean })[]; reactions: (RoomInteraction & { expiresAt: number })[]; overlayEnabled: boolean; + overlayAppearance: OverlayAppearance; overlayMessages: (RoomInteraction & { expiresAt: number; lane: number })[]; pending: { requestId: string; payload: InteractionPayload } | null; confirmed: RoomInteraction | null; @@ -21,7 +25,7 @@ interface InteractionState { error: InteractionError | null; } const initialState = (): InteractionState => ({ ready: false, peerId: null, messages: [], reactions: [], - overlayEnabled: false, overlayMessages: [], + overlayEnabled: false, overlayAppearance: { scale: 1, opacity: 1 }, overlayMessages: [], pending: null, confirmed: null, coolingDown: false, error: null }); // One owner per signaling client. Authentication admits room data; media @@ -96,7 +100,7 @@ export class RoomInteractionSession { // event to another lane, and bursts cannot accumulate a playback queue. overlayMessages = [...overlayMessages.filter(item => item.lane !== lane), { ...message, lane, expiresAt }]; } - this.update({ messages: [...this.state.messages.slice(-79), { ...message, isSelf: own }], + this.update({ messages: [...this.state.messages.slice(1 - CHAT_HISTORY_LIMIT), { ...message, isSelf: own }], overlayMessages, pending, confirmed, error: confirmsPending ? null : this.state.error }); } else { if (this.state.reactions.some(item => item.id === message.id)) return true; @@ -141,6 +145,14 @@ export class RoomInteractionSession { this.expireEffects(); } + setOverlayAppearance(appearance: OverlayAppearance) { + if (this.closed || !Number.isFinite(appearance.scale) || !Number.isFinite(appearance.opacity)) return; + this.update({ overlayAppearance: { + scale: Math.min(CHAT_OVERLAY_LIMITS.scale[1], Math.max(CHAT_OVERLAY_LIMITS.scale[0], appearance.scale)), + opacity: Math.min(CHAT_OVERLAY_LIMITS.opacity[1], Math.max(CHAT_OVERLAY_LIMITS.opacity[0], appearance.opacity)), + } }); + } + private expireEffects() { clearTimeout(this.effectsTimer); const next = Math.min(...[...this.state.reactions, ...this.state.overlayMessages].map(item => item.expiresAt)); diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index d34acdb..d6a4d7a 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -12,6 +12,7 @@ import type { SignalConnectionState } from "../types"; import { qualitySettingsEqual } from "../media/quality"; import { debugEvent } from "./debug"; import { RoomInteractionSession } from "./room-interactions"; +import { getSiteAccess } from "./api"; type WithoutProtocolEnvelope = T extends { type: string; @@ -36,7 +37,6 @@ export type SignalingTerminationReason = | "SIGNAL_TERMINATED"; const FATAL_SIGNAL_ERRORS = new Set([ - "AUTH_REQUIRED", "INVALID_TOKEN", "ROOM_NOT_FOUND", "ROOM_ACCESS_DENIED", @@ -48,6 +48,7 @@ const TERMINAL_SEND_TIMEOUT_MS = 15_000; const SIGNALING_CHALLENGE_INTERVAL_MS = 5_000; const SIGNALING_CHALLENGE_TIMEOUT_MS = 2_000; const SIGNALING_TIMER_LAG_TOLERANCE_MS = 1_000; +const AUTHENTICATION_TIMEOUT_MS = 8_000; interface PendingSignalingChallenge { generation: number; @@ -83,6 +84,7 @@ export class SignalingClient { private reconnectAttempt = 0; private reconnectTimer: number | null = null; private authenticationTimer: number | null = null; + private accessCheck: AbortController | null = null; private terminalTimer: number | null = null; private terminalMessage: ClientMessage | null = null; private socketGeneration = 0; @@ -282,6 +284,13 @@ export class SignalingClient { if (this.stopped) { return; } + // An explicit start may supersede a pending reconnect for the same room. + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.accessCheck?.abort(); + this.accessCheck = null; const generation = ++this.socketGeneration; debugEvent("signal", "connecting", { role: this.identity.role, generation }); @@ -306,7 +315,7 @@ export class SignalingClient { if (!this.authenticatedPeerId && this.socket === socket) { socket.close(4000, "authentication timeout"); } - }, 8_000); + }, AUTHENTICATION_TIMEOUT_MS); }); socket.addEventListener("message", (event) => { @@ -347,12 +356,14 @@ export class SignalingClient { return; } + if (message.type === "error" && message.code === "AUTH_REQUIRED") { + void this.recoverAuthentication(generation); + return; + } + if (message.type === "error" && FATAL_SIGNAL_ERRORS.has(message.code)) { this.stop(); this.events.onMessage(message); - if (message.code === "AUTH_REQUIRED" && this.identity.role === "host") { - this.events.onAccessRequired(); - } return; } @@ -444,6 +455,34 @@ export class SignalingClient { }, delay); } + private async recoverAuthentication(generation: number): Promise { + this.retireSocketForRecovery(); + // AUTH_REQUIRED also means the room handshake timed out or was incomplete. + // Only the site-access owner can require a Host to enter a site password. + if (this.identity.role === "host") { + this.events.onStatus("reconnecting"); + if (this.stopped || generation !== this.socketGeneration) return; + const controller = new AbortController(); + this.accessCheck = controller; + const timer = window.setTimeout(() => controller.abort(), AUTHENTICATION_TIMEOUT_MS); + try { + const access = await getSiteAccess(controller.signal); + if (this.stopped || generation !== this.socketGeneration) return; + if (access.required && !access.authenticated) { + this.stop(); + this.events.onAccessRequired(); + return; + } + } catch { + // An unavailable check is a connection failure, not evidence of denial. + } finally { + window.clearTimeout(timer); + if (this.accessCheck === controller) this.accessCheck = null; + } + } + if (!this.stopped && generation === this.socketGeneration) this.scheduleReconnect(); + } + private terminateForProtocolMismatch(): void { this.stop(); this.events.onTerminated("STALE_CLIENT"); @@ -582,6 +621,8 @@ export class SignalingClient { } private clearTimers(): void { + this.accessCheck?.abort(); + this.accessCheck = null; this.clearAuthenticationTimer(); this.clearSignalingWatchdog(); if (this.reconnectTimer !== null) { @@ -675,20 +716,24 @@ export class SignalingClient { } private replaceUnresponsiveSocket(generation: number): void { - const socket = this.socket; - if (!socket || generation !== this.socketGeneration || this.stopped) { + if (!this.socket || generation !== this.socketGeneration || this.stopped) { return; } + this.retireSocketForRecovery(); + this.events.onStatus("reconnecting"); + this.connect(); + } + + private retireSocketForRecovery(): void { + const socket = this.socket; this.socket = null; this.authenticatedPeerId = null; this.interactions?.disconnected(); this.clearAuthenticationTimer(); this.clearSignalingWatchdog(); - this.events.onStatus("reconnecting"); - if (socket.readyState < WebSocket.CLOSING) { - socket.close(SIGNAL_CLOSE_CODES.clientReconnect, "signaling timeout"); + if (socket && socket.readyState < WebSocket.CLOSING) { + socket.close(SIGNAL_CLOSE_CODES.clientReconnect, "signaling recovery"); } - this.connect(); } private rebaselineSignalingWatchdog(): void { diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index e400d2e..10db146 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -29,6 +29,7 @@ export const en: Record = { "brand.home": "Piik home", "common.roomCode": "Room code", + "host.roomCodePending": "A room code appears when sharing starts", "common.copy": "Copy", "common.copied": "Copied", "common.copyFailed": "Copy failed", @@ -52,6 +53,10 @@ export const en: Record = { "common.name.hostDefault": "Host", "client.launch.title": "Open Piik", + "client.launch.version": "Installed version {version}", + "client.launch.credit": "Free and open source · TNTcraft & contributors", + "client.launch.website": "Official website", + "client.launch.source": "Source on GitHub", "client.launch.local": "Local room", "client.launch.localHint": "Use on the same local network", "client.launch.link": "Public invite", @@ -102,8 +107,8 @@ export const en: Record = { "host.title": "{name}'s screen", "host.start": "Start sharing", "host.sourcePicker.title": "Choose what to share", - "host.sourcePicker.browser": "Browser picker", - "host.sourcePicker.browserHint": "Choose a source in your browser's capture picker", + "host.sourcePicker.browser": "Choose what to share", + "host.sourcePicker.browserHint": "Choose a screen, window or tab in your browser's sharing dialog.", "host.sourcePicker.camera": "Share camera: {title}", "host.camera.denied": "Camera access was not allowed. Allow it in your browser and try again.", "host.camera.unavailable": "The camera is unavailable. Check that it is connected and not in use by another app.", @@ -118,8 +123,19 @@ export const en: Record = { "host.device.listFailed": "Could not load devices. Try refreshing.", "host.device.permission.microphone": "Turn on the microphone and allow access to see the available devices here.", "host.microphone.volume": "Microphone volume", + "host.microphone.voiceProcessing": "Noise & echo reduction", + "host.microphone.voiceProcessingHint": "When on, the browser reduces microphone noise and echo and adjusts volume automatically. When off, these features are disabled.", + "host.microphone.voiceProcessingUsage": "On for speech; off for music or virtual audio.", "interaction.open": "Chat", "interaction.title": "Chat", + "interaction.settings": "Chat settings", + "interaction.settingsHint": "On-screen chat settings apply only to your picture.", + "interaction.back": "Back to chat", + "interaction.overlay.scale": "Text size", + "interaction.overlay.opacity": "Opacity", + "interaction.export": "Export chat", + "interaction.exportHint": "Save messages received on this page as a text file.", + "interaction.exportLimit": "Exports up to the latest {count} messages kept on this page.", "interaction.close": "Hide chat window", "floating.move": "Move window", "floating.moveHint": "Drag the title bar, use arrow keys to adjust, or choose a corner under Position.", @@ -227,7 +243,7 @@ export const en: Record = { "host.pauseNotice": "Audio and video sharing paused", "host.stopNotice": "Sharing stopped", "host.roomReplaced": "Room code replaced", - "host.roomInvalid": "Room no longer available; press again to create a new one", + "host.roomInvalid": "This room has expired. Start sharing to create a new one.", "host.roomReplace": "Replace room code", "host.roomReplaceConfirm": "Replace the room code: this ends the current share and disconnects viewers", "host.terminated.stale": "Page updated; refresh to continue", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index 3346dfe..8a08c6f 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -29,6 +29,7 @@ export const zh = { "brand.home": "Piik 首页", "common.roomCode": "房间号", + "host.roomCodePending": "开始分享后生成房间号", "common.copy": "复制", "common.copied": "已复制", "common.copyFailed": "复制失败", @@ -52,6 +53,10 @@ export const zh = { "common.name.hostDefault": "房主", "client.launch.title": "打开 Piik", + "client.launch.version": "当前版本 {version}", + "client.launch.credit": "免费开源 · TNTcraft 与贡献者", + "client.launch.website": "官方网站", + "client.launch.source": "GitHub 源码", "client.launch.local": "本地房间", "client.launch.localHint": "同一局域网内使用", "client.launch.link": "公网邀请", @@ -102,8 +107,8 @@ export const zh = { "host.title": "{name} 的屏幕", "host.start": "开始分享", "host.sourcePicker.title": "选择分享来源", - "host.sourcePicker.browser": "浏览器选择器", - "host.sourcePicker.browserHint": "在浏览器弹出的选择器中选择画面", + "host.sourcePicker.browser": "选择画面", + "host.sourcePicker.browserHint": "在浏览器弹窗中选择屏幕、窗口或标签页。", "host.sourcePicker.camera": "分享摄像头:{title}", "host.camera.denied": "未获得摄像头权限,请在浏览器中允许访问后重试。", "host.camera.unavailable": "暂时无法使用摄像头,请检查连接,以及是否正被其他应用占用。", @@ -118,8 +123,19 @@ export const zh = { "host.device.listFailed": "暂时无法获取设备,请刷新重试。", "host.device.permission.microphone": "开启麦克风并允许访问后,可以在这里选择设备。", "host.microphone.volume": "麦克风音量", + "host.microphone.voiceProcessing": "降噪与回声消除", + "host.microphone.voiceProcessingHint": "开启后,浏览器会降低麦克风杂音、消除回声并自动调节音量;关闭后停用这些处理。", + "host.microphone.voiceProcessingUsage": "说话时开启;音乐或虚拟声卡输入时关闭。", "interaction.open": "聊天", "interaction.title": "聊天", + "interaction.settings": "聊天设置", + "interaction.settingsHint": "弹幕设置仅影响自己的画面。", + "interaction.back": "返回聊天", + "interaction.overlay.scale": "弹幕字号", + "interaction.overlay.opacity": "不透明度", + "interaction.export": "导出聊天记录", + "interaction.exportHint": "将本页收到的聊天记录保存为文本文件。", + "interaction.exportLimit": "导出本页保留的最近 {count} 条消息。", "interaction.close": "收起聊天窗口", "floating.move": "移动窗口", "floating.moveHint": "拖动标题栏移动窗口;方向键可微调,也可以从「位置」选择角落。", @@ -227,7 +243,7 @@ export const zh = { "host.pauseNotice": "音视频分享已暂停", "host.stopNotice": "已停止分享", "host.roomReplaced": "房间号已更换", - "host.roomInvalid": "房间已失效,再次点击将创建新房", + "host.roomInvalid": "房间已失效,开始分享时将创建新房", "host.roomReplace": "更换房间号", "host.roomReplaceConfirm": "确认更换房间号:当前分享会结束,观众需要重新加入", "host.terminated.stale": "页面版本已更新,请刷新后重试", diff --git a/src/client/media/audio-capture.ts b/src/client/media/audio-capture.ts index 1768360..2be31af 100644 --- a/src/client/media/audio-capture.ts +++ b/src/client/media/audio-capture.ts @@ -3,24 +3,27 @@ type DisplayMediaAudioHints = DisplayMediaStreamOptions & { windowAudio?: "exclude" | "window" | "system"; }; -type DisplayMediaAudioConstraints = MediaTrackConstraints & { +type BrowserAudioConstraints = MediaTrackConstraints & { voiceIsolation?: ConstrainBoolean; }; +export function audioInputConstraints(voiceProcessing: boolean): BrowserAudioConstraints { + return { + echoCancellation: voiceProcessing, + noiseSuppression: voiceProcessing, + autoGainControl: voiceProcessing, + // Keep music/virtual inputs on the same unprocessed, stereo request as screen audio. + ...(!voiceProcessing ? { voiceIsolation: false, channelCount: { ideal: 2 } } : {}), + }; +} + export function displayMediaOptions( video: MediaTrackConstraints, ): DisplayMediaStreamOptions { // Chromium web display capture otherwise defaults to speech processing. - const audio: DisplayMediaAudioConstraints = { - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false, - voiceIsolation: false, - channelCount: { ideal: 2 }, - }; return { video, - audio, + audio: audioInputConstraints(false), systemAudio: "include", windowAudio: "window", } as DisplayMediaAudioHints; diff --git a/src/client/media/host-audio.ts b/src/client/media/host-audio.ts index 9a6102c..d01aa31 100644 --- a/src/client/media/host-audio.ts +++ b/src/client/media/host-audio.ts @@ -1,4 +1,5 @@ import type { BrowserCaptureSource } from "./quality"; +import { audioInputConstraints } from "./audio-capture"; // The Host session owns capture and lifetime. This helper owns only the raw // audio inputs and mixer; transports borrow its single output track. @@ -11,6 +12,7 @@ export class HostAudio { private microphoneGain: GainNode | null = null; private microphoneVolume = 1; private microphoneDevice = ""; + private voiceProcessing = true; private closed = false; constructor(source: MediaStream, private changed: (enabled: boolean) => void, @@ -43,15 +45,17 @@ export class HostAudio { return this.setMicrophone(!this.microphone?.enabled, deviceId); } - async setMicrophone(enabled: boolean, deviceId: string): Promise { + async setMicrophone(enabled: boolean, deviceId: string, voiceProcessing = this.voiceProcessing): Promise { if (this.closed) return null; - if (!enabled || (this.microphone && deviceId === this.microphoneDevice)) { - if (this.microphone && deviceId !== this.microphoneDevice) { + const sameInput = deviceId === this.microphoneDevice && voiceProcessing === this.voiceProcessing; + if (!enabled || (this.microphone && sameInput)) { + if (this.microphone && !sameInput) { this.microphone.stop(); this.microphone = null; this.compose(); } this.microphoneDevice = deviceId; + this.voiceProcessing = voiceProcessing; if (this.microphone) this.microphone.enabled = enabled; this.changed(enabled); return null; @@ -68,7 +72,7 @@ export class HostAudio { let stream: MediaStream | null = null; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { - echoCancellation: true, noiseSuppression: true, autoGainControl: true, + ...audioInputConstraints(voiceProcessing), ...(deviceId ? { deviceId: { exact: deviceId } } : {}), } }); if (this.closed) return null; @@ -79,6 +83,7 @@ export class HostAudio { const previous = this.microphone; this.microphone = microphone; this.microphoneDevice = deviceId; + this.voiceProcessing = voiceProcessing; microphone.onended = () => { if (this.closed || this.microphone !== microphone) return; this.microphone = null; diff --git a/src/client/native/native-viewer-peer.ts b/src/client/native/native-viewer-peer.ts index 83a5c53..fff61be 100644 --- a/src/client/native/native-viewer-peer.ts +++ b/src/client/native/native-viewer-peer.ts @@ -110,6 +110,7 @@ export class NativeCapableViewerPeer implements ViewerMediaPeer { const pending = this.pendingCandidates.get(payload.connectionId) ?? []; this.pendingCandidates.delete(payload.connectionId); for (const candidate of pending) { + if (this.disposed) return; await this.backend.acceptSignal(parentPeerId, candidate); } return; diff --git a/src/client/pages/AppLauncherPage.tsx b/src/client/pages/AppLauncherPage.tsx index d227b00..f3e3f12 100644 --- a/src/client/pages/AppLauncherPage.tsx +++ b/src/client/pages/AppLauncherPage.tsx @@ -46,6 +46,7 @@ export function AppLauncherPage() { const [debug, setDebug] = useState(false); const [lan, setLan] = useState["lan"]>(); const [update, setUpdate] = useState(null); + const [version, setVersion] = useState(); useEffect(() => { let current = true; @@ -57,6 +58,7 @@ export function AppLauncherPage() { .then((state) => { if (!current) return; setMode(state.defaultMode); + setVersion(state.version); setSite(state.site); setLocalAccessPassword(state.localAccessPassword); setAppDebug(state.debug); @@ -177,6 +179,7 @@ export function AppLauncherPage() { ) : ( ("link"); @@ -110,7 +111,8 @@ export function ControlsPreview() { } audio={} + enabled={microphone} disabled={paused} volume={microphoneVolume} onVolume={setMicrophoneVolume} + voiceProcessing={voiceProcessing} onVoiceProcessing={setVoiceProcessing} />} />
@@ -166,7 +168,7 @@ export function ControlsPreview() { {invalid ? : null} -
setRoomCode(code => code === "6020" ? "2048" : "6020")} />
+
setRoomCode(code => code === "6020" ? "2048" : "6020")} />
@@ -224,7 +226,7 @@ export function ControlsPreview() {
08

{t("client.launch.title")}

- (null); const [microphoneVolume, setMicrophoneVolume] = useState(1); + const [microphoneVoiceProcessing, setMicrophoneVoiceProcessing] = useState(true); const [microphonePending, setMicrophonePending] = useState(false); const [microphoneDevices, setMicrophoneDevices] = useState({ browser: "", native: "" }); const [cameraDevice, setCameraDevice] = useState(""); @@ -506,6 +508,20 @@ export function HostPage({ function setNoticeKey(key: CopyKey, comic: ComicKind | HintKind, tone: ComicTone, vars?: Record): void { setNoticeValue({ kind: "key", key, vars, target: "operation", comic, tone }); } + function setStatusNotice( + message: string | { key: CopyKey; vars?: Record }, + comic: ComicKind, + tone: ComicTone, + ): void { + setNoticeValue({ + ...(typeof message === "string" + ? { kind: "text", text: message } + : { kind: "key", key: message.key, vars: message.vars }), + target: "television", + comic, + tone, + }); + } function setNoticeError( error: unknown, action: HostAction, @@ -988,14 +1004,7 @@ export function HostPage({ writePreferredRoom(currentRoom.roomId); } disposeResources(notifyServer, keepRoomSession); - setNoticeValue({ - ...(typeof message === "string" - ? { kind: "text", text: message } - : { kind: "key", key: message.key, vars: message.vars }), - target: "television", - comic, - tone, - }); + setStatusNotice(message, comic, tone); setPhase("ended"); } @@ -2468,7 +2477,7 @@ export function HostPage({ endSharing({ key: hostTerminationKey(reason) }, false, "signal-failed", "bad", false); } else { disposeResources(false); - setNoticeKey(hostTerminationKey(reason), "signal-failed", "bad"); + setStatusNotice({ key: hostTerminationKey(reason) }, "signal-failed", "bad"); setPhase("error"); } if (reason === "SESSION_REPLACED") forgetRoom(activeRoom, true); @@ -2479,7 +2488,7 @@ export function HostPage({ endSharing({ key: "gate.expired" }, false, "access-denied", "bad", false); } else { disposeResources(false); - setNoticeKey("gate.expired", "access-denied", "bad"); + setStatusNotice({ key: "gate.expired" }, "access-denied", "bad"); setPhase("error"); } onAuthorizationRequired?.(); @@ -2505,7 +2514,7 @@ export function HostPage({ // Capture may still be awaiting permission. Its ready path owns // creation/publication; room recovery must not start it early. if (!localShareActive) { - setNoticeKey("host.roomInvalid", "room-not-found", "bad"); + setStatusNotice({ key: "host.roomInvalid" }, "room-not-found", "bad"); setPhase("error"); } return; @@ -2523,7 +2532,7 @@ export function HostPage({ } else { disposeResources(false); forgetRoom(activeRoom); - setNoticeKey("host.roomInvalid", "room-not-found", "bad"); + setStatusNotice({ key: "host.roomInvalid" }, "room-not-found", "bad"); setPhase("error"); } return; @@ -2531,10 +2540,10 @@ export function HostPage({ if (message.type === "room-closed") { if (!forgetRoom(activeRoom)) return; if (localShareActive) { - endSharing(say("host.roomClosed"), false, "room-closed", "off", false); + endSharing({ key: "host.roomClosed" }, false, "room-closed", "off", false); } else { disposeResources(false); - setNoticeKey("host.roomClosed", "room-closed", "off"); + setStatusNotice({ key: "host.roomClosed" }, "room-closed", "off"); setPhase("ended"); } return; @@ -2544,7 +2553,7 @@ export function HostPage({ endSharing(hostServerErrorNotice(message.code), false, "signal-failed", "bad", false); } else { disposeResources(false); - setNotice(hostServerErrorNotice(message.code), "signal-failed", "bad"); + setStatusNotice(hostServerErrorNotice(message.code), "signal-failed", "bad"); setPhase("error"); } return; @@ -2598,7 +2607,7 @@ export function HostPage({ return; } if (message.type === "error") { - if (message.code !== "AUTH_REQUIRED") setNotice(hostServerErrorNotice(message.code), "signal-failed", "bad"); + setNotice(hostServerErrorNotice(message.code), "signal-failed", "bad"); return; } const mediaActive = @@ -2970,7 +2979,7 @@ export function HostPage({ ? "host.camera.denied" : "host.camera.unavailable", target, comic: "source-failed", tone: "warn" }); } - async function changeMicrophone(enabled: boolean, deviceId: string): Promise { + async function changeMicrophone(enabled: boolean, deviceId: string, voiceProcessing = microphoneVoiceProcessing): Promise { const audio = hostAudioRef.current; const generation = activeGenerationRef.current; const client = nativeModeRef.current ? nativeClientRef.current : null; @@ -2990,9 +2999,10 @@ export function HostPage({ } if (!audio) return; audio.setMicrophoneVolume(microphoneVolume); - const mixed = await audio.setMicrophone(enabled, deviceId); + const mixed = await audio.setMicrophone(enabled, deviceId, voiceProcessing); if (!isCurrentGeneration(generation) || hostAudioRef.current !== audio) return; setMicrophoneDevices(previous => ({ ...previous, browser: deviceId })); + setMicrophoneVoiceProcessing(voiceProcessing); if (mixed) await replaceBrowserStream(mixed, generation, token); } catch (error) { if (isCurrentGeneration(generation) && sourceSwitchRef.current === token) { @@ -3013,7 +3023,7 @@ export function HostPage({ const previousStream = streamRef.current; if (!previousStream) { captured.getTracks().forEach((track) => track.stop()); - setNoticeKey("host.shareEnded", "share-ended", "off"); + setStatusNotice({ key: "host.shareEnded" }, "share-ended", "off"); return; } @@ -3535,8 +3545,13 @@ export function HostPage({ phase === "idle" || phase === "ended" || phase === "error" } label={t("host.stageAria")} - indicator={} + indicator={<> + + + {statusNotice ? noticeText : null} + + } > {stream ? (